@devxcrew/platform 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +82 -0
  3. package/dist/index.d.ts +5 -0
  4. package/dist/index.js +2 -0
  5. package/dist/modules/identity/identity.administration-migration.d.ts +2 -0
  6. package/dist/modules/identity/identity.administration-migration.js +75 -0
  7. package/dist/modules/identity/identity.administration-repository.d.ts +19 -0
  8. package/dist/modules/identity/identity.administration-repository.js +102 -0
  9. package/dist/modules/identity/identity.administration-schema.d.ts +113 -0
  10. package/dist/modules/identity/identity.administration-schema.js +121 -0
  11. package/dist/modules/identity/identity.administration-service.d.ts +97 -0
  12. package/dist/modules/identity/identity.administration-service.js +517 -0
  13. package/dist/modules/identity/identity.controller.d.ts +22 -0
  14. package/dist/modules/identity/identity.controller.js +278 -0
  15. package/dist/modules/identity/identity.error.d.ts +6 -0
  16. package/dist/modules/identity/identity.error.js +15 -0
  17. package/dist/modules/identity/identity.lifecycle-schema.d.ts +19 -0
  18. package/dist/modules/identity/identity.lifecycle-schema.js +29 -0
  19. package/dist/modules/identity/identity.lifecycle-service.d.ts +64 -0
  20. package/dist/modules/identity/identity.lifecycle-service.js +395 -0
  21. package/dist/modules/identity/identity.membership-policy.d.ts +3 -0
  22. package/dist/modules/identity/identity.membership-policy.js +27 -0
  23. package/dist/modules/identity/identity.migration.d.ts +2 -0
  24. package/dist/modules/identity/identity.migration.js +79 -0
  25. package/dist/modules/identity/identity.password.d.ts +3 -0
  26. package/dist/modules/identity/identity.password.js +21 -0
  27. package/dist/modules/identity/identity.permission-declarations-migration.d.ts +5 -0
  28. package/dist/modules/identity/identity.permission-declarations-migration.js +15 -0
  29. package/dist/modules/identity/identity.permission-declarations.d.ts +23 -0
  30. package/dist/modules/identity/identity.permission-declarations.js +57 -0
  31. package/dist/modules/identity/identity.permission-labels-migration.d.ts +5 -0
  32. package/dist/modules/identity/identity.permission-labels-migration.js +9 -0
  33. package/dist/modules/identity/identity.presentation-service.d.ts +23 -0
  34. package/dist/modules/identity/identity.presentation-service.js +51 -0
  35. package/dist/modules/identity/identity.provider.d.ts +78 -0
  36. package/dist/modules/identity/identity.provider.js +119 -0
  37. package/dist/modules/identity/identity.public-schemas.d.ts +4 -0
  38. package/dist/modules/identity/identity.public-schemas.js +3 -0
  39. package/dist/modules/identity/identity.repository.d.ts +40 -0
  40. package/dist/modules/identity/identity.repository.js +196 -0
  41. package/dist/modules/identity/identity.request-context.d.ts +2 -0
  42. package/dist/modules/identity/identity.request-context.js +10 -0
  43. package/dist/modules/identity/identity.roles-migration.d.ts +2 -0
  44. package/dist/modules/identity/identity.roles-migration.js +64 -0
  45. package/dist/modules/identity/identity.roles-service.d.ts +49 -0
  46. package/dist/modules/identity/identity.roles-service.js +327 -0
  47. package/dist/modules/identity/identity.routes.d.ts +22 -0
  48. package/dist/modules/identity/identity.routes.js +11 -0
  49. package/dist/modules/identity/identity.schema.d.ts +27 -0
  50. package/dist/modules/identity/identity.schema.js +34 -0
  51. package/dist/modules/identity/identity.seed.d.ts +3 -0
  52. package/dist/modules/identity/identity.seed.js +81 -0
  53. package/dist/modules/identity/identity.service.d.ts +32 -0
  54. package/dist/modules/identity/identity.service.js +83 -0
  55. package/dist/modules/identity/identity.types.d.ts +150 -0
  56. package/dist/modules/identity/identity.types.js +1 -0
  57. package/dist/modules/identity/index.d.ts +12 -0
  58. package/dist/modules/identity/index.js +9 -0
  59. package/package.json +62 -0
@@ -0,0 +1,395 @@
1
+ import { randomBytes, randomUUID } from "node:crypto";
2
+ import { digest, hashPassword } from "./identity.password.js";
3
+ import { IdentityError } from "./identity.error.js";
4
+ import { IdentityRepository } from "./identity.repository.js";
5
+ import { checkIdentityRequest } from "./identity.request-context.js";
6
+ import { invitationSchema, recoverySchema, tokenCompletionSchema, } from "./identity.lifecycle-schema.js";
7
+ export class IdentityLifecycleService {
8
+ db;
9
+ config;
10
+ delivery;
11
+ constructor(db, config, delivery) {
12
+ this.db = db;
13
+ this.config = config;
14
+ this.delivery = delivery;
15
+ }
16
+ async invite(actor, raw) {
17
+ if (actor.portal === "user" ||
18
+ !actor.permissions.includes("identity.manage"))
19
+ throw new IdentityError(403, "Access denied.");
20
+ const input = invitationSchema.parse(raw);
21
+ const tenantId = input.tenantId ?? actor.tenant.id;
22
+ if (actor.portal !== "super-admin" &&
23
+ (tenantId !== actor.tenant.id || input.roleId !== "user"))
24
+ throw new IdentityError(403, "Access denied.");
25
+ this.requireDelivery();
26
+ if ((await new IdentityRepository(this.db).throttle(digest(`${actor.appId}:invitation:${actor.user.id}`))) > 20)
27
+ throw new IdentityError(429, "Too many invitations. Try again later.");
28
+ const tenant = await this.db
29
+ .selectFrom("identity_tenants")
30
+ .select("id")
31
+ .where("id", "=", tenantId)
32
+ .where("active", "=", 1)
33
+ .executeTakeFirst();
34
+ if (!tenant)
35
+ throw new IdentityError(422, "Organization is inactive or unavailable.");
36
+ if (await this.db
37
+ .selectFrom("identity_users")
38
+ .select("id")
39
+ .where("email", "=", input.email)
40
+ .executeTakeFirst())
41
+ throw new IdentityError(409, "This account already exists. Manage its membership instead.");
42
+ const row = await this.issue("invitation", input.email, input.name, input.roleId, tenantId, null, actor.user.id);
43
+ return this.present(row);
44
+ }
45
+ async requestRecovery(portal, raw, address) {
46
+ const input = recoverySchema
47
+ .refine((value) => this.config.mode !== "multi-tenant" || Boolean(value.tenantId), { path: ["tenantId"], message: "Enter an organization ID." })
48
+ .parse(raw);
49
+ this.requireDelivery();
50
+ const repository = new IdentityRepository(this.db);
51
+ if ((await repository.throttle(digest(`${this.config.appId}:recovery-ip:${address}`))) > 20)
52
+ throw new IdentityError(429, "Too many recovery requests. Try again later.");
53
+ if ((await repository.throttle(digest(`${this.config.appId}:recovery:${address}:${input.email}`))) > 5)
54
+ throw new IdentityError(429, "Too many recovery requests. Try again later.");
55
+ const tenantId = this.config.mode === "single-client"
56
+ ? this.config.tenantId
57
+ : input.tenantId;
58
+ const user = await repository.user(input.email);
59
+ const principal = user?.active && tenantId
60
+ ? await repository.principal(user.id, tenantId, portal, this.config.appId)
61
+ : null;
62
+ if (user?.active &&
63
+ tenantId &&
64
+ principal?.permissions.includes("identity.password")) {
65
+ await this.db
66
+ .updateTable("identity_tokens")
67
+ .set({ consumed_at: new Date().toISOString() })
68
+ .where("app_id", "=", this.config.appId)
69
+ .where("kind", "=", "recovery")
70
+ .where("user_id", "=", user.id)
71
+ .execute();
72
+ await this.issue("recovery", user.email, user.name, portal, tenantId, user.id);
73
+ }
74
+ return {
75
+ message: "If this account can recover access, an email will arrive.",
76
+ };
77
+ }
78
+ async complete(portal, kind, raw, address) {
79
+ const input = tokenCompletionSchema.parse(raw);
80
+ const repository = new IdentityRepository(this.db);
81
+ if ((await repository.throttle(digest(`${this.config.appId}:complete:${address}`))) > 10)
82
+ throw new IdentityError(429, "Too many account requests. Try again later.");
83
+ const available = await this.db
84
+ .selectFrom("identity_tokens")
85
+ .select("id")
86
+ .where("token_hash", "=", digest(input.token))
87
+ .where("app_id", "=", this.config.appId)
88
+ .where("kind", "=", kind)
89
+ .where("role_id", "=", portal)
90
+ .where("delivered", "=", 1)
91
+ .where("consumed_at", "is", null)
92
+ .where("expires_at", ">", new Date().toISOString())
93
+ .executeTakeFirst();
94
+ if (!available)
95
+ throw new IdentityError(422, "This link is invalid or expired.");
96
+ const passwordHash = await hashPassword(input.password);
97
+ checkIdentityRequest();
98
+ return this.db.transaction().execute(async (trx) => {
99
+ checkIdentityRequest();
100
+ const token = await trx
101
+ .selectFrom("identity_tokens")
102
+ .selectAll()
103
+ .where("token_hash", "=", digest(input.token))
104
+ .where("app_id", "=", this.config.appId)
105
+ .where("kind", "=", kind)
106
+ .where("role_id", "=", portal)
107
+ .where("delivered", "=", 1)
108
+ .where("consumed_at", "is", null)
109
+ .where("expires_at", ">", new Date().toISOString())
110
+ .executeTakeFirst();
111
+ if (!token ||
112
+ (this.config.mode === "single-client" &&
113
+ token.tenant_id !== this.config.tenantId))
114
+ throw new IdentityError(422, "This link is invalid or expired.");
115
+ const activeTenant = await trx
116
+ .selectFrom("identity_tenants")
117
+ .select("id")
118
+ .where("id", "=", token.tenant_id)
119
+ .where("active", "=", 1)
120
+ .executeTakeFirst();
121
+ if (!activeTenant)
122
+ throw new IdentityError(422, "This link is invalid or expired.");
123
+ const claimed = await trx
124
+ .updateTable("identity_tokens")
125
+ .set({ consumed_at: new Date().toISOString() })
126
+ .where("id", "=", token.id)
127
+ .where("consumed_at", "is", null)
128
+ .executeTakeFirst();
129
+ if (claimed.numUpdatedRows !== 1n)
130
+ throw new IdentityError(409, "This link was already used.");
131
+ const userId = token.user_id ?? randomUUID();
132
+ if (kind === "invitation") {
133
+ if (await trx
134
+ .selectFrom("identity_users")
135
+ .select("id")
136
+ .where("email", "=", token.email)
137
+ .executeTakeFirst())
138
+ throw new IdentityError(409, "Account already exists. Request a new invitation.");
139
+ await trx
140
+ .insertInto("identity_users")
141
+ .values({
142
+ id: userId,
143
+ email: token.email,
144
+ name: token.name,
145
+ password_hash: passwordHash,
146
+ active: 1,
147
+ })
148
+ .execute();
149
+ await trx
150
+ .insertInto("identity_memberships")
151
+ .values({
152
+ user_id: userId,
153
+ tenant_id: token.tenant_id,
154
+ role_id: token.role_id,
155
+ })
156
+ .execute();
157
+ }
158
+ else {
159
+ const principal = await new IdentityRepository(trx).principal(userId, token.tenant_id, portal, this.config.appId);
160
+ if (!principal?.permissions.includes("identity.password"))
161
+ throw new IdentityError(422, "This link is invalid or expired.");
162
+ await trx
163
+ .updateTable("identity_users")
164
+ .set({ password_hash: passwordHash })
165
+ .where("id", "=", userId)
166
+ .execute();
167
+ await trx
168
+ .deleteFrom("identity_sessions")
169
+ .where("user_id", "=", userId)
170
+ .execute();
171
+ await trx
172
+ .updateTable("identity_tokens")
173
+ .set({ consumed_at: new Date().toISOString() })
174
+ .where("user_id", "=", userId)
175
+ .where("kind", "=", "recovery")
176
+ .execute();
177
+ }
178
+ await trx
179
+ .insertInto("identity_audit_events")
180
+ .values({
181
+ id: randomUUID(),
182
+ app_id: this.config.appId,
183
+ actor_id: userId,
184
+ tenant_id: token.tenant_id,
185
+ action: `identity.${kind}.completed`,
186
+ resource_id: token.id,
187
+ created_at: new Date().toISOString(),
188
+ })
189
+ .execute();
190
+ checkIdentityRequest();
191
+ return { message: "Account access updated. Sign in to continue." };
192
+ });
193
+ }
194
+ async list(actor, query) {
195
+ if (actor.portal === "user" ||
196
+ !actor.permissions.includes("identity.manage"))
197
+ throw new IdentityError(403, "Access denied.");
198
+ let select = this.db
199
+ .selectFrom("identity_tokens")
200
+ .where("app_id", "=", actor.appId)
201
+ .where("kind", "=", "invitation");
202
+ if (actor.portal !== "super-admin")
203
+ select = select.where("tenant_id", "=", actor.tenant.id);
204
+ if (query.search)
205
+ select = select.where((eb) => eb.or([
206
+ eb("email", "like", `%${query.search}%`),
207
+ eb("name", "like", `%${query.search}%`),
208
+ ]));
209
+ const count = await select
210
+ .select((eb) => eb.fn.countAll().as("total"))
211
+ .executeTakeFirstOrThrow();
212
+ const rows = await select
213
+ .selectAll()
214
+ .orderBy(query.sort, query.direction)
215
+ .orderBy("id", "asc")
216
+ .limit(query.perPage)
217
+ .offset((query.page - 1) * query.perPage)
218
+ .execute();
219
+ const total = Number(count.total);
220
+ return {
221
+ data: rows.map((row) => this.present(row)),
222
+ meta: {
223
+ page: query.page,
224
+ perPage: query.perPage,
225
+ total,
226
+ lastPage: Math.max(1, Math.ceil(total / query.perPage)),
227
+ },
228
+ };
229
+ }
230
+ async show(actor, id) {
231
+ if (actor.portal === "user" ||
232
+ !actor.permissions.includes("identity.manage"))
233
+ throw new IdentityError(403, "Access denied.");
234
+ let query = this.db
235
+ .selectFrom("identity_tokens")
236
+ .selectAll()
237
+ .where("id", "=", id)
238
+ .where("app_id", "=", actor.appId)
239
+ .where("kind", "=", "invitation");
240
+ if (actor.portal !== "super-admin")
241
+ query = query.where("tenant_id", "=", actor.tenant.id);
242
+ const row = await query.executeTakeFirst();
243
+ if (!row)
244
+ throw new IdentityError(404, "Invitation not found.");
245
+ return this.present(row);
246
+ }
247
+ async resend(actor, id) {
248
+ const old = await this.show(actor, id);
249
+ if (old.status === "closed")
250
+ throw new IdentityError(409, "This invitation is closed.");
251
+ const result = await this.invite(actor, {
252
+ email: old.email,
253
+ name: old.name,
254
+ roleId: old.roleId,
255
+ tenantId: old.tenantId,
256
+ });
257
+ await this.revoke(actor, id);
258
+ return result;
259
+ }
260
+ async revoke(actor, id) {
261
+ if (actor.portal === "user" ||
262
+ !actor.permissions.includes("identity.manage"))
263
+ throw new IdentityError(403, "Access denied.");
264
+ const token = await this.db
265
+ .selectFrom("identity_tokens")
266
+ .select("tenant_id")
267
+ .where("id", "=", id)
268
+ .where("app_id", "=", actor.appId)
269
+ .where("kind", "=", "invitation")
270
+ .executeTakeFirst();
271
+ if (!token ||
272
+ (actor.portal !== "super-admin" && token.tenant_id !== actor.tenant.id))
273
+ throw new IdentityError(404, "Invitation not found.");
274
+ await this.db.transaction().execute(async (trx) => {
275
+ checkIdentityRequest();
276
+ let query = trx
277
+ .updateTable("identity_tokens")
278
+ .set({ consumed_at: new Date().toISOString() })
279
+ .where("id", "=", id)
280
+ .where("app_id", "=", actor.appId)
281
+ .where("kind", "=", "invitation");
282
+ if (actor.portal !== "super-admin")
283
+ query = query.where("tenant_id", "=", actor.tenant.id);
284
+ if ((await query.executeTakeFirst()).numUpdatedRows !== 1n)
285
+ throw new IdentityError(404, "Invitation not found.");
286
+ await trx
287
+ .insertInto("identity_audit_events")
288
+ .values({
289
+ id: randomUUID(),
290
+ app_id: actor.appId,
291
+ actor_id: actor.user.id,
292
+ tenant_id: token.tenant_id,
293
+ action: "identity.invitation.revoked",
294
+ resource_id: id,
295
+ created_at: new Date().toISOString(),
296
+ })
297
+ .execute();
298
+ checkIdentityRequest();
299
+ });
300
+ }
301
+ requireDelivery() {
302
+ if (!this.delivery)
303
+ throw new IdentityError(503, "Account email is unavailable. Contact your administrator.");
304
+ }
305
+ async issue(kind, email, name, role, tenantId, userId, actorId = userId ?? "system") {
306
+ const token = randomBytes(32).toString("base64url");
307
+ const row = {
308
+ id: randomUUID(),
309
+ token_hash: digest(token),
310
+ app_id: this.config.appId,
311
+ tenant_id: tenantId,
312
+ kind,
313
+ email,
314
+ name,
315
+ role_id: role,
316
+ user_id: userId,
317
+ expires_at: new Date(Date.now() + (kind === "recovery" ? 1800 : 86400) * 1000).toISOString(),
318
+ consumed_at: null,
319
+ delivered: 0,
320
+ };
321
+ checkIdentityRequest();
322
+ await this.db.insertInto("identity_tokens").values(row).execute();
323
+ const path = role === "user" ? "" : role === "admin" ? "/admin" : "/sa";
324
+ const link = `${this.config.origin}${path}/${kind === "invitation" ? "accept-invitation" : "reset-password"}#token=${token}`;
325
+ try {
326
+ const delivered = await this.delivery.send({
327
+ to: email,
328
+ subject: kind === "invitation"
329
+ ? "Account invitation"
330
+ : "Recover account access",
331
+ text: `${name}, open this link to ${kind === "invitation" ? "set up your account" : "reset your password"}: ${link}\nThis link expires at ${row.expires_at}.`,
332
+ });
333
+ if (!delivered.messageId)
334
+ throw new Error("Missing delivery receipt.");
335
+ checkIdentityRequest();
336
+ await this.db.transaction().execute(async (trx) => {
337
+ await trx
338
+ .updateTable("identity_tokens")
339
+ .set({ delivered: 1 })
340
+ .where("id", "=", row.id)
341
+ .execute();
342
+ await trx
343
+ .insertInto("identity_audit_events")
344
+ .values({
345
+ id: randomUUID(),
346
+ app_id: this.config.appId,
347
+ actor_id: actorId,
348
+ tenant_id: tenantId,
349
+ action: `identity.${kind}.issued`,
350
+ resource_id: row.id,
351
+ created_at: new Date().toISOString(),
352
+ })
353
+ .execute();
354
+ checkIdentityRequest();
355
+ });
356
+ return { ...row, delivered: 1 };
357
+ }
358
+ catch {
359
+ await this.db
360
+ .deleteFrom("identity_tokens")
361
+ .where("id", "=", row.id)
362
+ .execute();
363
+ throw new IdentityError(503, "Account email is unavailable. Contact your administrator.");
364
+ }
365
+ }
366
+ present(row) {
367
+ return {
368
+ id: row.id,
369
+ email: row.email,
370
+ name: row.name,
371
+ roleId: row.role_id,
372
+ tenantId: row.tenant_id,
373
+ expiresAt: row.expires_at,
374
+ status: row.consumed_at
375
+ ? "closed"
376
+ : row.expires_at <= new Date().toISOString()
377
+ ? "expired"
378
+ : "pending",
379
+ };
380
+ }
381
+ async audit(actor, action, resourceId, tenantId = actor.tenant.id) {
382
+ await this.db
383
+ .insertInto("identity_audit_events")
384
+ .values({
385
+ id: randomUUID(),
386
+ app_id: actor.appId,
387
+ actor_id: actor.user.id,
388
+ tenant_id: tenantId,
389
+ action,
390
+ resource_id: resourceId,
391
+ created_at: new Date().toISOString(),
392
+ })
393
+ .execute();
394
+ }
395
+ }
@@ -0,0 +1,3 @@
1
+ import type { Transaction } from "kysely";
2
+ import type { IdentitySchema } from "./identity.types.js";
3
+ export declare function protectAdministrators(trx: Transaction<IdentitySchema>, userId: string, tenantId?: string, roleId?: string): Promise<void>;
@@ -0,0 +1,27 @@
1
+ import { IdentityError } from "./identity.error.js";
2
+ export async function protectAdministrators(trx, userId, tenantId, roleId) {
3
+ let query = trx
4
+ .selectFrom("identity_memberships")
5
+ .selectAll()
6
+ .where("user_id", "=", userId)
7
+ .where("active", "=", 1)
8
+ .where("role_id", "in", ["admin", "super-admin"]);
9
+ if (tenantId)
10
+ query = query.where("tenant_id", "=", tenantId);
11
+ if (roleId)
12
+ query = query.where("role_id", "=", roleId);
13
+ for (const member of await query.execute()) {
14
+ const peer = await trx
15
+ .selectFrom("identity_memberships as m")
16
+ .innerJoin("identity_users as u", "u.id", "m.user_id")
17
+ .select("u.id")
18
+ .where("m.tenant_id", "=", member.tenant_id)
19
+ .where("m.role_id", "=", member.role_id)
20
+ .where("m.active", "=", 1)
21
+ .where("u.active", "=", 1)
22
+ .where("u.id", "!=", userId)
23
+ .executeTakeFirst();
24
+ if (!peer)
25
+ throw new IdentityError(409, "Keep at least one active administrator for this role and organization.");
26
+ }
27
+ }
@@ -0,0 +1,2 @@
1
+ import type { Migration } from "kysely/migration";
2
+ export declare const identityMigration: Migration;
@@ -0,0 +1,79 @@
1
+ export const identityMigration = {
2
+ async up(db) {
3
+ await db.schema
4
+ .createTable("identity_users")
5
+ .addColumn("id", "text", (c) => c.primaryKey())
6
+ .addColumn("email", "text", (c) => c.notNull().unique())
7
+ .addColumn("name", "text", (c) => c.notNull())
8
+ .addColumn("password_hash", "text", (c) => c.notNull())
9
+ .addColumn("active", "integer", (c) => c.notNull().defaultTo(1))
10
+ .execute();
11
+ await db.schema
12
+ .createTable("identity_tenants")
13
+ .addColumn("id", "text", (c) => c.primaryKey())
14
+ .addColumn("name", "text", (c) => c.notNull())
15
+ .addColumn("active", "integer", (c) => c.notNull().defaultTo(1))
16
+ .execute();
17
+ await db.schema
18
+ .createTable("identity_roles")
19
+ .addColumn("id", "text", (c) => c.primaryKey())
20
+ .execute();
21
+ await db.schema
22
+ .createTable("identity_permissions")
23
+ .addColumn("id", "text", (c) => c.primaryKey())
24
+ .execute();
25
+ await db.schema
26
+ .createTable("identity_role_permissions")
27
+ .addColumn("role_id", "text", (c) => c.notNull().references("identity_roles.id"))
28
+ .addColumn("permission_id", "text", (c) => c.notNull().references("identity_permissions.id"))
29
+ .addPrimaryKeyConstraint("identity_role_permission_pk", [
30
+ "role_id",
31
+ "permission_id",
32
+ ])
33
+ .execute();
34
+ await db.schema
35
+ .createTable("identity_memberships")
36
+ .addColumn("user_id", "text", (c) => c.notNull().references("identity_users.id").onDelete("cascade"))
37
+ .addColumn("tenant_id", "text", (c) => c.notNull().references("identity_tenants.id").onDelete("cascade"))
38
+ .addColumn("role_id", "text", (c) => c.notNull().references("identity_roles.id"))
39
+ .addPrimaryKeyConstraint("identity_membership_pk", [
40
+ "user_id",
41
+ "tenant_id",
42
+ "role_id",
43
+ ])
44
+ .execute();
45
+ await db.schema
46
+ .createTable("identity_sessions")
47
+ .addColumn("token_hash", "text", (c) => c.primaryKey())
48
+ .addColumn("user_id", "text", (c) => c.notNull().references("identity_users.id").onDelete("cascade"))
49
+ .addColumn("tenant_id", "text", (c) => c.notNull().references("identity_tenants.id").onDelete("cascade"))
50
+ .addColumn("app_id", "text", (c) => c.notNull())
51
+ .addColumn("portal", "text", (c) => c.notNull())
52
+ .addColumn("expires_at", "text", (c) => c.notNull())
53
+ .execute();
54
+ await db.schema
55
+ .createIndex("identity_sessions_expiry")
56
+ .on("identity_sessions")
57
+ .column("expires_at")
58
+ .execute();
59
+ await db.schema
60
+ .createTable("identity_throttles")
61
+ .addColumn("key", "text", (c) => c.primaryKey())
62
+ .addColumn("attempts", "integer", (c) => c.notNull())
63
+ .addColumn("expires_at", "text", (c) => c.notNull())
64
+ .execute();
65
+ },
66
+ async down(db) {
67
+ for (const table of [
68
+ "identity_throttles",
69
+ "identity_sessions",
70
+ "identity_memberships",
71
+ "identity_role_permissions",
72
+ "identity_permissions",
73
+ "identity_roles",
74
+ "identity_tenants",
75
+ "identity_users",
76
+ ])
77
+ await db.schema.dropTable(table).execute();
78
+ },
79
+ };
@@ -0,0 +1,3 @@
1
+ export declare function hashPassword(password: string): Promise<string>;
2
+ export declare function verifyPassword(password: string, encoded: string): Promise<boolean>;
3
+ export declare function digest(value: string): string;
@@ -0,0 +1,21 @@
1
+ import { randomBytes, scrypt, timingSafeEqual, createHash } from "node:crypto";
2
+ const options = { N: 32768, r: 8, p: 3, maxmem: 64 * 1024 * 1024 };
3
+ export async function hashPassword(password) {
4
+ const salt = randomBytes(16).toString("hex");
5
+ const key = await derive(password, salt);
6
+ return `scrypt$${salt}$${key.toString("hex")}`;
7
+ }
8
+ export async function verifyPassword(password, encoded) {
9
+ const [algorithm, salt, hex] = encoded.split("$");
10
+ if (algorithm !== "scrypt" ||
11
+ !/^[a-f0-9]{32}$/.test(salt ?? "") ||
12
+ !/^[a-f0-9]{128}$/.test(hex ?? ""))
13
+ return false;
14
+ return timingSafeEqual(await derive(password, salt), Buffer.from(hex, "hex"));
15
+ }
16
+ export function digest(value) {
17
+ return createHash("sha256").update(value).digest("hex");
18
+ }
19
+ function derive(password, salt) {
20
+ return new Promise((resolve, reject) => scrypt(password, salt, 64, options, (error, key) => error ? reject(error) : resolve(key)));
21
+ }
@@ -0,0 +1,5 @@
1
+ import type { Kysely } from "kysely";
2
+ export declare const identityPermissionDeclarationsMigration: {
3
+ up(db: Kysely<any>): Promise<void>;
4
+ down(db: Kysely<any>): Promise<void>;
5
+ };
@@ -0,0 +1,15 @@
1
+ export const identityPermissionDeclarationsMigration = {
2
+ async up(db) {
3
+ await db.schema.createTable("identity_permission_declarations")
4
+ .addColumn("permission_id", "text", (column) => column.primaryKey().references("identity_permissions.id"))
5
+ .addColumn("app_id", "text", (column) => column.notNull())
6
+ .addColumn("owner", "text", (column) => column.notNull())
7
+ .addColumn("portals", "text", (column) => column.notNull()).execute();
8
+ },
9
+ async down(db) {
10
+ const declared = await db.selectFrom("identity_permission_declarations").select("permission_id").executeTakeFirst();
11
+ if (declared)
12
+ throw new Error("Restore a compatible snapshot before removing permission ownership restrictions.");
13
+ await db.schema.dropTable("identity_permission_declarations").execute();
14
+ },
15
+ };
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+ import type { Kysely, Transaction } from "kysely";
3
+ import type { IdentitySchema, Portal } from "./identity.types.js";
4
+ declare const declarationSchema: z.ZodObject<{
5
+ owner: z.ZodString;
6
+ permissions: z.ZodArray<z.ZodObject<{
7
+ id: z.ZodString;
8
+ portals: z.ZodArray<z.ZodEnum<{
9
+ admin: "admin";
10
+ "super-admin": "super-admin";
11
+ user: "user";
12
+ }>>;
13
+ label: z.ZodOptional<z.ZodString>;
14
+ }, z.core.$strict>>;
15
+ }, z.core.$strict>;
16
+ export type IdentityPermissionDeclaration = z.input<typeof declarationSchema>;
17
+ export declare function registerIdentityPermissions(db: Kysely<IdentitySchema>, appId: string, raw: unknown): Promise<{
18
+ owner: string;
19
+ permissionIds: string[];
20
+ }>;
21
+ export declare function validateDeclaredPermissions(db: Kysely<IdentitySchema> | Transaction<IdentitySchema>, appId: string, portal: Portal, ids: string[]): Promise<void>;
22
+ export declare function filterDeclaredPermissions(db: Kysely<IdentitySchema>, appId: string, portal: Portal, ids: string[]): Promise<string[]>;
23
+ export {};
@@ -0,0 +1,57 @@
1
+ import { z } from "zod";
2
+ import { IdentityError } from "./identity.error.js";
3
+ import { portalSchema } from "./identity.schema.js";
4
+ const declarationSchema = z.object({
5
+ owner: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/).refine((value) => !["identity", "desk"].includes(value)),
6
+ permissions: z.array(z.object({
7
+ id: z.string().regex(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*){2,8}$/).max(250),
8
+ portals: z.array(portalSchema).min(1).max(3),
9
+ label: z.string().trim().min(1).max(100).regex(/^[^\x00-\x1f\x7f]+$/).optional(),
10
+ }).strict()).min(1).max(100),
11
+ }).strict();
12
+ export async function registerIdentityPermissions(db, appId, raw) {
13
+ const declaration = declarationSchema.parse(raw);
14
+ const ids = new Set();
15
+ for (const permission of declaration.permissions) {
16
+ if (!permission.id.startsWith(`${appId}.${declaration.owner}.`) || ids.has(permission.id))
17
+ throw new IdentityError(422, "Use unique permission IDs in this app and owner namespace.");
18
+ ids.add(permission.id);
19
+ }
20
+ return db.transaction().execute(async (trx) => {
21
+ for (const permission of declaration.permissions) {
22
+ const portals = JSON.stringify([...new Set(permission.portals)].sort());
23
+ const previous = await trx.selectFrom("identity_permission_declarations").selectAll()
24
+ .where("permission_id", "=", permission.id).executeTakeFirst();
25
+ if (previous) {
26
+ if (previous.app_id !== appId || previous.owner !== declaration.owner || previous.portals !== portals)
27
+ throw new IdentityError(409, "Permission declaration conflicts with its registered owner or portal policy.");
28
+ if (permission.label !== undefined && permission.label !== previous.label)
29
+ await trx.updateTable("identity_permission_declarations").set({ label: permission.label })
30
+ .where("permission_id", "=", permission.id).execute();
31
+ continue;
32
+ }
33
+ if (await trx.selectFrom("identity_permissions").select("id").where("id", "=", permission.id).executeTakeFirst())
34
+ throw new IdentityError(409, "Permission already exists without this owner declaration.");
35
+ await trx.insertInto("identity_permissions").values({ id: permission.id }).execute();
36
+ await trx.insertInto("identity_permission_declarations").values({
37
+ permission_id: permission.id, app_id: appId, owner: declaration.owner, portals, label: permission.label ?? null,
38
+ }).execute();
39
+ }
40
+ return { owner: declaration.owner, permissionIds: [...ids] };
41
+ });
42
+ }
43
+ export async function validateDeclaredPermissions(db, appId, portal, ids) {
44
+ const declarations = await db.selectFrom("identity_permission_declarations").selectAll().execute();
45
+ for (const id of ids) {
46
+ const declaration = declarations.find((row) => row.permission_id === id);
47
+ if (declaration && (declaration.app_id !== appId || !JSON.parse(declaration.portals).includes(portal)))
48
+ throw new IdentityError(403, "Permission is unavailable in this app or portal.");
49
+ }
50
+ }
51
+ export async function filterDeclaredPermissions(db, appId, portal, ids) {
52
+ const declarations = await db.selectFrom("identity_permission_declarations").selectAll().execute();
53
+ return ids.filter((id) => {
54
+ const declaration = declarations.find((row) => row.permission_id === id);
55
+ return !declaration || (declaration.app_id === appId && JSON.parse(declaration.portals).includes(portal));
56
+ });
57
+ }
@@ -0,0 +1,5 @@
1
+ import type { Kysely } from "kysely";
2
+ export declare const identityPermissionLabelsMigration: {
3
+ up(db: Kysely<any>): Promise<void>;
4
+ down(db: Kysely<any>): Promise<void>;
5
+ };
@@ -0,0 +1,9 @@
1
+ // Additive migration. Do not rewrite the previously applied declaration schema.
2
+ export const identityPermissionLabelsMigration = {
3
+ async up(db) {
4
+ await db.schema.alterTable("identity_permission_declarations").addColumn("label", "text").execute();
5
+ },
6
+ async down(db) {
7
+ await db.schema.alterTable("identity_permission_declarations").dropColumn("label").execute();
8
+ },
9
+ };