@happyvertical/smrt-users 0.37.2 → 0.37.3

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/index.js CHANGED
@@ -1,2123 +1,1663 @@
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
- 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";
1
+ import { $ as DEFAULT_ROLE_SLUGS, A as RolePermissionCollection, B as GroupRoleCollection, C as TenantHierarchyError, D as DEFAULT_SESSION_TTL, E as SessionCollection, F as parsePermissionSlug, G as Group, H as GroupMemberCollection, I as MembershipOverrideCollection, J as User, K as UsersCliAuthRequestCollection, L as MembershipOverride, M as PermissionCollection, N as Permission, O as Session, P as isValidPermissionSlug, Q as DEFAULT_ROLES, R as MembershipCollection, S as TenantCollection, T as Tenant, U as GroupMember, V as GroupRole, W as GroupCollection, X as normalizeEmail, Y as isValidEmail, Z as AccessRequestStatus, _ as resolveOidcProviderConfig, a as TerminalAuthRateLimitError, at as TenantStatus, b as TenantPermissionOverrideCollection, c as getRequestScopedDatabase, d as PermissionResolver, et as DEFAULT_TENANT_POLICY, f as OidcLoginError, g as getUsersOidcConfig, h as encodeOidcTransaction, i as TerminalAuthError, it as TenantPermissionEffect, j as RolePermission, k as generateSessionId, l as withSessionPermissionContext, m as decodeOidcTransaction, n as DEFAULT_CLI_AUTH_REQUEST_TTL_SECONDS, nt as OverrideEffect, o as TerminalAuthService, ot as UserStatus, p as OidcLoginService, q as UsersCliAuthRequest, r as DEFAULT_CLI_SESSION_TTL_SECONDS, rt as SessionStatus, s as getCurrentSessionPermissionContext, t as DEFAULT_CLI_AUTH_POLL_INTERVAL_SECONDS, tt as MembershipStatus, u as SessionService, w as MAX_TENANT_HIERARCHY_DEPTH, x as TenantPermissionOverride, y as UserCollection, z as Membership } from "./chunks/TerminalAuthService-BXAAuaXf.js";
2
+ import { ObjectRegistry, SmrtCollection, SmrtObject, field, findManifestEntryByQualifiedName, foreignKey, smrt } from "@happyvertical/smrt-core";
6
3
  import { createLogger } from "@happyvertical/logger";
7
4
  import { getPackageConfig } from "@happyvertical/smrt-config";
8
5
  import { createHash } from "node:crypto";
6
+ //#region src/models/AccessRequest.ts
9
7
  var __defProp$2 = Object.defineProperty;
10
8
  var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
11
9
  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;
10
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$2(target, key) : target;
11
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
12
+ if (kind && result) __defProp$2(target, key, result);
13
+ return result;
18
14
  };
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
- }
15
+ var AccessRequest = class extends SmrtObject {
16
+ email = "";
17
+ name = null;
18
+ status = AccessRequestStatus.REQUESTED;
19
+ source = "";
20
+ requestContext = "{}";
21
+ note = null;
22
+ requestedAt = /* @__PURE__ */ new Date();
23
+ decidedAt = null;
24
+ decidedBy = null;
25
+ resultingUserId = null;
26
+ tenantHint = null;
27
+ constructor(options = {}) {
28
+ super(options);
29
+ if (options.email !== void 0) this.email = normalizeEmail(options.email);
30
+ if (options.name !== void 0) this.name = options.name;
31
+ if (options.status !== void 0) this.status = options.status;
32
+ if (options.source !== void 0) this.source = options.source;
33
+ if (options.requestContext !== void 0) this.requestContext = options.requestContext;
34
+ if (options.note !== void 0) this.note = options.note;
35
+ if (options.requestedAt !== void 0) this.requestedAt = options.requestedAt;
36
+ if (options.decidedAt !== void 0) this.decidedAt = options.decidedAt;
37
+ if (options.decidedBy !== void 0) this.decidedBy = options.decidedBy;
38
+ if (options.resultingUserId !== void 0) this.resultingUserId = options.resultingUserId;
39
+ if (options.tenantHint !== void 0) this.tenantHint = options.tenantHint;
40
+ }
41
+ /**
42
+ * Parse {@link requestContext} into an object. Returns `{}` on missing or
43
+ * malformed JSON (graceful — never throws).
44
+ */
45
+ getRequestContext() {
46
+ try {
47
+ const parsed = JSON.parse(this.requestContext);
48
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
49
+ } catch {
50
+ return {};
51
+ }
52
+ }
53
+ /**
54
+ * Serialize and store {@link requestContext} from an object.
55
+ */
56
+ setRequestContext(value) {
57
+ this.requestContext = JSON.stringify(value ?? {});
58
+ }
59
+ /**
60
+ * Parse {@link tenantHint} into an object, or `null` when unset / malformed.
61
+ */
62
+ getTenantHint() {
63
+ if (!this.tenantHint) return null;
64
+ try {
65
+ const parsed = JSON.parse(this.tenantHint);
66
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+ /**
72
+ * Serialize and store {@link tenantHint} from an object (or clear with null).
73
+ */
74
+ setTenantHint(value) {
75
+ this.tenantHint = value ? JSON.stringify(value) : null;
76
+ }
77
+ /**
78
+ * Whether the request is still open (awaiting a decision).
79
+ */
80
+ isOpen() {
81
+ return this.status === AccessRequestStatus.REQUESTED;
82
+ }
83
+ /**
84
+ * Whether the request has been approved (and not yet graduated).
85
+ */
86
+ isApproved() {
87
+ return this.status === AccessRequestStatus.APPROVED;
88
+ }
89
+ /**
90
+ * Whether the request reached a terminal state (declined, graduated, or
91
+ * canceled) and can no longer transition.
92
+ */
93
+ isTerminal() {
94
+ return this.status === AccessRequestStatus.DECLINED || this.status === AccessRequestStatus.GRADUATED || this.status === AccessRequestStatus.CANCELED;
95
+ }
103
96
  };
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
- }
97
+ __decorateClass$2([field({
98
+ required: true,
99
+ indexed: true
100
+ })], AccessRequest.prototype, "email", 2);
101
+ __decorateClass$2([field({ nullable: true })], AccessRequest.prototype, "name", 2);
102
+ __decorateClass$2([field({
103
+ type: "text",
104
+ indexed: true
105
+ })], AccessRequest.prototype, "status", 2);
106
+ __decorateClass$2([field({ indexed: true })], AccessRequest.prototype, "source", 2);
107
+ __decorateClass$2([field()], AccessRequest.prototype, "requestContext", 2);
108
+ __decorateClass$2([field({ nullable: true })], AccessRequest.prototype, "note", 2);
109
+ __decorateClass$2([field()], AccessRequest.prototype, "requestedAt", 2);
110
+ __decorateClass$2([field({ nullable: true })], AccessRequest.prototype, "decidedAt", 2);
111
+ __decorateClass$2([field({ nullable: true })], AccessRequest.prototype, "decidedBy", 2);
112
+ __decorateClass$2([field({ nullable: true })], AccessRequest.prototype, "resultingUserId", 2);
113
+ __decorateClass$2([field({ nullable: true })], AccessRequest.prototype, "tenantHint", 2);
114
+ AccessRequest = __decorateClass$2([smrt({
115
+ tableName: "access_requests",
116
+ conflictColumns: ["id"],
117
+ api: { include: [] },
118
+ mcp: { include: [] },
119
+ cli: { include: [] }
120
+ })], AccessRequest);
121
+ //#endregion
122
+ //#region src/collections/AccessRequestCollection.ts
123
+ var AccessRequestCollection = class extends SmrtCollection {
124
+ static _itemClass = AccessRequest;
125
+ /**
126
+ * Find all access requests for an email (any status), newest first. The email
127
+ * is normalized before querying so callers can pass any case.
128
+ */
129
+ async findByEmail(email) {
130
+ return await this.list({
131
+ where: { email: normalizeEmail(email) },
132
+ orderBy: "created_at DESC"
133
+ });
134
+ }
135
+ /**
136
+ * Find the single open (`REQUESTED`) request for an email, if any. This is
137
+ * the dedup key used by {@link AccessRequestService.createAccessRequest}.
138
+ */
139
+ async findOpenByEmail(email) {
140
+ const results = await this.list({
141
+ where: {
142
+ email: normalizeEmail(email),
143
+ status: AccessRequestStatus.REQUESTED
144
+ },
145
+ limit: 1,
146
+ orderBy: "created_at DESC"
147
+ });
148
+ return results.length > 0 ? results[0] : null;
149
+ }
150
+ /**
151
+ * Find access requests by status, newest first.
152
+ */
153
+ async findByStatus(status) {
154
+ return await this.list({
155
+ where: { status },
156
+ orderBy: "created_at DESC"
157
+ });
158
+ }
159
+ /**
160
+ * Find all open (`REQUESTED`) access requests — the operator triage queue.
161
+ */
162
+ async findOpen() {
163
+ return await this.findByStatus(AccessRequestStatus.REQUESTED);
164
+ }
165
+ };
166
+ //#endregion
167
+ //#region src/models/MagicLinkToken.ts
196
168
  var __defProp$1 = Object.defineProperty;
197
169
  var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
198
170
  var __decorateClass$1 = (decorators, target, key, kind) => {
199
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
200
- for (var i = decorators.length - 1, decorator; i >= 0; i--)
201
- if (decorator = decorators[i])
202
- result = (kind ? decorator(target, key, result) : decorator(result)) || result;
203
- if (kind && result) __defProp$1(target, key, result);
204
- return result;
171
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
172
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
173
+ if (kind && result) __defProp$1(target, key, result);
174
+ return result;
205
175
  };
206
- const DEFAULT_TOKEN_EXPIRY_SECONDS = 10 * 60;
207
- let UsersMagicLinkToken = class extends SmrtObject {
208
- nonce = "";
209
- /** Email address this token was generated for */
210
- email = "";
211
- /** Whether this token has been used */
212
- used = false;
213
- /** When this token expires */
214
- expiresAt = new Date(Date.now() + DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3);
215
- constructor(options = {}) {
216
- super(options);
217
- if (options.nonce !== void 0) this.nonce = options.nonce;
218
- if (options.email !== void 0) this.email = options.email;
219
- if (options.used !== void 0) this.used = options.used;
220
- if (options.expiresAt !== void 0) {
221
- this.expiresAt = options.expiresAt instanceof Date ? options.expiresAt : new Date(options.expiresAt);
222
- }
223
- }
224
- /** Check if the token has expired */
225
- isExpired() {
226
- return /* @__PURE__ */ new Date() > this.expiresAt;
227
- }
228
- /** Check if the token is still valid (unused and not expired) */
229
- isValid() {
230
- return !this.used && !this.isExpired();
231
- }
176
+ var DEFAULT_TOKEN_EXPIRY_SECONDS = 600;
177
+ var UsersMagicLinkToken = class extends SmrtObject {
178
+ nonce = "";
179
+ /** Email address this token was generated for */
180
+ email = "";
181
+ /** Whether this token has been used */
182
+ used = false;
183
+ /** When this token expires */
184
+ expiresAt = new Date(Date.now() + 600 * 1e3);
185
+ constructor(options = {}) {
186
+ super(options);
187
+ if (options.nonce !== void 0) this.nonce = options.nonce;
188
+ if (options.email !== void 0) this.email = options.email;
189
+ if (options.used !== void 0) this.used = options.used;
190
+ if (options.expiresAt !== void 0) this.expiresAt = options.expiresAt instanceof Date ? options.expiresAt : new Date(options.expiresAt);
191
+ }
192
+ /** Check if the token has expired */
193
+ isExpired() {
194
+ return /* @__PURE__ */ new Date() > this.expiresAt;
195
+ }
196
+ /** Check if the token is still valid (unused and not expired) */
197
+ isValid() {
198
+ return !this.used && !this.isExpired();
199
+ }
232
200
  };
233
- __decorateClass$1([
234
- field({ required: true, unique: true })
235
- ], UsersMagicLinkToken.prototype, "nonce", 2);
236
- UsersMagicLinkToken = __decorateClass$1([
237
- smrt({
238
- tableName: "users_magic_link_tokens",
239
- // Magic link tokens are security-sensitive — no public API
240
- api: { include: [] },
241
- mcp: { include: [] },
242
- cli: true
243
- })
244
- ], UsersMagicLinkToken);
245
- class UsersMagicLinkTokenCollection extends SmrtCollection {
246
- static _itemClass = UsersMagicLinkToken;
247
- /**
248
- * Find a token by its nonce
249
- */
250
- async findByNonce(nonce) {
251
- return this.findOne({
252
- where: { nonce }
253
- });
254
- }
255
- /**
256
- * Atomically mark a token as used (single-use enforcement).
257
- *
258
- * Returns true if the nonce was successfully claimed (transitioned from
259
- * unused to used). Returns false if the nonce was already used, expired,
260
- * or doesn't exist — preventing race conditions in concurrent verify() calls.
261
- */
262
- async markUsed(nonce) {
263
- const now = (/* @__PURE__ */ new Date()).toISOString();
264
- const { rowCount } = await this.db.query(
265
- `UPDATE ${this.tableName}
201
+ __decorateClass$1([field({
202
+ required: true,
203
+ unique: true
204
+ })], UsersMagicLinkToken.prototype, "nonce", 2);
205
+ UsersMagicLinkToken = __decorateClass$1([smrt({
206
+ tableName: "users_magic_link_tokens",
207
+ api: { include: [] },
208
+ mcp: { include: [] },
209
+ cli: true
210
+ })], UsersMagicLinkToken);
211
+ //#endregion
212
+ //#region src/collections/MagicLinkTokenCollection.ts
213
+ var UsersMagicLinkTokenCollection = class extends SmrtCollection {
214
+ static _itemClass = UsersMagicLinkToken;
215
+ /**
216
+ * Find a token by its nonce
217
+ */
218
+ async findByNonce(nonce) {
219
+ return this.findOne({ where: { nonce } });
220
+ }
221
+ /**
222
+ * Atomically mark a token as used (single-use enforcement).
223
+ *
224
+ * Returns true if the nonce was successfully claimed (transitioned from
225
+ * unused to used). Returns false if the nonce was already used, expired,
226
+ * or doesn't exist preventing race conditions in concurrent verify() calls.
227
+ */
228
+ async markUsed(nonce) {
229
+ const now = (/* @__PURE__ */ new Date()).toISOString();
230
+ const { rowCount } = await this.db.query(`UPDATE ${this.tableName}
266
231
  SET used = ?, updated_at = ?
267
- WHERE nonce = ? AND used = ? AND expires_at > ?`,
268
- true,
269
- now,
270
- nonce,
271
- false,
272
- now
273
- );
274
- return rowCount > 0;
275
- }
276
- /**
277
- * Delete expired tokens (cleanup job)
278
- */
279
- async deleteExpired() {
280
- const now = /* @__PURE__ */ new Date();
281
- const tokens = await this.list({
282
- where: {
283
- "expiresAt <": now.toISOString()
284
- }
285
- });
286
- let count = 0;
287
- for (const token of tokens) {
288
- await token.delete();
289
- count++;
290
- }
291
- return count;
292
- }
293
- }
232
+ WHERE nonce = ? AND used = ? AND expires_at > ?`, true, now, nonce, false, now);
233
+ return rowCount > 0;
234
+ }
235
+ /**
236
+ * Delete expired tokens (cleanup job)
237
+ */
238
+ async deleteExpired() {
239
+ const now = /* @__PURE__ */ new Date();
240
+ const tokens = await this.list({ where: { "expiresAt <": now.toISOString() } });
241
+ let count = 0;
242
+ for (const token of tokens) {
243
+ await token.delete();
244
+ count++;
245
+ }
246
+ return count;
247
+ }
248
+ };
249
+ //#endregion
250
+ //#region src/models/Role.ts
294
251
  var __defProp = Object.defineProperty;
295
252
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
296
253
  var __decorateClass = (decorators, target, key, kind) => {
297
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
298
- for (var i = decorators.length - 1, decorator; i >= 0; i--)
299
- if (decorator = decorators[i])
300
- result = (kind ? decorator(target, key, result) : decorator(result)) || result;
301
- if (kind && result) __defProp(target, key, result);
302
- return result;
254
+ var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
255
+ for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
256
+ if (kind && result) __defProp(target, key, result);
257
+ return result;
303
258
  };
304
- let Role = class extends SmrtObject {
305
- tenantId;
306
- /**
307
- * Display name for the role
308
- */
309
- name = "";
310
- /**
311
- * Description of the role
312
- */
313
- description = "";
314
- /**
315
- * Whether this is a system role (cannot be deleted)
316
- */
317
- isSystem = false;
318
- constructor(options = {}) {
319
- super(options);
320
- if (options.tenantId !== void 0) this.tenantId = options.tenantId;
321
- if (options.name !== void 0) this.name = options.name;
322
- if (options.description !== void 0)
323
- this.description = options.description;
324
- if (options.isSystem !== void 0) this.isSystem = options.isSystem;
325
- }
326
- /**
327
- * Check if this is a system-wide role
328
- */
329
- isSystemRole() {
330
- return this.tenantId === null || this.tenantId === void 0;
331
- }
332
- /**
333
- * Check if this is a tenant-specific role
334
- */
335
- isTenantRole() {
336
- return this.tenantId !== null && this.tenantId !== void 0;
337
- }
338
- /**
339
- * Check if this role can be deleted.
340
- * System roles (isSystem = true) cannot be deleted.
341
- * @returns true if the role can be deleted
342
- */
343
- canDelete() {
344
- return !this.isSystem;
345
- }
346
- /**
347
- * Delete guard - prevents deletion of system roles.
348
- * Override the delete method to check isSystem flag first.
349
- */
350
- async delete() {
351
- if (this.isSystem) {
352
- throw new Error(
353
- `Cannot delete system role '${this.slug}'. System roles are protected.`
354
- );
355
- }
356
- return super.delete();
357
- }
259
+ var Role = class extends SmrtObject {
260
+ tenantId;
261
+ /**
262
+ * Display name for the role
263
+ */
264
+ name = "";
265
+ /**
266
+ * Description of the role
267
+ */
268
+ description = "";
269
+ /**
270
+ * Whether this is a system role (cannot be deleted)
271
+ */
272
+ isSystem = false;
273
+ constructor(options = {}) {
274
+ super(options);
275
+ if (options.tenantId !== void 0) this.tenantId = options.tenantId;
276
+ if (options.name !== void 0) this.name = options.name;
277
+ if (options.description !== void 0) this.description = options.description;
278
+ if (options.isSystem !== void 0) this.isSystem = options.isSystem;
279
+ }
280
+ /**
281
+ * Check if this is a system-wide role
282
+ */
283
+ isSystemRole() {
284
+ return this.tenantId === null || this.tenantId === void 0;
285
+ }
286
+ /**
287
+ * Check if this is a tenant-specific role
288
+ */
289
+ isTenantRole() {
290
+ return this.tenantId !== null && this.tenantId !== void 0;
291
+ }
292
+ /**
293
+ * Check if this role can be deleted.
294
+ * System roles (isSystem = true) cannot be deleted.
295
+ * @returns true if the role can be deleted
296
+ */
297
+ canDelete() {
298
+ return !this.isSystem;
299
+ }
300
+ /**
301
+ * Delete guard - prevents deletion of system roles.
302
+ * Override the delete method to check isSystem flag first.
303
+ */
304
+ async delete() {
305
+ if (this.isSystem) throw new Error(`Cannot delete system role '${this.slug}'. System roles are protected.`);
306
+ return super.delete();
307
+ }
358
308
  };
359
- __decorateClass([
360
- foreignKey("Tenant", { nullable: true })
361
- ], Role.prototype, "tenantId", 2);
362
- Role = __decorateClass([
363
- smrt({
364
- // #1400: read-only generated surface — RBAC/identity writes go through
365
- // permission-gated services, not auth-only generated CRUD.
366
- api: { include: ["list", "get"] },
367
- mcp: { include: ["list", "get"] },
368
- cli: true
369
- })
370
- ], Role);
371
- class RoleCollection extends SmrtCollection {
372
- static _itemClass = Role;
373
- /**
374
- * Find all system roles (tenantId is null)
375
- */
376
- async findSystemRoles() {
377
- return await this.query(
378
- `SELECT * FROM ${this.tableName} WHERE tenant_id IS NULL ORDER BY name ASC`
379
- );
380
- }
381
- /**
382
- * Find roles available for a tenant (system + tenant-specific)
383
- */
384
- async findByTenant(tenantId) {
385
- return await this.query(
386
- `SELECT * FROM ${this.tableName}
309
+ __decorateClass([foreignKey("Tenant", { nullable: true })], Role.prototype, "tenantId", 2);
310
+ Role = __decorateClass([smrt({
311
+ api: { include: ["list", "get"] },
312
+ mcp: { include: ["list", "get"] },
313
+ cli: true
314
+ })], Role);
315
+ //#endregion
316
+ //#region src/collections/RoleCollection.ts
317
+ var RoleCollection = class extends SmrtCollection {
318
+ static _itemClass = Role;
319
+ /**
320
+ * Find all system roles (tenantId is null)
321
+ */
322
+ async findSystemRoles() {
323
+ return await this.query(`SELECT * FROM ${this.tableName} WHERE tenant_id IS NULL ORDER BY name ASC`);
324
+ }
325
+ /**
326
+ * Find roles available for a tenant (system + tenant-specific)
327
+ */
328
+ async findByTenant(tenantId) {
329
+ return await this.query(`SELECT * FROM ${this.tableName}
387
330
  WHERE tenant_id IS NULL OR tenant_id = ?
388
- ORDER BY is_system DESC, name ASC`,
389
- [tenantId]
390
- );
391
- }
392
- /**
393
- * Find tenant-specific roles only
394
- */
395
- async findTenantRoles(tenantId) {
396
- return await this.list({
397
- where: { tenantId },
398
- orderBy: "name ASC"
399
- });
400
- }
401
- /**
402
- * Find role by slug within a tenant context
403
- */
404
- async findBySlug(slug, tenantId) {
405
- if (tenantId) {
406
- const tenantRoles = await this.list({
407
- where: { slug, tenantId },
408
- limit: 1
409
- });
410
- if (tenantRoles.length > 0) {
411
- return tenantRoles[0];
412
- }
413
- }
414
- const systemRoles = await this.query(
415
- `SELECT * FROM ${this.tableName} WHERE slug = ? AND tenant_id IS NULL LIMIT 1`,
416
- [slug]
417
- );
418
- return systemRoles.length > 0 ? systemRoles[0] : null;
419
- }
420
- /**
421
- * Seed default system roles
422
- */
423
- async seedSystemRoles() {
424
- const roles = [];
425
- for (const roleDef of DEFAULT_ROLES) {
426
- const existing = await this.findBySlug(roleDef.slug);
427
- if (existing) {
428
- roles.push(existing);
429
- continue;
430
- }
431
- const role = await this.create({
432
- slug: roleDef.slug,
433
- name: roleDef.name,
434
- description: roleDef.description,
435
- tenantId: null,
436
- isSystem: true
437
- });
438
- await role.save();
439
- roles.push(role);
440
- }
441
- return roles;
442
- }
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"
331
+ ORDER BY is_system DESC, name ASC`, [tenantId]);
332
+ }
333
+ /**
334
+ * Find tenant-specific roles only
335
+ */
336
+ async findTenantRoles(tenantId) {
337
+ return await this.list({
338
+ where: { tenantId },
339
+ orderBy: "name ASC"
340
+ });
341
+ }
342
+ /**
343
+ * Find role by slug within a tenant context
344
+ */
345
+ async findBySlug(slug, tenantId) {
346
+ if (tenantId) {
347
+ const tenantRoles = await this.list({
348
+ where: {
349
+ slug,
350
+ tenantId
351
+ },
352
+ limit: 1
353
+ });
354
+ if (tenantRoles.length > 0) return tenantRoles[0];
355
+ }
356
+ const systemRoles = await this.query(`SELECT * FROM ${this.tableName} WHERE slug = ? AND tenant_id IS NULL LIMIT 1`, [slug]);
357
+ return systemRoles.length > 0 ? systemRoles[0] : null;
358
+ }
359
+ /**
360
+ * Seed default system roles
361
+ */
362
+ async seedSystemRoles() {
363
+ const roles = [];
364
+ for (const roleDef of DEFAULT_ROLES) {
365
+ const existing = await this.findBySlug(roleDef.slug);
366
+ if (existing) {
367
+ roles.push(existing);
368
+ continue;
369
+ }
370
+ const role = await this.create({
371
+ slug: roleDef.slug,
372
+ name: roleDef.name,
373
+ description: roleDef.description,
374
+ tenantId: null,
375
+ isSystem: true
376
+ });
377
+ await role.save();
378
+ roles.push(role);
379
+ }
380
+ return roles;
381
+ }
450
382
  };
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
- }
959
- class MagicLinkError extends Error {
960
- constructor(message) {
961
- super(message);
962
- this.name = "MagicLinkError";
963
- }
964
- }
965
- class MagicLinkService {
966
- tokenCollection;
967
- signingKey = null;
968
- secret;
969
- tokenExpiry;
970
- issuer;
971
- options;
972
- constructor(options) {
973
- if (!options.secret) {
974
- throw new Error("MagicLinkService requires a secret for token signing");
975
- }
976
- this.secret = options.secret;
977
- this.tokenExpiry = options.tokenExpiry ?? DEFAULT_TOKEN_EXPIRY_SECONDS;
978
- this.issuer = options.issuer ?? "smrt:magiclink";
979
- this.options = options;
980
- }
981
- /**
982
- * Initialize collections
983
- */
984
- async initialize() {
985
- this.tokenCollection = await UsersMagicLinkTokenCollection.create(this.options);
986
- }
987
- /**
988
- * Derive the HMAC signing key from the secret
989
- */
990
- async getSigningKey() {
991
- if (this.signingKey) return this.signingKey;
992
- const encoder = new TextEncoder();
993
- const data = encoder.encode(`magiclink:${this.secret}`);
994
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
995
- this.signingKey = new Uint8Array(hashBuffer);
996
- return this.signingKey;
997
- }
998
- /**
999
- * Generate a magic link token for the given email.
1000
- *
1001
- * Stores a nonce in the database for replay protection.
1002
- * The caller is responsible for emailing the token to the user.
1003
- */
1004
- async generate(email) {
1005
- const { SignJWT } = await import("./chunks/index-C3E55ikp.js");
1006
- const key = await this.getSigningKey();
1007
- const nonce = crypto.randomUUID();
1008
- const normalizedEmail = normalizeEmail(email);
1009
- if (!isValidEmail(normalizedEmail)) {
1010
- throw new MagicLinkError("Invalid email address");
1011
- }
1012
- const expiresAt = new Date(Date.now() + this.tokenExpiry * 1e3);
1013
- await this.tokenCollection.create({
1014
- nonce,
1015
- email: normalizedEmail,
1016
- used: false,
1017
- expiresAt
1018
- });
1019
- const token = await new SignJWT({
1020
- email: normalizedEmail,
1021
- nonce
1022
- }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(`${this.tokenExpiry}s`).setIssuer(this.issuer).sign(key);
1023
- return { token, expiresAt };
1024
- }
1025
- /**
1026
- * Verify a magic link token.
1027
- *
1028
- * Checks JWT signature, expiry, and that the nonce hasn't been used.
1029
- * Marks the nonce as used on success (single-use enforcement).
1030
- *
1031
- * @throws {MagicLinkError} If the token is invalid, expired, or already used
1032
- */
1033
- async verify(token) {
1034
- const { jwtVerify, errors } = await import("./chunks/index-C3E55ikp.js");
1035
- const key = await this.getSigningKey();
1036
- let payload;
1037
- try {
1038
- const result = await jwtVerify(token, key, {
1039
- issuer: this.issuer
1040
- });
1041
- payload = result.payload;
1042
- } catch (err) {
1043
- if (err instanceof errors.JWTExpired) {
1044
- throw new MagicLinkError("Token has expired");
1045
- }
1046
- throw new MagicLinkError("Invalid token");
1047
- }
1048
- const email = payload.email;
1049
- const nonce = payload.nonce;
1050
- if (typeof email !== "string" || typeof nonce !== "string") {
1051
- throw new MagicLinkError("Invalid token payload");
1052
- }
1053
- const claimed = await this.tokenCollection.markUsed(nonce);
1054
- if (!claimed) {
1055
- throw new MagicLinkError("Token has already been used or has expired");
1056
- }
1057
- return { email: normalizeEmail(email), nonce };
1058
- }
1059
- /**
1060
- * Clean up expired tokens (run periodically)
1061
- */
1062
- async cleanupExpiredTokens() {
1063
- return this.tokenCollection.deleteExpired();
1064
- }
1065
- /**
1066
- * Static factory method
1067
- */
1068
- static async create(options) {
1069
- const service = new MagicLinkService(options);
1070
- await service.initialize();
1071
- return service;
1072
- }
1073
- }
383
+ //#endregion
384
+ //#region src/services/AccessRequestService.ts
385
+ var logger = createLogger({ level: "info" });
386
+ var ACCESS_REQUEST_CAPABILITIES = {
387
+ /** Read the access-request queue (`list` / `get`). */
388
+ READ: "access-requests:read",
389
+ /** Decide requests (`approve` / `decline` / `cancel` / `graduate`). */
390
+ MANAGE: "access-requests:manage"
391
+ };
392
+ var AccessRequestError = class extends Error {
393
+ code;
394
+ constructor(message, code) {
395
+ super(message);
396
+ this.name = "AccessRequestError";
397
+ this.code = code;
398
+ }
399
+ };
400
+ var APPROVE_FROM = [AccessRequestStatus.REQUESTED];
401
+ var DECLINE_FROM = [AccessRequestStatus.REQUESTED, AccessRequestStatus.APPROVED];
402
+ var CANCEL_FROM = [AccessRequestStatus.REQUESTED, AccessRequestStatus.APPROVED];
403
+ var AccessRequestService = class AccessRequestService {
404
+ #options;
405
+ #authorize;
406
+ #onEvent;
407
+ #requests;
408
+ #users;
409
+ #tenants;
410
+ #memberships;
411
+ #roles;
412
+ #rolesSeeded = false;
413
+ constructor(options) {
414
+ this.#options = options;
415
+ this.#authorize = options.authorize;
416
+ this.#onEvent = options.onEvent;
417
+ }
418
+ /**
419
+ * Initialize the backing collections (creates/verifies their tables).
420
+ */
421
+ async initialize() {
422
+ this.#requests = await AccessRequestCollection.create(this.#options);
423
+ this.#users = await UserCollection.create(this.#options);
424
+ this.#tenants = await TenantCollection.create(this.#options);
425
+ this.#memberships = await MembershipCollection.create(this.#options);
426
+ this.#roles = await RoleCollection.create(this.#options);
427
+ }
428
+ /**
429
+ * Static factory construct and initialize in one call.
430
+ */
431
+ static async create(options) {
432
+ const service = new AccessRequestService(options);
433
+ await service.initialize();
434
+ return service;
435
+ }
436
+ /**
437
+ * The underlying collection, for advanced read scenarios. Prefer the service
438
+ * methods, which apply normalization, the state machine, capability gating,
439
+ * and events.
440
+ */
441
+ get collection() {
442
+ return this.#requests;
443
+ }
444
+ /**
445
+ * Create an access request. **Public-safe**: no capability check — meant to be
446
+ * callable unauthenticated by apps (which add their own rate-limiting).
447
+ *
448
+ * Validates and normalizes the email, then de-duplicates: if an open
449
+ * (`REQUESTED`) request already exists for the email, this merges any newly
450
+ * supplied context/name/source/hint into it and returns it instead of
451
+ * creating a duplicate (no second `created` event).
452
+ *
453
+ * @remarks
454
+ * De-duplication is **best-effort, not atomic**: it is a read-then-write
455
+ * (`findOpenByEmail` `create`) with no DB-level partial-unique constraint
456
+ * (the table is append-style, keyed on `id`, because the same email may
457
+ * accumulate many requests over its lifetime). Two requests for the same
458
+ * email racing concurrently can therefore both create an open row. This is by
459
+ * design the spec makes dedup configurable and pushes abuse control to the
460
+ * app (rate-limiting on the public endpoint). Operators triaging two open rows
461
+ * for one email is benign; apps needing a hard single-open-request guarantee
462
+ * should add a partial unique index (`UNIQUE(email) WHERE status='requested'`)
463
+ * in their migration.
464
+ *
465
+ * @throws {@link AccessRequestError} (`INVALID_EMAIL`) when the email is invalid.
466
+ */
467
+ async createAccessRequest(input) {
468
+ const email = normalizeEmail(input.email);
469
+ if (!isValidEmail(email)) throw new AccessRequestError("A valid email address is required to request access.", "INVALID_EMAIL");
470
+ const existing = await this.#requests.findOpenByEmail(email);
471
+ if (existing) {
472
+ let changed = false;
473
+ if (input.context && Object.keys(input.context).length > 0) {
474
+ existing.setRequestContext({
475
+ ...existing.getRequestContext(),
476
+ ...input.context
477
+ });
478
+ changed = true;
479
+ }
480
+ if (input.tenantHint) {
481
+ existing.setTenantHint({
482
+ ...existing.getTenantHint() ?? {},
483
+ ...input.tenantHint
484
+ });
485
+ changed = true;
486
+ }
487
+ if (input.name && !existing.name) {
488
+ existing.name = input.name;
489
+ changed = true;
490
+ }
491
+ if (input.source && !existing.source) {
492
+ existing.source = input.source;
493
+ changed = true;
494
+ }
495
+ if (changed) await existing.save();
496
+ return existing;
497
+ }
498
+ const request = await this.#requests.create({
499
+ email,
500
+ name: input.name ?? null,
501
+ source: input.source ?? "",
502
+ status: AccessRequestStatus.REQUESTED,
503
+ requestedAt: /* @__PURE__ */ new Date(),
504
+ requestContext: JSON.stringify(input.context ?? {}),
505
+ tenantHint: input.tenantHint ? JSON.stringify(input.tenantHint) : null,
506
+ note: input.note ?? null
507
+ });
508
+ await this.#emit({
509
+ type: "access-request.created",
510
+ accessRequest: request,
511
+ at: /* @__PURE__ */ new Date()
512
+ });
513
+ return request;
514
+ }
515
+ /**
516
+ * List access requests (operator-facing). Requires the `access-requests:read`
517
+ * capability when an authorizer is configured.
518
+ */
519
+ async listAccessRequests(filter = {}) {
520
+ await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.READ, { by: filter.by });
521
+ const where = {};
522
+ if (filter.status !== void 0) where.status = filter.status;
523
+ if (filter.email !== void 0) where.email = normalizeEmail(filter.email);
524
+ if (filter.source !== void 0) where.source = filter.source;
525
+ return await this.#requests.list({
526
+ where,
527
+ limit: filter.limit,
528
+ offset: filter.offset,
529
+ orderBy: filter.orderBy ?? "created_at DESC"
530
+ });
531
+ }
532
+ /**
533
+ * Get a single access request by id (operator-facing). Requires the
534
+ * `access-requests:read` capability when an authorizer is configured.
535
+ */
536
+ async getAccessRequest(id, options = {}) {
537
+ await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.READ, {
538
+ by: options.by,
539
+ accessRequestId: id
540
+ });
541
+ return await this.#requests.get(id);
542
+ }
543
+ /**
544
+ * Approve a request: `REQUESTED → APPROVED`. Idempotent (re-approving an
545
+ * already-`APPROVED` request is a no-op returning it). Requires
546
+ * `access-requests:manage`.
547
+ *
548
+ * @throws {@link AccessRequestError} (`INVALID_TRANSITION`) from a terminal state.
549
+ */
550
+ async approveAccessRequest(id, options = {}) {
551
+ await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.MANAGE, {
552
+ by: options.by,
553
+ accessRequestId: id
554
+ });
555
+ const request = await this.#load(id);
556
+ if (request.status === AccessRequestStatus.APPROVED) return request;
557
+ this.#assertTransition(request, APPROVE_FROM, "approve");
558
+ request.status = AccessRequestStatus.APPROVED;
559
+ request.decidedAt = /* @__PURE__ */ new Date();
560
+ if (options.by) request.decidedBy = options.by;
561
+ if (options.note != null) request.note = options.note;
562
+ await request.save();
563
+ await this.#emit({
564
+ type: "access-request.approved",
565
+ accessRequest: request,
566
+ at: /* @__PURE__ */ new Date(),
567
+ by: options.by
568
+ });
569
+ return request;
570
+ }
571
+ /**
572
+ * Decline a request: `REQUESTED | APPROVED → DECLINED`. Idempotent. Requires
573
+ * `access-requests:manage`.
574
+ *
575
+ * @throws {@link AccessRequestError} (`INVALID_TRANSITION`) from a terminal state.
576
+ */
577
+ async declineAccessRequest(id, options = {}) {
578
+ await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.MANAGE, {
579
+ by: options.by,
580
+ accessRequestId: id
581
+ });
582
+ const request = await this.#load(id);
583
+ if (request.status === AccessRequestStatus.DECLINED) return request;
584
+ this.#assertTransition(request, DECLINE_FROM, "decline");
585
+ request.status = AccessRequestStatus.DECLINED;
586
+ request.decidedAt = /* @__PURE__ */ new Date();
587
+ if (options.by) request.decidedBy = options.by;
588
+ if (options.reason != null) request.note = options.reason;
589
+ await request.save();
590
+ await this.#emit({
591
+ type: "access-request.declined",
592
+ accessRequest: request,
593
+ at: /* @__PURE__ */ new Date(),
594
+ by: options.by
595
+ });
596
+ return request;
597
+ }
598
+ /**
599
+ * Cancel a request: `REQUESTED | APPROVED → CANCELED`. Idempotent. Requires
600
+ * `access-requests:manage`.
601
+ *
602
+ * @throws {@link AccessRequestError} (`INVALID_TRANSITION`) from a terminal state.
603
+ */
604
+ async cancelAccessRequest(id, options = {}) {
605
+ await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.MANAGE, {
606
+ by: options.by,
607
+ accessRequestId: id
608
+ });
609
+ const request = await this.#load(id);
610
+ if (request.status === AccessRequestStatus.CANCELED) return request;
611
+ this.#assertTransition(request, CANCEL_FROM, "cancel");
612
+ request.status = AccessRequestStatus.CANCELED;
613
+ request.decidedAt = /* @__PURE__ */ new Date();
614
+ if (options.by) request.decidedBy = options.by;
615
+ if (options.reason != null) request.note = options.reason;
616
+ await request.save();
617
+ await this.#emit({
618
+ type: "access-request.canceled",
619
+ accessRequest: request,
620
+ at: /* @__PURE__ */ new Date(),
621
+ by: options.by
622
+ });
623
+ return request;
624
+ }
625
+ /**
626
+ * Graduate an approved request into a `User`, optionally attaching a tenant.
627
+ *
628
+ * Valid from `APPROVED` (or from `REQUESTED` when
629
+ * {@link GraduateAccessRequestOptions.allowFromRequested} is set). Creates a
630
+ * user when none exists for the email, or **links** the existing one
631
+ * otherwise (reusing {@link UserCollection}). Idempotent: a second call on an
632
+ * already-`GRADUATED` request returns the same user (and an existing
633
+ * membership for the requested tenant, if any) without re-creating anything.
634
+ *
635
+ * Requires `access-requests:manage`.
636
+ *
637
+ * @throws {@link AccessRequestError} `NOT_FOUND` (unknown id),
638
+ * `INVALID_TRANSITION` (terminal/declined/canceled or `REQUESTED` without
639
+ * `allowFromRequested`), `TENANT_NOT_FOUND`, or `ROLE_NOT_FOUND`.
640
+ */
641
+ async graduateAccessRequest(id, options = {}) {
642
+ await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.MANAGE, {
643
+ by: options.by,
644
+ accessRequestId: id
645
+ });
646
+ const request = await this.#load(id);
647
+ const tenantOption = options.tenant ?? "none";
648
+ if (request.status === AccessRequestStatus.GRADUATED && request.resultingUserId) {
649
+ const existingUser = await this.#users.get(request.resultingUserId);
650
+ if (existingUser) return {
651
+ user: existingUser,
652
+ membership: await this.#resolveExistingMembership(existingUser.id, tenantOption) ?? void 0,
653
+ accessRequest: request,
654
+ created: false
655
+ };
656
+ }
657
+ if (request.status !== AccessRequestStatus.GRADUATED) {
658
+ const allowFromRequested = options.allowFromRequested ?? false;
659
+ if (!(request.status === AccessRequestStatus.APPROVED || request.status === AccessRequestStatus.REQUESTED && allowFromRequested)) {
660
+ const hint = request.status === AccessRequestStatus.REQUESTED ? " (approve it first, or pass allowFromRequested)" : "";
661
+ throw new AccessRequestError(`Cannot graduate an access request in status "${request.status}"${hint}.`, "INVALID_TRANSITION");
662
+ }
663
+ }
664
+ if (tenantOption !== "none") {
665
+ await this.#ensureRolesSeeded();
666
+ await this.#validateTenantOption(tenantOption);
667
+ }
668
+ const email = normalizeEmail(request.email);
669
+ let user = await this.#users.findByEmail(email);
670
+ let created = false;
671
+ if (!user) {
672
+ user = await this.#users.create({
673
+ email,
674
+ status: options.activate ?? UserStatus.ACTIVE
675
+ });
676
+ created = true;
677
+ } else if (options.activate !== void 0 && user.status !== options.activate) {
678
+ user.status = options.activate;
679
+ await user.save();
680
+ }
681
+ let membership;
682
+ let tenant;
683
+ if (tenantOption !== "none") {
684
+ const attached = await this.#attachTenant(user.id, tenantOption);
685
+ membership = attached.membership;
686
+ tenant = attached.tenant;
687
+ }
688
+ request.status = AccessRequestStatus.GRADUATED;
689
+ request.resultingUserId = user.id;
690
+ request.decidedAt = /* @__PURE__ */ new Date();
691
+ if (options.by) request.decidedBy = options.by;
692
+ if (options.note != null) request.note = options.note;
693
+ await request.save();
694
+ await this.#emit({
695
+ type: "access-request.graduated",
696
+ accessRequest: request,
697
+ at: /* @__PURE__ */ new Date(),
698
+ by: options.by,
699
+ user,
700
+ membership,
701
+ tenant
702
+ });
703
+ return {
704
+ user,
705
+ membership,
706
+ tenant,
707
+ accessRequest: request,
708
+ created
709
+ };
710
+ }
711
+ /**
712
+ * Load a request or throw `NOT_FOUND`.
713
+ */
714
+ async #load(id) {
715
+ const request = await this.#requests.get(id);
716
+ if (!request) throw new AccessRequestError("Access request not found.", "NOT_FOUND");
717
+ return request;
718
+ }
719
+ /**
720
+ * Guard a state transition; throw `INVALID_TRANSITION` if the current status
721
+ * is not an allowed source.
722
+ */
723
+ #assertTransition(request, allowedFrom, action) {
724
+ if (!allowedFrom.includes(request.status)) throw new AccessRequestError(`Cannot ${action} an access request in status "${request.status}".`, "INVALID_TRANSITION");
725
+ }
726
+ /**
727
+ * Run the configured authorizer (if any). Absent an authorizer, operator
728
+ * methods are ungated see the class-level security note.
729
+ */
730
+ async #requireCapability(capability, context) {
731
+ if (!this.#authorize) return;
732
+ await this.#authorize({
733
+ capability,
734
+ by: context.by ?? null,
735
+ accessRequestId: context.accessRequestId
736
+ });
737
+ }
738
+ /**
739
+ * Best-effort event delivery — a throwing handler is logged and swallowed so
740
+ * it cannot roll back an already-persisted transition.
741
+ */
742
+ async #emit(event) {
743
+ if (!this.#onEvent) return;
744
+ try {
745
+ await this.#onEvent(event);
746
+ } catch (error) {
747
+ logger.error(`AccessRequest event handler threw for "${event.type}" (request ${event.accessRequest.id})`, { error });
748
+ }
749
+ }
750
+ /**
751
+ * Throw `ROLE_NOT_FOUND` if no role with `roleSlug` is resolvable (tenant-
752
+ * specific first, then system). Roles must already be seeded.
753
+ */
754
+ async #assertRoleExists(roleSlug, tenantId) {
755
+ if (!await this.#roles.findBySlug(roleSlug, tenantId)) throw new AccessRequestError(`Role "${roleSlug}" not found \u2014 seed system roles or pass a valid role slug.`, "ROLE_NOT_FOUND");
756
+ }
757
+ /**
758
+ * Validate a graduation tenant option with NO side effects: the target tenant
759
+ * must exist (existing-tenant variant) and the role slug must resolve. Run
760
+ * before any persistence so a bad option can't leave orphan rows.
761
+ */
762
+ async #validateTenantOption(option) {
763
+ if ("tenantId" in option) {
764
+ if (!await this.#tenants.get(option.tenantId)) throw new AccessRequestError(`Target tenant "${option.tenantId}" not found.`, "TENANT_NOT_FOUND");
765
+ await this.#assertRoleExists(option.role ?? DEFAULT_ROLE_SLUGS.MEMBER, option.tenantId);
766
+ } else await this.#assertRoleExists(option.role ?? DEFAULT_ROLE_SLUGS.OWNER);
767
+ }
768
+ /**
769
+ * Create-or-attach the requester to a tenant, reusing the tenant / role /
770
+ * membership collections (no duplication of user/membership logic).
771
+ */
772
+ async #attachTenant(userId, option) {
773
+ await this.#ensureRolesSeeded();
774
+ if ("tenantId" in option) {
775
+ const tenant2 = await this.#tenants.get(option.tenantId);
776
+ if (!tenant2) throw new AccessRequestError(`Target tenant "${option.tenantId}" not found.`, "TENANT_NOT_FOUND");
777
+ return {
778
+ tenant: tenant2,
779
+ membership: await this.#getOrCreateMembership(userId, tenant2.id, option.role ?? DEFAULT_ROLE_SLUGS.MEMBER)
780
+ };
781
+ }
782
+ const tenant = await this.#tenants.create({
783
+ name: option.create.name,
784
+ slug: option.create.slug,
785
+ description: option.create.description,
786
+ status: option.create.status
787
+ });
788
+ return {
789
+ tenant,
790
+ membership: await this.#getOrCreateMembership(userId, tenant.id, option.role ?? DEFAULT_ROLE_SLUGS.OWNER)
791
+ };
792
+ }
793
+ /**
794
+ * Resolve (creating if needed) the user's membership in a tenant with the
795
+ * given role slug. Idempotent — returns any existing membership for the pair.
796
+ */
797
+ async #getOrCreateMembership(userId, tenantId, roleSlug) {
798
+ const existing = await this.#memberships.findByUserAndTenant(userId, tenantId);
799
+ if (existing) return existing;
800
+ const role = await this.#roles.findBySlug(roleSlug, tenantId);
801
+ if (!role) throw new AccessRequestError(`Role "${roleSlug}" not found \u2014 seed system roles or pass a valid role slug.`, "ROLE_NOT_FOUND");
802
+ return await this.#memberships.create({
803
+ userId,
804
+ tenantId,
805
+ roleId: role.id,
806
+ status: MembershipStatus.ACTIVE
807
+ });
808
+ }
809
+ /**
810
+ * For idempotent re-graduation: find an existing membership for the requested
811
+ * tenant. Only the existing-tenant variant is resolvable (a `{ create }`
812
+ * variant has no known tenant id on a re-call).
813
+ */
814
+ async #resolveExistingMembership(userId, tenantOption) {
815
+ if (tenantOption === "none" || !("tenantId" in tenantOption)) return null;
816
+ return await this.#memberships.findByUserAndTenant(userId, tenantOption.tenantId);
817
+ }
818
+ /**
819
+ * Lazily seed the default system roles (owner/admin/member/viewer) the first
820
+ * time graduation attaches a tenant so the public create path never incurs
821
+ * the write.
822
+ */
823
+ async #ensureRolesSeeded() {
824
+ if (this.#rolesSeeded) return;
825
+ await this.#roles.seedSystemRoles();
826
+ this.#rolesSeeded = true;
827
+ }
828
+ };
829
+ //#endregion
830
+ //#region src/services/MagicLinkService.ts
831
+ var MagicLinkError = class extends Error {
832
+ constructor(message) {
833
+ super(message);
834
+ this.name = "MagicLinkError";
835
+ }
836
+ };
837
+ var MagicLinkService = class MagicLinkService {
838
+ tokenCollection;
839
+ signingKey = null;
840
+ secret;
841
+ tokenExpiry;
842
+ issuer;
843
+ options;
844
+ constructor(options) {
845
+ if (!options.secret) throw new Error("MagicLinkService requires a secret for token signing");
846
+ this.secret = options.secret;
847
+ this.tokenExpiry = options.tokenExpiry ?? 600;
848
+ this.issuer = options.issuer ?? "smrt:magiclink";
849
+ this.options = options;
850
+ }
851
+ /**
852
+ * Initialize collections
853
+ */
854
+ async initialize() {
855
+ this.tokenCollection = await UsersMagicLinkTokenCollection.create(this.options);
856
+ }
857
+ /**
858
+ * Derive the HMAC signing key from the secret
859
+ */
860
+ async getSigningKey() {
861
+ if (this.signingKey) return this.signingKey;
862
+ const data = new TextEncoder().encode(`magiclink:${this.secret}`);
863
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
864
+ this.signingKey = new Uint8Array(hashBuffer);
865
+ return this.signingKey;
866
+ }
867
+ /**
868
+ * Generate a magic link token for the given email.
869
+ *
870
+ * Stores a nonce in the database for replay protection.
871
+ * The caller is responsible for emailing the token to the user.
872
+ */
873
+ async generate(email) {
874
+ const { SignJWT } = await import("./chunks/TerminalAuthService-BXAAuaXf.js").then((n) => n.v);
875
+ const key = await this.getSigningKey();
876
+ const nonce = crypto.randomUUID();
877
+ const normalizedEmail = normalizeEmail(email);
878
+ if (!isValidEmail(normalizedEmail)) throw new MagicLinkError("Invalid email address");
879
+ const expiresAt = new Date(Date.now() + this.tokenExpiry * 1e3);
880
+ await this.tokenCollection.create({
881
+ nonce,
882
+ email: normalizedEmail,
883
+ used: false,
884
+ expiresAt
885
+ });
886
+ return {
887
+ token: await new SignJWT({
888
+ email: normalizedEmail,
889
+ nonce
890
+ }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(`${this.tokenExpiry}s`).setIssuer(this.issuer).sign(key),
891
+ expiresAt
892
+ };
893
+ }
894
+ /**
895
+ * Verify a magic link token.
896
+ *
897
+ * Checks JWT signature, expiry, and that the nonce hasn't been used.
898
+ * Marks the nonce as used on success (single-use enforcement).
899
+ *
900
+ * @throws {MagicLinkError} If the token is invalid, expired, or already used
901
+ */
902
+ async verify(token) {
903
+ const { jwtVerify, errors } = await import("./chunks/TerminalAuthService-BXAAuaXf.js").then((n) => n.v);
904
+ const key = await this.getSigningKey();
905
+ let payload;
906
+ try {
907
+ payload = (await jwtVerify(token, key, { issuer: this.issuer })).payload;
908
+ } catch (err) {
909
+ if (err instanceof errors.JWTExpired) throw new MagicLinkError("Token has expired");
910
+ throw new MagicLinkError("Invalid token");
911
+ }
912
+ const email = payload.email;
913
+ const nonce = payload.nonce;
914
+ if (typeof email !== "string" || typeof nonce !== "string") throw new MagicLinkError("Invalid token payload");
915
+ if (!await this.tokenCollection.markUsed(nonce)) throw new MagicLinkError("Token has already been used or has expired");
916
+ return {
917
+ email: normalizeEmail(email),
918
+ nonce
919
+ };
920
+ }
921
+ /**
922
+ * Clean up expired tokens (run periodically)
923
+ */
924
+ async cleanupExpiredTokens() {
925
+ return this.tokenCollection.deleteExpired();
926
+ }
927
+ /**
928
+ * Static factory method
929
+ */
930
+ static async create(options) {
931
+ const service = new MagicLinkService(options);
932
+ await service.initialize();
933
+ return service;
934
+ }
935
+ };
936
+ //#endregion
937
+ //#region src/services/PermissionCatalogService.ts
1074
938
  function getRuntimePermissionRegistrations() {
1075
- globalThis.__smrtUsersPermissionRegistrations ??= /* @__PURE__ */ new Map();
1076
- return globalThis.__smrtUsersPermissionRegistrations;
939
+ globalThis.__smrtUsersPermissionRegistrations ??= /* @__PURE__ */ new Map();
940
+ return globalThis.__smrtUsersPermissionRegistrations;
1077
941
  }
1078
942
  function toSnakeCase$1(value) {
1079
- return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toLowerCase();
943
+ return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toLowerCase();
1080
944
  }
1081
945
  function pluralize(word) {
1082
- if (word.endsWith("y") && !/[aeiou]y$/i.test(word)) {
1083
- return `${word.slice(0, -1)}ies`;
1084
- }
1085
- if (word.endsWith("s") || word.endsWith("x") || word.endsWith("z") || word.endsWith("ch") || word.endsWith("sh")) {
1086
- return `${word}es`;
1087
- }
1088
- return `${word}s`;
946
+ if (word.endsWith("y") && !/[aeiou]y$/i.test(word)) return `${word.slice(0, -1)}ies`;
947
+ if (word.endsWith("s") || word.endsWith("x") || word.endsWith("z") || word.endsWith("ch") || word.endsWith("sh")) return `${word}es`;
948
+ return `${word}s`;
1089
949
  }
1090
950
  function deriveCollectionName(className) {
1091
- return pluralize(toSnakeCase$1(className));
951
+ return pluralize(toSnakeCase$1(className));
1092
952
  }
1093
953
  function humanizeResource(resource) {
1094
- return resource.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
954
+ return resource.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
1095
955
  }
1096
956
  function capitalize(value) {
1097
- return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
957
+ return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
1098
958
  }
1099
959
  function defaultPermissionName(slug) {
1100
- const parsed = parsePermissionSlug(slug);
1101
- if (!parsed.isValid) {
1102
- return humanizeResource(slug);
1103
- }
1104
- return `${capitalize(parsed.action)} ${humanizeResource(parsed.resource)}`;
960
+ const parsed = parsePermissionSlug(slug);
961
+ if (!parsed.isValid) return humanizeResource(slug);
962
+ return `${capitalize(parsed.action)} ${humanizeResource(parsed.resource)}`;
1105
963
  }
1106
964
  function defaultPermissionDescription(slug) {
1107
- const parsed = parsePermissionSlug(slug);
1108
- if (!parsed.isValid) {
1109
- return `Allows ${slug}`;
1110
- }
1111
- return `Allows ${parsed.action} access for ${humanizeResource(parsed.resource).toLowerCase()}`;
965
+ const parsed = parsePermissionSlug(slug);
966
+ if (!parsed.isValid) return `Allows ${slug}`;
967
+ return `Allows ${parsed.action} access for ${humanizeResource(parsed.resource).toLowerCase()}`;
1112
968
  }
1113
969
  function isCollectionManifestEntry(objectDef) {
1114
- return objectDef?.extends === "SmrtCollection" || objectDef?.extendsTypeArg !== void 0;
970
+ return objectDef?.extends === "SmrtCollection" || objectDef?.extendsTypeArg !== void 0;
1115
971
  }
1116
972
  function getPublicCustomMethodNames(methodEntries, standardActions) {
1117
- return Array.from(
1118
- new Set(
1119
- methodEntries.filter(
1120
- (method) => Boolean(method?.name) && method?.isPublic === true && !standardActions.includes(method.name)
1121
- ).map((method) => method.name)
1122
- )
1123
- );
973
+ return Array.from(new Set(methodEntries.filter((method) => Boolean(method?.name) && method?.isPublic === true && !standardActions.includes(method.name)).map((method) => method.name)));
1124
974
  }
1125
975
  function getCustomMethodExposureNames(config, availableCustomMethods) {
1126
- if (!config || config === false) {
1127
- return /* @__PURE__ */ new Set();
1128
- }
1129
- if (config === true || typeof config !== "object") {
1130
- return new Set(availableCustomMethods);
1131
- }
1132
- const rawInclude = config.include;
1133
- const include = Array.isArray(rawInclude) ? [...rawInclude] : void 0;
1134
- const rawExclude = config.exclude;
1135
- const exclude = Array.isArray(rawExclude) ? [...rawExclude] : [];
1136
- if (!include) {
1137
- return new Set(
1138
- availableCustomMethods.filter(
1139
- (methodName) => !exclude.includes(methodName)
1140
- )
1141
- );
1142
- }
1143
- const baseMethods = include.filter(
1144
- (methodName) => availableCustomMethods.includes(methodName)
1145
- );
1146
- return new Set(
1147
- baseMethods.filter((methodName) => !exclude.includes(methodName))
1148
- );
976
+ if (!config || config === false) return /* @__PURE__ */ new Set();
977
+ if (config === true || typeof config !== "object") return new Set(availableCustomMethods);
978
+ const rawInclude = config.include;
979
+ const include = Array.isArray(rawInclude) ? [...rawInclude] : void 0;
980
+ const rawExclude = config.exclude;
981
+ const exclude = Array.isArray(rawExclude) ? [...rawExclude] : [];
982
+ if (!include) return new Set(availableCustomMethods.filter((methodName) => !exclude.includes(methodName)));
983
+ const baseMethods = include.filter((methodName) => availableCustomMethods.includes(methodName));
984
+ return new Set(baseMethods.filter((methodName) => !exclude.includes(methodName)));
1149
985
  }
1150
986
  function isOperationEnabled(config, action) {
1151
- if (config === false) {
1152
- return false;
1153
- }
1154
- if (config && typeof config === "object") {
1155
- const include = Array.isArray(config.include) ? config.include : void 0;
1156
- const rawExclude = config.exclude;
1157
- const exclude = Array.isArray(rawExclude) ? [...rawExclude] : [];
1158
- if (include && !include.includes(action)) {
1159
- return false;
1160
- }
1161
- if (exclude.includes(action)) {
1162
- return false;
1163
- }
1164
- }
1165
- return true;
987
+ if (config === false) return false;
988
+ if (config && typeof config === "object") {
989
+ const include = Array.isArray(config.include) ? config.include : void 0;
990
+ const rawExclude = config.exclude;
991
+ const exclude = Array.isArray(rawExclude) ? [...rawExclude] : [];
992
+ if (include && !include.includes(action)) return false;
993
+ if (exclude.includes(action)) return false;
994
+ }
995
+ return true;
1166
996
  }
1167
997
  function normalizePostgresAction(action) {
1168
- const normalized = action.toUpperCase();
1169
- if (normalized === "SELECT" || normalized === "INSERT" || normalized === "UPDATE" || normalized === "DELETE") {
1170
- return normalized;
1171
- }
1172
- throw new Error(
1173
- `Unsupported Postgres permission action '${action}'. Expected SELECT, INSERT, UPDATE, or DELETE.`
1174
- );
998
+ const normalized = action.toUpperCase();
999
+ if (normalized === "SELECT" || normalized === "INSERT" || normalized === "UPDATE" || normalized === "DELETE") return normalized;
1000
+ throw new Error(`Unsupported Postgres permission action '${action}'. Expected SELECT, INSERT, UPDATE, or DELETE.`);
1175
1001
  }
1176
1002
  function normalizeBinding(binding, fallbackPermission) {
1177
- return {
1178
- action: normalizePostgresAction(binding.action),
1179
- permission: binding.permission || fallbackPermission,
1180
- schemaName: binding.schemaName,
1181
- tableName: binding.tableName,
1182
- tenantField: binding.tenantField
1183
- };
1003
+ return {
1004
+ action: normalizePostgresAction(binding.action),
1005
+ permission: binding.permission || fallbackPermission,
1006
+ schemaName: binding.schemaName,
1007
+ tableName: binding.tableName,
1008
+ tenantField: binding.tenantField
1009
+ };
1184
1010
  }
1185
1011
  function mergeStringField(fieldName, existing, incoming, slug) {
1186
- const existingValue = existing[fieldName];
1187
- const incomingValue = incoming[fieldName];
1188
- if (!incomingValue) {
1189
- return;
1190
- }
1191
- if (!existingValue) {
1192
- existing[fieldName] = incomingValue;
1193
- return;
1194
- }
1195
- if (existingValue !== incomingValue) {
1196
- throw new Error(
1197
- `Conflicting permission metadata for '${slug}' field '${fieldName}': '${existingValue}' !== '${incomingValue}'`
1198
- );
1199
- }
1012
+ const existingValue = existing[fieldName];
1013
+ const incomingValue = incoming[fieldName];
1014
+ if (!incomingValue) return;
1015
+ if (!existingValue) {
1016
+ existing[fieldName] = incomingValue;
1017
+ return;
1018
+ }
1019
+ if (existingValue !== incomingValue) throw new Error(`Conflicting permission metadata for '${slug}' field '${fieldName}': '${existingValue}' !== '${incomingValue}'`);
1200
1020
  }
1201
1021
  function mergeBindings(existing, incoming) {
1202
- const existingBindings = existing.postgres?.bindings ?? [];
1203
- const incomingBindings = incoming.postgres?.bindings ?? [];
1204
- if (incomingBindings.length === 0) {
1205
- return;
1206
- }
1207
- const seen = new Set(
1208
- existingBindings.map(
1209
- (binding) => [
1210
- binding.permission,
1211
- binding.action,
1212
- binding.schemaName ?? "",
1213
- binding.tableName,
1214
- binding.tenantField ?? ""
1215
- ].join("|")
1216
- )
1217
- );
1218
- const mergedBindings = [...existingBindings];
1219
- for (const binding of incomingBindings) {
1220
- const normalized = normalizeBinding(binding, incoming.slug);
1221
- const key = [
1222
- normalized.permission,
1223
- normalized.action,
1224
- normalized.schemaName ?? "",
1225
- normalized.tableName,
1226
- normalized.tenantField ?? ""
1227
- ].join("|");
1228
- if (!seen.has(key)) {
1229
- seen.add(key);
1230
- mergedBindings.push(normalized);
1231
- }
1232
- }
1233
- existing.postgres = {
1234
- bindings: mergedBindings
1235
- };
1022
+ const existingBindings = existing.postgres?.bindings ?? [];
1023
+ const incomingBindings = incoming.postgres?.bindings ?? [];
1024
+ if (incomingBindings.length === 0) return;
1025
+ const seen = new Set(existingBindings.map((binding) => [
1026
+ binding.permission,
1027
+ binding.action,
1028
+ binding.schemaName ?? "",
1029
+ binding.tableName,
1030
+ binding.tenantField ?? ""
1031
+ ].join("|")));
1032
+ const mergedBindings = [...existingBindings];
1033
+ for (const binding of incomingBindings) {
1034
+ const normalized = normalizeBinding(binding, incoming.slug);
1035
+ const key = [
1036
+ normalized.permission,
1037
+ normalized.action,
1038
+ normalized.schemaName ?? "",
1039
+ normalized.tableName,
1040
+ normalized.tenantField ?? ""
1041
+ ].join("|");
1042
+ if (!seen.has(key)) {
1043
+ seen.add(key);
1044
+ mergedBindings.push(normalized);
1045
+ }
1046
+ }
1047
+ existing.postgres = { bindings: mergedBindings };
1236
1048
  }
1237
1049
  function normalizeDefinition(definition, source) {
1238
- if (!definition.slug || !isValidPermissionSlug(definition.slug.trim())) {
1239
- throw new Error(
1240
- `Invalid permission slug '${definition.slug}'. Expected 'resource.action'.`
1241
- );
1242
- }
1243
- const slug = definition.slug.trim();
1244
- return {
1245
- category: definition.category ?? parsePermissionSlug(slug).resource,
1246
- className: definition.className,
1247
- collection: definition.collection,
1248
- description: definition.description ?? defaultPermissionDescription(slug),
1249
- name: definition.name ?? defaultPermissionName(slug),
1250
- postgres: definition.postgres?.bindings ? {
1251
- bindings: definition.postgres.bindings.map(
1252
- (binding) => normalizeBinding(binding, slug)
1253
- )
1254
- } : void 0,
1255
- qualifiedName: definition.qualifiedName,
1256
- slug,
1257
- source
1258
- };
1050
+ if (!definition.slug || !isValidPermissionSlug(definition.slug.trim())) throw new Error(`Invalid permission slug '${definition.slug}'. Expected 'resource.action'.`);
1051
+ const slug = definition.slug.trim();
1052
+ return {
1053
+ category: definition.category ?? parsePermissionSlug(slug).resource,
1054
+ className: definition.className,
1055
+ collection: definition.collection,
1056
+ description: definition.description ?? defaultPermissionDescription(slug),
1057
+ name: definition.name ?? defaultPermissionName(slug),
1058
+ postgres: definition.postgres?.bindings ? { bindings: definition.postgres.bindings.map((binding) => normalizeBinding(binding, slug)) } : void 0,
1059
+ qualifiedName: definition.qualifiedName,
1060
+ slug,
1061
+ source
1062
+ };
1259
1063
  }
1260
1064
  function mergeDefinitionSet(current, incomingDefinitions, source) {
1261
- for (const rawDefinition of incomingDefinitions) {
1262
- const definition = normalizeDefinition(rawDefinition, source);
1263
- const existing = current.get(definition.slug);
1264
- if (!existing) {
1265
- current.set(definition.slug, definition);
1266
- continue;
1267
- }
1268
- mergeStringField("category", existing, definition, definition.slug);
1269
- mergeStringField("className", existing, definition, definition.slug);
1270
- mergeStringField("collection", existing, definition, definition.slug);
1271
- mergeStringField("description", existing, definition, definition.slug);
1272
- mergeStringField("name", existing, definition, definition.slug);
1273
- mergeStringField("qualifiedName", existing, definition, definition.slug);
1274
- mergeBindings(existing, definition);
1275
- }
1065
+ for (const rawDefinition of incomingDefinitions) {
1066
+ const definition = normalizeDefinition(rawDefinition, source);
1067
+ const existing = current.get(definition.slug);
1068
+ if (!existing) {
1069
+ current.set(definition.slug, definition);
1070
+ continue;
1071
+ }
1072
+ mergeStringField("category", existing, definition, definition.slug);
1073
+ mergeStringField("className", existing, definition, definition.slug);
1074
+ mergeStringField("collection", existing, definition, definition.slug);
1075
+ mergeStringField("description", existing, definition, definition.slug);
1076
+ mergeStringField("name", existing, definition, definition.slug);
1077
+ mergeStringField("qualifiedName", existing, definition, definition.slug);
1078
+ mergeBindings(existing, definition);
1079
+ }
1276
1080
  }
1277
1081
  function registerPermissionDefinitions(definitions) {
1278
- globalThis.__smrtUsersPermissionRegistrationCounter = (globalThis.__smrtUsersPermissionRegistrationCounter ?? 0) + 1;
1279
- const registrationId = globalThis.__smrtUsersPermissionRegistrationCounter;
1280
- getRuntimePermissionRegistrations().set(registrationId, definitions);
1281
- return () => {
1282
- getRuntimePermissionRegistrations().delete(registrationId);
1283
- };
1284
- }
1285
- class PermissionCatalogService {
1286
- constructor(options = {}) {
1287
- this.options = options;
1288
- }
1289
- options;
1290
- getUsersConfig() {
1291
- return getPackageConfig("users", {});
1292
- }
1293
- getRuntimePermissionDefinitions() {
1294
- return Array.from(getRuntimePermissionRegistrations().values()).flat();
1295
- }
1296
- getCustomPermissionDefinitions() {
1297
- return this.getUsersConfig().permissions?.custom ?? [];
1298
- }
1299
- getCatalog() {
1300
- const manifestPermissions = this.getManifestPermissionDefinitions();
1301
- const customPermissions = this.getCustomPermissionDefinitions();
1302
- const runtimePermissions = this.getRuntimePermissionDefinitions();
1303
- const merged = /* @__PURE__ */ new Map();
1304
- mergeDefinitionSet(merged, manifestPermissions, "manifest");
1305
- mergeDefinitionSet(merged, customPermissions, "config");
1306
- mergeDefinitionSet(merged, runtimePermissions, "runtime");
1307
- return {
1308
- customPermissions: customPermissions.map(
1309
- (definition) => normalizeDefinition(definition, "config")
1310
- ),
1311
- manifestPermissions: manifestPermissions.map(
1312
- (definition) => normalizeDefinition(definition, "manifest")
1313
- ),
1314
- permissions: Array.from(merged.values()).sort(
1315
- (left, right) => left.slug.localeCompare(right.slug)
1316
- ),
1317
- runtimePermissions: runtimePermissions.map(
1318
- (definition) => normalizeDefinition(definition, "runtime")
1319
- )
1320
- };
1321
- }
1322
- async syncPermissionCatalog() {
1323
- const catalog = this.getCatalog();
1324
- const permissions = await PermissionCollection.create(this.options);
1325
- const created = [];
1326
- const unchanged = [];
1327
- const updated = [];
1328
- for (const definition of catalog.permissions) {
1329
- const existing = await permissions.findBySlug(definition.slug);
1330
- if (!existing) {
1331
- const permission = await permissions.create({
1332
- category: definition.category ?? parsePermissionSlug(definition.slug).resource,
1333
- description: definition.description ?? "",
1334
- name: definition.name ?? definition.slug,
1335
- slug: definition.slug
1336
- });
1337
- await permission.save();
1338
- created.push(definition.slug);
1339
- continue;
1340
- }
1341
- const nextName = definition.name ?? existing.name;
1342
- const nextDescription = definition.description ?? existing.description;
1343
- const nextCategory = definition.category ?? existing.category;
1344
- if (existing.name === nextName && existing.description === nextDescription && existing.category === nextCategory) {
1345
- unchanged.push(definition.slug);
1346
- continue;
1347
- }
1348
- existing.name = nextName;
1349
- existing.description = nextDescription;
1350
- existing.category = nextCategory;
1351
- await existing.save();
1352
- updated.push(definition.slug);
1353
- }
1354
- return {
1355
- catalog,
1356
- created,
1357
- unchanged,
1358
- updated
1359
- };
1360
- }
1361
- getManifestPermissionDefinitions() {
1362
- const standardActions = ["list", "get", "create", "update", "delete"];
1363
- const definitions = /* @__PURE__ */ new Map();
1364
- for (const metadata of ObjectRegistry.getAllObjectMetadata()) {
1365
- const registered = ObjectRegistry.getClassByConstructor(metadata.constructor) ?? ObjectRegistry.getClass(metadata.name);
1366
- const manifestEntry = registered?.qualifiedName ? findManifestEntryByQualifiedName(registered.qualifiedName) : void 0;
1367
- if (isCollectionManifestEntry(manifestEntry)) {
1368
- continue;
1369
- }
1370
- const className = metadata.name;
1371
- const qualifiedName = registered?.qualifiedName;
1372
- const objectConfig = manifestEntry?.decoratorConfig ?? metadata.config;
1373
- const rawCollection = objectConfig?.collection;
1374
- const configuredCollection = typeof rawCollection === "string" && rawCollection.length > 0 ? rawCollection : void 0;
1375
- const collection = configuredCollection ?? manifestEntry?.collection ?? deriveCollectionName(metadata.name);
1376
- const readExposed = isOperationEnabled(objectConfig.api, "list") || isOperationEnabled(objectConfig.api, "get") || isOperationEnabled(objectConfig.cli, "list") || isOperationEnabled(objectConfig.cli, "get") || isOperationEnabled(objectConfig.mcp, "list") || isOperationEnabled(objectConfig.mcp, "get");
1377
- if (readExposed) {
1378
- definitions.set(`${collection}.read`, {
1379
- className,
1380
- collection,
1381
- qualifiedName,
1382
- slug: `${collection}.read`
1383
- });
1384
- }
1385
- for (const action of ["create", "update", "delete"]) {
1386
- const exposed = isOperationEnabled(objectConfig.api, action) || isOperationEnabled(objectConfig.cli, action) || isOperationEnabled(objectConfig.mcp, action);
1387
- if (!exposed) {
1388
- continue;
1389
- }
1390
- definitions.set(`${collection}.${action}`, {
1391
- className,
1392
- collection,
1393
- qualifiedName,
1394
- slug: `${collection}.${action}`
1395
- });
1396
- }
1397
- const methodEntries = manifestEntry?.methods ? Object.values(manifestEntry.methods) : Array.from(metadata.methods.values());
1398
- const publicCustomMethodNames = getPublicCustomMethodNames(
1399
- methodEntries,
1400
- standardActions
1401
- );
1402
- const customApiMethods = /* @__PURE__ */ new Set();
1403
- const customCliMethods = getCustomMethodExposureNames(
1404
- objectConfig.cli,
1405
- publicCustomMethodNames
1406
- );
1407
- const customMcpMethods = getCustomMethodExposureNames(
1408
- objectConfig.mcp,
1409
- publicCustomMethodNames
1410
- );
1411
- for (const methodName of publicCustomMethodNames) {
1412
- if (isOperationEnabled(objectConfig.api, methodName)) {
1413
- customApiMethods.add(methodName);
1414
- }
1415
- }
1416
- const customMethods = /* @__PURE__ */ new Set([
1417
- ...customApiMethods,
1418
- ...customCliMethods,
1419
- ...customMcpMethods
1420
- ]);
1421
- for (const methodName of customMethods) {
1422
- definitions.set(`${collection}.${methodName}`, {
1423
- className,
1424
- collection,
1425
- description: `Allows ${methodName} on ${humanizeResource(collection).toLowerCase()}`,
1426
- name: `${capitalize(methodName)} ${humanizeResource(collection)}`,
1427
- qualifiedName,
1428
- slug: `${collection}.${methodName}`
1429
- });
1430
- }
1431
- }
1432
- return Array.from(definitions.values()).sort(
1433
- (left, right) => left.slug.localeCompare(right.slug)
1434
- );
1435
- }
1436
- static create(options = {}) {
1437
- return new PermissionCatalogService(options);
1438
- }
1082
+ globalThis.__smrtUsersPermissionRegistrationCounter = (globalThis.__smrtUsersPermissionRegistrationCounter ?? 0) + 1;
1083
+ const registrationId = globalThis.__smrtUsersPermissionRegistrationCounter;
1084
+ getRuntimePermissionRegistrations().set(registrationId, definitions);
1085
+ return () => {
1086
+ getRuntimePermissionRegistrations().delete(registrationId);
1087
+ };
1439
1088
  }
1089
+ var PermissionCatalogService = class PermissionCatalogService {
1090
+ constructor(options = {}) {
1091
+ this.options = options;
1092
+ }
1093
+ options;
1094
+ getUsersConfig() {
1095
+ return getPackageConfig("users", {});
1096
+ }
1097
+ getRuntimePermissionDefinitions() {
1098
+ return Array.from(getRuntimePermissionRegistrations().values()).flat();
1099
+ }
1100
+ getCustomPermissionDefinitions() {
1101
+ return this.getUsersConfig().permissions?.custom ?? [];
1102
+ }
1103
+ getCatalog() {
1104
+ const manifestPermissions = this.getManifestPermissionDefinitions();
1105
+ const customPermissions = this.getCustomPermissionDefinitions();
1106
+ const runtimePermissions = this.getRuntimePermissionDefinitions();
1107
+ const merged = /* @__PURE__ */ new Map();
1108
+ mergeDefinitionSet(merged, manifestPermissions, "manifest");
1109
+ mergeDefinitionSet(merged, customPermissions, "config");
1110
+ mergeDefinitionSet(merged, runtimePermissions, "runtime");
1111
+ return {
1112
+ customPermissions: customPermissions.map((definition) => normalizeDefinition(definition, "config")),
1113
+ manifestPermissions: manifestPermissions.map((definition) => normalizeDefinition(definition, "manifest")),
1114
+ permissions: Array.from(merged.values()).sort((left, right) => left.slug.localeCompare(right.slug)),
1115
+ runtimePermissions: runtimePermissions.map((definition) => normalizeDefinition(definition, "runtime"))
1116
+ };
1117
+ }
1118
+ async syncPermissionCatalog() {
1119
+ const catalog = this.getCatalog();
1120
+ const permissions = await PermissionCollection.create(this.options);
1121
+ const created = [];
1122
+ const unchanged = [];
1123
+ const updated = [];
1124
+ for (const definition of catalog.permissions) {
1125
+ const existing = await permissions.findBySlug(definition.slug);
1126
+ if (!existing) {
1127
+ await (await permissions.create({
1128
+ category: definition.category ?? parsePermissionSlug(definition.slug).resource,
1129
+ description: definition.description ?? "",
1130
+ name: definition.name ?? definition.slug,
1131
+ slug: definition.slug
1132
+ })).save();
1133
+ created.push(definition.slug);
1134
+ continue;
1135
+ }
1136
+ const nextName = definition.name ?? existing.name;
1137
+ const nextDescription = definition.description ?? existing.description;
1138
+ const nextCategory = definition.category ?? existing.category;
1139
+ if (existing.name === nextName && existing.description === nextDescription && existing.category === nextCategory) {
1140
+ unchanged.push(definition.slug);
1141
+ continue;
1142
+ }
1143
+ existing.name = nextName;
1144
+ existing.description = nextDescription;
1145
+ existing.category = nextCategory;
1146
+ await existing.save();
1147
+ updated.push(definition.slug);
1148
+ }
1149
+ return {
1150
+ catalog,
1151
+ created,
1152
+ unchanged,
1153
+ updated
1154
+ };
1155
+ }
1156
+ getManifestPermissionDefinitions() {
1157
+ const standardActions = [
1158
+ "list",
1159
+ "get",
1160
+ "create",
1161
+ "update",
1162
+ "delete"
1163
+ ];
1164
+ const definitions = /* @__PURE__ */ new Map();
1165
+ for (const metadata of ObjectRegistry.getAllObjectMetadata()) {
1166
+ const registered = ObjectRegistry.getClassByConstructor(metadata.constructor) ?? ObjectRegistry.getClass(metadata.name);
1167
+ const manifestEntry = registered?.qualifiedName ? findManifestEntryByQualifiedName(registered.qualifiedName) : void 0;
1168
+ if (isCollectionManifestEntry(manifestEntry)) continue;
1169
+ const className = metadata.name;
1170
+ const qualifiedName = registered?.qualifiedName;
1171
+ const objectConfig = manifestEntry?.decoratorConfig ?? metadata.config;
1172
+ const rawCollection = objectConfig?.collection;
1173
+ const collection = (typeof rawCollection === "string" && rawCollection.length > 0 ? rawCollection : void 0) ?? manifestEntry?.collection ?? deriveCollectionName(metadata.name);
1174
+ if (isOperationEnabled(objectConfig.api, "list") || isOperationEnabled(objectConfig.api, "get") || isOperationEnabled(objectConfig.cli, "list") || isOperationEnabled(objectConfig.cli, "get") || isOperationEnabled(objectConfig.mcp, "list") || isOperationEnabled(objectConfig.mcp, "get")) definitions.set(`${collection}.read`, {
1175
+ className,
1176
+ collection,
1177
+ qualifiedName,
1178
+ slug: `${collection}.read`
1179
+ });
1180
+ for (const action of [
1181
+ "create",
1182
+ "update",
1183
+ "delete"
1184
+ ]) {
1185
+ if (!(isOperationEnabled(objectConfig.api, action) || isOperationEnabled(objectConfig.cli, action) || isOperationEnabled(objectConfig.mcp, action))) continue;
1186
+ definitions.set(`${collection}.${action}`, {
1187
+ className,
1188
+ collection,
1189
+ qualifiedName,
1190
+ slug: `${collection}.${action}`
1191
+ });
1192
+ }
1193
+ const publicCustomMethodNames = getPublicCustomMethodNames(manifestEntry?.methods ? Object.values(manifestEntry.methods) : Array.from(metadata.methods.values()), standardActions);
1194
+ const customApiMethods = /* @__PURE__ */ new Set();
1195
+ const customCliMethods = getCustomMethodExposureNames(objectConfig.cli, publicCustomMethodNames);
1196
+ const customMcpMethods = getCustomMethodExposureNames(objectConfig.mcp, publicCustomMethodNames);
1197
+ for (const methodName of publicCustomMethodNames) if (isOperationEnabled(objectConfig.api, methodName)) customApiMethods.add(methodName);
1198
+ const customMethods = /* @__PURE__ */ new Set([
1199
+ ...customApiMethods,
1200
+ ...customCliMethods,
1201
+ ...customMcpMethods
1202
+ ]);
1203
+ for (const methodName of customMethods) definitions.set(`${collection}.${methodName}`, {
1204
+ className,
1205
+ collection,
1206
+ description: `Allows ${methodName} on ${humanizeResource(collection).toLowerCase()}`,
1207
+ name: `${capitalize(methodName)} ${humanizeResource(collection)}`,
1208
+ qualifiedName,
1209
+ slug: `${collection}.${methodName}`
1210
+ });
1211
+ }
1212
+ return Array.from(definitions.values()).sort((left, right) => left.slug.localeCompare(right.slug));
1213
+ }
1214
+ static create(options = {}) {
1215
+ return new PermissionCatalogService(options);
1216
+ }
1217
+ };
1440
1218
  async function syncPermissionCatalog(options = {}) {
1441
- return PermissionCatalogService.create(options).syncPermissionCatalog();
1219
+ return PermissionCatalogService.create(options).syncPermissionCatalog();
1442
1220
  }
1221
+ //#endregion
1222
+ //#region src/services/PostgresPermissionPolicies.ts
1443
1223
  function toSnakeCase(value) {
1444
- return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toLowerCase();
1224
+ return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toLowerCase();
1445
1225
  }
1446
1226
  function quoteIdent(identifier) {
1447
- return `"${identifier.replaceAll('"', '""')}"`;
1227
+ return `"${identifier.replaceAll("\"", "\"\"")}"`;
1448
1228
  }
1449
1229
  function quoteLiteral(value) {
1450
- return `'${value.replaceAll("'", "''")}'`;
1230
+ return `'${value.replaceAll("'", "''")}'`;
1451
1231
  }
1452
1232
  function isProbablyPostgres(configDb, database) {
1453
- if (configDb && typeof configDb === "object" && !("query" in configDb) && "type" in configDb && configDb.type === "postgres") {
1454
- return true;
1455
- }
1456
- if (typeof database.url === "string" && database.url.startsWith("postgres")) {
1457
- return true;
1458
- }
1459
- return (database.constructor?.name || "").toLowerCase().includes("postgres");
1233
+ if (configDb && typeof configDb === "object" && !("query" in configDb) && "type" in configDb && configDb.type === "postgres") return true;
1234
+ if (typeof database.url === "string" && database.url.startsWith("postgres")) return true;
1235
+ return (database.constructor?.name || "").toLowerCase().includes("postgres");
1460
1236
  }
1461
1237
  function normalizePostgresPermissionAction(action) {
1462
- const normalized = action.toUpperCase();
1463
- if (normalized === "SELECT" || normalized === "INSERT" || normalized === "UPDATE" || normalized === "DELETE") {
1464
- return normalized;
1465
- }
1466
- throw new Error(
1467
- `Invalid Postgres permission binding action "${action}". Expected one of SELECT, INSERT, UPDATE, DELETE.`
1468
- );
1238
+ const normalized = action.toUpperCase();
1239
+ if (normalized === "SELECT" || normalized === "INSERT" || normalized === "UPDATE" || normalized === "DELETE") return normalized;
1240
+ throw new Error(`Invalid Postgres permission binding action "${action}". Expected one of SELECT, INSERT, UPDATE, DELETE.`);
1469
1241
  }
1470
1242
  function normalizePostgresPermissionBinding(binding, fallbackPermission) {
1471
- const tableName = binding.tableName?.trim();
1472
- if (!tableName) {
1473
- throw new Error(
1474
- "Postgres permission binding is missing a tableName value."
1475
- );
1476
- }
1477
- const permission = binding.permission ?? fallbackPermission;
1478
- if (!permission) {
1479
- throw new Error(
1480
- "Postgres permission binding is missing a permission value."
1481
- );
1482
- }
1483
- return {
1484
- action: normalizePostgresPermissionAction(binding.action),
1485
- permission,
1486
- schemaName: binding.schemaName,
1487
- tableName,
1488
- tenantField: binding.tenantField
1489
- };
1243
+ const tableName = binding.tableName?.trim();
1244
+ if (!tableName) throw new Error("Postgres permission binding is missing a tableName value.");
1245
+ const permission = binding.permission ?? fallbackPermission;
1246
+ if (!permission) throw new Error("Postgres permission binding is missing a permission value.");
1247
+ return {
1248
+ action: normalizePostgresPermissionAction(binding.action),
1249
+ permission,
1250
+ schemaName: binding.schemaName,
1251
+ tableName,
1252
+ tenantField: binding.tenantField
1253
+ };
1490
1254
  }
1491
1255
  function parseTableReference(binding) {
1492
- if (binding.tableName.includes(".")) {
1493
- const [schemaName, tableName] = binding.tableName.split(".", 2);
1494
- return {
1495
- schemaName,
1496
- tableName
1497
- };
1498
- }
1499
- return {
1500
- schemaName: binding.schemaName ?? "public",
1501
- tableName: binding.tableName
1502
- };
1256
+ if (binding.tableName.includes(".")) {
1257
+ const [schemaName, tableName] = binding.tableName.split(".", 2);
1258
+ return {
1259
+ schemaName,
1260
+ tableName
1261
+ };
1262
+ }
1263
+ return {
1264
+ schemaName: binding.schemaName ?? "public",
1265
+ tableName: binding.tableName
1266
+ };
1503
1267
  }
1504
1268
  function buildPolicyName(tableName, action) {
1505
- const actionSegment = action.toLowerCase();
1506
- const hash = createHash("sha1").update(`${tableName}:${action}`).digest("hex").slice(0, 8);
1507
- const sanitizedTable = tableName.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
1508
- const prefix = "smrt_";
1509
- const separatorLength = 2;
1510
- const maxTableSegmentLength = 63 - prefix.length - actionSegment.length - hash.length - separatorLength;
1511
- const tableSegment = (sanitizedTable || "table").slice(
1512
- 0,
1513
- Math.max(maxTableSegmentLength, 1)
1514
- );
1515
- return `${prefix}${tableSegment}_${actionSegment}_${hash}`;
1269
+ const actionSegment = action.toLowerCase();
1270
+ const hash = createHash("sha1").update(`${tableName}:${action}`).digest("hex").slice(0, 8);
1271
+ const sanitizedTable = tableName.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
1272
+ const prefix = "smrt_";
1273
+ const maxTableSegmentLength = 58 - actionSegment.length - hash.length - 2;
1274
+ return `${prefix}${(sanitizedTable || "table").slice(0, Math.max(maxTableSegmentLength, 1))}_${actionSegment}_${hash}`;
1516
1275
  }
1517
1276
  function buildPermissionExpression(permissionSlugs) {
1518
- if (permissionSlugs.length === 0) {
1519
- return "FALSE";
1520
- }
1521
- return permissionSlugs.map((permission) => `smrt_has_permission(${quoteLiteral(permission)})`).join(" OR ");
1277
+ if (permissionSlugs.length === 0) return "FALSE";
1278
+ return permissionSlugs.map((permission) => `smrt_has_permission(${quoteLiteral(permission)})`).join(" OR ");
1522
1279
  }
1523
1280
  function buildTenantMatchExpression(tenantField) {
1524
- return `${quoteIdent(tenantField)}::text = smrt_current_tenant_id()`;
1281
+ return `${quoteIdent(tenantField)}::text = smrt_current_tenant_id()`;
1525
1282
  }
1526
1283
  function buildSelectPolicySql(target, permissions) {
1527
- const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
1528
- const policyName = buildPolicyName(target.tableName, "SELECT");
1529
- const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
1530
- return [
1531
- `DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
1532
- `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR SELECT USING (${condition})`
1533
- ];
1284
+ const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
1285
+ const policyName = buildPolicyName(target.tableName, "SELECT");
1286
+ const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
1287
+ return [`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`, `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR SELECT USING (${condition})`];
1534
1288
  }
1535
1289
  function buildInsertPolicySql(target, permissions) {
1536
- const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
1537
- const policyName = buildPolicyName(target.tableName, "INSERT");
1538
- const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
1539
- return [
1540
- `DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
1541
- `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR INSERT WITH CHECK (${condition})`
1542
- ];
1290
+ const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
1291
+ const policyName = buildPolicyName(target.tableName, "INSERT");
1292
+ const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
1293
+ return [`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`, `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR INSERT WITH CHECK (${condition})`];
1543
1294
  }
1544
1295
  function buildUpdatePolicySql(target, permissions) {
1545
- const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
1546
- const policyName = buildPolicyName(target.tableName, "UPDATE");
1547
- const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
1548
- return [
1549
- `DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
1550
- `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR UPDATE USING (${condition}) WITH CHECK (${condition})`
1551
- ];
1296
+ const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
1297
+ const policyName = buildPolicyName(target.tableName, "UPDATE");
1298
+ const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
1299
+ return [`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`, `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR UPDATE USING (${condition}) WITH CHECK (${condition})`];
1552
1300
  }
1553
1301
  function buildDeletePolicySql(target, permissions) {
1554
- const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
1555
- const policyName = buildPolicyName(target.tableName, "DELETE");
1556
- const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
1557
- return [
1558
- `DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
1559
- `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR DELETE USING (${condition})`
1560
- ];
1302
+ const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
1303
+ const policyName = buildPolicyName(target.tableName, "DELETE");
1304
+ const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
1305
+ return [`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`, `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR DELETE USING (${condition})`];
1561
1306
  }
1562
1307
  function buildHelperStatements() {
1563
- return [
1564
- [
1565
- "CREATE OR REPLACE FUNCTION smrt_rls_bypass()",
1566
- "RETURNS boolean",
1567
- "LANGUAGE sql",
1568
- "STABLE",
1569
- "AS $$",
1570
- " SELECT COALESCE(NULLIF(current_setting('smrt.system_context', true), ''), 'false')::boolean",
1571
- " OR COALESCE(NULLIF(current_setting('smrt.super_admin_bypass', true), ''), 'false')::boolean",
1572
- "$$"
1573
- ].join("\n"),
1574
- [
1575
- "CREATE OR REPLACE FUNCTION smrt_current_tenant_id()",
1576
- "RETURNS text",
1577
- "LANGUAGE sql",
1578
- "STABLE",
1579
- "AS $$",
1580
- " SELECT NULLIF(current_setting('smrt.tenant_id', true), '')",
1581
- "$$"
1582
- ].join("\n"),
1583
- [
1584
- "CREATE OR REPLACE FUNCTION smrt_has_permission(required_permission text)",
1585
- "RETURNS boolean",
1586
- "LANGUAGE sql",
1587
- "STABLE",
1588
- "AS $$",
1589
- " SELECT smrt_rls_bypass()",
1590
- " OR jsonb_exists(COALESCE(NULLIF(current_setting('smrt.permissions', true), ''), '[]')::jsonb, required_permission)",
1591
- "$$"
1592
- ].join("\n")
1593
- ];
1308
+ return [
1309
+ [
1310
+ "CREATE OR REPLACE FUNCTION smrt_rls_bypass()",
1311
+ "RETURNS boolean",
1312
+ "LANGUAGE sql",
1313
+ "STABLE",
1314
+ "AS $$",
1315
+ " SELECT COALESCE(NULLIF(current_setting('smrt.system_context', true), ''), 'false')::boolean",
1316
+ " OR COALESCE(NULLIF(current_setting('smrt.super_admin_bypass', true), ''), 'false')::boolean",
1317
+ "$$"
1318
+ ].join("\n"),
1319
+ [
1320
+ "CREATE OR REPLACE FUNCTION smrt_current_tenant_id()",
1321
+ "RETURNS text",
1322
+ "LANGUAGE sql",
1323
+ "STABLE",
1324
+ "AS $$",
1325
+ " SELECT NULLIF(current_setting('smrt.tenant_id', true), '')",
1326
+ "$$"
1327
+ ].join("\n"),
1328
+ [
1329
+ "CREATE OR REPLACE FUNCTION smrt_has_permission(required_permission text)",
1330
+ "RETURNS boolean",
1331
+ "LANGUAGE sql",
1332
+ "STABLE",
1333
+ "AS $$",
1334
+ " SELECT smrt_rls_bypass()",
1335
+ " OR jsonb_exists(COALESCE(NULLIF(current_setting('smrt.permissions', true), ''), '[]')::jsonb, required_permission)",
1336
+ "$$"
1337
+ ].join("\n")
1338
+ ];
1594
1339
  }
1595
1340
  function addBindingToTarget(targets, binding, source = {}) {
1596
- const { schemaName, tableName } = parseTableReference(binding);
1597
- const targetKey = `${schemaName}.${tableName}`;
1598
- const existing = targets.get(targetKey);
1599
- const tenantField = binding.tenantField ?? "tenant_id";
1600
- if (existing && existing.tenantField !== tenantField) {
1601
- throw new Error(
1602
- `Conflicting tenant fields for table '${targetKey}': '${existing.tenantField}' !== '${tenantField}'`
1603
- );
1604
- }
1605
- const target = existing ?? {
1606
- actions: /* @__PURE__ */ new Map(),
1607
- ...source,
1608
- schemaName,
1609
- tableName,
1610
- tenantField
1611
- };
1612
- const action = normalizePostgresPermissionAction(binding.action);
1613
- const permissions = target.actions.get(action) ?? /* @__PURE__ */ new Set();
1614
- if (binding.permission) {
1615
- permissions.add(binding.permission);
1616
- }
1617
- target.actions.set(action, permissions);
1618
- targets.set(targetKey, target);
1341
+ const { schemaName, tableName } = parseTableReference(binding);
1342
+ const targetKey = `${schemaName}.${tableName}`;
1343
+ const existing = targets.get(targetKey);
1344
+ const tenantField = binding.tenantField ?? "tenant_id";
1345
+ if (existing && existing.tenantField !== tenantField) throw new Error(`Conflicting tenant fields for table '${targetKey}': '${existing.tenantField}' !== '${tenantField}'`);
1346
+ const target = existing ?? {
1347
+ actions: /* @__PURE__ */ new Map(),
1348
+ ...source,
1349
+ schemaName,
1350
+ tableName,
1351
+ tenantField
1352
+ };
1353
+ const action = normalizePostgresPermissionAction(binding.action);
1354
+ const permissions = target.actions.get(action) ?? /* @__PURE__ */ new Set();
1355
+ if (binding.permission) permissions.add(binding.permission);
1356
+ target.actions.set(action, permissions);
1357
+ targets.set(targetKey, target);
1619
1358
  }
1620
1359
  function generatePostgresPermissionSql(options = {}) {
1621
- const catalogService = PermissionCatalogService.create(options);
1622
- const catalog = catalogService.getCatalog();
1623
- const config = catalogService.getUsersConfig();
1624
- const candidateTargets = /* @__PURE__ */ new Map();
1625
- const skipped = [];
1626
- const autoCandidates = /* @__PURE__ */ new Map();
1627
- for (const metadata of ObjectRegistry.getAllObjectMetadata()) {
1628
- const registered = ObjectRegistry.getClassByConstructor(metadata.constructor) ?? ObjectRegistry.getClass(metadata.name);
1629
- const tenantScoped = registered?.tenantScopedConfig;
1630
- const manifestEntry = registered?.qualifiedName ? findManifestEntryByQualifiedName(registered.qualifiedName) : void 0;
1631
- if (!tenantScoped) {
1632
- skipped.push({
1633
- className: metadata.name,
1634
- qualifiedName: registered?.qualifiedName,
1635
- reason: "not tenant-scoped"
1636
- });
1637
- continue;
1638
- }
1639
- if (tenantScoped.mode !== "required") {
1640
- skipped.push({
1641
- className: metadata.name,
1642
- qualifiedName: registered?.qualifiedName,
1643
- reason: `tenant mode '${tenantScoped.mode}' is not supported for automatic Postgres RLS generation`
1644
- });
1645
- continue;
1646
- }
1647
- const rawTableName = registered?.schema?.tableName ?? manifestEntry?.schema?.tableName;
1648
- if (!rawTableName) {
1649
- skipped.push({
1650
- className: metadata.name,
1651
- qualifiedName: registered?.qualifiedName,
1652
- reason: "no schema table name available"
1653
- });
1654
- continue;
1655
- }
1656
- const parsedTable = parseTableReference({
1657
- tableName: rawTableName
1658
- });
1659
- const tableKey = `${parsedTable.schemaName}.${parsedTable.tableName}`;
1660
- const objectConfig = manifestEntry?.decoratorConfig ?? metadata.config;
1661
- const rawCollection = objectConfig?.collection;
1662
- const configuredCollection = typeof rawCollection === "string" && rawCollection.length > 0 ? rawCollection : void 0;
1663
- const collection = configuredCollection ?? manifestEntry?.collection ?? `${toSnakeCase(metadata.name)}s`;
1664
- const entries = autoCandidates.get(tableKey) ?? [];
1665
- entries.push({
1666
- className: metadata.name,
1667
- collection,
1668
- qualifiedName: registered?.qualifiedName,
1669
- schemaName: parsedTable.schemaName,
1670
- tableName: parsedTable.tableName,
1671
- tenantField: toSnakeCase(tenantScoped.field)
1672
- });
1673
- autoCandidates.set(tableKey, entries);
1674
- }
1675
- for (const [tableKey, entries] of autoCandidates) {
1676
- if (entries.length > 1) {
1677
- for (const entry2 of entries) {
1678
- skipped.push({
1679
- className: entry2.className,
1680
- collection: entry2.collection,
1681
- qualifiedName: entry2.qualifiedName,
1682
- reason: `table '${tableKey}' is shared by multiple objects, so automatic policy generation was skipped`,
1683
- schemaName: entry2.schemaName,
1684
- tableName: entry2.tableName
1685
- });
1686
- }
1687
- continue;
1688
- }
1689
- const entry = entries[0];
1690
- addBindingToTarget(
1691
- candidateTargets,
1692
- {
1693
- action: "SELECT",
1694
- permission: `${entry.collection}.read`,
1695
- schemaName: entry.schemaName,
1696
- tableName: entry.tableName,
1697
- tenantField: entry.tenantField
1698
- },
1699
- entry
1700
- );
1701
- addBindingToTarget(
1702
- candidateTargets,
1703
- {
1704
- action: "INSERT",
1705
- permission: `${entry.collection}.create`,
1706
- schemaName: entry.schemaName,
1707
- tableName: entry.tableName,
1708
- tenantField: entry.tenantField
1709
- },
1710
- entry
1711
- );
1712
- addBindingToTarget(
1713
- candidateTargets,
1714
- {
1715
- action: "UPDATE",
1716
- permission: `${entry.collection}.update`,
1717
- schemaName: entry.schemaName,
1718
- tableName: entry.tableName,
1719
- tenantField: entry.tenantField
1720
- },
1721
- entry
1722
- );
1723
- addBindingToTarget(
1724
- candidateTargets,
1725
- {
1726
- action: "DELETE",
1727
- permission: `${entry.collection}.delete`,
1728
- schemaName: entry.schemaName,
1729
- tableName: entry.tableName,
1730
- tenantField: entry.tenantField
1731
- },
1732
- entry
1733
- );
1734
- }
1735
- const explicitBindings = [];
1736
- for (const definition of catalog.permissions) {
1737
- for (const binding of definition.postgres?.bindings ?? []) {
1738
- explicitBindings.push(
1739
- normalizePostgresPermissionBinding(binding, definition.slug)
1740
- );
1741
- }
1742
- }
1743
- for (const binding of config.permissions?.postgres?.bindings ?? []) {
1744
- explicitBindings.push(normalizePostgresPermissionBinding(binding));
1745
- }
1746
- for (const binding of explicitBindings) {
1747
- addBindingToTarget(candidateTargets, binding);
1748
- }
1749
- const statements = [...buildHelperStatements()];
1750
- const targets = Array.from(candidateTargets.values()).sort(
1751
- (left, right) => `${left.schemaName}.${left.tableName}`.localeCompare(
1752
- `${right.schemaName}.${right.tableName}`
1753
- )
1754
- ).map((target) => ({
1755
- actions: Object.fromEntries(
1756
- Array.from(target.actions.entries()).map(([action, permissions]) => [
1757
- action,
1758
- Array.from(permissions).sort()
1759
- ])
1760
- ),
1761
- className: target.className,
1762
- collection: target.collection,
1763
- qualifiedName: target.qualifiedName,
1764
- schemaName: target.schemaName,
1765
- tableName: target.tableName,
1766
- tenantField: target.tenantField
1767
- }));
1768
- for (const target of Array.from(candidateTargets.values())) {
1769
- const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
1770
- statements.push(`ALTER TABLE ${qualifiedTable} ENABLE ROW LEVEL SECURITY`);
1771
- statements.push(`ALTER TABLE ${qualifiedTable} FORCE ROW LEVEL SECURITY`);
1772
- const selectPermissions = Array.from(
1773
- target.actions.get("SELECT") ?? []
1774
- ).sort();
1775
- const insertPermissions = Array.from(
1776
- target.actions.get("INSERT") ?? []
1777
- ).sort();
1778
- const updatePermissions = Array.from(
1779
- target.actions.get("UPDATE") ?? []
1780
- ).sort();
1781
- const deletePermissions = Array.from(
1782
- target.actions.get("DELETE") ?? []
1783
- ).sort();
1784
- if (selectPermissions.length > 0) {
1785
- statements.push(...buildSelectPolicySql(target, selectPermissions));
1786
- }
1787
- if (insertPermissions.length > 0) {
1788
- statements.push(...buildInsertPolicySql(target, insertPermissions));
1789
- }
1790
- if (updatePermissions.length > 0) {
1791
- statements.push(...buildUpdatePolicySql(target, updatePermissions));
1792
- }
1793
- if (deletePermissions.length > 0) {
1794
- statements.push(...buildDeletePolicySql(target, deletePermissions));
1795
- }
1796
- }
1797
- return {
1798
- bindings: explicitBindings,
1799
- skipped,
1800
- sql: `${statements.join(";\n")};
1360
+ const catalogService = PermissionCatalogService.create(options);
1361
+ const catalog = catalogService.getCatalog();
1362
+ const config = catalogService.getUsersConfig();
1363
+ const candidateTargets = /* @__PURE__ */ new Map();
1364
+ const skipped = [];
1365
+ const autoCandidates = /* @__PURE__ */ new Map();
1366
+ for (const metadata of ObjectRegistry.getAllObjectMetadata()) {
1367
+ const registered = ObjectRegistry.getClassByConstructor(metadata.constructor) ?? ObjectRegistry.getClass(metadata.name);
1368
+ const tenantScoped = registered?.tenantScopedConfig;
1369
+ const manifestEntry = registered?.qualifiedName ? findManifestEntryByQualifiedName(registered.qualifiedName) : void 0;
1370
+ if (!tenantScoped) {
1371
+ skipped.push({
1372
+ className: metadata.name,
1373
+ qualifiedName: registered?.qualifiedName,
1374
+ reason: "not tenant-scoped"
1375
+ });
1376
+ continue;
1377
+ }
1378
+ if (tenantScoped.mode !== "required") {
1379
+ skipped.push({
1380
+ className: metadata.name,
1381
+ qualifiedName: registered?.qualifiedName,
1382
+ reason: `tenant mode '${tenantScoped.mode}' is not supported for automatic Postgres RLS generation`
1383
+ });
1384
+ continue;
1385
+ }
1386
+ const rawTableName = registered?.schema?.tableName ?? manifestEntry?.schema?.tableName;
1387
+ if (!rawTableName) {
1388
+ skipped.push({
1389
+ className: metadata.name,
1390
+ qualifiedName: registered?.qualifiedName,
1391
+ reason: "no schema table name available"
1392
+ });
1393
+ continue;
1394
+ }
1395
+ const parsedTable = parseTableReference({ tableName: rawTableName });
1396
+ const tableKey = `${parsedTable.schemaName}.${parsedTable.tableName}`;
1397
+ const rawCollection = (manifestEntry?.decoratorConfig ?? metadata.config)?.collection;
1398
+ const collection = (typeof rawCollection === "string" && rawCollection.length > 0 ? rawCollection : void 0) ?? manifestEntry?.collection ?? `${toSnakeCase(metadata.name)}s`;
1399
+ const entries = autoCandidates.get(tableKey) ?? [];
1400
+ entries.push({
1401
+ className: metadata.name,
1402
+ collection,
1403
+ qualifiedName: registered?.qualifiedName,
1404
+ schemaName: parsedTable.schemaName,
1405
+ tableName: parsedTable.tableName,
1406
+ tenantField: toSnakeCase(tenantScoped.field)
1407
+ });
1408
+ autoCandidates.set(tableKey, entries);
1409
+ }
1410
+ for (const [tableKey, entries] of autoCandidates) {
1411
+ if (entries.length > 1) {
1412
+ for (const entry2 of entries) skipped.push({
1413
+ className: entry2.className,
1414
+ collection: entry2.collection,
1415
+ qualifiedName: entry2.qualifiedName,
1416
+ reason: `table '${tableKey}' is shared by multiple objects, so automatic policy generation was skipped`,
1417
+ schemaName: entry2.schemaName,
1418
+ tableName: entry2.tableName
1419
+ });
1420
+ continue;
1421
+ }
1422
+ const entry = entries[0];
1423
+ addBindingToTarget(candidateTargets, {
1424
+ action: "SELECT",
1425
+ permission: `${entry.collection}.read`,
1426
+ schemaName: entry.schemaName,
1427
+ tableName: entry.tableName,
1428
+ tenantField: entry.tenantField
1429
+ }, entry);
1430
+ addBindingToTarget(candidateTargets, {
1431
+ action: "INSERT",
1432
+ permission: `${entry.collection}.create`,
1433
+ schemaName: entry.schemaName,
1434
+ tableName: entry.tableName,
1435
+ tenantField: entry.tenantField
1436
+ }, entry);
1437
+ addBindingToTarget(candidateTargets, {
1438
+ action: "UPDATE",
1439
+ permission: `${entry.collection}.update`,
1440
+ schemaName: entry.schemaName,
1441
+ tableName: entry.tableName,
1442
+ tenantField: entry.tenantField
1443
+ }, entry);
1444
+ addBindingToTarget(candidateTargets, {
1445
+ action: "DELETE",
1446
+ permission: `${entry.collection}.delete`,
1447
+ schemaName: entry.schemaName,
1448
+ tableName: entry.tableName,
1449
+ tenantField: entry.tenantField
1450
+ }, entry);
1451
+ }
1452
+ const explicitBindings = [];
1453
+ for (const definition of catalog.permissions) for (const binding of definition.postgres?.bindings ?? []) explicitBindings.push(normalizePostgresPermissionBinding(binding, definition.slug));
1454
+ for (const binding of config.permissions?.postgres?.bindings ?? []) explicitBindings.push(normalizePostgresPermissionBinding(binding));
1455
+ for (const binding of explicitBindings) addBindingToTarget(candidateTargets, binding);
1456
+ const statements = [...buildHelperStatements()];
1457
+ const targets = Array.from(candidateTargets.values()).sort((left, right) => `${left.schemaName}.${left.tableName}`.localeCompare(`${right.schemaName}.${right.tableName}`)).map((target) => ({
1458
+ actions: Object.fromEntries(Array.from(target.actions.entries()).map(([action, permissions]) => [action, Array.from(permissions).sort()])),
1459
+ className: target.className,
1460
+ collection: target.collection,
1461
+ qualifiedName: target.qualifiedName,
1462
+ schemaName: target.schemaName,
1463
+ tableName: target.tableName,
1464
+ tenantField: target.tenantField
1465
+ }));
1466
+ for (const target of Array.from(candidateTargets.values())) {
1467
+ const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
1468
+ statements.push(`ALTER TABLE ${qualifiedTable} ENABLE ROW LEVEL SECURITY`);
1469
+ statements.push(`ALTER TABLE ${qualifiedTable} FORCE ROW LEVEL SECURITY`);
1470
+ const selectPermissions = Array.from(target.actions.get("SELECT") ?? []).sort();
1471
+ const insertPermissions = Array.from(target.actions.get("INSERT") ?? []).sort();
1472
+ const updatePermissions = Array.from(target.actions.get("UPDATE") ?? []).sort();
1473
+ const deletePermissions = Array.from(target.actions.get("DELETE") ?? []).sort();
1474
+ if (selectPermissions.length > 0) statements.push(...buildSelectPolicySql(target, selectPermissions));
1475
+ if (insertPermissions.length > 0) statements.push(...buildInsertPolicySql(target, insertPermissions));
1476
+ if (updatePermissions.length > 0) statements.push(...buildUpdatePolicySql(target, updatePermissions));
1477
+ if (deletePermissions.length > 0) statements.push(...buildDeletePolicySql(target, deletePermissions));
1478
+ }
1479
+ return {
1480
+ bindings: explicitBindings,
1481
+ skipped,
1482
+ sql: `${statements.join(";\n")};
1801
1483
  `,
1802
- statements,
1803
- targets
1804
- };
1484
+ statements,
1485
+ targets
1486
+ };
1805
1487
  }
1806
1488
  async function applyPostgresPermissionPolicies(options = {}) {
1807
- const permissions = await PermissionCollection.create(options);
1808
- const databaseOptions = options.db ?? options.persistence;
1809
- if (!isProbablyPostgres(databaseOptions, permissions.db)) {
1810
- throw new Error(
1811
- "applyPostgresPermissionPolicies() requires a Postgres database connection."
1812
- );
1813
- }
1814
- const result = generatePostgresPermissionSql(options);
1815
- for (const statement of result.statements) {
1816
- try {
1817
- await permissions.db.query(statement);
1818
- } catch (error) {
1819
- throw new Error(
1820
- `Failed to apply Postgres permission policy statement:
1821
- ${statement}`,
1822
- {
1823
- cause: error
1824
- }
1825
- );
1826
- }
1827
- }
1828
- return result;
1829
- }
1830
- class TenantService {
1831
- options;
1832
- policy;
1833
- tenantCollection;
1834
- membershipCollection;
1835
- roleCollection;
1836
- constructor(options, policy) {
1837
- this.options = options;
1838
- this.policy = policy ?? DEFAULT_TENANT_POLICY;
1839
- }
1840
- /**
1841
- * Initialize collections
1842
- */
1843
- async initialize() {
1844
- this.tenantCollection = await TenantCollection.create(this.options);
1845
- this.membershipCollection = await MembershipCollection.create(this.options);
1846
- this.roleCollection = await RoleCollection.create(this.options);
1847
- await this.roleCollection.seedSystemRoles();
1848
- }
1849
- /**
1850
- * Get the current policy
1851
- */
1852
- getPolicy() {
1853
- return { ...this.policy };
1854
- }
1855
- /**
1856
- * Create a tenant and make the user the owner
1857
- *
1858
- * @param userId - The user to make owner
1859
- * @param name - Tenant name
1860
- * @param options - Optional slug override
1861
- * @returns The created tenant and membership
1862
- */
1863
- async createTenantWithOwnership(userId, name, options) {
1864
- if (!await this.canCreateTenant(userId)) {
1865
- throw new Error(
1866
- `User has reached maximum tenant limit (${this.policy.maxTenants})`
1867
- );
1868
- }
1869
- const tenant = await this.tenantCollection.create({
1870
- name,
1871
- slug: options?.slug
1872
- });
1873
- await tenant.save();
1874
- const ownerRole = await this.roleCollection.findBySlug(
1875
- DEFAULT_ROLE_SLUGS.OWNER
1876
- );
1877
- if (!ownerRole) {
1878
- throw new Error("Owner role not found - run seedSystemRoles first");
1879
- }
1880
- const membership = await this.membershipCollection.create({
1881
- userId,
1882
- tenantId: tenant.id,
1883
- roleId: ownerRole.id,
1884
- status: MembershipStatus.ACTIVE
1885
- });
1886
- await membership.save();
1887
- return { tenant, membership };
1888
- }
1889
- /**
1890
- * Check if a user can create a new tenant
1891
- *
1892
- * Returns false if maxTenants limit is reached (0 = unlimited)
1893
- */
1894
- async canCreateTenant(userId) {
1895
- if (this.policy.maxTenants === 0) {
1896
- return true;
1897
- }
1898
- const ownerRole = await this.roleCollection.findBySlug(
1899
- DEFAULT_ROLE_SLUGS.OWNER
1900
- );
1901
- if (!ownerRole) {
1902
- return false;
1903
- }
1904
- const memberships = await this.membershipCollection.findActiveByUser(userId);
1905
- const ownedCount = memberships.filter(
1906
- (m2) => m2.roleId === ownerRole.id
1907
- ).length;
1908
- return ownedCount < this.policy.maxTenants;
1909
- }
1910
- /**
1911
- * Get a specific error message explaining why a tenant cannot be deleted.
1912
- *
1913
- * @returns Error message if deletion is not allowed, or null if allowed.
1914
- */
1915
- async getDeleteTenantError(userId, tenantId) {
1916
- const membership = await this.membershipCollection.findByUserAndTenant(
1917
- userId,
1918
- tenantId
1919
- );
1920
- if (!membership || membership.status !== MembershipStatus.ACTIVE) {
1921
- return "You are not a member of this tenant or it does not exist.";
1922
- }
1923
- const ownerRole = await this.roleCollection.findBySlug(
1924
- DEFAULT_ROLE_SLUGS.OWNER
1925
- );
1926
- if (!ownerRole) {
1927
- return "Owner role is not configured. Cannot determine deletion permissions.";
1928
- }
1929
- if (membership.roleId !== ownerRole.id) {
1930
- return "Only the tenant owner can delete this tenant.";
1931
- }
1932
- if (this.policy.mode === "required") {
1933
- const allMemberships = await this.membershipCollection.findActiveByUser(userId);
1934
- const ownerMemberships = allMemberships.filter(
1935
- (m2) => m2.roleId === ownerRole.id
1936
- );
1937
- if (ownerMemberships.length <= 1) {
1938
- return "Cannot delete your last tenant. Policy requires at least one tenant.";
1939
- }
1940
- }
1941
- return null;
1942
- }
1943
- /**
1944
- * Check if a user can delete a specific tenant
1945
- *
1946
- * Returns false if:
1947
- * - User is not an active member
1948
- * - User is not the owner
1949
- * - Policy is 'required' and this is the last tenant
1950
- */
1951
- async canDeleteTenant(userId, tenantId) {
1952
- const error = await this.getDeleteTenantError(userId, tenantId);
1953
- return error === null;
1954
- }
1955
- /**
1956
- * Delete a tenant
1957
- *
1958
- * Note: This does not cascade delete related records (memberships, etc.).
1959
- * The caller should handle cleanup of related data if needed.
1960
- *
1961
- * @throws Error if user cannot delete the tenant (with specific reason)
1962
- */
1963
- async deleteTenant(userId, tenantId) {
1964
- const error = await this.getDeleteTenantError(userId, tenantId);
1965
- if (error) {
1966
- throw new Error(error);
1967
- }
1968
- const tenant = await this.tenantCollection.get(tenantId);
1969
- if (tenant) {
1970
- await tenant.delete();
1971
- }
1972
- }
1973
- /**
1974
- * Ensure a tenant exists for the user based on policy
1975
- *
1976
- * Called during OIDC login to apply tenant policy:
1977
- * - `flexible`: Returns first existing tenant or null (no auto-create)
1978
- * - `personal`/`required`: Creates default tenant if none exists
1979
- *
1980
- * @param userId - The user ID
1981
- * @param userInfo - User info for naming the auto-created tenant
1982
- * @returns Tenant and membership (may be null in flexible mode)
1983
- */
1984
- async ensureTenantForUser(userId, userInfo) {
1985
- const memberships = await this.membershipCollection.findActiveByUser(userId);
1986
- if (memberships.length > 0) {
1987
- const firstMembership = memberships[0];
1988
- const tenant2 = await this.tenantCollection.get(
1989
- firstMembership.tenantId
1990
- );
1991
- return {
1992
- tenant: tenant2 ?? null,
1993
- membership: firstMembership,
1994
- created: false
1995
- };
1996
- }
1997
- if (this.policy.mode === "flexible") {
1998
- return {
1999
- tenant: null,
2000
- membership: null,
2001
- created: false
2002
- };
2003
- }
2004
- const tenantName = userInfo.name ? `${userInfo.name}'s Workspace` : this.policy.defaultName;
2005
- const { tenant, membership } = await this.createTenantWithOwnership(
2006
- userId,
2007
- tenantName
2008
- );
2009
- return {
2010
- tenant,
2011
- membership,
2012
- created: true
2013
- };
2014
- }
2015
- /**
2016
- * Get all tenants for a user (where they are owner)
2017
- */
2018
- async getOwnedTenants(userId) {
2019
- const ownerRole = await this.roleCollection.findBySlug(
2020
- DEFAULT_ROLE_SLUGS.OWNER
2021
- );
2022
- if (!ownerRole) {
2023
- return [];
2024
- }
2025
- const memberships = await this.membershipCollection.findActiveByUser(userId);
2026
- const ownerMemberships = memberships.filter(
2027
- (m2) => m2.roleId === ownerRole.id
2028
- );
2029
- const tenantPromises = ownerMemberships.map(
2030
- (m2) => this.tenantCollection.get(m2.tenantId)
2031
- );
2032
- const tenantsOrNull = await Promise.all(tenantPromises);
2033
- return tenantsOrNull.filter((tenant) => tenant !== null);
2034
- }
2035
- /**
2036
- * Static factory method
2037
- */
2038
- static async create(options, policy) {
2039
- const service = new TenantService(options, policy);
2040
- await service.initialize();
2041
- return service;
2042
- }
1489
+ const permissions = await PermissionCollection.create(options);
1490
+ if (!isProbablyPostgres(options.db ?? options.persistence, permissions.db)) throw new Error("applyPostgresPermissionPolicies() requires a Postgres database connection.");
1491
+ const result = generatePostgresPermissionSql(options);
1492
+ for (const statement of result.statements) try {
1493
+ await permissions.db.query(statement);
1494
+ } catch (error) {
1495
+ throw new Error(`Failed to apply Postgres permission policy statement:
1496
+ ${statement}`, { cause: error });
1497
+ }
1498
+ return result;
2043
1499
  }
2044
- export {
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,
2056
- DEFAULT_ROLES,
2057
- DEFAULT_ROLE_SLUGS,
2058
- j as DEFAULT_SESSION_TTL,
2059
- DEFAULT_TENANT_POLICY,
2060
- DEFAULT_TOKEN_EXPIRY_SECONDS,
2061
- G as Group,
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,
2068
- MagicLinkError,
2069
- MagicLinkService,
2070
- UsersMagicLinkToken as MagicLinkToken,
2071
- UsersMagicLinkTokenCollection as MagicLinkTokenCollection,
2072
- s as Membership,
2073
- MembershipCollection,
2074
- t as MembershipOverride,
2075
- u as MembershipOverrideCollection,
2076
- MembershipStatus2 as MembershipStatus,
2077
- O as OidcLoginError,
2078
- v as OidcLoginService,
2079
- OverrideEffect,
2080
- w as Permission,
2081
- PermissionCatalogService,
2082
- PermissionCollection,
2083
- x as PermissionResolver,
2084
- Role,
2085
- RoleCollection,
2086
- R as RolePermission,
2087
- y as RolePermissionCollection,
2088
- S as Session,
2089
- z as SessionCollection,
2090
- A as SessionService,
2091
- SessionStatus,
2092
- B as Tenant,
2093
- TenantCollection,
2094
- C as TenantHierarchyError,
2095
- TenantPermissionEffect,
2096
- E as TenantPermissionOverride,
2097
- F as TenantPermissionOverrideCollection,
2098
- TenantService,
2099
- TenantStatus,
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,
2108
- UsersMagicLinkToken,
2109
- UsersMagicLinkTokenCollection,
2110
- applyPostgresPermissionPolicies,
2111
- L as decodeOidcTransaction,
2112
- N as encodeOidcTransaction,
2113
- generatePostgresPermissionSql,
2114
- Q as generateSessionId,
2115
- V as getCurrentSessionPermissionContext,
2116
- W as getRequestScopedDatabase,
2117
- X as getUsersOidcConfig,
2118
- registerPermissionDefinitions,
2119
- Y as resolveOidcProviderConfig,
2120
- syncPermissionCatalog,
2121
- Z as withSessionPermissionContext
1500
+ //#endregion
1501
+ //#region src/services/TenantService.ts
1502
+ var TenantService = class TenantService {
1503
+ options;
1504
+ policy;
1505
+ tenantCollection;
1506
+ membershipCollection;
1507
+ roleCollection;
1508
+ constructor(options, policy) {
1509
+ this.options = options;
1510
+ this.policy = policy ?? DEFAULT_TENANT_POLICY;
1511
+ }
1512
+ /**
1513
+ * Initialize collections
1514
+ */
1515
+ async initialize() {
1516
+ this.tenantCollection = await TenantCollection.create(this.options);
1517
+ this.membershipCollection = await MembershipCollection.create(this.options);
1518
+ this.roleCollection = await RoleCollection.create(this.options);
1519
+ await this.roleCollection.seedSystemRoles();
1520
+ }
1521
+ /**
1522
+ * Get the current policy
1523
+ */
1524
+ getPolicy() {
1525
+ return { ...this.policy };
1526
+ }
1527
+ /**
1528
+ * Create a tenant and make the user the owner
1529
+ *
1530
+ * @param userId - The user to make owner
1531
+ * @param name - Tenant name
1532
+ * @param options - Optional slug override
1533
+ * @returns The created tenant and membership
1534
+ */
1535
+ async createTenantWithOwnership(userId, name, options) {
1536
+ if (!await this.canCreateTenant(userId)) throw new Error(`User has reached maximum tenant limit (${this.policy.maxTenants})`);
1537
+ const tenant = await this.tenantCollection.create({
1538
+ name,
1539
+ slug: options?.slug
1540
+ });
1541
+ await tenant.save();
1542
+ const ownerRole = await this.roleCollection.findBySlug(DEFAULT_ROLE_SLUGS.OWNER);
1543
+ if (!ownerRole) throw new Error("Owner role not found - run seedSystemRoles first");
1544
+ const membership = await this.membershipCollection.create({
1545
+ userId,
1546
+ tenantId: tenant.id,
1547
+ roleId: ownerRole.id,
1548
+ status: MembershipStatus.ACTIVE
1549
+ });
1550
+ await membership.save();
1551
+ return {
1552
+ tenant,
1553
+ membership
1554
+ };
1555
+ }
1556
+ /**
1557
+ * Check if a user can create a new tenant
1558
+ *
1559
+ * Returns false if maxTenants limit is reached (0 = unlimited)
1560
+ */
1561
+ async canCreateTenant(userId) {
1562
+ if (this.policy.maxTenants === 0) return true;
1563
+ const ownerRole = await this.roleCollection.findBySlug(DEFAULT_ROLE_SLUGS.OWNER);
1564
+ if (!ownerRole) return false;
1565
+ return (await this.membershipCollection.findActiveByUser(userId)).filter((m) => m.roleId === ownerRole.id).length < this.policy.maxTenants;
1566
+ }
1567
+ /**
1568
+ * Get a specific error message explaining why a tenant cannot be deleted.
1569
+ *
1570
+ * @returns Error message if deletion is not allowed, or null if allowed.
1571
+ */
1572
+ async getDeleteTenantError(userId, tenantId) {
1573
+ const membership = await this.membershipCollection.findByUserAndTenant(userId, tenantId);
1574
+ if (!membership || membership.status !== MembershipStatus.ACTIVE) return "You are not a member of this tenant or it does not exist.";
1575
+ const ownerRole = await this.roleCollection.findBySlug(DEFAULT_ROLE_SLUGS.OWNER);
1576
+ if (!ownerRole) return "Owner role is not configured. Cannot determine deletion permissions.";
1577
+ if (membership.roleId !== ownerRole.id) return "Only the tenant owner can delete this tenant.";
1578
+ if (this.policy.mode === "required") {
1579
+ if ((await this.membershipCollection.findActiveByUser(userId)).filter((m) => m.roleId === ownerRole.id).length <= 1) return "Cannot delete your last tenant. Policy requires at least one tenant.";
1580
+ }
1581
+ return null;
1582
+ }
1583
+ /**
1584
+ * Check if a user can delete a specific tenant
1585
+ *
1586
+ * Returns false if:
1587
+ * - User is not an active member
1588
+ * - User is not the owner
1589
+ * - Policy is 'required' and this is the last tenant
1590
+ */
1591
+ async canDeleteTenant(userId, tenantId) {
1592
+ return await this.getDeleteTenantError(userId, tenantId) === null;
1593
+ }
1594
+ /**
1595
+ * Delete a tenant
1596
+ *
1597
+ * Note: This does not cascade delete related records (memberships, etc.).
1598
+ * The caller should handle cleanup of related data if needed.
1599
+ *
1600
+ * @throws Error if user cannot delete the tenant (with specific reason)
1601
+ */
1602
+ async deleteTenant(userId, tenantId) {
1603
+ const error = await this.getDeleteTenantError(userId, tenantId);
1604
+ if (error) throw new Error(error);
1605
+ const tenant = await this.tenantCollection.get(tenantId);
1606
+ if (tenant) await tenant.delete();
1607
+ }
1608
+ /**
1609
+ * Ensure a tenant exists for the user based on policy
1610
+ *
1611
+ * Called during OIDC login to apply tenant policy:
1612
+ * - `flexible`: Returns first existing tenant or null (no auto-create)
1613
+ * - `personal`/`required`: Creates default tenant if none exists
1614
+ *
1615
+ * @param userId - The user ID
1616
+ * @param userInfo - User info for naming the auto-created tenant
1617
+ * @returns Tenant and membership (may be null in flexible mode)
1618
+ */
1619
+ async ensureTenantForUser(userId, userInfo) {
1620
+ const memberships = await this.membershipCollection.findActiveByUser(userId);
1621
+ if (memberships.length > 0) {
1622
+ const firstMembership = memberships[0];
1623
+ return {
1624
+ tenant: await this.tenantCollection.get(firstMembership.tenantId) ?? null,
1625
+ membership: firstMembership,
1626
+ created: false
1627
+ };
1628
+ }
1629
+ if (this.policy.mode === "flexible") return {
1630
+ tenant: null,
1631
+ membership: null,
1632
+ created: false
1633
+ };
1634
+ const tenantName = userInfo.name ? `${userInfo.name}'s Workspace` : this.policy.defaultName;
1635
+ const { tenant, membership } = await this.createTenantWithOwnership(userId, tenantName);
1636
+ return {
1637
+ tenant,
1638
+ membership,
1639
+ created: true
1640
+ };
1641
+ }
1642
+ /**
1643
+ * Get all tenants for a user (where they are owner)
1644
+ */
1645
+ async getOwnedTenants(userId) {
1646
+ const ownerRole = await this.roleCollection.findBySlug(DEFAULT_ROLE_SLUGS.OWNER);
1647
+ if (!ownerRole) return [];
1648
+ const tenantPromises = (await this.membershipCollection.findActiveByUser(userId)).filter((m) => m.roleId === ownerRole.id).map((m) => this.tenantCollection.get(m.tenantId));
1649
+ return (await Promise.all(tenantPromises)).filter((tenant) => tenant !== null);
1650
+ }
1651
+ /**
1652
+ * Static factory method
1653
+ */
1654
+ static async create(options, policy) {
1655
+ const service = new TenantService(options, policy);
1656
+ await service.initialize();
1657
+ return service;
1658
+ }
2122
1659
  };
2123
- //# sourceMappingURL=index.js.map
1660
+ //#endregion
1661
+ export { ACCESS_REQUEST_CAPABILITIES, AccessRequest, AccessRequestCollection, AccessRequestError, AccessRequestService, AccessRequestStatus, UsersCliAuthRequest as CliAuthRequest, UsersCliAuthRequest, UsersCliAuthRequestCollection as CliAuthRequestCollection, UsersCliAuthRequestCollection, DEFAULT_CLI_AUTH_POLL_INTERVAL_SECONDS, DEFAULT_CLI_AUTH_REQUEST_TTL_SECONDS, DEFAULT_CLI_SESSION_TTL_SECONDS, DEFAULT_ROLES, DEFAULT_ROLE_SLUGS, DEFAULT_SESSION_TTL, DEFAULT_TENANT_POLICY, DEFAULT_TOKEN_EXPIRY_SECONDS, Group, GroupCollection, GroupMember, GroupMemberCollection, GroupRole, GroupRoleCollection, MAX_TENANT_HIERARCHY_DEPTH, MagicLinkError, MagicLinkService, UsersMagicLinkToken as MagicLinkToken, UsersMagicLinkToken, UsersMagicLinkTokenCollection as MagicLinkTokenCollection, UsersMagicLinkTokenCollection, Membership, MembershipCollection, MembershipOverride, MembershipOverrideCollection, MembershipStatus, OidcLoginError, OidcLoginService, OverrideEffect, Permission, PermissionCatalogService, PermissionCollection, PermissionResolver, Role, RoleCollection, RolePermission, RolePermissionCollection, Session, SessionCollection, SessionService, SessionStatus, Tenant, TenantCollection, TenantHierarchyError, TenantPermissionEffect, TenantPermissionOverride, TenantPermissionOverrideCollection, TenantService, TenantStatus, TerminalAuthError, TerminalAuthRateLimitError, TerminalAuthService, User, UserCollection, UserStatus, applyPostgresPermissionPolicies, decodeOidcTransaction, encodeOidcTransaction, generatePostgresPermissionSql, generateSessionId, getCurrentSessionPermissionContext, getRequestScopedDatabase, getUsersOidcConfig, registerPermissionDefinitions, resolveOidcProviderConfig, syncPermissionCatalog, withSessionPermissionContext };
1662
+
1663
+ //# sourceMappingURL=index.js.map