@happyvertical/smrt-users 0.37.1 → 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.
Files changed (35) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +63 -0
  3. package/dist/chunks/TerminalAuthService-BXAAuaXf.js +4531 -0
  4. package/dist/chunks/TerminalAuthService-BXAAuaXf.js.map +1 -0
  5. package/dist/collections/AccessRequestCollection.d.ts +28 -0
  6. package/dist/collections/AccessRequestCollection.d.ts.map +1 -0
  7. package/dist/collections/index.d.ts +1 -0
  8. package/dist/collections/index.d.ts.map +1 -1
  9. package/dist/index.d.ts +4 -4
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1571 -1322
  12. package/dist/index.js.map +1 -1
  13. package/dist/manifest.json +421 -2
  14. package/dist/models/AccessRequest.d.ts +131 -0
  15. package/dist/models/AccessRequest.d.ts.map +1 -0
  16. package/dist/models/index.d.ts +1 -0
  17. package/dist/models/index.d.ts.map +1 -1
  18. package/dist/playground.js +114 -136
  19. package/dist/playground.js.map +1 -1
  20. package/dist/services/AccessRequestService.d.ts +326 -0
  21. package/dist/services/AccessRequestService.d.ts.map +1 -0
  22. package/dist/services/index.d.ts +1 -0
  23. package/dist/services/index.d.ts.map +1 -1
  24. package/dist/smrt-knowledge.json +129 -15
  25. package/dist/sveltekit.js +685 -881
  26. package/dist/sveltekit.js.map +1 -1
  27. package/dist/types/index.d.ts +1 -1
  28. package/dist/types/index.d.ts.map +1 -1
  29. package/dist/ui.js +79 -73
  30. package/dist/ui.js.map +1 -1
  31. package/package.json +19 -19
  32. package/dist/chunks/TerminalAuthService-bY1oWeAh.js +0 -5261
  33. package/dist/chunks/TerminalAuthService-bY1oWeAh.js.map +0 -1
  34. package/dist/chunks/index-DBpq-WMK.js +0 -169
  35. package/dist/chunks/index-DBpq-WMK.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,1414 +1,1663 @@
1
- import { D as DEFAULT_ROLES, n as normalizeEmail, i as isValidEmail, P as PermissionCollection, p as parsePermissionSlug, a as isValidPermissionSlug, b as DEFAULT_TENANT_POLICY, T as TenantCollection, M as MembershipCollection, c as DEFAULT_ROLE_SLUGS } from "./chunks/TerminalAuthService-bY1oWeAh.js";
2
- import { U, d, e, f, g, h, G, j, k, l, m, o, q, r, s, t, O, u, v, w, R, x, S, y, z, A, B, C, E, F, H, I, J, K, U as U2, d as d2, L, N, Q, V, W, X, Y, Z } from "./chunks/TerminalAuthService-bY1oWeAh.js";
3
- import { field, smrt, SmrtObject, SmrtCollection, foreignKey, ObjectRegistry, findManifestEntryByQualifiedName } from "@happyvertical/smrt-core";
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";
3
+ import { createLogger } from "@happyvertical/logger";
4
4
  import { getPackageConfig } from "@happyvertical/smrt-config";
5
5
  import { createHash } from "node:crypto";
6
- import { MembershipStatus } from "@happyvertical/smrt-types";
7
- import { MembershipStatus as MembershipStatus2, OverrideEffect, SessionStatus, TenantPermissionEffect, TenantStatus, UserStatus } from "@happyvertical/smrt-types";
6
+ //#region src/models/AccessRequest.ts
7
+ var __defProp$2 = Object.defineProperty;
8
+ var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
9
+ var __decorateClass$2 = (decorators, target, key, kind) => {
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;
14
+ };
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
+ }
96
+ };
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
8
168
  var __defProp$1 = Object.defineProperty;
9
169
  var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
10
170
  var __decorateClass$1 = (decorators, target, key, kind) => {
11
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
12
- for (var i = decorators.length - 1, decorator; i >= 0; i--)
13
- if (decorator = decorators[i])
14
- result = (kind ? decorator(target, key, result) : decorator(result)) || result;
15
- if (kind && result) __defProp$1(target, key, result);
16
- 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;
17
175
  };
18
- const DEFAULT_TOKEN_EXPIRY_SECONDS = 10 * 60;
19
- let UsersMagicLinkToken = class extends SmrtObject {
20
- nonce = "";
21
- /** Email address this token was generated for */
22
- email = "";
23
- /** Whether this token has been used */
24
- used = false;
25
- /** When this token expires */
26
- expiresAt = new Date(Date.now() + DEFAULT_TOKEN_EXPIRY_SECONDS * 1e3);
27
- constructor(options = {}) {
28
- super(options);
29
- if (options.nonce !== void 0) this.nonce = options.nonce;
30
- if (options.email !== void 0) this.email = options.email;
31
- if (options.used !== void 0) this.used = options.used;
32
- if (options.expiresAt !== void 0) {
33
- this.expiresAt = options.expiresAt instanceof Date ? options.expiresAt : new Date(options.expiresAt);
34
- }
35
- }
36
- /** Check if the token has expired */
37
- isExpired() {
38
- return /* @__PURE__ */ new Date() > this.expiresAt;
39
- }
40
- /** Check if the token is still valid (unused and not expired) */
41
- isValid() {
42
- return !this.used && !this.isExpired();
43
- }
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
+ }
44
200
  };
45
- __decorateClass$1([
46
- field({ required: true, unique: true })
47
- ], UsersMagicLinkToken.prototype, "nonce", 2);
48
- UsersMagicLinkToken = __decorateClass$1([
49
- smrt({
50
- tableName: "users_magic_link_tokens",
51
- // Magic link tokens are security-sensitive — no public API
52
- api: { include: [] },
53
- mcp: { include: [] },
54
- cli: true
55
- })
56
- ], UsersMagicLinkToken);
57
- class UsersMagicLinkTokenCollection extends SmrtCollection {
58
- static _itemClass = UsersMagicLinkToken;
59
- /**
60
- * Find a token by its nonce
61
- */
62
- async findByNonce(nonce) {
63
- return this.findOne({
64
- where: { nonce }
65
- });
66
- }
67
- /**
68
- * Atomically mark a token as used (single-use enforcement).
69
- *
70
- * Returns true if the nonce was successfully claimed (transitioned from
71
- * unused to used). Returns false if the nonce was already used, expired,
72
- * or doesn't exist — preventing race conditions in concurrent verify() calls.
73
- */
74
- async markUsed(nonce) {
75
- const now = (/* @__PURE__ */ new Date()).toISOString();
76
- const { rowCount } = await this.db.query(
77
- `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}
78
231
  SET used = ?, updated_at = ?
79
- WHERE nonce = ? AND used = ? AND expires_at > ?`,
80
- true,
81
- now,
82
- nonce,
83
- false,
84
- now
85
- );
86
- return rowCount > 0;
87
- }
88
- /**
89
- * Delete expired tokens (cleanup job)
90
- */
91
- async deleteExpired() {
92
- const now = /* @__PURE__ */ new Date();
93
- const tokens = await this.list({
94
- where: {
95
- "expiresAt <": now.toISOString()
96
- }
97
- });
98
- let count = 0;
99
- for (const token of tokens) {
100
- await token.delete();
101
- count++;
102
- }
103
- return count;
104
- }
105
- }
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
106
251
  var __defProp = Object.defineProperty;
107
252
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
108
253
  var __decorateClass = (decorators, target, key, kind) => {
109
- var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
110
- for (var i = decorators.length - 1, decorator; i >= 0; i--)
111
- if (decorator = decorators[i])
112
- result = (kind ? decorator(target, key, result) : decorator(result)) || result;
113
- if (kind && result) __defProp(target, key, result);
114
- 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;
115
258
  };
116
- let Role = class extends SmrtObject {
117
- tenantId;
118
- /**
119
- * Display name for the role
120
- */
121
- name = "";
122
- /**
123
- * Description of the role
124
- */
125
- description = "";
126
- /**
127
- * Whether this is a system role (cannot be deleted)
128
- */
129
- isSystem = false;
130
- constructor(options = {}) {
131
- super(options);
132
- if (options.tenantId !== void 0) this.tenantId = options.tenantId;
133
- if (options.name !== void 0) this.name = options.name;
134
- if (options.description !== void 0)
135
- this.description = options.description;
136
- if (options.isSystem !== void 0) this.isSystem = options.isSystem;
137
- }
138
- /**
139
- * Check if this is a system-wide role
140
- */
141
- isSystemRole() {
142
- return this.tenantId === null || this.tenantId === void 0;
143
- }
144
- /**
145
- * Check if this is a tenant-specific role
146
- */
147
- isTenantRole() {
148
- return this.tenantId !== null && this.tenantId !== void 0;
149
- }
150
- /**
151
- * Check if this role can be deleted.
152
- * System roles (isSystem = true) cannot be deleted.
153
- * @returns true if the role can be deleted
154
- */
155
- canDelete() {
156
- return !this.isSystem;
157
- }
158
- /**
159
- * Delete guard - prevents deletion of system roles.
160
- * Override the delete method to check isSystem flag first.
161
- */
162
- async delete() {
163
- if (this.isSystem) {
164
- throw new Error(
165
- `Cannot delete system role '${this.slug}'. System roles are protected.`
166
- );
167
- }
168
- return super.delete();
169
- }
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
+ }
170
308
  };
171
- __decorateClass([
172
- foreignKey("Tenant", { nullable: true })
173
- ], Role.prototype, "tenantId", 2);
174
- Role = __decorateClass([
175
- smrt({
176
- // #1400: read-only generated surface — RBAC/identity writes go through
177
- // permission-gated services, not auth-only generated CRUD.
178
- api: { include: ["list", "get"] },
179
- mcp: { include: ["list", "get"] },
180
- cli: true
181
- })
182
- ], Role);
183
- class RoleCollection extends SmrtCollection {
184
- static _itemClass = Role;
185
- /**
186
- * Find all system roles (tenantId is null)
187
- */
188
- async findSystemRoles() {
189
- return await this.query(
190
- `SELECT * FROM ${this.tableName} WHERE tenant_id IS NULL ORDER BY name ASC`
191
- );
192
- }
193
- /**
194
- * Find roles available for a tenant (system + tenant-specific)
195
- */
196
- async findByTenant(tenantId) {
197
- return await this.query(
198
- `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}
199
330
  WHERE tenant_id IS NULL OR tenant_id = ?
200
- ORDER BY is_system DESC, name ASC`,
201
- [tenantId]
202
- );
203
- }
204
- /**
205
- * Find tenant-specific roles only
206
- */
207
- async findTenantRoles(tenantId) {
208
- return await this.list({
209
- where: { tenantId },
210
- orderBy: "name ASC"
211
- });
212
- }
213
- /**
214
- * Find role by slug within a tenant context
215
- */
216
- async findBySlug(slug, tenantId) {
217
- if (tenantId) {
218
- const tenantRoles = await this.list({
219
- where: { slug, tenantId },
220
- limit: 1
221
- });
222
- if (tenantRoles.length > 0) {
223
- return tenantRoles[0];
224
- }
225
- }
226
- const systemRoles = await this.query(
227
- `SELECT * FROM ${this.tableName} WHERE slug = ? AND tenant_id IS NULL LIMIT 1`,
228
- [slug]
229
- );
230
- return systemRoles.length > 0 ? systemRoles[0] : null;
231
- }
232
- /**
233
- * Seed default system roles
234
- */
235
- async seedSystemRoles() {
236
- const roles = [];
237
- for (const roleDef of DEFAULT_ROLES) {
238
- const existing = await this.findBySlug(roleDef.slug);
239
- if (existing) {
240
- roles.push(existing);
241
- continue;
242
- }
243
- const role = await this.create({
244
- slug: roleDef.slug,
245
- name: roleDef.name,
246
- description: roleDef.description,
247
- tenantId: null,
248
- isSystem: true
249
- });
250
- await role.save();
251
- roles.push(role);
252
- }
253
- return roles;
254
- }
255
- }
256
- class MagicLinkError extends Error {
257
- constructor(message) {
258
- super(message);
259
- this.name = "MagicLinkError";
260
- }
261
- }
262
- class MagicLinkService {
263
- tokenCollection;
264
- signingKey = null;
265
- secret;
266
- tokenExpiry;
267
- issuer;
268
- options;
269
- constructor(options) {
270
- if (!options.secret) {
271
- throw new Error("MagicLinkService requires a secret for token signing");
272
- }
273
- this.secret = options.secret;
274
- this.tokenExpiry = options.tokenExpiry ?? DEFAULT_TOKEN_EXPIRY_SECONDS;
275
- this.issuer = options.issuer ?? "smrt:magiclink";
276
- this.options = options;
277
- }
278
- /**
279
- * Initialize collections
280
- */
281
- async initialize() {
282
- this.tokenCollection = await UsersMagicLinkTokenCollection.create(this.options);
283
- }
284
- /**
285
- * Derive the HMAC signing key from the secret
286
- */
287
- async getSigningKey() {
288
- if (this.signingKey) return this.signingKey;
289
- const encoder = new TextEncoder();
290
- const data = encoder.encode(`magiclink:${this.secret}`);
291
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
292
- this.signingKey = new Uint8Array(hashBuffer);
293
- return this.signingKey;
294
- }
295
- /**
296
- * Generate a magic link token for the given email.
297
- *
298
- * Stores a nonce in the database for replay protection.
299
- * The caller is responsible for emailing the token to the user.
300
- */
301
- async generate(email) {
302
- const { SignJWT } = await import("./chunks/index-DBpq-WMK.js");
303
- const key = await this.getSigningKey();
304
- const nonce = crypto.randomUUID();
305
- const normalizedEmail = normalizeEmail(email);
306
- if (!isValidEmail(normalizedEmail)) {
307
- throw new MagicLinkError("Invalid email address");
308
- }
309
- const expiresAt = new Date(Date.now() + this.tokenExpiry * 1e3);
310
- await this.tokenCollection.create({
311
- nonce,
312
- email: normalizedEmail,
313
- used: false,
314
- expiresAt
315
- });
316
- const token = await new SignJWT({
317
- email: normalizedEmail,
318
- nonce
319
- }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(`${this.tokenExpiry}s`).setIssuer(this.issuer).sign(key);
320
- return { token, expiresAt };
321
- }
322
- /**
323
- * Verify a magic link token.
324
- *
325
- * Checks JWT signature, expiry, and that the nonce hasn't been used.
326
- * Marks the nonce as used on success (single-use enforcement).
327
- *
328
- * @throws {MagicLinkError} If the token is invalid, expired, or already used
329
- */
330
- async verify(token) {
331
- const { jwtVerify, errors } = await import("./chunks/index-DBpq-WMK.js");
332
- const key = await this.getSigningKey();
333
- let payload;
334
- try {
335
- const result = await jwtVerify(token, key, {
336
- issuer: this.issuer
337
- });
338
- payload = result.payload;
339
- } catch (err) {
340
- if (err instanceof errors.JWTExpired) {
341
- throw new MagicLinkError("Token has expired");
342
- }
343
- throw new MagicLinkError("Invalid token");
344
- }
345
- const email = payload.email;
346
- const nonce = payload.nonce;
347
- if (typeof email !== "string" || typeof nonce !== "string") {
348
- throw new MagicLinkError("Invalid token payload");
349
- }
350
- const claimed = await this.tokenCollection.markUsed(nonce);
351
- if (!claimed) {
352
- throw new MagicLinkError("Token has already been used or has expired");
353
- }
354
- return { email: normalizeEmail(email), nonce };
355
- }
356
- /**
357
- * Clean up expired tokens (run periodically)
358
- */
359
- async cleanupExpiredTokens() {
360
- return this.tokenCollection.deleteExpired();
361
- }
362
- /**
363
- * Static factory method
364
- */
365
- static async create(options) {
366
- const service = new MagicLinkService(options);
367
- await service.initialize();
368
- return service;
369
- }
370
- }
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
+ }
382
+ };
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
371
938
  function getRuntimePermissionRegistrations() {
372
- globalThis.__smrtUsersPermissionRegistrations ??= /* @__PURE__ */ new Map();
373
- return globalThis.__smrtUsersPermissionRegistrations;
939
+ globalThis.__smrtUsersPermissionRegistrations ??= /* @__PURE__ */ new Map();
940
+ return globalThis.__smrtUsersPermissionRegistrations;
374
941
  }
375
942
  function toSnakeCase$1(value) {
376
- 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();
377
944
  }
378
945
  function pluralize(word) {
379
- if (word.endsWith("y") && !/[aeiou]y$/i.test(word)) {
380
- return `${word.slice(0, -1)}ies`;
381
- }
382
- if (word.endsWith("s") || word.endsWith("x") || word.endsWith("z") || word.endsWith("ch") || word.endsWith("sh")) {
383
- return `${word}es`;
384
- }
385
- 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`;
386
949
  }
387
950
  function deriveCollectionName(className) {
388
- return pluralize(toSnakeCase$1(className));
951
+ return pluralize(toSnakeCase$1(className));
389
952
  }
390
953
  function humanizeResource(resource) {
391
- return resource.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
954
+ return resource.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
392
955
  }
393
956
  function capitalize(value) {
394
- return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
957
+ return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
395
958
  }
396
959
  function defaultPermissionName(slug) {
397
- const parsed = parsePermissionSlug(slug);
398
- if (!parsed.isValid) {
399
- return humanizeResource(slug);
400
- }
401
- 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)}`;
402
963
  }
403
964
  function defaultPermissionDescription(slug) {
404
- const parsed = parsePermissionSlug(slug);
405
- if (!parsed.isValid) {
406
- return `Allows ${slug}`;
407
- }
408
- 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()}`;
409
968
  }
410
969
  function isCollectionManifestEntry(objectDef) {
411
- return objectDef?.extends === "SmrtCollection" || objectDef?.extendsTypeArg !== void 0;
970
+ return objectDef?.extends === "SmrtCollection" || objectDef?.extendsTypeArg !== void 0;
412
971
  }
413
972
  function getPublicCustomMethodNames(methodEntries, standardActions) {
414
- return Array.from(
415
- new Set(
416
- methodEntries.filter(
417
- (method) => Boolean(method?.name) && method?.isPublic === true && !standardActions.includes(method.name)
418
- ).map((method) => method.name)
419
- )
420
- );
973
+ return Array.from(new Set(methodEntries.filter((method) => Boolean(method?.name) && method?.isPublic === true && !standardActions.includes(method.name)).map((method) => method.name)));
421
974
  }
422
975
  function getCustomMethodExposureNames(config, availableCustomMethods) {
423
- if (!config || config === false) {
424
- return /* @__PURE__ */ new Set();
425
- }
426
- if (config === true || typeof config !== "object") {
427
- return new Set(availableCustomMethods);
428
- }
429
- const rawInclude = config.include;
430
- const include = Array.isArray(rawInclude) ? [...rawInclude] : void 0;
431
- const rawExclude = config.exclude;
432
- const exclude = Array.isArray(rawExclude) ? [...rawExclude] : [];
433
- if (!include) {
434
- return new Set(
435
- availableCustomMethods.filter(
436
- (methodName) => !exclude.includes(methodName)
437
- )
438
- );
439
- }
440
- const baseMethods = include.filter(
441
- (methodName) => availableCustomMethods.includes(methodName)
442
- );
443
- return new Set(
444
- baseMethods.filter((methodName) => !exclude.includes(methodName))
445
- );
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)));
446
985
  }
447
986
  function isOperationEnabled(config, action) {
448
- if (config === false) {
449
- return false;
450
- }
451
- if (config && typeof config === "object") {
452
- const include = Array.isArray(config.include) ? config.include : void 0;
453
- const rawExclude = config.exclude;
454
- const exclude = Array.isArray(rawExclude) ? [...rawExclude] : [];
455
- if (include && !include.includes(action)) {
456
- return false;
457
- }
458
- if (exclude.includes(action)) {
459
- return false;
460
- }
461
- }
462
- 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;
463
996
  }
464
997
  function normalizePostgresAction(action) {
465
- const normalized = action.toUpperCase();
466
- if (normalized === "SELECT" || normalized === "INSERT" || normalized === "UPDATE" || normalized === "DELETE") {
467
- return normalized;
468
- }
469
- throw new Error(
470
- `Unsupported Postgres permission action '${action}'. Expected SELECT, INSERT, UPDATE, or DELETE.`
471
- );
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.`);
472
1001
  }
473
1002
  function normalizeBinding(binding, fallbackPermission) {
474
- return {
475
- action: normalizePostgresAction(binding.action),
476
- permission: binding.permission || fallbackPermission,
477
- schemaName: binding.schemaName,
478
- tableName: binding.tableName,
479
- tenantField: binding.tenantField
480
- };
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
+ };
481
1010
  }
482
1011
  function mergeStringField(fieldName, existing, incoming, slug) {
483
- const existingValue = existing[fieldName];
484
- const incomingValue = incoming[fieldName];
485
- if (!incomingValue) {
486
- return;
487
- }
488
- if (!existingValue) {
489
- existing[fieldName] = incomingValue;
490
- return;
491
- }
492
- if (existingValue !== incomingValue) {
493
- throw new Error(
494
- `Conflicting permission metadata for '${slug}' field '${fieldName}': '${existingValue}' !== '${incomingValue}'`
495
- );
496
- }
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}'`);
497
1020
  }
498
1021
  function mergeBindings(existing, incoming) {
499
- const existingBindings = existing.postgres?.bindings ?? [];
500
- const incomingBindings = incoming.postgres?.bindings ?? [];
501
- if (incomingBindings.length === 0) {
502
- return;
503
- }
504
- const seen = new Set(
505
- existingBindings.map(
506
- (binding) => [
507
- binding.permission,
508
- binding.action,
509
- binding.schemaName ?? "",
510
- binding.tableName,
511
- binding.tenantField ?? ""
512
- ].join("|")
513
- )
514
- );
515
- const mergedBindings = [...existingBindings];
516
- for (const binding of incomingBindings) {
517
- const normalized = normalizeBinding(binding, incoming.slug);
518
- const key = [
519
- normalized.permission,
520
- normalized.action,
521
- normalized.schemaName ?? "",
522
- normalized.tableName,
523
- normalized.tenantField ?? ""
524
- ].join("|");
525
- if (!seen.has(key)) {
526
- seen.add(key);
527
- mergedBindings.push(normalized);
528
- }
529
- }
530
- existing.postgres = {
531
- bindings: mergedBindings
532
- };
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 };
533
1048
  }
534
1049
  function normalizeDefinition(definition, source) {
535
- if (!definition.slug || !isValidPermissionSlug(definition.slug.trim())) {
536
- throw new Error(
537
- `Invalid permission slug '${definition.slug}'. Expected 'resource.action'.`
538
- );
539
- }
540
- const slug = definition.slug.trim();
541
- return {
542
- category: definition.category ?? parsePermissionSlug(slug).resource,
543
- className: definition.className,
544
- collection: definition.collection,
545
- description: definition.description ?? defaultPermissionDescription(slug),
546
- name: definition.name ?? defaultPermissionName(slug),
547
- postgres: definition.postgres?.bindings ? {
548
- bindings: definition.postgres.bindings.map(
549
- (binding) => normalizeBinding(binding, slug)
550
- )
551
- } : void 0,
552
- qualifiedName: definition.qualifiedName,
553
- slug,
554
- source
555
- };
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
+ };
556
1063
  }
557
1064
  function mergeDefinitionSet(current, incomingDefinitions, source) {
558
- for (const rawDefinition of incomingDefinitions) {
559
- const definition = normalizeDefinition(rawDefinition, source);
560
- const existing = current.get(definition.slug);
561
- if (!existing) {
562
- current.set(definition.slug, definition);
563
- continue;
564
- }
565
- mergeStringField("category", existing, definition, definition.slug);
566
- mergeStringField("className", existing, definition, definition.slug);
567
- mergeStringField("collection", existing, definition, definition.slug);
568
- mergeStringField("description", existing, definition, definition.slug);
569
- mergeStringField("name", existing, definition, definition.slug);
570
- mergeStringField("qualifiedName", existing, definition, definition.slug);
571
- mergeBindings(existing, definition);
572
- }
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
+ }
573
1080
  }
574
1081
  function registerPermissionDefinitions(definitions) {
575
- globalThis.__smrtUsersPermissionRegistrationCounter = (globalThis.__smrtUsersPermissionRegistrationCounter ?? 0) + 1;
576
- const registrationId = globalThis.__smrtUsersPermissionRegistrationCounter;
577
- getRuntimePermissionRegistrations().set(registrationId, definitions);
578
- return () => {
579
- getRuntimePermissionRegistrations().delete(registrationId);
580
- };
581
- }
582
- class PermissionCatalogService {
583
- constructor(options = {}) {
584
- this.options = options;
585
- }
586
- options;
587
- getUsersConfig() {
588
- return getPackageConfig("users", {});
589
- }
590
- getRuntimePermissionDefinitions() {
591
- return Array.from(getRuntimePermissionRegistrations().values()).flat();
592
- }
593
- getCustomPermissionDefinitions() {
594
- return this.getUsersConfig().permissions?.custom ?? [];
595
- }
596
- getCatalog() {
597
- const manifestPermissions = this.getManifestPermissionDefinitions();
598
- const customPermissions = this.getCustomPermissionDefinitions();
599
- const runtimePermissions = this.getRuntimePermissionDefinitions();
600
- const merged = /* @__PURE__ */ new Map();
601
- mergeDefinitionSet(merged, manifestPermissions, "manifest");
602
- mergeDefinitionSet(merged, customPermissions, "config");
603
- mergeDefinitionSet(merged, runtimePermissions, "runtime");
604
- return {
605
- customPermissions: customPermissions.map(
606
- (definition) => normalizeDefinition(definition, "config")
607
- ),
608
- manifestPermissions: manifestPermissions.map(
609
- (definition) => normalizeDefinition(definition, "manifest")
610
- ),
611
- permissions: Array.from(merged.values()).sort(
612
- (left, right) => left.slug.localeCompare(right.slug)
613
- ),
614
- runtimePermissions: runtimePermissions.map(
615
- (definition) => normalizeDefinition(definition, "runtime")
616
- )
617
- };
618
- }
619
- async syncPermissionCatalog() {
620
- const catalog = this.getCatalog();
621
- const permissions = await PermissionCollection.create(this.options);
622
- const created = [];
623
- const unchanged = [];
624
- const updated = [];
625
- for (const definition of catalog.permissions) {
626
- const existing = await permissions.findBySlug(definition.slug);
627
- if (!existing) {
628
- const permission = await permissions.create({
629
- category: definition.category ?? parsePermissionSlug(definition.slug).resource,
630
- description: definition.description ?? "",
631
- name: definition.name ?? definition.slug,
632
- slug: definition.slug
633
- });
634
- await permission.save();
635
- created.push(definition.slug);
636
- continue;
637
- }
638
- const nextName = definition.name ?? existing.name;
639
- const nextDescription = definition.description ?? existing.description;
640
- const nextCategory = definition.category ?? existing.category;
641
- if (existing.name === nextName && existing.description === nextDescription && existing.category === nextCategory) {
642
- unchanged.push(definition.slug);
643
- continue;
644
- }
645
- existing.name = nextName;
646
- existing.description = nextDescription;
647
- existing.category = nextCategory;
648
- await existing.save();
649
- updated.push(definition.slug);
650
- }
651
- return {
652
- catalog,
653
- created,
654
- unchanged,
655
- updated
656
- };
657
- }
658
- getManifestPermissionDefinitions() {
659
- const standardActions = ["list", "get", "create", "update", "delete"];
660
- const definitions = /* @__PURE__ */ new Map();
661
- for (const metadata of ObjectRegistry.getAllObjectMetadata()) {
662
- const registered = ObjectRegistry.getClassByConstructor(metadata.constructor) ?? ObjectRegistry.getClass(metadata.name);
663
- const manifestEntry = registered?.qualifiedName ? findManifestEntryByQualifiedName(registered.qualifiedName) : void 0;
664
- if (isCollectionManifestEntry(manifestEntry)) {
665
- continue;
666
- }
667
- const className = metadata.name;
668
- const qualifiedName = registered?.qualifiedName;
669
- const objectConfig = manifestEntry?.decoratorConfig ?? metadata.config;
670
- const rawCollection = objectConfig?.collection;
671
- const configuredCollection = typeof rawCollection === "string" && rawCollection.length > 0 ? rawCollection : void 0;
672
- const collection = configuredCollection ?? manifestEntry?.collection ?? deriveCollectionName(metadata.name);
673
- 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");
674
- if (readExposed) {
675
- definitions.set(`${collection}.read`, {
676
- className,
677
- collection,
678
- qualifiedName,
679
- slug: `${collection}.read`
680
- });
681
- }
682
- for (const action of ["create", "update", "delete"]) {
683
- const exposed = isOperationEnabled(objectConfig.api, action) || isOperationEnabled(objectConfig.cli, action) || isOperationEnabled(objectConfig.mcp, action);
684
- if (!exposed) {
685
- continue;
686
- }
687
- definitions.set(`${collection}.${action}`, {
688
- className,
689
- collection,
690
- qualifiedName,
691
- slug: `${collection}.${action}`
692
- });
693
- }
694
- const methodEntries = manifestEntry?.methods ? Object.values(manifestEntry.methods) : Array.from(metadata.methods.values());
695
- const publicCustomMethodNames = getPublicCustomMethodNames(
696
- methodEntries,
697
- standardActions
698
- );
699
- const customApiMethods = /* @__PURE__ */ new Set();
700
- const customCliMethods = getCustomMethodExposureNames(
701
- objectConfig.cli,
702
- publicCustomMethodNames
703
- );
704
- const customMcpMethods = getCustomMethodExposureNames(
705
- objectConfig.mcp,
706
- publicCustomMethodNames
707
- );
708
- for (const methodName of publicCustomMethodNames) {
709
- if (isOperationEnabled(objectConfig.api, methodName)) {
710
- customApiMethods.add(methodName);
711
- }
712
- }
713
- const customMethods = /* @__PURE__ */ new Set([
714
- ...customApiMethods,
715
- ...customCliMethods,
716
- ...customMcpMethods
717
- ]);
718
- for (const methodName of customMethods) {
719
- definitions.set(`${collection}.${methodName}`, {
720
- className,
721
- collection,
722
- description: `Allows ${methodName} on ${humanizeResource(collection).toLowerCase()}`,
723
- name: `${capitalize(methodName)} ${humanizeResource(collection)}`,
724
- qualifiedName,
725
- slug: `${collection}.${methodName}`
726
- });
727
- }
728
- }
729
- return Array.from(definitions.values()).sort(
730
- (left, right) => left.slug.localeCompare(right.slug)
731
- );
732
- }
733
- static create(options = {}) {
734
- return new PermissionCatalogService(options);
735
- }
1082
+ globalThis.__smrtUsersPermissionRegistrationCounter = (globalThis.__smrtUsersPermissionRegistrationCounter ?? 0) + 1;
1083
+ const registrationId = globalThis.__smrtUsersPermissionRegistrationCounter;
1084
+ getRuntimePermissionRegistrations().set(registrationId, definitions);
1085
+ return () => {
1086
+ getRuntimePermissionRegistrations().delete(registrationId);
1087
+ };
736
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
+ };
737
1218
  async function syncPermissionCatalog(options = {}) {
738
- return PermissionCatalogService.create(options).syncPermissionCatalog();
1219
+ return PermissionCatalogService.create(options).syncPermissionCatalog();
739
1220
  }
1221
+ //#endregion
1222
+ //#region src/services/PostgresPermissionPolicies.ts
740
1223
  function toSnakeCase(value) {
741
- 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();
742
1225
  }
743
1226
  function quoteIdent(identifier) {
744
- return `"${identifier.replaceAll('"', '""')}"`;
1227
+ return `"${identifier.replaceAll("\"", "\"\"")}"`;
745
1228
  }
746
1229
  function quoteLiteral(value) {
747
- return `'${value.replaceAll("'", "''")}'`;
1230
+ return `'${value.replaceAll("'", "''")}'`;
748
1231
  }
749
1232
  function isProbablyPostgres(configDb, database) {
750
- if (configDb && typeof configDb === "object" && !("query" in configDb) && "type" in configDb && configDb.type === "postgres") {
751
- return true;
752
- }
753
- if (typeof database.url === "string" && database.url.startsWith("postgres")) {
754
- return true;
755
- }
756
- 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");
757
1236
  }
758
1237
  function normalizePostgresPermissionAction(action) {
759
- const normalized = action.toUpperCase();
760
- if (normalized === "SELECT" || normalized === "INSERT" || normalized === "UPDATE" || normalized === "DELETE") {
761
- return normalized;
762
- }
763
- throw new Error(
764
- `Invalid Postgres permission binding action "${action}". Expected one of SELECT, INSERT, UPDATE, DELETE.`
765
- );
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.`);
766
1241
  }
767
1242
  function normalizePostgresPermissionBinding(binding, fallbackPermission) {
768
- const tableName = binding.tableName?.trim();
769
- if (!tableName) {
770
- throw new Error(
771
- "Postgres permission binding is missing a tableName value."
772
- );
773
- }
774
- const permission = binding.permission ?? fallbackPermission;
775
- if (!permission) {
776
- throw new Error(
777
- "Postgres permission binding is missing a permission value."
778
- );
779
- }
780
- return {
781
- action: normalizePostgresPermissionAction(binding.action),
782
- permission,
783
- schemaName: binding.schemaName,
784
- tableName,
785
- tenantField: binding.tenantField
786
- };
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
+ };
787
1254
  }
788
1255
  function parseTableReference(binding) {
789
- if (binding.tableName.includes(".")) {
790
- const [schemaName, tableName] = binding.tableName.split(".", 2);
791
- return {
792
- schemaName,
793
- tableName
794
- };
795
- }
796
- return {
797
- schemaName: binding.schemaName ?? "public",
798
- tableName: binding.tableName
799
- };
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
+ };
800
1267
  }
801
1268
  function buildPolicyName(tableName, action) {
802
- const actionSegment = action.toLowerCase();
803
- const hash = createHash("sha1").update(`${tableName}:${action}`).digest("hex").slice(0, 8);
804
- const sanitizedTable = tableName.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
805
- const prefix = "smrt_";
806
- const separatorLength = 2;
807
- const maxTableSegmentLength = 63 - prefix.length - actionSegment.length - hash.length - separatorLength;
808
- const tableSegment = (sanitizedTable || "table").slice(
809
- 0,
810
- Math.max(maxTableSegmentLength, 1)
811
- );
812
- 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}`;
813
1275
  }
814
1276
  function buildPermissionExpression(permissionSlugs) {
815
- if (permissionSlugs.length === 0) {
816
- return "FALSE";
817
- }
818
- 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 ");
819
1279
  }
820
1280
  function buildTenantMatchExpression(tenantField) {
821
- return `${quoteIdent(tenantField)}::text = smrt_current_tenant_id()`;
1281
+ return `${quoteIdent(tenantField)}::text = smrt_current_tenant_id()`;
822
1282
  }
823
1283
  function buildSelectPolicySql(target, permissions) {
824
- const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
825
- const policyName = buildPolicyName(target.tableName, "SELECT");
826
- const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
827
- return [
828
- `DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
829
- `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR SELECT USING (${condition})`
830
- ];
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})`];
831
1288
  }
832
1289
  function buildInsertPolicySql(target, permissions) {
833
- const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
834
- const policyName = buildPolicyName(target.tableName, "INSERT");
835
- const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
836
- return [
837
- `DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
838
- `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR INSERT WITH CHECK (${condition})`
839
- ];
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})`];
840
1294
  }
841
1295
  function buildUpdatePolicySql(target, permissions) {
842
- const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
843
- const policyName = buildPolicyName(target.tableName, "UPDATE");
844
- const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
845
- return [
846
- `DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
847
- `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR UPDATE USING (${condition}) WITH CHECK (${condition})`
848
- ];
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})`];
849
1300
  }
850
1301
  function buildDeletePolicySql(target, permissions) {
851
- const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
852
- const policyName = buildPolicyName(target.tableName, "DELETE");
853
- const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
854
- return [
855
- `DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
856
- `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR DELETE USING (${condition})`
857
- ];
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})`];
858
1306
  }
859
1307
  function buildHelperStatements() {
860
- return [
861
- [
862
- "CREATE OR REPLACE FUNCTION smrt_rls_bypass()",
863
- "RETURNS boolean",
864
- "LANGUAGE sql",
865
- "STABLE",
866
- "AS $$",
867
- " SELECT COALESCE(NULLIF(current_setting('smrt.system_context', true), ''), 'false')::boolean",
868
- " OR COALESCE(NULLIF(current_setting('smrt.super_admin_bypass', true), ''), 'false')::boolean",
869
- "$$"
870
- ].join("\n"),
871
- [
872
- "CREATE OR REPLACE FUNCTION smrt_current_tenant_id()",
873
- "RETURNS text",
874
- "LANGUAGE sql",
875
- "STABLE",
876
- "AS $$",
877
- " SELECT NULLIF(current_setting('smrt.tenant_id', true), '')",
878
- "$$"
879
- ].join("\n"),
880
- [
881
- "CREATE OR REPLACE FUNCTION smrt_has_permission(required_permission text)",
882
- "RETURNS boolean",
883
- "LANGUAGE sql",
884
- "STABLE",
885
- "AS $$",
886
- " SELECT smrt_rls_bypass()",
887
- " OR jsonb_exists(COALESCE(NULLIF(current_setting('smrt.permissions', true), ''), '[]')::jsonb, required_permission)",
888
- "$$"
889
- ].join("\n")
890
- ];
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
+ ];
891
1339
  }
892
1340
  function addBindingToTarget(targets, binding, source = {}) {
893
- const { schemaName, tableName } = parseTableReference(binding);
894
- const targetKey = `${schemaName}.${tableName}`;
895
- const existing = targets.get(targetKey);
896
- const tenantField = binding.tenantField ?? "tenant_id";
897
- if (existing && existing.tenantField !== tenantField) {
898
- throw new Error(
899
- `Conflicting tenant fields for table '${targetKey}': '${existing.tenantField}' !== '${tenantField}'`
900
- );
901
- }
902
- const target = existing ?? {
903
- actions: /* @__PURE__ */ new Map(),
904
- ...source,
905
- schemaName,
906
- tableName,
907
- tenantField
908
- };
909
- const action = normalizePostgresPermissionAction(binding.action);
910
- const permissions = target.actions.get(action) ?? /* @__PURE__ */ new Set();
911
- if (binding.permission) {
912
- permissions.add(binding.permission);
913
- }
914
- target.actions.set(action, permissions);
915
- 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);
916
1358
  }
917
1359
  function generatePostgresPermissionSql(options = {}) {
918
- const catalogService = PermissionCatalogService.create(options);
919
- const catalog = catalogService.getCatalog();
920
- const config = catalogService.getUsersConfig();
921
- const candidateTargets = /* @__PURE__ */ new Map();
922
- const skipped = [];
923
- const autoCandidates = /* @__PURE__ */ new Map();
924
- for (const metadata of ObjectRegistry.getAllObjectMetadata()) {
925
- const registered = ObjectRegistry.getClassByConstructor(metadata.constructor) ?? ObjectRegistry.getClass(metadata.name);
926
- const tenantScoped = registered?.tenantScopedConfig;
927
- const manifestEntry = registered?.qualifiedName ? findManifestEntryByQualifiedName(registered.qualifiedName) : void 0;
928
- if (!tenantScoped) {
929
- skipped.push({
930
- className: metadata.name,
931
- qualifiedName: registered?.qualifiedName,
932
- reason: "not tenant-scoped"
933
- });
934
- continue;
935
- }
936
- if (tenantScoped.mode !== "required") {
937
- skipped.push({
938
- className: metadata.name,
939
- qualifiedName: registered?.qualifiedName,
940
- reason: `tenant mode '${tenantScoped.mode}' is not supported for automatic Postgres RLS generation`
941
- });
942
- continue;
943
- }
944
- const rawTableName = registered?.schema?.tableName ?? manifestEntry?.schema?.tableName;
945
- if (!rawTableName) {
946
- skipped.push({
947
- className: metadata.name,
948
- qualifiedName: registered?.qualifiedName,
949
- reason: "no schema table name available"
950
- });
951
- continue;
952
- }
953
- const parsedTable = parseTableReference({
954
- tableName: rawTableName
955
- });
956
- const tableKey = `${parsedTable.schemaName}.${parsedTable.tableName}`;
957
- const objectConfig = manifestEntry?.decoratorConfig ?? metadata.config;
958
- const rawCollection = objectConfig?.collection;
959
- const configuredCollection = typeof rawCollection === "string" && rawCollection.length > 0 ? rawCollection : void 0;
960
- const collection = configuredCollection ?? manifestEntry?.collection ?? `${toSnakeCase(metadata.name)}s`;
961
- const entries = autoCandidates.get(tableKey) ?? [];
962
- entries.push({
963
- className: metadata.name,
964
- collection,
965
- qualifiedName: registered?.qualifiedName,
966
- schemaName: parsedTable.schemaName,
967
- tableName: parsedTable.tableName,
968
- tenantField: toSnakeCase(tenantScoped.field)
969
- });
970
- autoCandidates.set(tableKey, entries);
971
- }
972
- for (const [tableKey, entries] of autoCandidates) {
973
- if (entries.length > 1) {
974
- for (const entry2 of entries) {
975
- skipped.push({
976
- className: entry2.className,
977
- collection: entry2.collection,
978
- qualifiedName: entry2.qualifiedName,
979
- reason: `table '${tableKey}' is shared by multiple objects, so automatic policy generation was skipped`,
980
- schemaName: entry2.schemaName,
981
- tableName: entry2.tableName
982
- });
983
- }
984
- continue;
985
- }
986
- const entry = entries[0];
987
- addBindingToTarget(
988
- candidateTargets,
989
- {
990
- action: "SELECT",
991
- permission: `${entry.collection}.read`,
992
- schemaName: entry.schemaName,
993
- tableName: entry.tableName,
994
- tenantField: entry.tenantField
995
- },
996
- entry
997
- );
998
- addBindingToTarget(
999
- candidateTargets,
1000
- {
1001
- action: "INSERT",
1002
- permission: `${entry.collection}.create`,
1003
- schemaName: entry.schemaName,
1004
- tableName: entry.tableName,
1005
- tenantField: entry.tenantField
1006
- },
1007
- entry
1008
- );
1009
- addBindingToTarget(
1010
- candidateTargets,
1011
- {
1012
- action: "UPDATE",
1013
- permission: `${entry.collection}.update`,
1014
- schemaName: entry.schemaName,
1015
- tableName: entry.tableName,
1016
- tenantField: entry.tenantField
1017
- },
1018
- entry
1019
- );
1020
- addBindingToTarget(
1021
- candidateTargets,
1022
- {
1023
- action: "DELETE",
1024
- permission: `${entry.collection}.delete`,
1025
- schemaName: entry.schemaName,
1026
- tableName: entry.tableName,
1027
- tenantField: entry.tenantField
1028
- },
1029
- entry
1030
- );
1031
- }
1032
- const explicitBindings = [];
1033
- for (const definition of catalog.permissions) {
1034
- for (const binding of definition.postgres?.bindings ?? []) {
1035
- explicitBindings.push(
1036
- normalizePostgresPermissionBinding(binding, definition.slug)
1037
- );
1038
- }
1039
- }
1040
- for (const binding of config.permissions?.postgres?.bindings ?? []) {
1041
- explicitBindings.push(normalizePostgresPermissionBinding(binding));
1042
- }
1043
- for (const binding of explicitBindings) {
1044
- addBindingToTarget(candidateTargets, binding);
1045
- }
1046
- const statements = [...buildHelperStatements()];
1047
- const targets = Array.from(candidateTargets.values()).sort(
1048
- (left, right) => `${left.schemaName}.${left.tableName}`.localeCompare(
1049
- `${right.schemaName}.${right.tableName}`
1050
- )
1051
- ).map((target) => ({
1052
- actions: Object.fromEntries(
1053
- Array.from(target.actions.entries()).map(([action, permissions]) => [
1054
- action,
1055
- Array.from(permissions).sort()
1056
- ])
1057
- ),
1058
- className: target.className,
1059
- collection: target.collection,
1060
- qualifiedName: target.qualifiedName,
1061
- schemaName: target.schemaName,
1062
- tableName: target.tableName,
1063
- tenantField: target.tenantField
1064
- }));
1065
- for (const target of Array.from(candidateTargets.values())) {
1066
- const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
1067
- statements.push(`ALTER TABLE ${qualifiedTable} ENABLE ROW LEVEL SECURITY`);
1068
- statements.push(`ALTER TABLE ${qualifiedTable} FORCE ROW LEVEL SECURITY`);
1069
- const selectPermissions = Array.from(
1070
- target.actions.get("SELECT") ?? []
1071
- ).sort();
1072
- const insertPermissions = Array.from(
1073
- target.actions.get("INSERT") ?? []
1074
- ).sort();
1075
- const updatePermissions = Array.from(
1076
- target.actions.get("UPDATE") ?? []
1077
- ).sort();
1078
- const deletePermissions = Array.from(
1079
- target.actions.get("DELETE") ?? []
1080
- ).sort();
1081
- if (selectPermissions.length > 0) {
1082
- statements.push(...buildSelectPolicySql(target, selectPermissions));
1083
- }
1084
- if (insertPermissions.length > 0) {
1085
- statements.push(...buildInsertPolicySql(target, insertPermissions));
1086
- }
1087
- if (updatePermissions.length > 0) {
1088
- statements.push(...buildUpdatePolicySql(target, updatePermissions));
1089
- }
1090
- if (deletePermissions.length > 0) {
1091
- statements.push(...buildDeletePolicySql(target, deletePermissions));
1092
- }
1093
- }
1094
- return {
1095
- bindings: explicitBindings,
1096
- skipped,
1097
- 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")};
1098
1483
  `,
1099
- statements,
1100
- targets
1101
- };
1484
+ statements,
1485
+ targets
1486
+ };
1102
1487
  }
1103
1488
  async function applyPostgresPermissionPolicies(options = {}) {
1104
- const permissions = await PermissionCollection.create(options);
1105
- const databaseOptions = options.db ?? options.persistence;
1106
- if (!isProbablyPostgres(databaseOptions, permissions.db)) {
1107
- throw new Error(
1108
- "applyPostgresPermissionPolicies() requires a Postgres database connection."
1109
- );
1110
- }
1111
- const result = generatePostgresPermissionSql(options);
1112
- for (const statement of result.statements) {
1113
- try {
1114
- await permissions.db.query(statement);
1115
- } catch (error) {
1116
- throw new Error(
1117
- `Failed to apply Postgres permission policy statement:
1118
- ${statement}`,
1119
- {
1120
- cause: error
1121
- }
1122
- );
1123
- }
1124
- }
1125
- return result;
1126
- }
1127
- class TenantService {
1128
- options;
1129
- policy;
1130
- tenantCollection;
1131
- membershipCollection;
1132
- roleCollection;
1133
- constructor(options, policy) {
1134
- this.options = options;
1135
- this.policy = policy ?? DEFAULT_TENANT_POLICY;
1136
- }
1137
- /**
1138
- * Initialize collections
1139
- */
1140
- async initialize() {
1141
- this.tenantCollection = await TenantCollection.create(this.options);
1142
- this.membershipCollection = await MembershipCollection.create(this.options);
1143
- this.roleCollection = await RoleCollection.create(this.options);
1144
- await this.roleCollection.seedSystemRoles();
1145
- }
1146
- /**
1147
- * Get the current policy
1148
- */
1149
- getPolicy() {
1150
- return { ...this.policy };
1151
- }
1152
- /**
1153
- * Create a tenant and make the user the owner
1154
- *
1155
- * @param userId - The user to make owner
1156
- * @param name - Tenant name
1157
- * @param options - Optional slug override
1158
- * @returns The created tenant and membership
1159
- */
1160
- async createTenantWithOwnership(userId, name, options) {
1161
- if (!await this.canCreateTenant(userId)) {
1162
- throw new Error(
1163
- `User has reached maximum tenant limit (${this.policy.maxTenants})`
1164
- );
1165
- }
1166
- const tenant = await this.tenantCollection.create({
1167
- name,
1168
- slug: options?.slug
1169
- });
1170
- await tenant.save();
1171
- const ownerRole = await this.roleCollection.findBySlug(
1172
- DEFAULT_ROLE_SLUGS.OWNER
1173
- );
1174
- if (!ownerRole) {
1175
- throw new Error("Owner role not found - run seedSystemRoles first");
1176
- }
1177
- const membership = await this.membershipCollection.create({
1178
- userId,
1179
- tenantId: tenant.id,
1180
- roleId: ownerRole.id,
1181
- status: MembershipStatus.ACTIVE
1182
- });
1183
- await membership.save();
1184
- return { tenant, membership };
1185
- }
1186
- /**
1187
- * Check if a user can create a new tenant
1188
- *
1189
- * Returns false if maxTenants limit is reached (0 = unlimited)
1190
- */
1191
- async canCreateTenant(userId) {
1192
- if (this.policy.maxTenants === 0) {
1193
- return true;
1194
- }
1195
- const ownerRole = await this.roleCollection.findBySlug(
1196
- DEFAULT_ROLE_SLUGS.OWNER
1197
- );
1198
- if (!ownerRole) {
1199
- return false;
1200
- }
1201
- const memberships = await this.membershipCollection.findActiveByUser(userId);
1202
- const ownedCount = memberships.filter(
1203
- (m2) => m2.roleId === ownerRole.id
1204
- ).length;
1205
- return ownedCount < this.policy.maxTenants;
1206
- }
1207
- /**
1208
- * Get a specific error message explaining why a tenant cannot be deleted.
1209
- *
1210
- * @returns Error message if deletion is not allowed, or null if allowed.
1211
- */
1212
- async getDeleteTenantError(userId, tenantId) {
1213
- const membership = await this.membershipCollection.findByUserAndTenant(
1214
- userId,
1215
- tenantId
1216
- );
1217
- if (!membership || membership.status !== MembershipStatus.ACTIVE) {
1218
- return "You are not a member of this tenant or it does not exist.";
1219
- }
1220
- const ownerRole = await this.roleCollection.findBySlug(
1221
- DEFAULT_ROLE_SLUGS.OWNER
1222
- );
1223
- if (!ownerRole) {
1224
- return "Owner role is not configured. Cannot determine deletion permissions.";
1225
- }
1226
- if (membership.roleId !== ownerRole.id) {
1227
- return "Only the tenant owner can delete this tenant.";
1228
- }
1229
- if (this.policy.mode === "required") {
1230
- const allMemberships = await this.membershipCollection.findActiveByUser(userId);
1231
- const ownerMemberships = allMemberships.filter(
1232
- (m2) => m2.roleId === ownerRole.id
1233
- );
1234
- if (ownerMemberships.length <= 1) {
1235
- return "Cannot delete your last tenant. Policy requires at least one tenant.";
1236
- }
1237
- }
1238
- return null;
1239
- }
1240
- /**
1241
- * Check if a user can delete a specific tenant
1242
- *
1243
- * Returns false if:
1244
- * - User is not an active member
1245
- * - User is not the owner
1246
- * - Policy is 'required' and this is the last tenant
1247
- */
1248
- async canDeleteTenant(userId, tenantId) {
1249
- const error = await this.getDeleteTenantError(userId, tenantId);
1250
- return error === null;
1251
- }
1252
- /**
1253
- * Delete a tenant
1254
- *
1255
- * Note: This does not cascade delete related records (memberships, etc.).
1256
- * The caller should handle cleanup of related data if needed.
1257
- *
1258
- * @throws Error if user cannot delete the tenant (with specific reason)
1259
- */
1260
- async deleteTenant(userId, tenantId) {
1261
- const error = await this.getDeleteTenantError(userId, tenantId);
1262
- if (error) {
1263
- throw new Error(error);
1264
- }
1265
- const tenant = await this.tenantCollection.get(tenantId);
1266
- if (tenant) {
1267
- await tenant.delete();
1268
- }
1269
- }
1270
- /**
1271
- * Ensure a tenant exists for the user based on policy
1272
- *
1273
- * Called during OIDC login to apply tenant policy:
1274
- * - `flexible`: Returns first existing tenant or null (no auto-create)
1275
- * - `personal`/`required`: Creates default tenant if none exists
1276
- *
1277
- * @param userId - The user ID
1278
- * @param userInfo - User info for naming the auto-created tenant
1279
- * @returns Tenant and membership (may be null in flexible mode)
1280
- */
1281
- async ensureTenantForUser(userId, userInfo) {
1282
- const memberships = await this.membershipCollection.findActiveByUser(userId);
1283
- if (memberships.length > 0) {
1284
- const firstMembership = memberships[0];
1285
- const tenant2 = await this.tenantCollection.get(
1286
- firstMembership.tenantId
1287
- );
1288
- return {
1289
- tenant: tenant2 ?? null,
1290
- membership: firstMembership,
1291
- created: false
1292
- };
1293
- }
1294
- if (this.policy.mode === "flexible") {
1295
- return {
1296
- tenant: null,
1297
- membership: null,
1298
- created: false
1299
- };
1300
- }
1301
- const tenantName = userInfo.name ? `${userInfo.name}'s Workspace` : this.policy.defaultName;
1302
- const { tenant, membership } = await this.createTenantWithOwnership(
1303
- userId,
1304
- tenantName
1305
- );
1306
- return {
1307
- tenant,
1308
- membership,
1309
- created: true
1310
- };
1311
- }
1312
- /**
1313
- * Get all tenants for a user (where they are owner)
1314
- */
1315
- async getOwnedTenants(userId) {
1316
- const ownerRole = await this.roleCollection.findBySlug(
1317
- DEFAULT_ROLE_SLUGS.OWNER
1318
- );
1319
- if (!ownerRole) {
1320
- return [];
1321
- }
1322
- const memberships = await this.membershipCollection.findActiveByUser(userId);
1323
- const ownerMemberships = memberships.filter(
1324
- (m2) => m2.roleId === ownerRole.id
1325
- );
1326
- const tenantPromises = ownerMemberships.map(
1327
- (m2) => this.tenantCollection.get(m2.tenantId)
1328
- );
1329
- const tenantsOrNull = await Promise.all(tenantPromises);
1330
- return tenantsOrNull.filter((tenant) => tenant !== null);
1331
- }
1332
- /**
1333
- * Static factory method
1334
- */
1335
- static async create(options, policy) {
1336
- const service = new TenantService(options, policy);
1337
- await service.initialize();
1338
- return service;
1339
- }
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;
1340
1499
  }
1341
- export {
1342
- U as CliAuthRequest,
1343
- d as CliAuthRequestCollection,
1344
- e as DEFAULT_CLI_AUTH_POLL_INTERVAL_SECONDS,
1345
- f as DEFAULT_CLI_AUTH_REQUEST_TTL_SECONDS,
1346
- g as DEFAULT_CLI_SESSION_TTL_SECONDS,
1347
- DEFAULT_ROLES,
1348
- DEFAULT_ROLE_SLUGS,
1349
- h as DEFAULT_SESSION_TTL,
1350
- DEFAULT_TENANT_POLICY,
1351
- DEFAULT_TOKEN_EXPIRY_SECONDS,
1352
- G as Group,
1353
- j as GroupCollection,
1354
- k as GroupMember,
1355
- l as GroupMemberCollection,
1356
- m as GroupRole,
1357
- o as GroupRoleCollection,
1358
- q as MAX_TENANT_HIERARCHY_DEPTH,
1359
- MagicLinkError,
1360
- MagicLinkService,
1361
- UsersMagicLinkToken as MagicLinkToken,
1362
- UsersMagicLinkTokenCollection as MagicLinkTokenCollection,
1363
- r as Membership,
1364
- MembershipCollection,
1365
- s as MembershipOverride,
1366
- t as MembershipOverrideCollection,
1367
- MembershipStatus2 as MembershipStatus,
1368
- O as OidcLoginError,
1369
- u as OidcLoginService,
1370
- OverrideEffect,
1371
- v as Permission,
1372
- PermissionCatalogService,
1373
- PermissionCollection,
1374
- w as PermissionResolver,
1375
- Role,
1376
- RoleCollection,
1377
- R as RolePermission,
1378
- x as RolePermissionCollection,
1379
- S as Session,
1380
- y as SessionCollection,
1381
- z as SessionService,
1382
- SessionStatus,
1383
- A as Tenant,
1384
- TenantCollection,
1385
- B as TenantHierarchyError,
1386
- TenantPermissionEffect,
1387
- C as TenantPermissionOverride,
1388
- E as TenantPermissionOverrideCollection,
1389
- TenantService,
1390
- TenantStatus,
1391
- F as TerminalAuthError,
1392
- H as TerminalAuthRateLimitError,
1393
- I as TerminalAuthService,
1394
- J as User,
1395
- K as UserCollection,
1396
- UserStatus,
1397
- U2 as UsersCliAuthRequest,
1398
- d2 as UsersCliAuthRequestCollection,
1399
- UsersMagicLinkToken,
1400
- UsersMagicLinkTokenCollection,
1401
- applyPostgresPermissionPolicies,
1402
- L as decodeOidcTransaction,
1403
- N as encodeOidcTransaction,
1404
- generatePostgresPermissionSql,
1405
- Q as generateSessionId,
1406
- V as getCurrentSessionPermissionContext,
1407
- W as getRequestScopedDatabase,
1408
- X as getUsersOidcConfig,
1409
- registerPermissionDefinitions,
1410
- Y as resolveOidcProviderConfig,
1411
- syncPermissionCatalog,
1412
- 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
+ }
1413
1659
  };
1414
- //# 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