@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.
Files changed (190) hide show
  1. package/dist/index.js +25449 -0
  2. package/drizzle/0000_remarkable_charles_xavier.sql +210 -0
  3. package/drizzle/0001_wise_wolf_cub.sql +13 -0
  4. package/drizzle/0002_secret_spyke.sql +16 -0
  5. package/drizzle/0003_workspace_config_singleton.sql +16 -0
  6. package/drizzle/0004_terminal_claim_v120.sql +11 -0
  7. package/drizzle/0005_brainstorm_v124.sql +10 -0
  8. package/drizzle/0006_project_v140.sql +6 -0
  9. package/drizzle/0007_prd_wizard_v124.sql +14 -0
  10. package/drizzle/0008_workflow_artifact_v126.sql +24 -0
  11. package/drizzle/0009_project_import_v150.sql +7 -0
  12. package/drizzle/0010_quality_v151.sql +3 -0
  13. package/drizzle/meta/0000_snapshot.json +1335 -0
  14. package/drizzle/meta/0001_snapshot.json +1416 -0
  15. package/drizzle/meta/0002_snapshot.json +1510 -0
  16. package/drizzle/meta/_journal.json +83 -0
  17. package/package.json +41 -0
  18. package/plugin/.claude-plugin/marketplace.json +36 -0
  19. package/plugin/.claude-plugin/plugin.json +22 -0
  20. package/plugin/README.md +625 -0
  21. package/plugin/SKILLS.md +350 -0
  22. package/plugin/co2.svg +5 -0
  23. package/plugin/commands/brainstorm-loop.md +129 -0
  24. package/plugin/skills/conductor-defect/SKILL.md +898 -0
  25. package/plugin/skills/conductor-feature-develop/SKILL.md +1383 -0
  26. package/plugin/skills/conductor-feature-develop/references/implementation-domain-template.md +131 -0
  27. package/plugin/skills/conductor-feature-develop/references/implementation-master-template.md +89 -0
  28. package/plugin/skills/conductor-feature-develop/references/playwright-setup.md +224 -0
  29. package/plugin/skills/conductor-feature-prepare/SKILL.md +604 -0
  30. package/plugin/skills/depgen-k8s/SKILL.md +468 -0
  31. package/plugin/skills/depgen-k8s/references/dockerfile-laravel.md +197 -0
  32. package/plugin/skills/depgen-k8s/references/dockerfile-nodejs.md +163 -0
  33. package/plugin/skills/depgen-k8s/references/dockerfile-spring-boot.md +174 -0
  34. package/plugin/skills/depgen-k8s/references/k8s-patterns.md +869 -0
  35. package/plugin/skills/mockgen-shadcn/SKILL.md +1067 -0
  36. package/plugin/skills/mockgen-shadcn/references/admin-layout-template.md +1467 -0
  37. package/plugin/skills/mockgen-shadcn/references/mockup-hub-template.md +498 -0
  38. package/plugin/skills/mockgen-shadcn/references/mockup-index-template.md +216 -0
  39. package/plugin/skills/mockgen-tailwind/SKILL.md +904 -0
  40. package/plugin/skills/mockgen-tailwind/references/admin-layout-template.md +720 -0
  41. package/plugin/skills/mockgen-tailwind/references/mockup-hub-template.md +498 -0
  42. package/plugin/skills/mockgen-tailwind/references/mockup-index-template.md +190 -0
  43. package/plugin/skills/modelgen-nosql/SKILL.md +617 -0
  44. package/plugin/skills/modelgen-nosql/references/model-extraction-methodology.md +497 -0
  45. package/plugin/skills/modelgen-nosql/references/nosql-design-guide.md +258 -0
  46. package/plugin/skills/modelgen-relational/SKILL.md +707 -0
  47. package/plugin/skills/modelgen-relational/references/model-extraction-methodology.md +499 -0
  48. package/plugin/skills/specgen-flutter-riverpod/SKILL.md +898 -0
  49. package/plugin/skills/specgen-flutter-riverpod/references/component-patterns.md +971 -0
  50. package/plugin/skills/specgen-flutter-riverpod/references/notification-patterns.md +457 -0
  51. package/plugin/skills/specgen-flutter-riverpod/references/routing-patterns.md +362 -0
  52. package/plugin/skills/specgen-flutter-riverpod/references/security-patterns.md +709 -0
  53. package/plugin/skills/specgen-flutter-riverpod/references/spec-template.md +1566 -0
  54. package/plugin/skills/specgen-flutter-riverpod/references/state-patterns.md +518 -0
  55. package/plugin/skills/specgen-flutter-riverpod/references/storage-patterns.md +370 -0
  56. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/SKILL.md +984 -0
  57. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/batch-patterns.md +359 -0
  58. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/messaging-patterns.md +468 -0
  59. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/modulith-patterns.md +461 -0
  60. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/reporting-patterns.md +803 -0
  61. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/security-patterns.md +501 -0
  62. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/spec-template.md +1268 -0
  63. package/plugin/skills/specgen-laravel-eloquent-bladehtmx/references/web-patterns.md +301 -0
  64. package/plugin/skills/specgen-node-cli-web/SKILL.md +561 -0
  65. package/plugin/skills/specgen-node-cli-web/references/auth-patterns.md +293 -0
  66. package/plugin/skills/specgen-node-cli-web/references/cli-packaging-patterns.md +250 -0
  67. package/plugin/skills/specgen-node-cli-web/references/server-patterns.md +247 -0
  68. package/plugin/skills/specgen-node-cli-web/references/spec-template.md +401 -0
  69. package/plugin/skills/specgen-node-cli-web/references/testing-patterns.md +174 -0
  70. package/plugin/skills/specgen-react-mui/SKILL.md +789 -0
  71. package/plugin/skills/specgen-react-mui/references/component-patterns.md +1077 -0
  72. package/plugin/skills/specgen-react-mui/references/reporting-patterns.md +1998 -0
  73. package/plugin/skills/specgen-react-mui/references/routing-patterns.md +461 -0
  74. package/plugin/skills/specgen-react-mui/references/security-patterns.md +599 -0
  75. package/plugin/skills/specgen-react-mui/references/spec-template.md +1030 -0
  76. package/plugin/skills/specgen-react-mui/references/state-patterns.md +327 -0
  77. package/plugin/skills/specgen-react-tailwind/SKILL.md +859 -0
  78. package/plugin/skills/specgen-react-tailwind/references/component-patterns.md +1212 -0
  79. package/plugin/skills/specgen-react-tailwind/references/reporting-patterns.md +1529 -0
  80. package/plugin/skills/specgen-react-tailwind/references/routing-patterns.md +669 -0
  81. package/plugin/skills/specgen-react-tailwind/references/security-patterns.md +656 -0
  82. package/plugin/skills/specgen-react-tailwind/references/spec-template.md +1237 -0
  83. package/plugin/skills/specgen-react-tailwind/references/state-patterns.md +327 -0
  84. package/plugin/skills/specgen-sdk-java/SKILL.md +933 -0
  85. package/plugin/skills/specgen-sdk-java/references/api-extraction-patterns.md +247 -0
  86. package/plugin/skills/specgen-sdk-java/references/http-client-patterns.md +495 -0
  87. package/plugin/skills/specgen-sdk-java/references/packaging-patterns.md +366 -0
  88. package/plugin/skills/specgen-sdk-java/references/spec-template.md +956 -0
  89. package/plugin/skills/specgen-spring-jpa-jtehtmx/SKILL.md +1284 -0
  90. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/batch-patterns.md +1224 -0
  91. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/jasper-patterns.md +1638 -0
  92. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/messaging-patterns.md +512 -0
  93. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/modulith-patterns.md +354 -0
  94. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/security-patterns.md +551 -0
  95. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/spec-template.md +2502 -0
  96. package/plugin/skills/specgen-spring-jpa-jtehtmx/references/web-patterns.md +503 -0
  97. package/plugin/skills/specgen-spring-jpa-restapi/SKILL.md +990 -0
  98. package/plugin/skills/specgen-spring-jpa-restapi/references/batch-patterns.md +1224 -0
  99. package/plugin/skills/specgen-spring-jpa-restapi/references/messaging-patterns.md +512 -0
  100. package/plugin/skills/specgen-spring-jpa-restapi/references/modulith-patterns.md +279 -0
  101. package/plugin/skills/specgen-spring-jpa-restapi/references/reporting-patterns.md +1536 -0
  102. package/plugin/skills/specgen-spring-jpa-restapi/references/restapi-patterns.md +722 -0
  103. package/plugin/skills/specgen-spring-jpa-restapi/references/security-patterns.md +351 -0
  104. package/plugin/skills/specgen-spring-jpa-restapi/references/spec-template.md +553 -0
  105. package/plugin/skills/specgen-ts-cli/SKILL.md +861 -0
  106. package/plugin/skills/specgen-ts-cli/references/async-patterns.md +1101 -0
  107. package/plugin/skills/specgen-ts-cli/references/command-patterns.md +383 -0
  108. package/plugin/skills/specgen-ts-cli/references/config-patterns.md +430 -0
  109. package/plugin/skills/specgen-ts-cli/references/database-patterns.md +750 -0
  110. package/plugin/skills/specgen-ts-cli/references/packaging-patterns.md +426 -0
  111. package/plugin/skills/specgen-ts-cli/references/spec-template.md +1391 -0
  112. package/plugin/skills/specgen-ts-cli/references/testing-patterns.md +909 -0
  113. package/plugin/skills/testgen-functional/SKILL.md +1381 -0
  114. package/plugin/skills/tracegen-matrix/SKILL.md +243 -0
  115. package/plugin/skills/util-preparek8senv/SKILL.md +422 -0
  116. package/plugin/skills/util-projectinit/SKILL.md +307 -0
  117. package/plugin/skills/util-projectinit/reference/brainstorm-loop.md +129 -0
  118. package/plugin/skills/util-projectinit/reference/brainstorm-protocol.md +144 -0
  119. package/plugin/skills/util-projectinit/reference/brainstorm-setup-guide.md +153 -0
  120. package/plugin/skills/util-projectinit/reference/brainstorm-template-appdev.md +87 -0
  121. package/plugin/skills/util-projectinit/reference/co2-context-generation-guide.md +761 -0
  122. package/plugin/skills/util-projectsync/SKILL.md +501 -0
  123. package/plugin/skills/util-updprd/SKILL.md +324 -0
  124. package/plugin/skills/util-usanalyzer/SKILL.md +307 -0
  125. package/plugin/skills/util-ustagger/SKILL.md +154 -0
  126. package/static/assets/abnfDiagram-VRR7QNED-DvPuwyCV.js +1 -0
  127. package/static/assets/arc-DRKw7sjs.js +1 -0
  128. package/static/assets/architectureDiagram-ZJ3FMSHR-CaNcxUbC.js +36 -0
  129. package/static/assets/blockDiagram-677ZJIJ3-Demd4AOK.js +132 -0
  130. package/static/assets/c4Diagram-LMCZKHZV-PVKUfc2x.js +10 -0
  131. package/static/assets/channel-C2nVbuwv.js +1 -0
  132. package/static/assets/chunk-2Q5K7J3B-CExf8ww_.js +1 -0
  133. package/static/assets/chunk-32BRIVSS-CFPaoONF.js +1 -0
  134. package/static/assets/chunk-5VM5RSS4-DE2esM67.js +15 -0
  135. package/static/assets/chunk-EX3LRPZG-SON_YdDG.js +231 -0
  136. package/static/assets/chunk-JWPE2WC7-Qxah8LNr.js +1 -0
  137. package/static/assets/chunk-MOJQB5TN-BcWGQV3j.js +88 -0
  138. package/static/assets/chunk-RYQCIY6F-DkDj-OR5.js +1 -0
  139. package/static/assets/chunk-V7JOEXUC-BlI1Mrah.js +206 -0
  140. package/static/assets/chunk-VR4S4FIN-Agf__duj.js +1 -0
  141. package/static/assets/chunk-XXDRQBXY-B5CfAh85.js +1 -0
  142. package/static/assets/classDiagram-OUVF2IWQ-DXJIljsR.js +1 -0
  143. package/static/assets/classDiagram-v2-EOCWNBFH-DXJIljsR.js +1 -0
  144. package/static/assets/cose-bilkent-JH36ORCC-DO0cQkcy.js +1 -0
  145. package/static/assets/cynefin-VYW2F7L2-CfOnsnXc.js +178 -0
  146. package/static/assets/cynefinDiagram-TSTJHNR4-BJ8tULAv.js +62 -0
  147. package/static/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
  148. package/static/assets/dagre-VKFMJZFB-DfjwzHqP.js +4 -0
  149. package/static/assets/defaultLocale-DX6XiGOO.js +1 -0
  150. package/static/assets/diagram-FQU43EPY-CndSOIIM.js +3 -0
  151. package/static/assets/diagram-G47NLZAW-Czf5hN0Z.js +24 -0
  152. package/static/assets/diagram-NH7WQ7WH-COAyojGh.js +24 -0
  153. package/static/assets/diagram-OA4YK3LP-H8PoiN2m.js +30 -0
  154. package/static/assets/diagram-WEI45ONY-DzcaVzjp.js +41 -0
  155. package/static/assets/ebnfDiagram-CCIWWBDH-DPazGYLJ.js +1 -0
  156. package/static/assets/erDiagram-Q63AITRT-Bo-fubgF.js +85 -0
  157. package/static/assets/flowDiagram-23GEKE2U-CSvxUDwh.js +156 -0
  158. package/static/assets/ganttDiagram-NO4QXBWP-DnBveWX6.js +292 -0
  159. package/static/assets/gitGraphDiagram-IHSO6WYX-B3rVL8c-.js +106 -0
  160. package/static/assets/graph-C9eacEi8.js +1 -0
  161. package/static/assets/index-LZSQkLE2.css +41 -0
  162. package/static/assets/index-y7XDSgMG.js +495 -0
  163. package/static/assets/infoDiagram-FWYZ7A6U-CvWV9yy2.js +2 -0
  164. package/static/assets/init-Gi6I4Gst.js +1 -0
  165. package/static/assets/ishikawaDiagram-FXEZZL3T-DF28a1N6.js +70 -0
  166. package/static/assets/journeyDiagram-5HDEW3XC-CB6CugYk.js +139 -0
  167. package/static/assets/kanban-definition-HUTT4EX6-ResSLF6d.js +89 -0
  168. package/static/assets/katex-C5jXJg4s.js +257 -0
  169. package/static/assets/layout-DEXfKzaS.js +1 -0
  170. package/static/assets/linear-CLO--ryk.js +1 -0
  171. package/static/assets/map-Czzmt4hB.js +1 -0
  172. package/static/assets/mindmap-definition-LN4V7U3C-BvW3EbxX.js +96 -0
  173. package/static/assets/ordinal-Cboi1Yqb.js +1 -0
  174. package/static/assets/pegDiagram-2B236MQR-BHCgqrrM.js +1 -0
  175. package/static/assets/pieDiagram-ENE6RG2P-DVmCQ5FA.js +39 -0
  176. package/static/assets/quadrantDiagram-ABIIQ3AL-B3e5KR52.js +7 -0
  177. package/static/assets/railroadDiagram-RFXS5EU6-Csv5ysLc.js +1 -0
  178. package/static/assets/requirementDiagram-TGXJPOKE-DOMVzvnB.js +84 -0
  179. package/static/assets/sankeyDiagram-HTMAVEWB-B1dcTOvy.js +40 -0
  180. package/static/assets/sequenceDiagram-DBY2YBRQ-BY414yeO.js +162 -0
  181. package/static/assets/sizeCapture-X5ZJPWSS-CuJfrfBN.js +1 -0
  182. package/static/assets/stateDiagram-2N3HPSRC-FX4rJkzV.js +1 -0
  183. package/static/assets/stateDiagram-v2-6OUMAXLB-BVufbO_H.js +1 -0
  184. package/static/assets/swimlanes-5IMT3BWC-Sfy58mmv.js +2 -0
  185. package/static/assets/swimlanesDiagram-G3AALYLV-wDCDw08H.js +8 -0
  186. package/static/assets/timeline-definition-FHXFAJF6-CmV7by8p.js +120 -0
  187. package/static/assets/vennDiagram-L72KCM5P-FqIyFZFt.js +34 -0
  188. package/static/assets/wardleyDiagram-EHGQE667-BZ06O45i.js +78 -0
  189. package/static/assets/xychartDiagram-FW5EYKEG-BOkffR06.js +7 -0
  190. 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.