@rashidee/co2 1.2.5
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/dist/index.js +25449 -0
- package/drizzle/0000_remarkable_charles_xavier.sql +210 -0
- package/drizzle/0001_wise_wolf_cub.sql +13 -0
- package/drizzle/0002_secret_spyke.sql +16 -0
- package/drizzle/0003_workspace_config_singleton.sql +16 -0
- package/drizzle/0004_terminal_claim_v120.sql +11 -0
- package/drizzle/0005_brainstorm_v124.sql +10 -0
- package/drizzle/0006_project_v140.sql +6 -0
- package/drizzle/0007_prd_wizard_v124.sql +14 -0
- package/drizzle/0008_workflow_artifact_v126.sql +24 -0
- package/drizzle/0009_project_import_v150.sql +7 -0
- package/drizzle/0010_quality_v151.sql +3 -0
- package/drizzle/meta/0000_snapshot.json +1335 -0
- package/drizzle/meta/0001_snapshot.json +1416 -0
- package/drizzle/meta/0002_snapshot.json +1510 -0
- package/drizzle/meta/_journal.json +83 -0
- package/package.json +41 -0
- package/plugin/.claude-plugin/marketplace.json +36 -0
- package/plugin/.claude-plugin/plugin.json +22 -0
- package/plugin/README.md +625 -0
- package/plugin/SKILLS.md +350 -0
- package/plugin/co2.svg +5 -0
- package/plugin/commands/brainstorm-loop.md +129 -0
- package/plugin/skills/conductor-defect/SKILL.md +898 -0
- package/plugin/skills/conductor-feature-develop/SKILL.md +1383 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-domain-template.md +131 -0
- package/plugin/skills/conductor-feature-develop/references/implementation-master-template.md +89 -0
- package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +224 -0
- package/plugin/skills/conductor-feature-prepare/SKILL.md +604 -0
- package/plugin/skills/depgen-k8s/SKILL.md +468 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-laravel.md +197 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-nodejs.md +163 -0
- package/plugin/skills/depgen-k8s/references/dockerfile-spring-boot.md +174 -0
- package/plugin/skills/depgen-k8s/references/k8s-patterns.md +869 -0
- package/plugin/skills/mockgen-shadcn/SKILL.md +1067 -0
- package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +1467 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +216 -0
- package/plugin/skills/mockgen-tailwind/SKILL.md +904 -0
- package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +720 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +498 -0
- package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -0
- package/plugin/skills/modelgen-nosql/SKILL.md +617 -0
- package/plugin/skills/modelgen-nosql/references/model-extraction-methodology.md +497 -0
- package/plugin/skills/modelgen-nosql/references/nosql-design-guide.md +258 -0
- package/plugin/skills/modelgen-relational/SKILL.md +707 -0
- package/plugin/skills/modelgen-relational/references/model-extraction-methodology.md +499 -0
- package/plugin/skills/specgen-flutter-riverpod/SKILL.md +898 -0
- package/plugin/skills/specgen-flutter-riverpod/references/component-patterns.md +971 -0
- package/plugin/skills/specgen-flutter-riverpod/references/notification-patterns.md +457 -0
- package/plugin/skills/specgen-flutter-riverpod/references/routing-patterns.md +362 -0
- package/plugin/skills/specgen-flutter-riverpod/references/security-patterns.md +709 -0
- package/plugin/skills/specgen-flutter-riverpod/references/spec-template.md +1566 -0
- package/plugin/skills/specgen-flutter-riverpod/references/state-patterns.md +518 -0
- package/plugin/skills/specgen-flutter-riverpod/references/storage-patterns.md +370 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/SKILL.md +984 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/batch-patterns.md +359 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/messaging-patterns.md +468 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/modulith-patterns.md +461 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/reporting-patterns.md +803 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/security-patterns.md +501 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/spec-template.md +1268 -0
- package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/web-patterns.md +301 -0
- package/plugin/skills/specgen-node-cli-web/SKILL.md +561 -0
- package/plugin/skills/specgen-node-cli-web/references/auth-patterns.md +293 -0
- package/plugin/skills/specgen-node-cli-web/references/cli-packaging-patterns.md +250 -0
- package/plugin/skills/specgen-node-cli-web/references/server-patterns.md +247 -0
- package/plugin/skills/specgen-node-cli-web/references/spec-template.md +401 -0
- package/plugin/skills/specgen-node-cli-web/references/testing-patterns.md +174 -0
- package/plugin/skills/specgen-react-mui/SKILL.md +789 -0
- package/plugin/skills/specgen-react-mui/references/component-patterns.md +1077 -0
- package/plugin/skills/specgen-react-mui/references/reporting-patterns.md +1998 -0
- package/plugin/skills/specgen-react-mui/references/routing-patterns.md +461 -0
- package/plugin/skills/specgen-react-mui/references/security-patterns.md +599 -0
- package/plugin/skills/specgen-react-mui/references/spec-template.md +1030 -0
- package/plugin/skills/specgen-react-mui/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-react-tailwind/SKILL.md +859 -0
- package/plugin/skills/specgen-react-tailwind/references/component-patterns.md +1212 -0
- package/plugin/skills/specgen-react-tailwind/references/reporting-patterns.md +1529 -0
- package/plugin/skills/specgen-react-tailwind/references/routing-patterns.md +669 -0
- package/plugin/skills/specgen-react-tailwind/references/security-patterns.md +656 -0
- package/plugin/skills/specgen-react-tailwind/references/spec-template.md +1237 -0
- package/plugin/skills/specgen-react-tailwind/references/state-patterns.md +327 -0
- package/plugin/skills/specgen-sdk-java/SKILL.md +933 -0
- package/plugin/skills/specgen-sdk-java/references/api-extraction-patterns.md +247 -0
- package/plugin/skills/specgen-sdk-java/references/http-client-patterns.md +495 -0
- package/plugin/skills/specgen-sdk-java/references/packaging-patterns.md +366 -0
- package/plugin/skills/specgen-sdk-java/references/spec-template.md +956 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/SKILL.md +1284 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/jasper-patterns.md +1638 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/modulith-patterns.md +354 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/security-patterns.md +551 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/spec-template.md +2502 -0
- package/plugin/skills/specgen-spring-jpa-jtehtmx/references/web-patterns.md +503 -0
- package/plugin/skills/specgen-spring-jpa-restapi/SKILL.md +990 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/batch-patterns.md +1224 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/messaging-patterns.md +512 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/modulith-patterns.md +279 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/reporting-patterns.md +1536 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/restapi-patterns.md +722 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/security-patterns.md +351 -0
- package/plugin/skills/specgen-spring-jpa-restapi/references/spec-template.md +553 -0
- package/plugin/skills/specgen-ts-cli/SKILL.md +861 -0
- package/plugin/skills/specgen-ts-cli/references/async-patterns.md +1101 -0
- package/plugin/skills/specgen-ts-cli/references/command-patterns.md +383 -0
- package/plugin/skills/specgen-ts-cli/references/config-patterns.md +430 -0
- package/plugin/skills/specgen-ts-cli/references/database-patterns.md +750 -0
- package/plugin/skills/specgen-ts-cli/references/packaging-patterns.md +426 -0
- package/plugin/skills/specgen-ts-cli/references/spec-template.md +1391 -0
- package/plugin/skills/specgen-ts-cli/references/testing-patterns.md +909 -0
- package/plugin/skills/testgen-functional/SKILL.md +1381 -0
- package/plugin/skills/tracegen-matrix/SKILL.md +243 -0
- package/plugin/skills/util-preparek8senv/SKILL.md +422 -0
- package/plugin/skills/util-projectinit/SKILL.md +307 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-loop.md +129 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-protocol.md +144 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-setup-guide.md +153 -0
- package/plugin/skills/util-projectinit/reference/brainstorm-template-appdev.md +87 -0
- package/plugin/skills/util-projectinit/reference/co2-context-generation-guide.md +761 -0
- package/plugin/skills/util-projectsync/SKILL.md +501 -0
- package/plugin/skills/util-updprd/SKILL.md +324 -0
- package/plugin/skills/util-usanalyzer/SKILL.md +307 -0
- package/plugin/skills/util-ustagger/SKILL.md +154 -0
- package/static/assets/abnfDiagram-VRR7QNED-DvPuwyCV.js +1 -0
- package/static/assets/arc-DRKw7sjs.js +1 -0
- package/static/assets/architectureDiagram-ZJ3FMSHR-CaNcxUbC.js +36 -0
- package/static/assets/blockDiagram-677ZJIJ3-Demd4AOK.js +132 -0
- package/static/assets/c4Diagram-LMCZKHZV-PVKUfc2x.js +10 -0
- package/static/assets/channel-C2nVbuwv.js +1 -0
- package/static/assets/chunk-2Q5K7J3B-CExf8ww_.js +1 -0
- package/static/assets/chunk-32BRIVSS-CFPaoONF.js +1 -0
- package/static/assets/chunk-5VM5RSS4-DE2esM67.js +15 -0
- package/static/assets/chunk-EX3LRPZG-SON_YdDG.js +231 -0
- package/static/assets/chunk-JWPE2WC7-Qxah8LNr.js +1 -0
- package/static/assets/chunk-MOJQB5TN-BcWGQV3j.js +88 -0
- package/static/assets/chunk-RYQCIY6F-DkDj-OR5.js +1 -0
- package/static/assets/chunk-V7JOEXUC-BlI1Mrah.js +206 -0
- package/static/assets/chunk-VR4S4FIN-Agf__duj.js +1 -0
- package/static/assets/chunk-XXDRQBXY-B5CfAh85.js +1 -0
- package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +1 -0
- package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +1 -0
- package/static/assets/cose-bilkent-JH36ORCC-DO0cQkcy.js +1 -0
- package/static/assets/cynefin-VYW2F7L2-CfOnsnXc.js +178 -0
- package/static/assets/cynefinDiagram-TSTJHNR4-BJ8tULAv.js +62 -0
- package/static/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
- package/static/assets/dagre-VKFMJZFB-DfjwzHqP.js +4 -0
- package/static/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/static/assets/diagram-FQU43EPY-CndSOIIM.js +3 -0
- package/static/assets/diagram-G47NLZAW-Czf5hN0Z.js +24 -0
- package/static/assets/diagram-NH7WQ7WH-COAyojGh.js +24 -0
- package/static/assets/diagram-OA4YK3LP-H8PoiN2m.js +30 -0
- package/static/assets/diagram-WEI45ONY-DzcaVzjp.js +41 -0
- package/static/assets/ebnfDiagram-CCIWWBDH-DPazGYLJ.js +1 -0
- package/static/assets/erDiagram-Q63AITRT-Bo-fubgF.js +85 -0
- package/static/assets/flowDiagram-23GEKE2U-CSvxUDwh.js +156 -0
- package/static/assets/ganttDiagram-NO4QXBWP-DnBveWX6.js +292 -0
- package/static/assets/gitGraphDiagram-IHSO6WYX-B3rVL8c-.js +106 -0
- package/static/assets/graph-C9eacEi8.js +1 -0
- package/static/assets/index-LZSQkLE2.css +41 -0
- package/static/assets/index-y7XDSgMG.js +495 -0
- package/static/assets/infoDiagram-FWYZ7A6U-CvWV9yy2.js +2 -0
- package/static/assets/init-Gi6I4Gst.js +1 -0
- package/static/assets/ishikawaDiagram-FXEZZL3T-DF28a1N6.js +70 -0
- package/static/assets/journeyDiagram-5HDEW3XC-CB6CugYk.js +139 -0
- package/static/assets/kanban-definition-HUTT4EX6-ResSLF6d.js +89 -0
- package/static/assets/katex-C5jXJg4s.js +257 -0
- package/static/assets/layout-DEXfKzaS.js +1 -0
- package/static/assets/linear-CLO--ryk.js +1 -0
- package/static/assets/map-Czzmt4hB.js +1 -0
- package/static/assets/mindmap-definition-LN4V7U3C-BvW3EbxX.js +96 -0
- package/static/assets/ordinal-Cboi1Yqb.js +1 -0
- package/static/assets/pegDiagram-2B236MQR-BHCgqrrM.js +1 -0
- package/static/assets/pieDiagram-ENE6RG2P-DVmCQ5FA.js +39 -0
- package/static/assets/quadrantDiagram-ABIIQ3AL-B3e5KR52.js +7 -0
- package/static/assets/railroadDiagram-RFXS5EU6-Csv5ysLc.js +1 -0
- package/static/assets/requirementDiagram-TGXJPOKE-DOMVzvnB.js +84 -0
- package/static/assets/sankeyDiagram-HTMAVEWB-B1dcTOvy.js +40 -0
- package/static/assets/sequenceDiagram-DBY2YBRQ-BY414yeO.js +162 -0
- package/static/assets/sizeCapture-X5ZJPWSS-CuJfrfBN.js +1 -0
- package/static/assets/stateDiagram-2N3HPSRC-FX4rJkzV.js +1 -0
- package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +1 -0
- package/static/assets/swimlanes-5IMT3BWC-Sfy58mmv.js +2 -0
- package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +8 -0
- package/static/assets/timeline-definition-FHXFAJF6-CmV7by8p.js +120 -0
- package/static/assets/vennDiagram-L72KCM5P-FqIyFZFt.js +34 -0
- package/static/assets/wardleyDiagram-EHGQE667-BZ06O45i.js +78 -0
- package/static/assets/xychartDiagram-FW5EYKEG-BOkffR06.js +7 -0
- package/static/index.html +24 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Authentication & User Lifecycle Patterns — specgen-node-cli-web
|
|
2
|
+
|
|
3
|
+
Canonical patterns for first-run bootstrap (root spec Section 8), authentication
|
|
4
|
+
(Section 9), and the mandatory `user-management/SPEC.md`. These are security invariants:
|
|
5
|
+
PRD phrasing never weakens them — flag conflicts to the user instead of complying.
|
|
6
|
+
|
|
7
|
+
Tier-2 hand-rolled sessions, `node:crypto` only. The spec MUST NOT include: email flows
|
|
8
|
+
of any kind, self-serve registration, OAuth/social login, JWTs, or password recovery
|
|
9
|
+
other than `reset-admin` (CLI) and admin-reset (UI).
|
|
10
|
+
|
|
11
|
+
## 1. Schema
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
users: id, username (unique, case-insensitive), password_hash,
|
|
15
|
+
role ('admin' | 'user'), must_change_password (bool),
|
|
16
|
+
created_at, updated_at
|
|
17
|
+
sessions: id, token_hash (SHA-256 of 32-byte random token),
|
|
18
|
+
user_id FK (cascade delete), expires_at, created_at
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Drizzle definition:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
|
|
25
|
+
|
|
26
|
+
export const users = sqliteTable('users', {
|
|
27
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
28
|
+
username: text('username').notNull().unique(), // stored lowercased; compare lowercased
|
|
29
|
+
passwordHash: text('password_hash').notNull(), // "salt:hash", hex
|
|
30
|
+
role: text('role', { enum: ['admin', 'user'] }).notNull(),
|
|
31
|
+
mustChangePassword: integer('must_change_password', { mode: 'boolean' })
|
|
32
|
+
.notNull()
|
|
33
|
+
.default(false),
|
|
34
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
35
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
export const sessions = sqliteTable('sessions', {
|
|
39
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
40
|
+
tokenHash: text('token_hash').notNull().unique(),
|
|
41
|
+
userId: integer('user_id')
|
|
42
|
+
.notNull()
|
|
43
|
+
.references(() => users.id, { onDelete: 'cascade' }),
|
|
44
|
+
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
|
|
45
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 2. Password hashing (scrypt)
|
|
50
|
+
|
|
51
|
+
scrypt via `node:crypto` — N=2^15, r=8, p=1, 32-byte key, 16-byte random salt; stored as
|
|
52
|
+
`salt:hash`. Verify with `timingSafeEqual`:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
56
|
+
|
|
57
|
+
const SCRYPT_PARAMS = { N: 2 ** 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }
|
|
58
|
+
const KEY_LENGTH = 32
|
|
59
|
+
|
|
60
|
+
export function hashPassword(password: string): string {
|
|
61
|
+
const salt = randomBytes(16)
|
|
62
|
+
const hash = scryptSync(password, salt, KEY_LENGTH, SCRYPT_PARAMS)
|
|
63
|
+
return `${salt.toString('hex')}:${hash.toString('hex')}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function verifyPassword(password: string, stored: string): boolean {
|
|
67
|
+
const [saltHex, hashHex] = stored.split(':')
|
|
68
|
+
if (!saltHex || !hashHex) return false
|
|
69
|
+
const candidate = scryptSync(password, Buffer.from(saltHex, 'hex'), KEY_LENGTH, SCRYPT_PARAMS)
|
|
70
|
+
return timingSafeEqual(candidate, Buffer.from(hashHex, 'hex'))
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## 3. Password policy (shared Zod schema in core/)
|
|
75
|
+
|
|
76
|
+
NIST 800-63B posture: minimum 10 characters, reject the username as password, NO
|
|
77
|
+
composition-class rules. One schema in `core/`, consumed by both the SPA form and the API
|
|
78
|
+
route:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { z } from 'zod'
|
|
82
|
+
|
|
83
|
+
export const passwordSchema = z.string().min(10, 'At least 10 characters')
|
|
84
|
+
|
|
85
|
+
export const changePasswordSchema = z
|
|
86
|
+
.object({
|
|
87
|
+
currentPassword: z.string().min(1),
|
|
88
|
+
newPassword: passwordSchema,
|
|
89
|
+
username: z.string(), // injected server-side for the check below
|
|
90
|
+
})
|
|
91
|
+
.refine((v) => v.newPassword.toLowerCase() !== v.username.toLowerCase(), {
|
|
92
|
+
message: 'Password must not be your username',
|
|
93
|
+
path: ['newPassword'],
|
|
94
|
+
})
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Changing a password revokes all OTHER sessions for that user (delete rows), keeping only
|
|
98
|
+
the current session.
|
|
99
|
+
|
|
100
|
+
## 4. First-run bootstrap
|
|
101
|
+
|
|
102
|
+
On `start`, after migrations, check whether `users` is empty. If so:
|
|
103
|
+
|
|
104
|
+
1. Create user `admin`, role `admin`, `must_change_password = true`, with a
|
|
105
|
+
cryptographically random 16-character password — `randomBytes` mapped onto a
|
|
106
|
+
base58/alphanumeric alphabet with no ambiguous characters (no `0OIl1`).
|
|
107
|
+
2. Return the credentials so `start` prints them **once** inside the status block, with a
|
|
108
|
+
warning that they will not be shown again.
|
|
109
|
+
3. Never write the plaintext to any file or log; only the scrypt hash is stored.
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { randomBytes } from 'node:crypto'
|
|
113
|
+
|
|
114
|
+
const ALPHABET = '23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
|
|
115
|
+
|
|
116
|
+
export function generatePassword(length = 16): string {
|
|
117
|
+
const bytes = randomBytes(length)
|
|
118
|
+
let out = ''
|
|
119
|
+
for (const b of bytes) out += ALPHABET[b % ALPHABET.length]
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function bootstrapAdmin(db: Db): { username: string; password: string } | null {
|
|
124
|
+
const existing = db.select({ id: users.id }).from(users).limit(1).all()
|
|
125
|
+
if (existing.length > 0) return null // no-op once any user exists
|
|
126
|
+
const password = generatePassword()
|
|
127
|
+
const now = new Date()
|
|
128
|
+
db.insert(users)
|
|
129
|
+
.values({
|
|
130
|
+
username: 'admin',
|
|
131
|
+
passwordHash: hashPassword(password),
|
|
132
|
+
role: 'admin',
|
|
133
|
+
mustChangePassword: true,
|
|
134
|
+
createdAt: now,
|
|
135
|
+
updatedAt: now,
|
|
136
|
+
})
|
|
137
|
+
.run()
|
|
138
|
+
return { username: 'admin', password }
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
The empty-table guard runs on every start — the no-op on second start is a mandatory test.
|
|
143
|
+
|
|
144
|
+
## 5. Sessions
|
|
145
|
+
|
|
146
|
+
- Raw 32-byte random token goes to the client; only its SHA-256 hash is stored.
|
|
147
|
+
- Cookie: `httpOnly`, `SameSite=Lax`, `Path=/`. `Secure` is set only when the request
|
|
148
|
+
arrived over HTTPS — local HTTP on a LAN must still work; document that TLS termination
|
|
149
|
+
is a reverse-proxy concern.
|
|
150
|
+
- Sliding expiry on use; absolute cap 30 days. Logout deletes the row.
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { randomBytes, createHash } from 'node:crypto'
|
|
154
|
+
|
|
155
|
+
const SLIDING_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
156
|
+
const ABSOLUTE_CAP_MS = 30 * 24 * 60 * 60 * 1000
|
|
157
|
+
|
|
158
|
+
export function issueSession(db: Db, userId: number): { token: string; expiresAt: Date } {
|
|
159
|
+
const token = randomBytes(32).toString('base64url')
|
|
160
|
+
const expiresAt = new Date(Date.now() + SLIDING_TTL_MS)
|
|
161
|
+
db.insert(sessions)
|
|
162
|
+
.values({ tokenHash: sha256(token), userId, expiresAt, createdAt: new Date() })
|
|
163
|
+
.run()
|
|
164
|
+
return { token, expiresAt }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function resolveSession(db: Db, token: string): SessionUser | null {
|
|
168
|
+
const row = db
|
|
169
|
+
.select()
|
|
170
|
+
.from(sessions)
|
|
171
|
+
.innerJoin(users, eq(sessions.userId, users.id))
|
|
172
|
+
.where(eq(sessions.tokenHash, sha256(token)))
|
|
173
|
+
.get()
|
|
174
|
+
if (!row || row.sessions.expiresAt < new Date()) return null
|
|
175
|
+
const cap = new Date(row.sessions.createdAt.getTime() + ABSOLUTE_CAP_MS)
|
|
176
|
+
const slid = new Date(Date.now() + SLIDING_TTL_MS)
|
|
177
|
+
db.update(sessions)
|
|
178
|
+
.set({ expiresAt: slid < cap ? slid : cap })
|
|
179
|
+
.where(eq(sessions.id, row.sessions.id))
|
|
180
|
+
.run()
|
|
181
|
+
return { id: row.users.id, username: row.users.username, role: row.users.role,
|
|
182
|
+
mustChangePassword: row.users.mustChangePassword, sessionId: row.sessions.id }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function sha256(value: string): string {
|
|
186
|
+
return createHash('sha256').update(value).digest('hex')
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## 6. Login rate limiting
|
|
191
|
+
|
|
192
|
+
In-memory fixed window per `username+IP` — e.g. 10 attempts / 15 min. No external store:
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
const WINDOW_MS = 15 * 60 * 1000
|
|
196
|
+
const MAX_ATTEMPTS = 10
|
|
197
|
+
const attempts = new Map<string, { count: number; windowStart: number }>()
|
|
198
|
+
|
|
199
|
+
export function checkRateLimit(username: string, ip: string): boolean {
|
|
200
|
+
const key = `${username.toLowerCase()}|${ip}`
|
|
201
|
+
const now = Date.now()
|
|
202
|
+
const entry = attempts.get(key)
|
|
203
|
+
if (!entry || now - entry.windowStart > WINDOW_MS) {
|
|
204
|
+
attempts.set(key, { count: 1, windowStart: now })
|
|
205
|
+
return true
|
|
206
|
+
}
|
|
207
|
+
entry.count += 1
|
|
208
|
+
return entry.count <= MAX_ATTEMPTS
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## 7. Forced-change middleware
|
|
213
|
+
|
|
214
|
+
While `must_change_password` is true, every API route EXCEPT `POST /api/auth/login`,
|
|
215
|
+
`POST /api/auth/change-password`, and `POST /api/auth/logout` returns
|
|
216
|
+
`403 PASSWORD_CHANGE_REQUIRED`. Enforced **server-side in middleware** — the SPA redirect
|
|
217
|
+
is convenience, not the control:
|
|
218
|
+
|
|
219
|
+
```ts
|
|
220
|
+
const EXEMPT = new Set(['/api/auth/login', '/api/auth/change-password', '/api/auth/logout'])
|
|
221
|
+
|
|
222
|
+
export function forcedChangeGuard() {
|
|
223
|
+
return async (c: HonoContext, next: () => Promise<void>) => {
|
|
224
|
+
const user = c.var.user
|
|
225
|
+
if (user?.mustChangePassword && !EXEMPT.has(c.req.path)) {
|
|
226
|
+
return c.json({ code: 'PASSWORD_CHANGE_REQUIRED' }, 403)
|
|
227
|
+
}
|
|
228
|
+
await next()
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
The SPA's fetch wrapper intercepts this code and hard-redirects to
|
|
234
|
+
`/force-change-password` (see `server-patterns.md` §5).
|
|
235
|
+
|
|
236
|
+
## 8. User management flows
|
|
237
|
+
|
|
238
|
+
| Flow | Rule |
|
|
239
|
+
|---|---|
|
|
240
|
+
| Create user | **Admin role only.** Admin sets username + initial password; the new row gets `must_change_password = true`. |
|
|
241
|
+
| First login (forced change) | Middleware gate above; SPA hard-redirects on the 403 code. |
|
|
242
|
+
| Change password (anytime) | Any authenticated user, own password only; requires current password. Clears `must_change_password`; revokes all other sessions, keeping the current one. |
|
|
243
|
+
| Admin reset of a user | Admin sets a new temporary password for any user; sets `must_change_password = true`; revokes that user's sessions. |
|
|
244
|
+
| Delete/disable user | Admin only; **cannot delete the last remaining admin** — a server-side invariant with a test. |
|
|
245
|
+
|
|
246
|
+
Last-admin invariant in the service layer:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
export function deleteUser(db: Db, targetId: number): void {
|
|
250
|
+
const target = db.select().from(users).where(eq(users.id, targetId)).get()
|
|
251
|
+
if (!target) throw new NotFoundError('User not found')
|
|
252
|
+
if (target.role === 'admin') {
|
|
253
|
+
const adminCount = db
|
|
254
|
+
.select({ n: count() })
|
|
255
|
+
.from(users)
|
|
256
|
+
.where(eq(users.role, 'admin'))
|
|
257
|
+
.get()
|
|
258
|
+
if ((adminCount?.n ?? 0) <= 1) {
|
|
259
|
+
throw new InvariantError('Cannot delete the last remaining admin')
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
db.delete(users).where(eq(users.id, targetId)).run() // sessions cascade
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## 9. Route surface (user-management module)
|
|
267
|
+
|
|
268
|
+
| Method | Path | Guard | Purpose |
|
|
269
|
+
|---|---|---|---|
|
|
270
|
+
| POST | /api/auth/login | rate-limited, public | Issue session cookie |
|
|
271
|
+
| POST | /api/auth/logout | session | Delete session row, clear cookie |
|
|
272
|
+
| POST | /api/auth/change-password | session (exempt from forced-change gate) | Own password; clears flag; revokes other sessions |
|
|
273
|
+
| GET | /api/auth/me | session | Current user + mustChangePassword |
|
|
274
|
+
| GET | /api/users | admin | List users |
|
|
275
|
+
| POST | /api/users | admin | Create user (temp password, flag set) |
|
|
276
|
+
| POST | /api/users/:id/reset-password | admin | Temp password, flag set, sessions revoked |
|
|
277
|
+
| PATCH | /api/users/:id | admin | Update role/username |
|
|
278
|
+
| DELETE | /api/users/:id | admin | Delete (last-admin invariant) |
|
|
279
|
+
|
|
280
|
+
SPA pages: `/login`, `/force-change-password`, `/users` (admin only), `/settings`
|
|
281
|
+
(own password change).
|
|
282
|
+
|
|
283
|
+
## 10. Mandatory test coverage
|
|
284
|
+
|
|
285
|
+
Every generated spec lists these as required tests (Vitest over `:memory:` with real
|
|
286
|
+
migrations):
|
|
287
|
+
|
|
288
|
+
1. Forced-change middleware gating — the 403 on every guarded route, and the three
|
|
289
|
+
exemptions passing
|
|
290
|
+
2. Last-admin deletion invariant
|
|
291
|
+
3. Session revocation on password change (other sessions die, current survives)
|
|
292
|
+
4. Login rate limiting (11th attempt in the window rejected)
|
|
293
|
+
5. First-run bootstrap no-op on second start
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# CLI & Packaging Patterns — specgen-node-cli-web
|
|
2
|
+
|
|
3
|
+
Canonical patterns for the CLI contract (root spec Section 4) and the published package /
|
|
4
|
+
build pipeline (root spec Section 3). Substitute `{{PACKAGE_NAME}}` etc. with real values
|
|
5
|
+
and adapt samples to the actual application — but keep every rule stated here intact.
|
|
6
|
+
|
|
7
|
+
## 1. Published package manifest
|
|
8
|
+
|
|
9
|
+
`packages/cli/package.json` — the ONLY published artifact:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"name": "{{PACKAGE_NAME}}",
|
|
14
|
+
"version": "{{VERSION}}",
|
|
15
|
+
"description": "{{APP_DESCRIPTION}}",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"engines": { "node": ">=22" },
|
|
19
|
+
"bin": { "{{PACKAGE_NAME}}": "./dist/index.js" },
|
|
20
|
+
"files": ["dist", "static", "drizzle"],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsup && pnpm run build:web && pnpm run build:migrations",
|
|
23
|
+
"build:web": "pnpm --filter web build && rm -rf static && cp -r ../web/dist static",
|
|
24
|
+
"build:migrations": "rm -rf drizzle && cp -r ../server/drizzle drizzle",
|
|
25
|
+
"prepublishOnly": "pnpm -w verify"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"better-sqlite3": "^11.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"tsup": "^8.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Rules:
|
|
37
|
+
- `better-sqlite3` is the only runtime dependency: it is a **native module** — never
|
|
38
|
+
bundled, never a devDependency. Everything else (commander, hono, drizzle-orm, zod,
|
|
39
|
+
picocolors, the whole `server` and `core` source) is bundled by tsup into `dist/`.
|
|
40
|
+
- End users must not need pnpm or the workspace — the tarball is self-contained:
|
|
41
|
+
bundle + static UI + migrations.
|
|
42
|
+
- Tarball budget: SHOULD stay under 5 MB excluding better-sqlite3. Any new runtime
|
|
43
|
+
dependency requires explicit human approval.
|
|
44
|
+
|
|
45
|
+
## 2. tsup configuration
|
|
46
|
+
|
|
47
|
+
`packages/cli/tsup.config.ts`:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { defineConfig } from 'tsup'
|
|
51
|
+
|
|
52
|
+
export default defineConfig({
|
|
53
|
+
entry: ['src/index.ts'],
|
|
54
|
+
format: ['esm'],
|
|
55
|
+
platform: 'node',
|
|
56
|
+
target: 'node22',
|
|
57
|
+
clean: true,
|
|
58
|
+
minify: false,
|
|
59
|
+
sourcemap: false,
|
|
60
|
+
external: ['better-sqlite3'],
|
|
61
|
+
banner: { js: '' },
|
|
62
|
+
})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The shebang lives in `src/index.ts` itself (`#!/usr/bin/env node` as line 1); tsup
|
|
66
|
+
preserves it. Verify post-build in the smoke stage — shebang and bundling failures only
|
|
67
|
+
surface against the built artifact.
|
|
68
|
+
|
|
69
|
+
## 3. Commander program
|
|
70
|
+
|
|
71
|
+
`packages/cli/src/index.ts`:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
#!/usr/bin/env node
|
|
75
|
+
import { createRequire } from 'node:module'
|
|
76
|
+
import { Command } from 'commander'
|
|
77
|
+
import { registerStart } from './commands/start.js'
|
|
78
|
+
import { registerStatus } from './commands/status.js'
|
|
79
|
+
import { registerResetAdmin } from './commands/reset-admin.js'
|
|
80
|
+
|
|
81
|
+
const require = createRequire(import.meta.url)
|
|
82
|
+
const pkg = require('../package.json') as { version: string; description: string }
|
|
83
|
+
|
|
84
|
+
const program = new Command('{{PACKAGE_NAME}}')
|
|
85
|
+
.description(pkg.description)
|
|
86
|
+
.version(pkg.version)
|
|
87
|
+
|
|
88
|
+
registerStart(program)
|
|
89
|
+
registerStatus(program)
|
|
90
|
+
registerResetAdmin(program)
|
|
91
|
+
|
|
92
|
+
await program.parseAsync(process.argv)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
All handlers are thin: parse flags with commander → validate with Zod → delegate to
|
|
96
|
+
functions imported from `server`/`core`. No business logic in `packages/cli`.
|
|
97
|
+
|
|
98
|
+
## 4. `start` command
|
|
99
|
+
|
|
100
|
+
Resolution order for EVERY option: CLI flag → environment variable → config file →
|
|
101
|
+
default. Defaults: port `3000`, host `0.0.0.0` (the requirement is LAN access via
|
|
102
|
+
`IP:PORT`).
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { Command } from 'commander'
|
|
106
|
+
import { networkInterfaces } from 'node:os'
|
|
107
|
+
import { serve } from '@hono/node-server'
|
|
108
|
+
import { z } from 'zod'
|
|
109
|
+
import pc from 'picocolors'
|
|
110
|
+
import { resolveDataDir, loadConfig } from '@{{PACKAGE_NAME}}/server/lib/config'
|
|
111
|
+
import { openDatabase, runMigrations, bootstrapAdmin } from '@{{PACKAGE_NAME}}/server/db'
|
|
112
|
+
import { createApp } from '@{{PACKAGE_NAME}}/server/app'
|
|
113
|
+
|
|
114
|
+
const startOptionsSchema = z.object({
|
|
115
|
+
port: z.coerce.number().int().min(1).max(65535),
|
|
116
|
+
host: z.string().min(1),
|
|
117
|
+
dataDir: z.string().min(1),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
export function registerStart(program: Command): void {
|
|
121
|
+
program
|
|
122
|
+
.command('start')
|
|
123
|
+
.description('Start the {{APPLICATION_NAME}} web application (foreground)')
|
|
124
|
+
.option('--port <n>', 'HTTP port')
|
|
125
|
+
.option('--host <addr>', 'Bind address')
|
|
126
|
+
.option('--data-dir <path>', 'Data directory')
|
|
127
|
+
.action(async (flags: Record<string, string | undefined>) => {
|
|
128
|
+
const dataDir = resolveDataDir(flags.dataDir ?? process.env.APP_DATA_DIR)
|
|
129
|
+
const config = loadConfig(dataDir)
|
|
130
|
+
const options = startOptionsSchema.parse({
|
|
131
|
+
port: flags.port ?? process.env.APP_PORT ?? config.port ?? 3000,
|
|
132
|
+
host: flags.host ?? process.env.APP_HOST ?? config.host ?? '0.0.0.0',
|
|
133
|
+
dataDir,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const db = openDatabase(options.dataDir)
|
|
137
|
+
runMigrations(db)
|
|
138
|
+
const bootstrap = bootstrapAdmin(db)
|
|
139
|
+
|
|
140
|
+
const app = createApp({ db, dataDir: options.dataDir })
|
|
141
|
+
const server = serve(
|
|
142
|
+
{ fetch: app.fetch, port: options.port, hostname: options.host },
|
|
143
|
+
() => printStatusBlock(options, bootstrap),
|
|
144
|
+
)
|
|
145
|
+
server.on('error', (error: NodeJS.ErrnoException) => {
|
|
146
|
+
if (error.code === 'EADDRINUSE') {
|
|
147
|
+
console.error(
|
|
148
|
+
pc.red(`Port ${options.port} is already in use. `) +
|
|
149
|
+
`Choose another with ${pc.bold('--port <n>')} (or APP_PORT).`,
|
|
150
|
+
)
|
|
151
|
+
process.exit(1)
|
|
152
|
+
}
|
|
153
|
+
throw error
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const shutdown = () => {
|
|
157
|
+
server.close(() => {
|
|
158
|
+
db.close()
|
|
159
|
+
process.exit(0)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
process.on('SIGINT', shutdown)
|
|
163
|
+
process.on('SIGTERM', shutdown)
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function detectLanIp(): string | undefined {
|
|
168
|
+
for (const addrs of Object.values(networkInterfaces())) {
|
|
169
|
+
for (const addr of addrs ?? []) {
|
|
170
|
+
if (addr.family === 'IPv4' && !addr.internal) return addr.address
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return undefined
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The status block prints exactly once on successful start — local URL, network URL
|
|
178
|
+
(detected LAN IP), data directory, version, and (first run only) the admin credentials
|
|
179
|
+
with a will-not-be-shown-again warning. Structured logs after that:
|
|
180
|
+
|
|
181
|
+
```
|
|
182
|
+
{{APPLICATION_NAME}} v1.0.0
|
|
183
|
+
|
|
184
|
+
Local: http://localhost:3000
|
|
185
|
+
Network: http://192.168.1.23:3000
|
|
186
|
+
Data dir: /home/alice/.local/share/{{PACKAGE_NAME}}
|
|
187
|
+
|
|
188
|
+
First-run admin account created:
|
|
189
|
+
username: admin
|
|
190
|
+
password: h7Kp2mXw9RtBqZ4c ← shown ONCE, will not be displayed again
|
|
191
|
+
|
|
192
|
+
Press Ctrl-C to stop.
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Hard rules:
|
|
196
|
+
- Port taken → fail with a clear message naming `--port`; never auto-increment silently.
|
|
197
|
+
- Foreground only; Ctrl-C triggers graceful shutdown that closes the SQLite handle.
|
|
198
|
+
Daemonization is the OS's job (`systemd`, `pm2`) — the spec MUST NOT include forking or
|
|
199
|
+
daemon logic.
|
|
200
|
+
|
|
201
|
+
## 5. `status` command
|
|
202
|
+
|
|
203
|
+
Reads config the same way `start` does, then GETs `http://127.0.0.1:<port>/api/health`
|
|
204
|
+
with native `fetch` and a short timeout. Prints running/not-running plus version and
|
|
205
|
+
uptime from the health payload; exit code 0 when reachable, 1 when not.
|
|
206
|
+
|
|
207
|
+
## 6. `reset-admin` command
|
|
208
|
+
|
|
209
|
+
The recovery path when credentials are lost — filesystem access is the root of trust; no
|
|
210
|
+
email reset flow exists.
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
export function registerResetAdmin(program: Command): void {
|
|
214
|
+
program
|
|
215
|
+
.command('reset-admin')
|
|
216
|
+
.description('Regenerate the admin password (offline, direct DB access)')
|
|
217
|
+
.option('--data-dir <path>', 'Data directory')
|
|
218
|
+
.action(async (flags: { dataDir?: string }) => {
|
|
219
|
+
const dataDir = resolveDataDir(flags.dataDir ?? process.env.APP_DATA_DIR)
|
|
220
|
+
const db = openDatabase(dataDir)
|
|
221
|
+
runMigrations(db)
|
|
222
|
+
const password = resetAdminPassword(db) // core service: new random password,
|
|
223
|
+
// must_change_password = true,
|
|
224
|
+
// revoke all admin sessions
|
|
225
|
+
db.close()
|
|
226
|
+
console.log(`New admin password: ${password}`)
|
|
227
|
+
console.log('Shown once — it will not be displayed again.')
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Opens the DB directly (works while the server is stopped; WAL + busy_timeout make it safe
|
|
233
|
+
either way), sets `must_change_password = true`, revokes the admin's sessions, prints the
|
|
234
|
+
password once. Never writes the plaintext anywhere.
|
|
235
|
+
|
|
236
|
+
## 7. Pre-publish check
|
|
237
|
+
|
|
238
|
+
Before publishing, always run the local install check against the real artifact:
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
pnpm verify
|
|
242
|
+
npm pack ./packages/cli
|
|
243
|
+
npm i -g ./{{PACKAGE_NAME}}-{{VERSION}}.tgz
|
|
244
|
+
{{PACKAGE_NAME}} --version
|
|
245
|
+
node scripts/smoke-start.mjs --command {{PACKAGE_NAME}}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
This is the only place shebang, `files` whitelist, native-module resolution, and
|
|
249
|
+
static-asset path bugs surface. `prepublishOnly` additionally runs `pnpm -w verify` as a
|
|
250
|
+
safety net.
|