@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,327 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { validateDeclaredPermissions } from "./identity.permission-declarations.js";
3
+ import { IdentityError } from "./identity.error.js";
4
+ import { checkIdentityRequest } from "./identity.request-context.js";
5
+ import { protectAdministrators } from "./identity.membership-policy.js";
6
+ import { customRoleCreateSchema, customRoleUpdateSchema, membershipUpdateSchema, } from "./identity.administration-schema.js";
7
+ export class IdentityRolesService {
8
+ db;
9
+ constructor(db) {
10
+ this.db = db;
11
+ }
12
+ async resolve(database, actor, roleId, tenantId) {
13
+ if (["user", "admin", "super-admin"].includes(roleId)) {
14
+ if (actor.portal !== "super-admin" && roleId !== "user")
15
+ throw new IdentityError(403, "Only super administrators can assign privileged roles.");
16
+ return { portal: roleId, customRoleId: null };
17
+ }
18
+ const role = await database
19
+ .selectFrom("identity_custom_roles")
20
+ .select("id")
21
+ .where("id", "=", roleId)
22
+ .where("app_id", "=", actor.appId)
23
+ .where("tenant_id", "=", tenantId)
24
+ .where("active", "=", 1)
25
+ .executeTakeFirst();
26
+ if (!role)
27
+ throw new IdentityError(422, "Select an active role in this organization.");
28
+ return { portal: "user", customRoleId: role.id };
29
+ }
30
+ async create(actor, raw) {
31
+ this.manage(actor);
32
+ const input = customRoleCreateSchema.parse(raw);
33
+ const tenantId = input.tenantId ?? actor.tenant.id;
34
+ this.scope(actor, tenantId);
35
+ return this.db.transaction().execute(async (trx) => {
36
+ checkIdentityRequest();
37
+ const tenant = await trx
38
+ .selectFrom("identity_tenants")
39
+ .select("id")
40
+ .where("id", "=", tenantId)
41
+ .where("active", "=", 1)
42
+ .executeTakeFirst();
43
+ if (!tenant)
44
+ throw new IdentityError(422, "Organization is inactive or unavailable.");
45
+ await this.permissions(trx, input.permissionIds, actor.appId);
46
+ await this.uniqueName(trx, actor.appId, tenantId, input.name);
47
+ const id = randomUUID();
48
+ await trx
49
+ .insertInto("identity_custom_roles")
50
+ .values({
51
+ id,
52
+ app_id: actor.appId,
53
+ tenant_id: tenantId,
54
+ name: input.name,
55
+ })
56
+ .execute();
57
+ await this.replacePermissions(trx, id, input.permissionIds);
58
+ await this.audit(trx, actor, tenantId, "identity.role.created", id);
59
+ checkIdentityRequest();
60
+ return {
61
+ id,
62
+ name: input.name,
63
+ tenantId,
64
+ portal: "user",
65
+ system: false,
66
+ active: true,
67
+ version: 0,
68
+ permissionIds: [...new Set(input.permissionIds)],
69
+ };
70
+ });
71
+ }
72
+ async update(actor, id, raw) {
73
+ this.manage(actor);
74
+ const input = customRoleUpdateSchema.parse(raw);
75
+ return this.db.transaction().execute(async (trx) => {
76
+ checkIdentityRequest();
77
+ const role = await trx
78
+ .selectFrom("identity_custom_roles")
79
+ .selectAll()
80
+ .where("id", "=", id)
81
+ .where("app_id", "=", actor.appId)
82
+ .executeTakeFirst();
83
+ if (!role ||
84
+ (actor.portal !== "super-admin" && role.tenant_id !== actor.tenant.id))
85
+ throw new IdentityError(404, "Role not found.");
86
+ if (Number(role.version) !== input.expectedVersion)
87
+ throw new IdentityError(409, "This record changed. Reload before saving.");
88
+ if (input.permissionIds)
89
+ await this.permissions(trx, input.permissionIds, actor.appId);
90
+ if (input.name)
91
+ await this.uniqueName(trx, actor.appId, role.tenant_id, input.name, id);
92
+ const updated = await trx
93
+ .updateTable("identity_custom_roles")
94
+ .set({
95
+ name: input.name ?? role.name,
96
+ active: input.active === undefined ? role.active : Number(input.active),
97
+ version: input.expectedVersion + 1,
98
+ })
99
+ .where("id", "=", id)
100
+ .where("version", "=", input.expectedVersion)
101
+ .executeTakeFirst();
102
+ if (updated.numUpdatedRows !== 1n)
103
+ throw new IdentityError(409, "This record changed. Reload before saving.");
104
+ if (input.permissionIds)
105
+ await this.replacePermissions(trx, id, input.permissionIds);
106
+ await trx
107
+ .deleteFrom("identity_sessions")
108
+ .where("app_id", "=", actor.appId)
109
+ .where("tenant_id", "=", role.tenant_id)
110
+ .where("user_id", "in", trx
111
+ .selectFrom("identity_memberships")
112
+ .select("user_id")
113
+ .where("custom_role_id", "=", id))
114
+ .execute();
115
+ await this.audit(trx, actor, role.tenant_id, "identity.role.changed", id);
116
+ const permissions = await trx
117
+ .selectFrom("identity_custom_role_permissions")
118
+ .select("permission_id")
119
+ .where("role_id", "=", id)
120
+ .execute();
121
+ checkIdentityRequest();
122
+ return {
123
+ id,
124
+ name: input.name ?? role.name,
125
+ tenantId: role.tenant_id,
126
+ portal: "user",
127
+ system: false,
128
+ active: input.active ?? Boolean(role.active),
129
+ version: input.expectedVersion + 1,
130
+ permissionIds: permissions.map((row) => row.permission_id),
131
+ };
132
+ });
133
+ }
134
+ async updateMembership(actor, id, raw) {
135
+ this.manage(actor);
136
+ const input = membershipUpdateSchema.parse(raw);
137
+ const [userId, tenantId, oldPortal, extra] = id.split("~");
138
+ if (!userId || !tenantId || !oldPortal || extra)
139
+ throw new IdentityError(404, "Membership not found.");
140
+ this.scope(actor, tenantId);
141
+ if (actor.portal !== "super-admin" && oldPortal !== "user")
142
+ throw new IdentityError(403, "Only super administrators can change privileged memberships.");
143
+ return this.db.transaction().execute(async (trx) => {
144
+ checkIdentityRequest();
145
+ const current = await trx
146
+ .selectFrom("identity_memberships")
147
+ .selectAll()
148
+ .where("user_id", "=", userId)
149
+ .where("tenant_id", "=", tenantId)
150
+ .where("role_id", "=", oldPortal)
151
+ .executeTakeFirst();
152
+ if (!current)
153
+ throw new IdentityError(404, "Membership not found.");
154
+ if (current.custom_role_id &&
155
+ !(await trx
156
+ .selectFrom("identity_custom_roles")
157
+ .select("id")
158
+ .where("id", "=", current.custom_role_id)
159
+ .where("app_id", "=", actor.appId)
160
+ .executeTakeFirst()))
161
+ throw new IdentityError(404, "Membership not found.");
162
+ if (input.active &&
163
+ !(await trx
164
+ .selectFrom("identity_tenants")
165
+ .select("id")
166
+ .where("id", "=", tenantId)
167
+ .where("active", "=", 1)
168
+ .executeTakeFirst()))
169
+ throw new IdentityError(422, "Organization is inactive or unavailable.");
170
+ if (Number(current.version) !== input.expectedVersion)
171
+ throw new IdentityError(409, "This record changed. Reload before saving.");
172
+ const selected = await this.resolve(trx, actor, input.roleId, tenantId);
173
+ if (!input.active || selected.portal !== oldPortal)
174
+ await protectAdministrators(trx, userId, tenantId, oldPortal);
175
+ if (selected.portal !== oldPortal &&
176
+ (await trx
177
+ .selectFrom("identity_memberships")
178
+ .select("user_id")
179
+ .where("user_id", "=", userId)
180
+ .where("tenant_id", "=", tenantId)
181
+ .where("role_id", "=", selected.portal)
182
+ .executeTakeFirst()))
183
+ throw new IdentityError(409, "This account already has that portal membership.");
184
+ const changed = await trx
185
+ .updateTable("identity_memberships")
186
+ .set({
187
+ role_id: selected.portal,
188
+ custom_role_id: selected.customRoleId,
189
+ active: Number(input.active),
190
+ version: input.expectedVersion + 1,
191
+ })
192
+ .where("user_id", "=", userId)
193
+ .where("tenant_id", "=", tenantId)
194
+ .where("role_id", "=", oldPortal)
195
+ .where("version", "=", input.expectedVersion)
196
+ .executeTakeFirst();
197
+ if (changed.numUpdatedRows !== 1n)
198
+ throw new IdentityError(409, "This record changed. Reload before saving.");
199
+ await trx
200
+ .deleteFrom("identity_sessions")
201
+ .where("app_id", "=", actor.appId)
202
+ .where("user_id", "=", userId)
203
+ .where("tenant_id", "=", tenantId)
204
+ .where("portal", "=", oldPortal)
205
+ .execute();
206
+ const nextId = `${userId}~${tenantId}~${selected.portal}`;
207
+ await this.audit(trx, actor, tenantId, "identity.membership.changed", nextId);
208
+ checkIdentityRequest();
209
+ return {
210
+ id: nextId,
211
+ userId,
212
+ tenantId,
213
+ roleId: selected.customRoleId ?? selected.portal,
214
+ portal: selected.portal,
215
+ active: input.active,
216
+ version: input.expectedVersion + 1,
217
+ };
218
+ });
219
+ }
220
+ async removeMembership(actor, id, expectedVersion) {
221
+ this.manage(actor);
222
+ if (!Number.isInteger(expectedVersion) || Number(expectedVersion) < 0)
223
+ throw new IdentityError(422, "Provide the current membership version.");
224
+ const [userId, tenantId, portal, extra] = id.split("~");
225
+ if (!userId || !tenantId || !portal || extra)
226
+ throw new IdentityError(404, "Membership not found.");
227
+ this.scope(actor, tenantId);
228
+ if (actor.portal !== "super-admin" && portal !== "user")
229
+ throw new IdentityError(403, "Only super administrators can change privileged memberships.");
230
+ await this.db.transaction().execute(async (trx) => {
231
+ checkIdentityRequest();
232
+ const current = await trx
233
+ .selectFrom("identity_memberships")
234
+ .select(["custom_role_id", "version"])
235
+ .where("user_id", "=", userId)
236
+ .where("tenant_id", "=", tenantId)
237
+ .where("role_id", "=", portal)
238
+ .executeTakeFirst();
239
+ if (!current ||
240
+ (current.custom_role_id &&
241
+ !(await trx
242
+ .selectFrom("identity_custom_roles")
243
+ .select("id")
244
+ .where("id", "=", current.custom_role_id)
245
+ .where("app_id", "=", actor.appId)
246
+ .executeTakeFirst())))
247
+ throw new IdentityError(404, "Membership not found.");
248
+ if (Number(current.version) !== expectedVersion)
249
+ throw new IdentityError(409, "This record changed. Reload before saving.");
250
+ await protectAdministrators(trx, userId, tenantId, portal);
251
+ await trx
252
+ .deleteFrom("identity_memberships")
253
+ .where("user_id", "=", userId)
254
+ .where("tenant_id", "=", tenantId)
255
+ .where("role_id", "=", portal)
256
+ .execute();
257
+ await trx
258
+ .deleteFrom("identity_sessions")
259
+ .where("app_id", "=", actor.appId)
260
+ .where("user_id", "=", userId)
261
+ .where("tenant_id", "=", tenantId)
262
+ .where("portal", "=", portal)
263
+ .execute();
264
+ await this.audit(trx, actor, tenantId, "identity.membership.removed", id);
265
+ checkIdentityRequest();
266
+ });
267
+ }
268
+ manage(actor) {
269
+ if (actor.portal === "user" ||
270
+ !actor.permissions.includes("identity.manage"))
271
+ throw new IdentityError(403, "Access denied.");
272
+ }
273
+ scope(actor, tenantId) {
274
+ if (actor.portal !== "super-admin" && tenantId !== actor.tenant.id)
275
+ throw new IdentityError(403, "Access denied.");
276
+ }
277
+ async permissions(trx, ids, appId) {
278
+ await validateDeclaredPermissions(trx, appId, "user", ids);
279
+ if (!ids.includes("desk.user"))
280
+ throw new IdentityError(422, "Keep the user desk permission.");
281
+ if (ids.some((id) => id === "identity.manage" ||
282
+ (id.startsWith("desk.") && id !== "desk.user")))
283
+ throw new IdentityError(403, "Custom user roles cannot grant administration access.");
284
+ const known = await trx
285
+ .selectFrom("identity_permissions")
286
+ .select("id")
287
+ .execute();
288
+ if (ids.some((id) => !known.some((row) => row.id === id)))
289
+ throw new IdentityError(422, "Unknown permission.");
290
+ }
291
+ async uniqueName(trx, appId, tenantId, name, exceptId = "") {
292
+ if (await trx
293
+ .selectFrom("identity_custom_roles")
294
+ .select("id")
295
+ .where("app_id", "=", appId)
296
+ .where("tenant_id", "=", tenantId)
297
+ .where("name", "=", name)
298
+ .where("id", "!=", exceptId)
299
+ .executeTakeFirst())
300
+ throw new IdentityError(409, "A role with this name already exists.");
301
+ }
302
+ async replacePermissions(trx, id, permissions) {
303
+ await trx
304
+ .deleteFrom("identity_custom_role_permissions")
305
+ .where("role_id", "=", id)
306
+ .execute();
307
+ for (const permission of new Set(permissions))
308
+ await trx
309
+ .insertInto("identity_custom_role_permissions")
310
+ .values({ role_id: id, permission_id: permission })
311
+ .execute();
312
+ }
313
+ async audit(trx, actor, tenantId, action, resourceId) {
314
+ await trx
315
+ .insertInto("identity_audit_events")
316
+ .values({
317
+ id: randomUUID(),
318
+ app_id: actor.appId,
319
+ actor_id: actor.user.id,
320
+ tenant_id: tenantId,
321
+ action,
322
+ resource_id: resourceId,
323
+ created_at: new Date().toISOString(),
324
+ })
325
+ .execute();
326
+ }
327
+ }
@@ -0,0 +1,22 @@
1
+ import type { Portal } from "./identity.types.js";
2
+ export declare const identityRoutes: readonly [{
3
+ readonly method: "POST";
4
+ readonly path: "sessions";
5
+ readonly action: "login";
6
+ }, {
7
+ readonly method: "GET";
8
+ readonly path: "sessions/current";
9
+ readonly action: "session";
10
+ }, {
11
+ readonly method: "DELETE";
12
+ readonly path: "sessions/current";
13
+ readonly action: "logout";
14
+ }, {
15
+ readonly method: "PATCH";
16
+ readonly path: "password";
17
+ readonly action: "password";
18
+ }];
19
+ export declare const portalRoutes: Record<Portal, {
20
+ login: string;
21
+ desk: string;
22
+ }>;
@@ -0,0 +1,11 @@
1
+ export const identityRoutes = [
2
+ { method: "POST", path: "sessions", action: "login" },
3
+ { method: "GET", path: "sessions/current", action: "session" },
4
+ { method: "DELETE", path: "sessions/current", action: "logout" },
5
+ { method: "PATCH", path: "password", action: "password" },
6
+ ];
7
+ export const portalRoutes = {
8
+ user: { login: "/login", desk: "/desk" },
9
+ admin: { login: "/admin/login", desk: "/admin/desk" },
10
+ "super-admin": { login: "/sa/login", desk: "/sa/desk" },
11
+ };
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+ export declare const portalSchema: z.ZodEnum<{
3
+ admin: "admin";
4
+ "super-admin": "super-admin";
5
+ user: "user";
6
+ }>;
7
+ export declare const identityKeySchema: z.ZodString;
8
+ export declare const loginSchema: z.ZodObject<{
9
+ email: z.ZodPipe<z.ZodEmail, z.ZodTransform<string, string>>;
10
+ password: z.ZodString;
11
+ tenantId: z.ZodOptional<z.ZodString>;
12
+ }, z.core.$strict>;
13
+ export declare const passwordSchema: z.ZodObject<{
14
+ currentPassword: z.ZodString;
15
+ password: z.ZodString;
16
+ }, z.core.$strict>;
17
+ export declare const accountSchema: z.ZodObject<{
18
+ email: z.ZodPipe<z.ZodEmail, z.ZodTransform<string, string>>;
19
+ name: z.ZodString;
20
+ password: z.ZodString;
21
+ portal: z.ZodEnum<{
22
+ admin: "admin";
23
+ "super-admin": "super-admin";
24
+ user: "user";
25
+ }>;
26
+ tenantId: z.ZodString;
27
+ }, z.core.$strict>;
@@ -0,0 +1,34 @@
1
+ import { z } from "zod";
2
+ export const portalSchema = z.enum(["user", "admin", "super-admin"]);
3
+ export const identityKeySchema = z
4
+ .string()
5
+ .regex(/^[a-zA-Z0-9._-]{1,100}$/)
6
+ .refine((value) => !/^\.+$/.test(value), "Use an identifier with a letter or number.");
7
+ export const loginSchema = z
8
+ .object({
9
+ email: z
10
+ .email()
11
+ .max(254)
12
+ .transform((value) => value.trim().toLowerCase()),
13
+ password: z.string().min(1).max(256),
14
+ tenantId: identityKeySchema.optional(),
15
+ })
16
+ .strict();
17
+ export const passwordSchema = z
18
+ .object({
19
+ currentPassword: z.string().min(1).max(256),
20
+ password: z.string().min(12).max(256),
21
+ })
22
+ .strict();
23
+ export const accountSchema = z
24
+ .object({
25
+ email: z
26
+ .email()
27
+ .max(254)
28
+ .transform((value) => value.toLowerCase()),
29
+ name: z.string().trim().min(1).max(100),
30
+ password: z.string().min(12).max(256),
31
+ portal: portalSchema,
32
+ tenantId: identityKeySchema,
33
+ })
34
+ .strict();
@@ -0,0 +1,3 @@
1
+ import type { Kysely } from "kysely";
2
+ import type { IdentitySchema } from "./identity.types.js";
3
+ export declare function seedIdentity(db: Kysely<IdentitySchema>, env: NodeJS.ProcessEnv): Promise<void>;
@@ -0,0 +1,81 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { accountSchema } from "./identity.schema.js";
3
+ import { hashPassword } from "./identity.password.js";
4
+ export async function seedIdentity(db, env) {
5
+ const tenantId = env.IDENTITY_TENANT_ID ?? "default";
6
+ const accounts = ["user", "admin", "super-admin"].flatMap((portal) => {
7
+ const prefix = `IDENTITY_SEED_${portal.toUpperCase().replace("-", "_")}`;
8
+ const email = env[`${prefix}_EMAIL`];
9
+ const password = env[`${prefix}_PASSWORD`];
10
+ if (!email && !password)
11
+ return [];
12
+ return [
13
+ accountSchema.parse({
14
+ email,
15
+ password,
16
+ name: env[`${prefix}_NAME`]?.trim() || portal,
17
+ portal,
18
+ tenantId,
19
+ }),
20
+ ];
21
+ });
22
+ await db.transaction().execute(async (trx) => {
23
+ await trx
24
+ .insertInto("identity_tenants")
25
+ .values({
26
+ id: tenantId,
27
+ name: env.IDENTITY_TENANT_NAME ?? "Default organization",
28
+ active: 1,
29
+ })
30
+ .onConflict((c) => c.column("id").doNothing())
31
+ .execute();
32
+ for (const portal of ["user", "admin", "super-admin"]) {
33
+ await trx
34
+ .insertInto("identity_roles")
35
+ .values({ id: portal })
36
+ .onConflict((c) => c.column("id").doNothing())
37
+ .execute();
38
+ for (const permission of [
39
+ `desk.${portal}`,
40
+ "identity.self",
41
+ "identity.password",
42
+ ...(portal === "user" ? [] : ["identity.manage"]),
43
+ ]) {
44
+ await trx
45
+ .insertInto("identity_permissions")
46
+ .values({ id: permission })
47
+ .onConflict((c) => c.column("id").doNothing())
48
+ .execute();
49
+ await trx
50
+ .insertInto("identity_role_permissions")
51
+ .values({ role_id: portal, permission_id: permission })
52
+ .onConflict((c) => c.columns(["role_id", "permission_id"]).doNothing())
53
+ .execute();
54
+ }
55
+ }
56
+ for (const account of accounts) {
57
+ const existing = await trx
58
+ .selectFrom("identity_users")
59
+ .select("id")
60
+ .where("email", "=", account.email)
61
+ .executeTakeFirst();
62
+ if (existing)
63
+ continue;
64
+ const id = randomUUID();
65
+ await trx
66
+ .insertInto("identity_users")
67
+ .values({
68
+ id,
69
+ email: account.email,
70
+ name: account.name,
71
+ password_hash: await hashPassword(account.password),
72
+ active: 1,
73
+ })
74
+ .execute();
75
+ await trx
76
+ .insertInto("identity_memberships")
77
+ .values({ user_id: id, tenant_id: tenantId, role_id: account.portal })
78
+ .execute();
79
+ }
80
+ });
81
+ }
@@ -0,0 +1,32 @@
1
+ import { IdentityRepository } from "./identity.repository.js";
2
+ import type { IdentityConfig, Portal, Principal } from "./identity.types.js";
3
+ import type { z } from "zod";
4
+ import type { loginSchema, passwordSchema } from "./identity.schema.js";
5
+ export declare class IdentityService {
6
+ private readonly repository;
7
+ private readonly config;
8
+ private readonly dummyHash;
9
+ constructor(repository: IdentityRepository, config: IdentityConfig);
10
+ login(portal: Portal, input: z.infer<typeof loginSchema>, address: string, previous?: string): Promise<{
11
+ token: string;
12
+ sessionSeconds: number;
13
+ principal: {
14
+ user: {
15
+ id: string;
16
+ email: string;
17
+ name: string;
18
+ };
19
+ tenant: {
20
+ id: string;
21
+ name: string;
22
+ };
23
+ permissions: string[];
24
+ appId: string;
25
+ portal: Portal;
26
+ };
27
+ }>;
28
+ authenticate(portal: Portal, token?: string): Promise<Principal>;
29
+ requirePermission(principal: Principal, permission: string): void;
30
+ logout(portal: Portal, token?: string): Promise<void>;
31
+ changePassword(principal: Principal, input: z.infer<typeof passwordSchema>): Promise<void>;
32
+ }
@@ -0,0 +1,83 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { digest, hashPassword, verifyPassword } from "./identity.password.js";
3
+ import { IdentityError } from "./identity.error.js";
4
+ export class IdentityService {
5
+ repository;
6
+ config;
7
+ dummyHash = hashPassword(randomBytes(32).toString("hex"));
8
+ constructor(repository, config) {
9
+ this.repository = repository;
10
+ this.config = config;
11
+ }
12
+ async login(portal, input, address, previous) {
13
+ const key = digest(`${this.config.appId}:${portal}:${address}:${input.email}`);
14
+ const ipKey = digest(`${this.config.appId}:ip:${address}`);
15
+ if ((await this.repository.throttle(ipKey)) > 100 ||
16
+ (await this.repository.throttle(key)) > 10)
17
+ throw new IdentityError(429, "Too many sign-in attempts. Try again later.");
18
+ const user = await this.repository.user(input.email);
19
+ const valid = await verifyPassword(input.password, user?.password_hash ?? (await this.dummyHash));
20
+ const tenantId = this.config.mode === "single-client"
21
+ ? this.config.tenantId
22
+ : input.tenantId;
23
+ if (!valid ||
24
+ !user ||
25
+ !Number(user.active) ||
26
+ !tenantId ||
27
+ (this.config.mode === "single-client" &&
28
+ input.tenantId &&
29
+ input.tenantId !== tenantId))
30
+ throw new IdentityError(401, "Invalid credentials or portal access.");
31
+ const principal = await this.repository.principal(user.id, tenantId, portal, this.config.appId);
32
+ if (!principal?.permissions.includes(`desk.${portal}`))
33
+ throw new IdentityError(401, "Invalid credentials or portal access.");
34
+ const token = randomBytes(32).toString("base64url");
35
+ const sessionSeconds = await this.repository.sessionSeconds(this.config.appId, this.config.sessionSeconds);
36
+ const created = await this.repository.createSession({
37
+ token_hash: digest(token),
38
+ user_id: user.id,
39
+ tenant_id: tenantId,
40
+ app_id: this.config.appId,
41
+ portal,
42
+ expires_at: new Date(Date.now() + sessionSeconds * 1000).toISOString(),
43
+ }, user.password_hash, previous ? digest(previous) : undefined);
44
+ if (!created)
45
+ throw new IdentityError(401, "Invalid credentials or portal access.");
46
+ await this.repository.clearThrottle(key);
47
+ return {
48
+ token,
49
+ sessionSeconds,
50
+ principal: { ...principal, appId: this.config.appId, portal },
51
+ };
52
+ }
53
+ async authenticate(portal, token) {
54
+ if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token))
55
+ throw new IdentityError(401, "Sign in to continue.");
56
+ const session = await this.repository.session(digest(token), this.config.appId, portal, new Date().toISOString());
57
+ if (!session ||
58
+ (this.config.mode === "single-client" &&
59
+ session.tenant_id !== this.config.tenantId))
60
+ throw new IdentityError(401, "Sign in to continue.");
61
+ const principal = await this.repository.principal(session.user_id, session.tenant_id, portal, this.config.appId);
62
+ if (!principal)
63
+ throw new IdentityError(401, "Sign in to continue.");
64
+ return { ...principal, appId: this.config.appId, portal };
65
+ }
66
+ requirePermission(principal, permission) {
67
+ if (!principal.permissions.includes(permission))
68
+ throw new IdentityError(403, "Access denied.");
69
+ }
70
+ async logout(portal, token) {
71
+ if (token)
72
+ await this.repository.revoke(digest(token), this.config.appId, portal);
73
+ }
74
+ async changePassword(principal, input) {
75
+ this.requirePermission(principal, "identity.password");
76
+ const user = await this.repository.user(principal.user.email);
77
+ if (!user ||
78
+ !(await verifyPassword(input.currentPassword, user.password_hash)))
79
+ throw new IdentityError(422, "Current password is incorrect.");
80
+ if (!(await this.repository.changePassword(user.id, user.password_hash, await hashPassword(input.password))))
81
+ throw new IdentityError(409, "Account changed. Sign in again.");
82
+ }
83
+ }