@boopkit/cap-users 0.1.0

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 (58) hide show
  1. package/capability.json +50 -0
  2. package/dist/account-management-service.d.ts +52 -0
  3. package/dist/account-management-service.d.ts.map +1 -0
  4. package/dist/account-management-service.js +140 -0
  5. package/dist/account-management-service.js.map +1 -0
  6. package/dist/account-property-service.d.ts +18 -0
  7. package/dist/account-property-service.d.ts.map +1 -0
  8. package/dist/account-property-service.js +19 -0
  9. package/dist/account-property-service.js.map +1 -0
  10. package/dist/account-service.d.ts +84 -0
  11. package/dist/account-service.d.ts.map +1 -0
  12. package/dist/account-service.js +136 -0
  13. package/dist/account-service.js.map +1 -0
  14. package/dist/auth-middleware.d.ts +50 -0
  15. package/dist/auth-middleware.d.ts.map +1 -0
  16. package/dist/auth-middleware.js +149 -0
  17. package/dist/auth-middleware.js.map +1 -0
  18. package/dist/auth-service.d.ts +112 -0
  19. package/dist/auth-service.d.ts.map +1 -0
  20. package/dist/auth-service.js +575 -0
  21. package/dist/auth-service.js.map +1 -0
  22. package/dist/identity-service.d.ts +48 -0
  23. package/dist/identity-service.d.ts.map +1 -0
  24. package/dist/identity-service.js +98 -0
  25. package/dist/identity-service.js.map +1 -0
  26. package/dist/index.d.ts +27 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +40 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/team-invitation-service.d.ts +61 -0
  31. package/dist/team-invitation-service.d.ts.map +1 -0
  32. package/dist/team-invitation-service.js +237 -0
  33. package/dist/team-invitation-service.js.map +1 -0
  34. package/dist/types.d.ts +92 -0
  35. package/dist/types.d.ts.map +1 -0
  36. package/dist/types.js +24 -0
  37. package/dist/types.js.map +1 -0
  38. package/dist/user-property-service.d.ts +18 -0
  39. package/dist/user-property-service.d.ts.map +1 -0
  40. package/dist/user-property-service.js +19 -0
  41. package/dist/user-property-service.js.map +1 -0
  42. package/dist/user-service.d.ts +47 -0
  43. package/dist/user-service.d.ts.map +1 -0
  44. package/dist/user-service.js +39 -0
  45. package/dist/user-service.js.map +1 -0
  46. package/package.json +31 -0
  47. package/scaffold/prisma/schema/accounts.prisma +47 -0
  48. package/scaffold/prisma/schema/users.prisma +47 -0
  49. package/scaffold/reference/user-auth.ref.md +237 -0
  50. package/scaffold/src/controllers/auth.ts +158 -0
  51. package/scaffold/src/middleware/auth.ts +28 -0
  52. package/scaffold/src/routes/auth.ts +20 -0
  53. package/scaffold/src/services/accounts.service.ts +53 -0
  54. package/scaffold/src/services/users.service.ts +46 -0
  55. package/scaffold/styles/auth.css +120 -0
  56. package/scaffold/views/login.hbs +28 -0
  57. package/scaffold/views/partials/auth-nav.hbs +32 -0
  58. package/scaffold/views/signup.hbs +29 -0
@@ -0,0 +1,18 @@
1
+ import { createPropertyService, type PropertyService, type PropertyRecord } from '@boopkit/cap-services';
2
+ /** Options for creating a user property service. */
3
+ export interface UserPropertyServiceOptions {
4
+ onCreated?(record: PropertyRecord): void;
5
+ onUpdated?(record: PropertyRecord): void;
6
+ onRemoved?(record: PropertyRecord): void;
7
+ }
8
+ /** The service object returned by createUserPropertyService. */
9
+ export type UserPropertyService = PropertyService;
10
+ /**
11
+ * Create a property service pre-configured for user properties.
12
+ *
13
+ * Thin wrapper over createPropertyService with:
14
+ * - parentIdField set to 'userId'
15
+ * - sensitiveProperties set to ['credentials']
16
+ */
17
+ export declare function createUserPropertyService(model: Parameters<typeof createPropertyService>[0], options?: UserPropertyServiceOptions): UserPropertyService;
18
+ //# sourceMappingURL=user-property-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user-property-service.d.ts","sourceRoot":"","sources":["../src/user-property-service.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,qBAAqB,EACrB,KAAK,eAAe,EACpB,KAAK,cAAc,EACpB,MAAM,uBAAuB,CAAC;AAE/B,oDAAoD;AACpD,MAAM,WAAW,0BAA0B;IACzC,SAAS,CAAC,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAAC;IACzC,SAAS,CAAC,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAAC;IACzC,SAAS,CAAC,CAAC,MAAM,EAAE,cAAc,GAAG,IAAI,CAAC;CAC1C;AAED,gEAAgE;AAChE,MAAM,MAAM,mBAAmB,GAAG,eAAe,CAAC;AAElD;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC,CAAC,CAAC,EAClD,OAAO,CAAC,EAAE,0BAA0B,GACnC,mBAAmB,CAMrB"}
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createUserPropertyService = createUserPropertyService;
4
+ const cap_services_1 = require("@boopkit/cap-services");
5
+ /**
6
+ * Create a property service pre-configured for user properties.
7
+ *
8
+ * Thin wrapper over createPropertyService with:
9
+ * - parentIdField set to 'userId'
10
+ * - sensitiveProperties set to ['credentials']
11
+ */
12
+ function createUserPropertyService(model, options) {
13
+ return (0, cap_services_1.createPropertyService)(model, {
14
+ parentIdField: 'userId',
15
+ sensitiveProperties: ['credentials'],
16
+ ...options,
17
+ });
18
+ }
19
+ //# sourceMappingURL=user-property-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user-property-service.js","sourceRoot":"","sources":["../src/user-property-service.ts"],"names":[],"mappings":";;AAuBA,8DASC;AAhCD,wDAI+B;AAY/B;;;;;;GAMG;AACH,SAAgB,yBAAyB,CACvC,KAAkD,EAClD,OAAoC;IAEpC,OAAO,IAAA,oCAAqB,EAAC,KAAK,EAAE;QAClC,aAAa,EAAE,QAAQ;QACvB,mBAAmB,EAAE,CAAC,aAAa,CAAC;QACpC,GAAG,OAAO;KACX,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,47 @@
1
+ import type { EntityService } from '@boopkit/cap-services';
2
+ import type { BaseUserInfo, User } from './types.js';
3
+ /** Lifecycle callbacks for user mutations. */
4
+ export interface UserServiceOptions<TInfo extends BaseUserInfo> {
5
+ onCreated?(user: User<TInfo>): void;
6
+ onUpdated?(user: User<TInfo>): void;
7
+ onRemoved?(user: User<TInfo>): void;
8
+ }
9
+ /** The service object returned by createUserService. */
10
+ export interface UserService<TInfo extends BaseUserInfo> extends EntityService<TInfo> {
11
+ findByPublicId(publicId: string): Promise<User<TInfo> | null>;
12
+ }
13
+ /**
14
+ * Minimal delegate shape — extends the entity ModelDelegate with
15
+ * support for publicId lookups.
16
+ */
17
+ interface UserModelDelegate {
18
+ create(args: {
19
+ data: Record<string, unknown>;
20
+ }): Promise<Record<string, unknown>>;
21
+ findUnique(args: {
22
+ where: Record<string, unknown>;
23
+ }): Promise<Record<string, unknown> | null>;
24
+ findMany(args?: {
25
+ skip?: number;
26
+ take?: number;
27
+ }): Promise<Record<string, unknown>[]>;
28
+ update(args: {
29
+ where: {
30
+ id: number;
31
+ };
32
+ data: Record<string, unknown>;
33
+ }): Promise<Record<string, unknown>>;
34
+ delete(args: {
35
+ where: {
36
+ id: number;
37
+ };
38
+ }): Promise<Record<string, unknown>>;
39
+ }
40
+ /**
41
+ * Create a typed user service composing on top of createEntityService.
42
+ *
43
+ * Inherits CRUD from entity-service and adds `findByPublicId`.
44
+ */
45
+ export declare function createUserService<TInfo extends BaseUserInfo = BaseUserInfo>(model: UserModelDelegate, options?: UserServiceOptions<TInfo>): UserService<TInfo>;
46
+ export {};
47
+ //# sourceMappingURL=user-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user-service.d.ts","sourceRoot":"","sources":["../src/user-service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D,OAAO,KAAK,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAErD,8CAA8C;AAC9C,MAAM,WAAW,kBAAkB,CAAC,KAAK,SAAS,YAAY;IAC5D,SAAS,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC;IACpC,SAAS,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC;IACpC,SAAS,CAAC,CAAC,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC;CACrC;AAED,wDAAwD;AACxD,MAAM,WAAW,WAAW,CAAC,KAAK,SAAS,YAAY,CAAE,SAAQ,aAAa,CAAC,KAAK,CAAC;IACnF,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,CAAC;CAC/D;AAED;;;GAGG;AACH,UAAU,iBAAiB;IACzB,MAAM,CAAC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAClF,UAAU,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC;IAC9F,QAAQ,CAAC,IAAI,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC,CAAC;IACtF,MAAM,CAAC,IAAI,EAAE;QACX,KAAK,EAAE;YAAE,EAAE,EAAE,MAAM,CAAA;SAAE,CAAC;QACtB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KAC/B,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACrC,MAAM,CAAC,IAAI,EAAE;QAAE,KAAK,EAAE;YAAE,EAAE,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CAC3E;AAiBD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,SAAS,YAAY,GAAG,YAAY,EACzE,KAAK,EAAE,iBAAiB,EACxB,OAAO,CAAC,EAAE,kBAAkB,CAAC,KAAK,CAAC,GAClC,WAAW,CAAC,KAAK,CAAC,CAepB"}
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createUserService = createUserService;
4
+ const cap_services_1 = require("@boopkit/cap-services");
5
+ /**
6
+ * Map a raw delegate row to a typed User entity.
7
+ */
8
+ function toUser(row) {
9
+ return {
10
+ id: row.id,
11
+ publicId: row.publicId,
12
+ info: row.info,
13
+ infoSchema: row.infoSchema,
14
+ revision: row.revision,
15
+ createdAt: row.createdAt instanceof Date ? row.createdAt : new Date(row.createdAt),
16
+ updatedAt: row.updatedAt instanceof Date ? row.updatedAt : new Date(row.updatedAt),
17
+ };
18
+ }
19
+ /**
20
+ * Create a typed user service composing on top of createEntityService.
21
+ *
22
+ * Inherits CRUD from entity-service and adds `findByPublicId`.
23
+ */
24
+ function createUserService(model, options) {
25
+ const base = (0, cap_services_1.createEntityService)(model, {
26
+ infoSchema: 'user-01',
27
+ onCreated: options?.onCreated,
28
+ onUpdated: options?.onUpdated,
29
+ onRemoved: options?.onRemoved,
30
+ });
31
+ return {
32
+ ...base,
33
+ async findByPublicId(publicId) {
34
+ const row = await model.findUnique({ where: { publicId } });
35
+ return row ? toUser(row) : null;
36
+ },
37
+ };
38
+ }
39
+ //# sourceMappingURL=user-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"user-service.js","sourceRoot":"","sources":["../src/user-service.ts"],"names":[],"mappings":";;AAoDA,8CAkBC;AAtED,wDAA4D;AAgC5D;;GAEG;AACH,SAAS,MAAM,CAA6B,GAA4B;IACtE,OAAO;QACL,EAAE,EAAE,GAAG,CAAC,EAAY;QACpB,QAAQ,EAAE,GAAG,CAAC,QAAkB;QAChC,IAAI,EAAE,GAAG,CAAC,IAAa;QACvB,UAAU,EAAE,GAAG,CAAC,UAAoB;QACpC,QAAQ,EAAE,GAAG,CAAC,QAAkB;QAChC,SAAS,EAAE,GAAG,CAAC,SAAS,YAAY,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,SAAmB,CAAC;QAC5F,SAAS,EAAE,GAAG,CAAC,SAAS,YAAY,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,SAAmB,CAAC;KAC7F,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,SAAgB,iBAAiB,CAC/B,KAAwB,EACxB,OAAmC;IAEnC,MAAM,IAAI,GAAG,IAAA,kCAAmB,EAAQ,KAAY,EAAE;QACpD,UAAU,EAAE,SAAS;QACrB,SAAS,EAAE,OAAO,EAAE,SAAS;QAC7B,SAAS,EAAE,OAAO,EAAE,SAAS;QAC7B,SAAS,EAAE,OAAO,EAAE,SAA0D;KAC/E,CAAC,CAAC;IAEH,OAAO;QACL,GAAG,IAAI;QACP,KAAK,CAAC,cAAc,CAAC,QAAgB;YACnC,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC,CAAC;YAC5D,OAAO,GAAG,CAAC,CAAC,CAAC,MAAM,CAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;QACzC,CAAC;KACF,CAAC;AACJ,CAAC"}
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@boopkit/cap-users",
3
+ "version": "0.1.0",
4
+ "description": "User data service factories — typed CRUD for users, identities, and user properties",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "files": [
8
+ "dist/",
9
+ "scaffold/",
10
+ "capability.json"
11
+ ],
12
+ "boopkit": {
13
+ "capability": true,
14
+ "scaffoldDir": "scaffold",
15
+ "manifest": "capability.json"
16
+ },
17
+ "scripts": {
18
+ "build": "tsc -p tsconfig.json"
19
+ },
20
+ "dependencies": {
21
+ "@boopkit/cap-services": "^0.1.0",
22
+ "bcryptjs": "^2.4.3"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "^5.9.3"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "license": "ISC"
31
+ }
@@ -0,0 +1,47 @@
1
+ // @boopkit/cap-users — Account models
2
+ // Scaffolded by boop. Modify freely — this file is developer-owned.
3
+
4
+ model Account {
5
+ id Int @id @default(autoincrement())
6
+ publicId String @unique @default(uuid()) @map("public_id") @db.VarChar(255)
7
+ info Json
8
+ infoSchema String @default("account-01") @map("info_schema") @db.VarChar(255)
9
+ revision Int @default(1)
10
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
11
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
12
+
13
+ accountUsers AccountUser[]
14
+ accountProperties AccountProperty[]
15
+
16
+ @@map("accounts")
17
+ }
18
+
19
+ model AccountUser {
20
+ id Int @id @default(autoincrement())
21
+ accountId Int @map("account_id")
22
+ userId Int @map("user_id")
23
+ info Json
24
+ infoSchema String @default("account-user-01") @map("info_schema") @db.VarChar(255)
25
+ revision Int @default(1)
26
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
27
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
28
+ account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
29
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
30
+
31
+ @@unique([accountId, userId], map: "unique_account_user")
32
+ @@map("account_users")
33
+ }
34
+
35
+ model AccountProperty {
36
+ id Int @id @default(autoincrement())
37
+ accountId Int @map("account_id")
38
+ property String @db.VarChar(255)
39
+ info Json
40
+ infoSchema String @map("info_schema") @db.VarChar(255)
41
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
42
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
43
+ account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
44
+
45
+ @@unique([accountId, property], map: "unique_account_property")
46
+ @@map("account_properties")
47
+ }
@@ -0,0 +1,47 @@
1
+ // @boopkit/cap-users — User models
2
+ // Scaffolded by boop. Modify freely — this file is developer-owned.
3
+
4
+ model User {
5
+ id Int @id @default(autoincrement())
6
+ publicId String @unique @default(uuid()) @map("public_id") @db.VarChar(255)
7
+ info Json
8
+ infoSchema String @map("info_schema") @db.VarChar(255)
9
+ revision Int @default(0)
10
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
11
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
12
+
13
+ identities UserIdentity[]
14
+ properties UserProperty[]
15
+ accountUsers AccountUser[]
16
+
17
+ @@map("users")
18
+ }
19
+
20
+ model UserIdentity {
21
+ id Int @id @default(autoincrement())
22
+ userId Int @map("user_id")
23
+ domain String @db.VarChar(255)
24
+ key String @db.VarChar(255)
25
+ endedAt DateTime? @map("ended_at") @db.Timestamptz(6)
26
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
27
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
28
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
29
+
30
+ @@unique([domain, key], map: "unique_user_identity")
31
+ @@map("user_identities")
32
+ }
33
+
34
+ model UserProperty {
35
+ id Int @id @default(autoincrement())
36
+ userId Int @map("user_id")
37
+ property String @db.VarChar(255)
38
+ info Json
39
+ infoSchema String @map("info_schema") @db.VarChar(255)
40
+ revision Int @default(0)
41
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
42
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
43
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
44
+
45
+ @@unique([userId, property], map: "unique_user_property")
46
+ @@map("user_properties")
47
+ }
@@ -0,0 +1,237 @@
1
+ # User & Authentication Infrastructure
2
+
3
+ How this project models users, links identities, manages credentials, and tracks session trust.
4
+
5
+ **Related capabilities**: database, services
6
+
7
+ ---
8
+
9
+ ## Four-Layer Model
10
+
11
+ Authentication is built on four independent layers. Keeping them separate prevents bugs — logout shouldn't modify which methods are accepted, disabling a method shouldn't revoke existing sessions, and changing a password shouldn't touch authorization grants.
12
+
13
+ | Layer | Question | Keyed by | Storage |
14
+ |-------|----------|----------|---------|
15
+ | **Identity** | Who is this? | domain + key | UserIdentity table (end-dated, never deleted) |
16
+ | **Authentication** | What methods can they use? | strategy | UserProperty `authentication` |
17
+ | **Credentials** | What are the secrets? | strategy | UserProperty `credentials` (sensitive) |
18
+ | **Authorization** | What can this device/key do? | identity_key | UserProperty `authorization` |
19
+
20
+ **Identity** maps an incoming credential (cookie, email, API key, OAuth provider ID) to a user. A user has many identities. Identities are never deleted — they're end-dated, preserving history for audit.
21
+
22
+ **Authentication** declares which login methods are accepted. Disabling a method sets `accepted: false`, which the login flow checks before verifying credentials.
23
+
24
+ **Credentials** are the secrets (bcrypt hashes, TOTP seeds). Stored separately from authentication because they're sensitive and have a different lifecycle.
25
+
26
+ **Authorization** is the active permission grant, scoped **per-device**: each browser and each API key has its own trust level. Logging out on your phone doesn't affect your laptop.
27
+
28
+ ---
29
+
30
+ ## Data Model
31
+
32
+ Three tables, all following the standard boop entity shape (`id`, `publicId`, `info`, `infoSchema`, `revision`, timestamps):
33
+
34
+ ```
35
+ User ──< UserIdentity (one user, many auth mechanisms)
36
+ User ──< UserProperty (one user, many typed properties)
37
+ ```
38
+
39
+ ### User
40
+
41
+ The core record. Domain data lives in the `info` JSON column, typed via `BaseUserInfo` (extensible with generics).
42
+
43
+ ### UserIdentity
44
+
45
+ Links a user to an auth mechanism via `(domain, key)` compound unique key:
46
+
47
+ | Domain | Key | Example |
48
+ |--------|-----|---------|
49
+ | `email` | email address | `alice@example.com` |
50
+ | `cookie` | session token | `base64url-token` |
51
+ | `api-key` | hashed key | `hashed-base64url` |
52
+ | OAuth provider | external ID | `google-user-id` |
53
+
54
+ Soft-deleted via `endedAt` — revoked identities remain queryable with `includeEnded: true`.
55
+
56
+ ### UserProperty
57
+
58
+ Key-value storage scoped to a user via `(userId, property)` compound key. Well-known properties:
59
+
60
+ | Property | Contains | Sensitive? |
61
+ |----------|----------|-----------|
62
+ | `profile` | name, email (address + verified flag) | No |
63
+ | `credentials` | Array of `{ strategy, secret }` — bcrypt-hashed | **Yes** |
64
+ | `authentication` | Array of accepted auth methods with OAuth tokens | No |
65
+ | `authorization` | Array of per-device/per-key grants with trust tiers | No |
66
+
67
+ ---
68
+
69
+ ## Trust Model
70
+
71
+ Authorization grants track per-device trust with three graduated tiers — a user isn't simply "logged in" or "logged out":
72
+
73
+ | Tier | Meaning | How granted | Example use |
74
+ |------|---------|-------------|-------------|
75
+ | `recognized` | We think we know who this is | Default after expiry or logout | "Welcome back, Alice" — show name, preferences |
76
+ | `authenticated` | They proved identity recently | `login()` or `setTrust()` | Normal session — CRUD, purchases, account access |
77
+ | `verified` | Extra proof on an authenticated session | `setTrust()` after TOTP/email/biometric | Change email, delete account, manage API keys |
78
+
79
+ Grants have `expires_at` — expired grants downgrade to `recognized`. Standing grants (`expires_at = 0`) never expire (used for API keys).
80
+
81
+ ---
82
+
83
+ ## Session Rotation Rules
84
+
85
+ **Rotate the session** (new cookie) when the *authentication source* changes — login, re-authentication, switching users. This prevents session fixation: an attacker who knew the pre-auth token can't ride the post-auth session.
86
+
87
+ **Upgrade the existing grant** (same cookie) when the user proves *additional* identity strength on an already-authenticated session — TOTP, biometric, password re-entry for sensitive ops. Rotating would break their session for no security benefit.
88
+
89
+ *New credential source → rotate. Stronger proof on same session → upgrade.*
90
+
91
+ ---
92
+
93
+ ## Auth Flows
94
+
95
+ ### Password (local auth)
96
+
97
+ All four layers participate:
98
+
99
+ | Layer | What happens |
100
+ |-------|-------------|
101
+ | **Identity** | `domain: "email", key: "alice@example.com"` — lookup for login |
102
+ | **Authentication** | `{ strategy: "local", accepted: true }` — user can log in with password |
103
+ | **Credentials** | `{ strategy: "local", secret: "$2b$..." }` — bcrypt hash |
104
+ | **Authorization** | Created on login, tied to the session cookie |
105
+
106
+ **Signup:** create user → create email identity → set credentials (hash password) → add local auth method → mint session cookie → create per-device grant at `authenticated`.
107
+
108
+ **Login:** look up email identity → check `isMethodAccepted("local")` → verify bcrypt hash → rotate session (new cookie) → create per-device grant at `authenticated`.
109
+
110
+ ### Cookie (session)
111
+
112
+ A cookie is an identity — `domain: "cookie", key: "a8f3...hex"`. One per browser. Each cookie has its own authorization grant:
113
+
114
+ ```
115
+ Browser A: cookie aaa..., grant trust=authenticated
116
+ Browser B: cookie bbb..., grant trust=verified (just did TOTP)
117
+ Browser C: cookie ccc..., grant trust=recognized (session expired)
118
+ ```
119
+
120
+ **Session rotation:** on login (privilege-level change), the old cookie is end-dated and a new one minted. The old grant is removed, the new cookie gets a fresh grant.
121
+
122
+ **Session expiry:** when a grant's `expires_at` passes, that device's trust falls to `recognized`. The cookie still resolves the user, but they can't perform authenticated actions until they re-login.
123
+
124
+ ### API Key
125
+
126
+ An API key is simultaneously an identity, a credential, and a bearer token. All four layers participate:
127
+
128
+ | Layer | What happens |
129
+ |-------|-------------|
130
+ | **Identity** | `domain: "api-key", key: "47ZHJY-..."` — lookup on each request |
131
+ | **Authentication** | `{ strategy: "api-key", accepted: true }` — user can use API keys |
132
+ | **Credentials** | `{ strategy: "api-key", secret: "$2b$..." }` — bcrypt hash of the key |
133
+ | **Authorization** | `{ identity_key: "47ZHJY-...", strategy: "api-key", trust: "authenticated", expires_at: 0 }` — standing per-key grant |
134
+
135
+ **Three independent levers to control access:**
136
+ 1. **Revoke the key** — end-date the identity. Immediate.
137
+ 2. **Disable API key auth** — set `accepted: false` on the authentication entry. All API keys stop working, but keys still exist.
138
+ 3. **Expire the grant** — set `expires_at` on the per-key grant. Can be re-enabled without rotating the key.
139
+
140
+ **Validation flow:** identity lookup → `isMethodAccepted("api-key")` → bcrypt compare → check per-key grant → return user.
141
+
142
+ The plaintext key is returned exactly once at creation. Only the hash is stored.
143
+
144
+ ### OAuth (as client)
145
+
146
+ When a user logs in via Google (or other provider), the app is an OAuth *client*. The provider proves identity; boop stores the result.
147
+
148
+ | Layer | What happens |
149
+ |-------|-------------|
150
+ | **Identity** | `domain: "google", key: "google-sub-id-12345"` — maps provider ID to user |
151
+ | **Authentication** | `{ strategy: "oauth-google", accepted: true, refresh_token: "rt_...", access_token: "at_...", ... }` — delegated credentials stored |
152
+ | **Credentials** | None — OAuth credentials are delegated, stored in the authentication entry |
153
+ | **Authorization** | Created on login, tied to the session cookie — same as password login |
154
+
155
+ OAuth tokens (`refresh_token`, `access_token`) are **delegated credentials** — tokens held from the provider to act on the user's behalf. They are NOT how the app recognizes the user on subsequent requests. Users are recognized via cookies, same as password login.
156
+
157
+ **OAuth login flow:** provider callback with auth code → exchange for tokens → look up or create identity (`domain: "google"`) → store tokens in authentication entry → rotate session → create per-device grant.
158
+
159
+ ### TOTP / Second Factor
160
+
161
+ TOTP upgrades trust on the current device. It does NOT rotate the session — the user already proved identity at login (which rotated the session). TOTP is additional proof on the same session, not a new authentication source.
162
+
163
+ ---
164
+
165
+ ## Logout vs. Forget
166
+
167
+ | User action | Function | Cookie | Grant | Other devices |
168
+ |-------------|----------|--------|-------|---------------|
169
+ | "Log out" | `logout(userId, token)` | Survives | trust → `recognized` | Unaffected |
170
+ | "Log out everywhere" | `logoutAll(userId)` | Survive | All session grants → `recognized` | All downgraded |
171
+ | "Forget me" | `forgetDevice(userId, token)` | End-dated | Removed | Unaffected |
172
+ | "Sign out everywhere" | `forgetAllDevices(userId)` | All end-dated | All session grants removed | All forgotten |
173
+
174
+ API key grants are not affected by any of these — managed separately via `revokeApiKey`.
175
+
176
+ ---
177
+
178
+ ## Services
179
+
180
+ All services compose from `@boopkit/cap-services` primitives:
181
+
182
+ | Service | Factory | Purpose |
183
+ |---------|---------|---------|
184
+ | `userService` | `createUserService<TInfo>()` | CRUD on User with typed `info` |
185
+ | `identityService` | `createIdentityService()` | Link/unlink auth mechanisms |
186
+ | `userPropertyService` | `createUserPropertyService()` | Read/write user properties (credentials gated) |
187
+ | `authService` | `createAuthService(deps, opts)` | Orchestrates signup, login, sessions, API keys |
188
+
189
+ ### AuthService key operations
190
+
191
+ **Signup**: `signup({ email, password, name })` — creates user, email identity, profile, credentials, auth method, session token, and authorization grant in one call. Each step is also available individually for graduated control.
192
+
193
+ **Login**: `login(email, password, currentSessionToken?)` — validates credentials, rotates session (ends old token if provided), grants `authenticated` trust.
194
+
195
+ **Session management**:
196
+ - `logout(userId, token)` — downgrades trust to `recognized` (device remembers user but loses auth)
197
+ - `forgetDevice(userId, token)` — end-dates cookie identity and removes grant (irreversible)
198
+ - `logoutAll` / `forgetAllDevices` — bulk variants
199
+
200
+ **API keys**: `createApiKey(userId)` / `validateApiKey(key)` / `revokeApiKey` / `rotateApiKey` — standing grants that never expire.
201
+
202
+ **Trust queries**: `getTrustTier(userId, identityKey)`, `isAuthorized(userId, identityKey, requiredTrust?)`.
203
+
204
+ ---
205
+
206
+ ## Extension
207
+
208
+ Define project-specific user data via generics:
209
+
210
+ ```typescript
211
+ interface MyUserInfo extends BaseUserInfo {
212
+ department: string;
213
+ role: 'admin' | 'member';
214
+ }
215
+
216
+ const userService = createUserService<MyUserInfo>(db.user);
217
+ ```
218
+
219
+ The type propagates through all service operations.
220
+
221
+ ---
222
+
223
+ ## Environment
224
+
225
+ | Variable | Default | Purpose |
226
+ |----------|---------|---------|
227
+ | `SESSION_DURATION` | `604800` (7 days) | Session token lifetime in seconds |
228
+
229
+ ---
230
+
231
+ ## Key Files
232
+
233
+ | File | Purpose |
234
+ |------|---------|
235
+ | `prisma/users.prisma` | User, UserIdentity, UserProperty table definitions |
236
+ | `src/services/users.service.ts` | Service wiring — instantiates all user services |
237
+ | `@boopkit/cap-users` | Service factories (user, identity, property, auth) |
@@ -0,0 +1,158 @@
1
+ import type { Response } from 'express';
2
+ import type { AuthService } from '@boopkit/cap-users';
3
+ import { AuthValidationError } from '@boopkit/cap-users';
4
+
5
+ /** Extended Express Request with auth fields set by ensureVisitor middleware. */
6
+ interface AuthRequest {
7
+ body: Record<string, any>;
8
+ query: Record<string, any>;
9
+ cookies?: Record<string, string>;
10
+ user?: { id: number; [key: string]: any };
11
+ sessionToken?: string;
12
+ }
13
+
14
+ const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
15
+ const COOKIE_NAME = 'auth_id';
16
+
17
+ /** Map AuthValidationError field names to view-friendly error keys. */
18
+ const FIELD_ERROR_MAP: Record<string, string> = {
19
+ email: 'email_invalid',
20
+ password: 'password_weak',
21
+ passwordConfirmation: 'password_mismatch',
22
+ };
23
+
24
+ export interface AuthControllerDeps {
25
+ authService: AuthService;
26
+ }
27
+
28
+ export function createAuthController(deps: AuthControllerDeps) {
29
+ const { authService } = deps;
30
+
31
+ // ---- Login ----
32
+
33
+ async function showLogin(req: AuthRequest, res: Response): Promise<void> {
34
+ // Already authenticated? Redirect away.
35
+ if (res.locals.auth?.trustTier === 'authenticated' || res.locals.auth?.trustTier === 'verified') {
36
+ const next = req.query.next as string | undefined;
37
+ const redirectTo = (typeof next === 'string' && next.startsWith('/')) ? next : '/';
38
+ res.redirect(redirectTo);
39
+ return;
40
+ }
41
+
42
+ const next = req.query.next as string | undefined;
43
+ res.render('login', {
44
+ layout: 'main',
45
+ action: '/login',
46
+ next,
47
+ error: req.query.error as string | undefined,
48
+ });
49
+ }
50
+
51
+ async function handleLogin(req: AuthRequest, res: Response): Promise<void> {
52
+ const { email, password } = req.body;
53
+ const next = req.body.next || req.query.next as string | undefined;
54
+ const currentSessionToken = req.cookies?.[COOKIE_NAME] as string | undefined;
55
+
56
+ try {
57
+ const result = await authService.login(email, password, currentSessionToken);
58
+
59
+ // Set rotated session cookie
60
+ res.cookie(COOKIE_NAME, result.sessionToken, {
61
+ httpOnly: true,
62
+ maxAge: ONE_YEAR_MS,
63
+ });
64
+
65
+ // Redirect to ?next= or home
66
+ const redirectTo = (typeof next === 'string' && next.startsWith('/')) ? next : '/';
67
+ res.redirect(redirectTo);
68
+ } catch {
69
+ // Generic error — same message for invalid password and nonexistent email (no enumeration)
70
+ res.render('login', {
71
+ layout: 'main',
72
+ action: '/login',
73
+ next,
74
+ email,
75
+ errorMessage: 'Invalid email or password',
76
+ });
77
+ }
78
+ }
79
+
80
+ // ---- Signup ----
81
+
82
+ async function showSignup(req: AuthRequest, res: Response): Promise<void> {
83
+ // Already authenticated? Redirect away.
84
+ if (res.locals.auth?.trustTier === 'authenticated' || res.locals.auth?.trustTier === 'verified') {
85
+ const next = req.query.next as string | undefined;
86
+ const redirectTo = (typeof next === 'string' && next.startsWith('/')) ? next : '/';
87
+ res.redirect(redirectTo);
88
+ return;
89
+ }
90
+
91
+ const next = req.query.next as string | undefined;
92
+ res.render('signup', {
93
+ layout: 'main',
94
+ action: '/signup',
95
+ next,
96
+ });
97
+ }
98
+
99
+ async function handleSignup(req: AuthRequest, res: Response): Promise<void> {
100
+ const { email, password, passwordConfirmation } = req.body;
101
+ const next = req.body.next || req.query.next as string | undefined;
102
+
103
+ try {
104
+ const result = await authService.signup({ email, password, passwordConfirmation });
105
+
106
+ // Set session cookie
107
+ res.cookie(COOKIE_NAME, result.sessionToken, {
108
+ httpOnly: true,
109
+ maxAge: ONE_YEAR_MS,
110
+ });
111
+
112
+ // Redirect to ?next= or home
113
+ const redirectTo = (typeof next === 'string' && next.startsWith('/')) ? next : '/';
114
+ res.redirect(redirectTo);
115
+ } catch (err) {
116
+ // Build per-field error flags for the view
117
+ const errors: Record<string, boolean> = {};
118
+ let errorMessage = 'Signup failed';
119
+
120
+ if (err instanceof AuthValidationError) {
121
+ const key = FIELD_ERROR_MAP[err.field] ?? err.field;
122
+ errors[key] = true;
123
+ errorMessage = err.message;
124
+ } else if (err instanceof Error && err.message.includes('Unique constraint')) {
125
+ errors.user_exists = true;
126
+ errorMessage = 'An account with this email already exists';
127
+ }
128
+
129
+ res.render('signup', {
130
+ layout: 'main',
131
+ action: '/signup',
132
+ next,
133
+ email,
134
+ errors,
135
+ errorMessage,
136
+ });
137
+ }
138
+ }
139
+
140
+ // ---- Logout ----
141
+
142
+ async function handleLogout(req: AuthRequest, res: Response): Promise<void> {
143
+ if (req.user && req.sessionToken) {
144
+ await authService.logout(req.user.id, req.sessionToken);
145
+ }
146
+ res.redirect('/');
147
+ }
148
+
149
+ async function handleForgetDevice(req: AuthRequest, res: Response): Promise<void> {
150
+ if (req.user && req.sessionToken) {
151
+ await authService.forgetDevice(req.user.id, req.sessionToken);
152
+ }
153
+ res.clearCookie(COOKIE_NAME);
154
+ res.redirect('/');
155
+ }
156
+
157
+ return { showLogin, handleLogin, showSignup, handleSignup, handleLogout, handleForgetDevice };
158
+ }