@boopkit/cap-users 0.1.0 → 0.3.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.
- package/README.md +142 -0
- package/capability.json +3 -5
- package/package.json +5 -3
- package/scaffold/src/middleware/auth.ts +5 -13
- package/skills/design.user-data-modeling.skill.md +73 -0
package/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# @boopkit/cap-users
|
|
2
|
+
|
|
3
|
+
**Part of [Boop](https://www.npmjs.com/package/@boopkit/cli)** — a bootstrap platform for AI-first builders. `boop init` scaffolds a running Node/TypeScript web app from composable **capabilities**, each one bundling the code, dependencies, middleware wiring, and agent skills needed for a feature. You pick the capabilities; `boop add` handles the rest.
|
|
4
|
+
|
|
5
|
+
`cap-users` is the accounts-and-auth layer. It gives your app users, identities, accounts, properties, and cookie-based session authentication — backed by Prisma models, factory-built services, and Express middleware that resolves the current user on every request. Signup, login, and logout work out of the box; you get a trust-tier model (`recognized` / `authenticated` / `verified`) and a `requireAuth()` gate you can drop in front of any route.
|
|
6
|
+
|
|
7
|
+
## What You Get
|
|
8
|
+
|
|
9
|
+
### Scaffold files
|
|
10
|
+
|
|
11
|
+
Copied into your project on install:
|
|
12
|
+
|
|
13
|
+
- `prisma/schema/users.prisma` — `User`, `UserIdentity`, `UserProperty` models
|
|
14
|
+
- `prisma/schema/accounts.prisma` — `Account`, `AccountUser`, `AccountProperty` models
|
|
15
|
+
- `src/services/users.service.ts` — wires `userService`, `identityService`, `userPropertyService`, `authService` to Prisma
|
|
16
|
+
- `src/services/accounts.service.ts` — wires `accountService`, `accountPropertyService`, `accountManagementService`, `teamInvitationService`
|
|
17
|
+
- `src/middleware/auth.ts` — `ensureVisitor`, `addAuthContext`, `requireAuth` middleware instances
|
|
18
|
+
- `src/controllers/auth.ts` — signup, login, logout, forget-device controllers
|
|
19
|
+
- `src/routes/auth.ts` — Express router mounting `/signup`, `/login`, `/logout`, `/logout/forget`
|
|
20
|
+
- `views/signup.hbs`, `views/login.hbs`, `views/partials/auth-nav.hbs` — auth views and nav partial
|
|
21
|
+
- `styles/auth.css` — semantic styles for the auth views
|
|
22
|
+
- `reference/user-auth.ref.md` — reference doc explaining the four-layer auth model
|
|
23
|
+
|
|
24
|
+
### Server hooks
|
|
25
|
+
|
|
26
|
+
Wired into `src/server.ts` at install time:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import cookieParser from 'cookie-parser';
|
|
30
|
+
import { ensureVisitor, addAuthContext, authRoutes } from './middleware/auth';
|
|
31
|
+
import AccountService from './services/accounts.service';
|
|
32
|
+
|
|
33
|
+
// ...
|
|
34
|
+
|
|
35
|
+
app.use(cookieParser()); // order 14
|
|
36
|
+
app.use(ensureVisitor); // order 15 — resolve session from auth_id cookie
|
|
37
|
+
app.use(addAuthContext); // order 16 — populate res.locals.auth for views
|
|
38
|
+
app.use(authRoutes); // order 52 — mount /signup, /login, /logout
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`middleware/auth.ts` imports `UserService` transitively and builds the middleware + router from it, so the wiring stays in one place. `ensureVisitor` runs on every request; if the `auth_id` cookie is missing or stale it creates an anonymous `User` with a fresh session — every visitor has an identity from their first request, whether they've signed up or not. Login upgrades the trust tier on the same underlying user.
|
|
42
|
+
|
|
43
|
+
### Skills installed
|
|
44
|
+
|
|
45
|
+
Copied into `skills/` on install:
|
|
46
|
+
|
|
47
|
+
- `design.user-data-modeling.skill.md` — teaches agents where user-related data belongs: when to use `User.info` vs. `UserProperty`, why `User` / `Account` / `AccountUser` are distinct, and how to spot misplaced fields in review. Pairs with `reference/user-auth.ref.md`.
|
|
48
|
+
|
|
49
|
+
### Post-install
|
|
50
|
+
|
|
51
|
+
1. Run the database migration: `npm run db:migrate -- --name add-users-and-accounts`
|
|
52
|
+
2. Generate the Prisma client: `npm run db:generate`
|
|
53
|
+
3. Add `{{> auth-nav}}` inside `<body>` in your `views/layouts/main.hbs`, before your main content.
|
|
54
|
+
|
|
55
|
+
`boop status` reminds you of these steps.
|
|
56
|
+
|
|
57
|
+
## Requirements
|
|
58
|
+
|
|
59
|
+
**Other capabilities:**
|
|
60
|
+
|
|
61
|
+
- `cap-database` — Prisma schema merging and migration tooling
|
|
62
|
+
- `cap-services` — service factory pattern and boot-time `loadServices()` auto-discovery
|
|
63
|
+
- `cap-web` — Handlebars view engine (the `/signup` and `/login` pages render as server-side views)
|
|
64
|
+
|
|
65
|
+
**npm dependencies added to your project:**
|
|
66
|
+
|
|
67
|
+
- `bcryptjs` — password hashing
|
|
68
|
+
- `cookie-parser` — parse the `auth_id` session cookie
|
|
69
|
+
- `@boopkit/cap-users` — this package (exported services, middleware factories, and types)
|
|
70
|
+
|
|
71
|
+
**External services:**
|
|
72
|
+
|
|
73
|
+
- **PostgreSQL** — required by the `cap-database` capability. The user and account tables are created by `npm run db:migrate`.
|
|
74
|
+
|
|
75
|
+
**Environment variables:**
|
|
76
|
+
|
|
77
|
+
| Variable | Required | Default | Description |
|
|
78
|
+
|----------|----------|---------|-------------|
|
|
79
|
+
| `SESSION_DURATION` | Optional | `604800` (7 days) | Session token lifetime in **seconds** |
|
|
80
|
+
| `INVITATION_TTL` | Optional | `604800000` (7 days) | Team invitation expiry in **milliseconds** |
|
|
81
|
+
|
|
82
|
+
No secrets required — session tokens are generated with `crypto.randomBytes` and stored hashed.
|
|
83
|
+
|
|
84
|
+
## Usage
|
|
85
|
+
|
|
86
|
+
The middleware, routes, and views give you working signup and login immediately. Here's how to protect a page behind authentication and read the current user.
|
|
87
|
+
|
|
88
|
+
**1.** Protect a route with `requireAuth()`:
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// src/routes/pages.ts
|
|
92
|
+
import { Router } from 'express';
|
|
93
|
+
import { requireAuth } from '../middleware/auth';
|
|
94
|
+
import * as pages from '../controllers/pages';
|
|
95
|
+
|
|
96
|
+
const router = Router();
|
|
97
|
+
|
|
98
|
+
router.get('/', pages.home);
|
|
99
|
+
router.get('/dashboard', requireAuth(), pages.dashboard); // authenticated required
|
|
100
|
+
router.get('/account', requireAuth('verified'), pages.account); // verified (2FA-style) required
|
|
101
|
+
|
|
102
|
+
export default router;
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Unauthenticated web requests are redirected to `/login?next=<original-url>`. API requests (paths under `/api/`) get a `401 { error: 'Unauthorized' }` response instead.
|
|
106
|
+
|
|
107
|
+
**2.** Read the current user in a controller:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
// src/controllers/pages.ts
|
|
111
|
+
import type { Request, Response } from 'express';
|
|
112
|
+
import type { User, BaseUserInfo } from '@boopkit/cap-users';
|
|
113
|
+
|
|
114
|
+
export function dashboard(req: Request, res: Response): void {
|
|
115
|
+
const user = (req as Request & { user: User<BaseUserInfo> }).user;
|
|
116
|
+
res.render('dashboard', { email: user.info.email });
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`req.user` is set by `ensureVisitor` on every request. For views, `res.locals.auth` is populated by `addAuthContext` with `trustTier`, `userName`, `userEmail`, and `userInitial` — the `auth-nav` partial uses these to show the right sign-in / sign-out affordances.
|
|
121
|
+
|
|
122
|
+
### Extending the user shape
|
|
123
|
+
|
|
124
|
+
User and account domain data live in JSON `info` columns typed by `BaseUserInfo` / `BaseAccountInfo`. To add project-specific fields, declare your own interface and pass it as a type parameter:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
// src/services/users.service.ts
|
|
128
|
+
interface MyUserInfo extends BaseUserInfo {
|
|
129
|
+
department: string;
|
|
130
|
+
role: 'admin' | 'member';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const userService = createUserService<MyUserInfo>(db.user);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
No migration needed — the JSON column holds any shape. See `reference/user-auth.ref.md` for the full four-layer model (Identity / Authentication / Credentials / Authorization) and the trust-tier semantics.
|
|
137
|
+
|
|
138
|
+
## Added by
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
boop add users-and-accounts
|
|
142
|
+
```
|
package/capability.json
CHANGED
|
@@ -20,17 +20,15 @@
|
|
|
20
20
|
"styles/auth.css",
|
|
21
21
|
"reference/user-auth.ref.md"
|
|
22
22
|
],
|
|
23
|
-
"skills": [],
|
|
23
|
+
"skills": ["design.user-data-modeling.skill.md"],
|
|
24
24
|
"hooks": [
|
|
25
25
|
{ "phase": "imports", "code": "import cookieParser from 'cookie-parser';", "description": "cookie parsing for session tokens" },
|
|
26
|
-
{ "phase": "imports", "code": "import
|
|
27
|
-
{ "phase": "imports", "code": "import { ensureVisitor, addAuthContext } from './middleware/auth';", "description": "auth middleware — session resolution and trust context" },
|
|
28
|
-
{ "phase": "imports", "code": "import authRoutes from './routes/auth';", "description": "auth route definitions" },
|
|
26
|
+
{ "phase": "imports", "code": "import { ensureVisitor, addAuthContext, authRoutes } from './middleware/auth';", "description": "auth middleware and routes — session resolution, trust context, signup/login/logout" },
|
|
29
27
|
{ "phase": "imports", "code": "import AccountService from './services/accounts.service';", "description": "account service wiring" },
|
|
30
28
|
{ "phase": "preRoutes", "order": 14, "code": "app.use(cookieParser());", "description": "parse cookies (required for auth session resolution)" },
|
|
31
29
|
{ "phase": "preRoutes", "order": 15, "code": "app.use(ensureVisitor);", "description": "resolve visitor identity from auth_id cookie" },
|
|
32
30
|
{ "phase": "preRoutes", "order": 16, "code": "app.use(addAuthContext);", "description": "populate res.locals.auth for view templates" },
|
|
33
|
-
{ "phase": "preRoutes", "order": 52, "code": "app.use(authRoutes
|
|
31
|
+
{ "phase": "preRoutes", "order": 52, "code": "app.use(authRoutes);", "description": "mount auth routes (signup, login, logout)" }
|
|
34
32
|
],
|
|
35
33
|
"env": [
|
|
36
34
|
{ "name": "SESSION_DURATION", "description": "Session token lifetime in seconds (default: 7 days)", "default": "604800", "required": "runtime" },
|
package/package.json
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boopkit/cap-users",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "User
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "User accounts, authentication, and session management via Prisma",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"files": [
|
|
8
8
|
"dist/",
|
|
9
9
|
"scaffold/",
|
|
10
|
+
"skills/",
|
|
10
11
|
"capability.json"
|
|
11
12
|
],
|
|
12
13
|
"boopkit": {
|
|
13
14
|
"capability": true,
|
|
14
15
|
"scaffoldDir": "scaffold",
|
|
16
|
+
"skillsDir": "skills",
|
|
15
17
|
"manifest": "capability.json"
|
|
16
18
|
},
|
|
17
19
|
"scripts": {
|
|
18
20
|
"build": "tsc -p tsconfig.json"
|
|
19
21
|
},
|
|
20
22
|
"dependencies": {
|
|
21
|
-
"@boopkit/cap-services": "^0.
|
|
23
|
+
"@boopkit/cap-services": "^0.3.0",
|
|
22
24
|
"bcryptjs": "^2.4.3"
|
|
23
25
|
},
|
|
24
26
|
"devDependencies": {
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
/** @boopkit/cap-users Auth middleware
|
|
1
|
+
/** @boopkit/cap-users — Auth middleware and routes, pre-wired from UserService. */
|
|
2
2
|
|
|
3
|
-
import type { Express } from 'express';
|
|
4
3
|
import { createAuthMiddleware } from '@boopkit/cap-users';
|
|
5
|
-
|
|
6
|
-
// Import user services — adjust path if your service wiring is elsewhere
|
|
7
4
|
import UserService from '../services/users.service';
|
|
5
|
+
import createAuthRouter from '../routes/auth';
|
|
8
6
|
|
|
9
7
|
const { authService, identityService, userService } = UserService;
|
|
10
8
|
|
|
@@ -16,13 +14,7 @@ const { ensureVisitor, addAuthContext, requireAuth } = createAuthMiddleware(
|
|
|
16
14
|
},
|
|
17
15
|
);
|
|
18
16
|
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
* Call at preRoutes order 15 (after requestId, before routes).
|
|
22
|
-
*/
|
|
23
|
-
export function setupAuth(app: Express): void {
|
|
24
|
-
app.use(ensureVisitor);
|
|
25
|
-
app.use(addAuthContext);
|
|
26
|
-
}
|
|
17
|
+
/** Pre-built auth routes (signup, login, logout). Ready to mount with app.use(). */
|
|
18
|
+
const authRoutes = createAuthRouter({ authService });
|
|
27
19
|
|
|
28
|
-
export { ensureVisitor, addAuthContext, requireAuth };
|
|
20
|
+
export { ensureVisitor, addAuthContext, requireAuth, authRoutes };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# User Data Modeling
|
|
2
|
+
When you need to add, store, or query a field related to users, identities, or accounts in a boop project, decide *where* the field belongs before writing the schema or service.
|
|
3
|
+
|
|
4
|
+
## Goal
|
|
5
|
+
**`User.info` and `UserProperty` aren't "common vs. sparse" — they're "denormalized quick-access" vs. "named independent concerns."**
|
|
6
|
+
|
|
7
|
+
`User.info` is a single JSON column returned with every basic user query. It holds the handful of core fields you want to travel with the user every time — display name, user type, email-verified flag. One `infoSchema` governs the whole blob. One `revision` tracks any update, so every write to `.info` contends with every other.
|
|
8
|
+
|
|
9
|
+
`UserProperty` is one row per named concern (`(userId, property)`). Each property has its own `info`, its own `infoSchema`, and its own `revision`. You only load a property when a feature needs it; writes to one property don't contend with writes to `.info` or other properties.
|
|
10
|
+
|
|
11
|
+
Most new user data belongs in a property. The question is not "does every user have it" — plenty of properties apply to every user (notification prefs, onboarding state). The question is "does this evolve alone, or does it move with the rest of the user's core identity?"
|
|
12
|
+
|
|
13
|
+
The rest of the cap-users model — `Account`, `AccountUser`, `UserIdentity` — follows the same logic: distinct concerns get distinct tables so they can evolve, scale, and be secured independently.
|
|
14
|
+
|
|
15
|
+
## Decision tree
|
|
16
|
+
|
|
17
|
+
Before adding a field, work through these in order. The first "yes" wins.
|
|
18
|
+
|
|
19
|
+
**1. Is this how the user logs in?** (email, OAuth `provider+id`, magic-link token)
|
|
20
|
+
→ `UserIdentity`. One row per identity. Never put credentials or login identifiers on `User.info` — a human can have many identities, and changing one shouldn't touch the user record.
|
|
21
|
+
|
|
22
|
+
**2. Is this about the org, not the person?** (company name, plan tier, billing address)
|
|
23
|
+
→ `Account.info` for denormalized account-level identity, or `AccountProperty` for standalone account-scoped concerns. Same denormalized-vs-independent split as User.
|
|
24
|
+
|
|
25
|
+
**3. Is this about how the user participates in a specific account?** (role, invited_by, joined_at)
|
|
26
|
+
→ `AccountUser.info`. A user in two accounts can hold different roles in each — this field varies per membership, not per user.
|
|
27
|
+
|
|
28
|
+
**4. Is this part of the user's core identity, loaded with the user every time?** (display name, user type, email-verified flag, timezone)
|
|
29
|
+
→ `User.info`. Shape is governed by `infoSchema`; bump it when you add, remove, or change a field.
|
|
30
|
+
|
|
31
|
+
**5. Is this a standalone concern with its own lifecycle?** (notification prefs, onboarding state, billing address, API keys, 2FA config, locale settings)
|
|
32
|
+
→ `UserProperty`. Separate schema, separate revision, loaded only when queried. Updates don't contend with `.info` or with other properties.
|
|
33
|
+
|
|
34
|
+
### When the choice between `info` and property isn't obvious
|
|
35
|
+
|
|
36
|
+
Default to property. `User.info` should stay small — fields that essentially every feature touches. Reach for `UserProperty` when *any* of these apply:
|
|
37
|
+
|
|
38
|
+
- **It has its own schema evolution** — you can picture revving its schema without touching `user.info`'s.
|
|
39
|
+
- **Concurrent updates matter** — this feature might update while another updates `.info` or a different property.
|
|
40
|
+
- **Loading the user shouldn't always load this** — sensitive values, large blobs, or data only some features care about.
|
|
41
|
+
- **It may be absent for some users** — not everyone has completed onboarding, not everyone has enabled 2FA, not everyone has a billing address yet.
|
|
42
|
+
|
|
43
|
+
`User.info` stays reserved for "who the user is to the system." Everything else is a property.
|
|
44
|
+
|
|
45
|
+
## Red flags
|
|
46
|
+
|
|
47
|
+
- **Standalone concerns accreting in `User.info`** — notification prefs, onboarding progress, feature flags, 2FA config, billing details. Each is a separately-evolving concern with its own write cadence. Promote each to a property.
|
|
48
|
+
- **Large or sensitive values in `User.info`** — credentials, API tokens, long biographical text, encryption keys. These belong in properties, where `sensitiveProperties` can gate access and where loads stay cheap.
|
|
49
|
+
- **Two features writing to `User.info`** — if email verification and onboarding step both update `.info`, they contend on the row's `revision`. Move one of them to a property.
|
|
50
|
+
- **Email used as the user's primary key in queries** — conflates User with UserIdentity. Look up the identity (`identityService.findByDomainAndKey('email', ...)`), then fetch the user.
|
|
51
|
+
- **Team/org fields on `User.info`** — `role`, `company_id`, `invited_by`. These describe membership, not identity; they belong on `AccountUser.info`.
|
|
52
|
+
- **Account-level fields on `User.info`** — `company_name`, `plan`, `subscription_status`. Move to `Account.info` or `AccountProperty`.
|
|
53
|
+
- **Changing `User.info` shape without bumping `infoSchema`** — migrations use `infoSchema` to decide which rows to backfill. Silent shape changes leave old rows stale forever.
|
|
54
|
+
- **Adding a SQL column to the `users` table for a new field** — `.info` is the migration-free extension point. Only add SQL columns when you need to index or join on the value.
|
|
55
|
+
- **Putting credentials on a generic `UserProperty`** — credentials belong on `UserIdentity`, or on a property explicitly gated via `sensitiveProperties` in `createPropertyService`. Don't leak them via generic property listings.
|
|
56
|
+
|
|
57
|
+
## When to rev `infoSchema`
|
|
58
|
+
|
|
59
|
+
Bump `user-01` → `user-02` (same pattern for Account, AccountUser, and each UserProperty) when you:
|
|
60
|
+
- add a required field to `info`
|
|
61
|
+
- change the type of an existing field
|
|
62
|
+
- rename or remove a field that existing code still reads
|
|
63
|
+
|
|
64
|
+
Don't bump for:
|
|
65
|
+
- optional additions (`field?: X`) — old rows simply won't have the key
|
|
66
|
+
- changes scoped to a different `UserProperty` — each property has its own `infoSchema`
|
|
67
|
+
|
|
68
|
+
After bumping, write a migration that reads rows with the old schema name, transforms the `info`, writes back with the new name, and increments the revision. Rows the migration can't interpret should surface as errors at read time, not silently serve stale data.
|
|
69
|
+
|
|
70
|
+
## See also
|
|
71
|
+
|
|
72
|
+
- `../reference/user-auth.ref.md` — the what/how of the four-layer auth model (Identity / Authentication / Credentials / Authorization) and trust-tier semantics
|
|
73
|
+
- `design.services.skill.md` (from cap-services) — general entity-info-vs-property patterns and the `createEntityService` / `createPropertyService` factories
|