specpipe 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +1319 -0
  2. package/bin/devkit.js +3 -0
  3. package/package.json +61 -0
  4. package/src/cli.js +76 -0
  5. package/src/commands/check.js +33 -0
  6. package/src/commands/diff.js +84 -0
  7. package/src/commands/init-adopt.js +54 -0
  8. package/src/commands/init-agents.js +118 -0
  9. package/src/commands/init-global.js +102 -0
  10. package/src/commands/init.js +311 -0
  11. package/src/commands/list.js +54 -0
  12. package/src/commands/remove.js +133 -0
  13. package/src/commands/upgrade.js +215 -0
  14. package/src/lib/agent-guards.js +100 -0
  15. package/src/lib/agent-install.js +161 -0
  16. package/src/lib/agents.js +280 -0
  17. package/src/lib/claude-global.js +183 -0
  18. package/src/lib/detector.js +93 -0
  19. package/src/lib/hasher.js +21 -0
  20. package/src/lib/installer.js +213 -0
  21. package/src/lib/logger.js +16 -0
  22. package/src/lib/manifest.js +102 -0
  23. package/src/lib/reconcile.js +56 -0
  24. package/templates/.claude/CLAUDE.md +79 -0
  25. package/templates/.claude/hooks/comment-guard.js +126 -0
  26. package/templates/.claude/hooks/file-guard.js +216 -0
  27. package/templates/.claude/hooks/glob-guard.js +104 -0
  28. package/templates/.claude/hooks/path-guard.sh +118 -0
  29. package/templates/.claude/hooks/self-review.sh +27 -0
  30. package/templates/.claude/hooks/sensitive-guard.sh +227 -0
  31. package/templates/.claude/settings.json +68 -0
  32. package/templates/docs/WORKFLOW.md +325 -0
  33. package/templates/docs/specs/.gitkeep +0 -0
  34. package/templates/hooks/specpipe-read-guard.sh +42 -0
  35. package/templates/hooks/specpipe-shell-guard.sh +65 -0
  36. package/templates/rules/specpipe-guards.md +40 -0
  37. package/templates/scripts/test-hooks.sh +66 -0
  38. package/templates/skills/sp-build/SKILL.md +776 -0
  39. package/templates/skills/sp-challenge/SKILL.md +255 -0
  40. package/templates/skills/sp-commit/SKILL.md +174 -0
  41. package/templates/skills/sp-explore/SKILL.md +730 -0
  42. package/templates/skills/sp-fix/SKILL.md +266 -0
  43. package/templates/skills/sp-humanize/SKILL.md +212 -0
  44. package/templates/skills/sp-investigate/SKILL.md +648 -0
  45. package/templates/skills/sp-md-render/SKILL.md +200 -0
  46. package/templates/skills/sp-md-render/components.md +415 -0
  47. package/templates/skills/sp-md-render/template.html +283 -0
  48. package/templates/skills/sp-plan/SKILL.md +947 -0
  49. package/templates/skills/sp-review/SKILL.md +268 -0
  50. package/templates/skills/sp-scaffold/SKILL.md +237 -0
  51. package/templates/skills/sp-scaffold/references/ARCHITECTURE.md.tmpl +228 -0
  52. package/templates/skills/sp-scaffold/references/DESIGN.md.tmpl +113 -0
  53. package/templates/skills/sp-scaffold/references/adr/NNNN-template.md +92 -0
  54. package/templates/skills/sp-scaffold/references/stack-profiles/react.md +36 -0
  55. package/templates/skills/sp-spec-render/SKILL.md +254 -0
  56. package/templates/skills/sp-spec-render/components.md +418 -0
  57. package/templates/skills/sp-spec-render/examples/user-auth.html +749 -0
  58. package/templates/skills/sp-spec-render/examples/user-auth.md +114 -0
  59. package/templates/skills/sp-spec-render/template.html +222 -0
  60. package/templates/skills/sp-voices/SKILL.md +1184 -0
@@ -0,0 +1,749 @@
1
+ <!doctype html>
2
+ <html lang="vi" data-theme="auto">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Spec · user-auth</title>
7
+ <style>
8
+ :root {
9
+ --bg: #ffffff;
10
+ --bg-elev: #f6f8fa;
11
+ --bg-sunken: #fafbfc;
12
+ --fg: #0a0c10;
13
+ --fg-muted: #57606a;
14
+ --fg-subtle: #8c959f;
15
+ --border: #d0d7de;
16
+ --border-subtle: #e5e9ef;
17
+ --accent: #d97757;
18
+ --accent-bg: #fff4ef;
19
+ --p0: #cf222e;
20
+ --p0-bg: #ffebe9;
21
+ --p1: #bf8700;
22
+ --p1-bg: #fff8c5;
23
+ --p2: #57606a;
24
+ --p2-bg: #eaeef2;
25
+ --warn: #9a6700;
26
+ --warn-bg: #fff8c5;
27
+ --warn-border: #d4a72c;
28
+ --ok: #1a7f37;
29
+ --shadow: 0 1px 0 rgba(31,35,40,0.04);
30
+ --radius: 6px;
31
+ --radius-lg: 10px;
32
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
33
+ --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
34
+ }
35
+ @media (prefers-color-scheme: dark) {
36
+ :root[data-theme="auto"] {
37
+ --bg: #0d1117;
38
+ --bg-elev: #161b22;
39
+ --bg-sunken: #010409;
40
+ --fg: #e6edf3;
41
+ --fg-muted: #8b949e;
42
+ --fg-subtle: #6e7681;
43
+ --border: #30363d;
44
+ --border-subtle: #21262d;
45
+ --accent: #f0a378;
46
+ --accent-bg: #2a1810;
47
+ --p0: #f85149;
48
+ --p0-bg: #2d0e10;
49
+ --p1: #d29922;
50
+ --p1-bg: #2d2611;
51
+ --p2: #8b949e;
52
+ --p2-bg: #21262d;
53
+ --warn: #d29922;
54
+ --warn-bg: #2d2611;
55
+ --warn-border: #9e6a03;
56
+ --ok: #3fb950;
57
+ --shadow: 0 1px 0 rgba(0,0,0,0.3);
58
+ }
59
+ }
60
+ :root[data-theme="dark"] {
61
+ --bg: #0d1117;
62
+ --bg-elev: #161b22;
63
+ --bg-sunken: #010409;
64
+ --fg: #e6edf3;
65
+ --fg-muted: #8b949e;
66
+ --fg-subtle: #6e7681;
67
+ --border: #30363d;
68
+ --border-subtle: #21262d;
69
+ --accent: #f0a378;
70
+ --accent-bg: #2a1810;
71
+ --p0: #f85149;
72
+ --p0-bg: #2d0e10;
73
+ --p1: #d29922;
74
+ --p1-bg: #2d2611;
75
+ --p2: #8b949e;
76
+ --p2-bg: #21262d;
77
+ --warn: #d29922;
78
+ --warn-bg: #2d2611;
79
+ --warn-border: #9e6a03;
80
+ --ok: #3fb950;
81
+ --shadow: 0 1px 0 rgba(0,0,0,0.3);
82
+ }
83
+
84
+ * { box-sizing: border-box; }
85
+ html, body { margin: 0; padding: 0; }
86
+ body {
87
+ font-family: var(--sans);
88
+ font-size: 14px;
89
+ line-height: 1.55;
90
+ color: var(--fg);
91
+ background: var(--bg);
92
+ -webkit-font-smoothing: antialiased;
93
+ }
94
+ code, kbd, .mono { font-family: var(--mono); font-size: 0.92em; }
95
+ a { color: var(--accent); text-decoration: none; }
96
+ a:hover { text-decoration: underline; }
97
+
98
+ /* Skip link */
99
+ .skip { position: absolute; left: -9999px; top: 8px; padding: 8px 12px; background: var(--accent); color: white; border-radius: 6px; }
100
+ .skip:focus { left: 8px; z-index: 1000; }
101
+
102
+ /* Top bar */
103
+ .topbar {
104
+ position: sticky; top: 0; z-index: 50;
105
+ display: flex; align-items: center; gap: 16px;
106
+ padding: 10px 20px;
107
+ background: var(--bg);
108
+ border-bottom: 1px solid var(--border);
109
+ font-size: 13px;
110
+ }
111
+ .topbar .doc-type {
112
+ font-family: var(--mono);
113
+ font-size: 11px;
114
+ font-weight: 600;
115
+ letter-spacing: 0.05em;
116
+ padding: 2px 6px;
117
+ background: var(--accent-bg);
118
+ color: var(--accent);
119
+ border-radius: 4px;
120
+ }
121
+ .topbar .feature { font-weight: 600; color: var(--fg); }
122
+ .topbar .meta { color: var(--fg-muted); display: flex; gap: 14px; flex-wrap: wrap; }
123
+ .topbar .meta .sep { color: var(--fg-subtle); }
124
+ .topbar .spacer { flex: 1; }
125
+ .status {
126
+ display: inline-flex; align-items: center; gap: 6px;
127
+ font-size: 12px; font-weight: 500;
128
+ padding: 2px 8px;
129
+ border-radius: 999px;
130
+ background: color-mix(in srgb, var(--ok) 12%, transparent);
131
+ color: var(--ok);
132
+ }
133
+ .status::before {
134
+ content: ""; width: 6px; height: 6px; border-radius: 50%;
135
+ background: var(--ok);
136
+ }
137
+ .icon-btn {
138
+ background: none; border: 1px solid var(--border); color: var(--fg-muted);
139
+ width: 32px; height: 32px; border-radius: 6px; cursor: pointer;
140
+ display: inline-flex; align-items: center; justify-content: center;
141
+ transition: background 80ms, color 80ms;
142
+ }
143
+ .icon-btn:hover { background: var(--bg-elev); color: var(--fg); }
144
+ .icon-btn svg { width: 16px; height: 16px; fill: currentColor; }
145
+
146
+ /* Layout */
147
+ .layout { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 0; }
148
+ @media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
149
+
150
+ /* Sidebar */
151
+ .sidebar {
152
+ position: sticky; top: 53px; align-self: start;
153
+ height: calc(100vh - 53px);
154
+ overflow-y: auto;
155
+ border-right: 1px solid var(--border);
156
+ padding: 16px 12px 32px;
157
+ background: var(--bg);
158
+ }
159
+ @media (max-width: 900px) {
160
+ .sidebar { position: static; height: auto; border-right: none; border-bottom: 1px solid var(--border); }
161
+ }
162
+ .toc-search {
163
+ width: 100%; padding: 6px 10px;
164
+ font: inherit; font-size: 13px;
165
+ background: var(--bg-elev);
166
+ border: 1px solid var(--border);
167
+ border-radius: 6px; color: var(--fg);
168
+ margin-bottom: 12px;
169
+ }
170
+ .toc-search:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: var(--accent); }
171
+ .toc-group {
172
+ font-size: 11px; font-weight: 600; letter-spacing: 0.05em;
173
+ text-transform: uppercase; color: var(--fg-subtle);
174
+ margin: 14px 8px 6px;
175
+ }
176
+ .toc a {
177
+ display: flex; align-items: center; gap: 8px;
178
+ padding: 4px 8px;
179
+ color: var(--fg-muted);
180
+ border-radius: 4px;
181
+ font-size: 13px;
182
+ line-height: 1.45;
183
+ }
184
+ .toc a:hover { background: var(--bg-elev); color: var(--fg); text-decoration: none; }
185
+ .toc a.active {
186
+ background: var(--accent-bg); color: var(--accent); font-weight: 500;
187
+ }
188
+ .toc a .p { font-family: var(--mono); font-size: 10px; flex-shrink: 0; }
189
+ .toc a .p.p0 { color: var(--p0); }
190
+ .toc a .p.p1 { color: var(--p1); }
191
+ .toc a .p.p2 { color: var(--p2); }
192
+
193
+ /* Content */
194
+ main {
195
+ padding: 24px 32px 80px;
196
+ max-width: 920px;
197
+ min-width: 0;
198
+ }
199
+ @media (max-width: 600px) { main { padding: 16px; } }
200
+
201
+ h1 { font-size: 22px; font-weight: 600; margin: 4px 0 4px; letter-spacing: -0.01em; }
202
+ h2 { font-size: 18px; font-weight: 600; margin: 32px 0 12px; padding-bottom: 4px; border-bottom: 1px solid var(--border-subtle); }
203
+ h3 { font-size: 15px; font-weight: 600; margin: 0; }
204
+ p { margin: 0 0 12px; }
205
+
206
+ .subtitle { color: var(--fg-muted); margin: 0 0 24px; font-size: 14px; }
207
+
208
+ /* TL;DR */
209
+ .tldr {
210
+ background: var(--bg-elev);
211
+ border: 1px solid var(--border-subtle);
212
+ border-left: 3px solid var(--accent);
213
+ border-radius: var(--radius);
214
+ padding: 14px 18px;
215
+ margin: 16px 0 24px;
216
+ }
217
+ .tldr-label {
218
+ font-family: var(--mono); font-size: 11px; font-weight: 600;
219
+ letter-spacing: 0.05em; color: var(--accent); text-transform: uppercase;
220
+ margin-bottom: 6px;
221
+ }
222
+ .tldr p { margin: 0 0 8px; }
223
+ .tldr ul { margin: 6px 0 0; padding-left: 18px; }
224
+ .tldr li { margin: 2px 0; }
225
+
226
+ /* Priority badge */
227
+ .badge {
228
+ display: inline-flex; align-items: center;
229
+ font-family: var(--mono); font-size: 11px; font-weight: 600;
230
+ padding: 1px 6px; border-radius: 4px;
231
+ letter-spacing: 0.02em;
232
+ }
233
+ .badge.p0 { color: var(--p0); background: var(--p0-bg); }
234
+ .badge.p1 { color: var(--p1); background: var(--p1-bg); }
235
+ .badge.p2 { color: var(--p2); background: var(--p2-bg); }
236
+ .badge.count { color: var(--fg-muted); background: var(--bg-elev); font-weight: 500; }
237
+
238
+ /* Story card */
239
+ .story {
240
+ border: 1px solid var(--border);
241
+ border-radius: var(--radius-lg);
242
+ margin: 0 0 16px;
243
+ background: var(--bg);
244
+ box-shadow: var(--shadow);
245
+ overflow: hidden;
246
+ }
247
+ .story-head {
248
+ display: flex; align-items: center; gap: 10px;
249
+ padding: 12px 16px;
250
+ background: var(--bg-elev);
251
+ border-bottom: 1px solid var(--border-subtle);
252
+ }
253
+ .story-head .id {
254
+ font-family: var(--mono); font-size: 12px;
255
+ color: var(--fg-muted);
256
+ }
257
+ .story-head .title { flex: 1; font-weight: 600; font-size: 14px; }
258
+ .story-body { padding: 14px 16px; }
259
+ .story-desc { color: var(--fg); margin: 0 0 12px; font-size: 13.5px; }
260
+
261
+ /* AS table */
262
+ .as-list { display: flex; flex-direction: column; gap: 8px; }
263
+ .as {
264
+ border: 1px solid var(--border-subtle);
265
+ border-radius: var(--radius);
266
+ overflow: hidden;
267
+ }
268
+ .as-head {
269
+ display: flex; align-items: center; gap: 10px;
270
+ padding: 8px 12px;
271
+ background: var(--bg-sunken);
272
+ cursor: pointer;
273
+ user-select: none;
274
+ }
275
+ .as-head:hover { background: var(--bg-elev); }
276
+ .as-head .id { font-family: var(--mono); font-size: 12px; color: var(--accent); font-weight: 600; }
277
+ .as-head .desc { flex: 1; font-size: 13.5px; color: var(--fg); }
278
+ .as-head .chev { color: var(--fg-subtle); transition: transform 120ms; flex-shrink: 0; }
279
+ .as[open] .as-head .chev { transform: rotate(90deg); }
280
+ .as-body {
281
+ padding: 10px 12px;
282
+ border-top: 1px solid var(--border-subtle);
283
+ background: var(--bg);
284
+ }
285
+ .gwt {
286
+ display: grid; grid-template-columns: max-content 1fr;
287
+ gap: 4px 12px;
288
+ font-size: 13px;
289
+ }
290
+ .gwt dt {
291
+ font-family: var(--mono); font-size: 11px; font-weight: 600;
292
+ color: var(--fg-subtle); letter-spacing: 0.04em; text-transform: uppercase;
293
+ padding-top: 3px;
294
+ }
295
+ .gwt dd { margin: 0; }
296
+ .gwt code { background: var(--bg-elev); padding: 1px 4px; border-radius: 3px; }
297
+ .as-data {
298
+ margin-top: 8px; padding: 6px 10px;
299
+ background: var(--bg-sunken); border-radius: 4px;
300
+ font-family: var(--mono); font-size: 12px; color: var(--fg-muted);
301
+ }
302
+ .as-data b { color: var(--fg); font-weight: 600; }
303
+
304
+ /* Callout */
305
+ .callout {
306
+ display: flex; gap: 10px;
307
+ padding: 12px 14px;
308
+ background: var(--warn-bg);
309
+ border: 1px solid var(--warn-border);
310
+ border-radius: var(--radius);
311
+ margin: 0 0 8px;
312
+ font-size: 13.5px;
313
+ }
314
+ .callout .ico {
315
+ flex-shrink: 0; color: var(--warn);
316
+ width: 18px; height: 18px;
317
+ }
318
+ .callout-title { font-weight: 600; color: var(--warn); margin: 0 0 2px; font-size: 13px; }
319
+ .callout p { margin: 0; color: var(--fg); }
320
+
321
+ /* Constraint list */
322
+ .constraints { display: flex; flex-direction: column; gap: 8px; }
323
+
324
+ /* Timeline */
325
+ details.collapsible {
326
+ border: 1px solid var(--border-subtle);
327
+ border-radius: var(--radius);
328
+ background: var(--bg-elev);
329
+ margin: 0 0 10px;
330
+ }
331
+ details.collapsible > summary {
332
+ list-style: none; cursor: pointer;
333
+ padding: 10px 14px;
334
+ display: flex; align-items: center; gap: 8px;
335
+ font-weight: 500; font-size: 13.5px;
336
+ }
337
+ details.collapsible > summary::-webkit-details-marker { display: none; }
338
+ details.collapsible > summary::before {
339
+ content: "›"; color: var(--fg-subtle); font-size: 18px; line-height: 1;
340
+ transition: transform 120ms;
341
+ }
342
+ details.collapsible[open] > summary::before { transform: rotate(90deg); }
343
+ details.collapsible > summary .count {
344
+ color: var(--fg-muted); font-weight: 400; margin-left: auto;
345
+ }
346
+ details.collapsible > div { padding: 8px 16px 14px; border-top: 1px solid var(--border-subtle); background: var(--bg); }
347
+
348
+ .changelog { width: 100%; border-collapse: collapse; font-size: 13px; }
349
+ .changelog th, .changelog td { text-align: left; padding: 6px 10px 6px 0; border-bottom: 1px solid var(--border-subtle); }
350
+ .changelog th { font-size: 11px; font-weight: 600; color: var(--fg-subtle); letter-spacing: 0.04em; text-transform: uppercase; }
351
+ .changelog td.date { font-family: var(--mono); white-space: nowrap; color: var(--fg-muted); }
352
+
353
+ .snapshots-list { display: flex; flex-direction: column; gap: 6px; }
354
+ .snapshot-row { display: flex; align-items: center; gap: 12px; padding: 6px 0; border-bottom: 1px solid var(--border-subtle); font-size: 13px; }
355
+ .snapshot-row:last-child { border-bottom: none; }
356
+ .snapshot-row .date { font-family: var(--mono); color: var(--fg-muted); min-width: 100px; }
357
+ .snapshot-row .reason { color: var(--fg-subtle); font-size: 12px; }
358
+
359
+ /* Anchor */
360
+ h2[id], h3[id] { scroll-margin-top: 70px; }
361
+
362
+ /* Hidden by filter */
363
+ .toc-hidden { display: none !important; }
364
+
365
+ /* Print */
366
+ @media print {
367
+ .topbar, .sidebar, .icon-btn { display: none; }
368
+ .layout { grid-template-columns: 1fr; }
369
+ main { max-width: none; padding: 0; }
370
+ details.collapsible[open] > summary::before,
371
+ details.collapsible > summary::before { display: none; }
372
+ details.collapsible, details:not([open]) > div, .as { break-inside: avoid; }
373
+ .as[open] .as-body, .as .as-body { display: block !important; border-top: 1px solid var(--border); }
374
+ }
375
+ </style>
376
+ </head>
377
+ <body>
378
+ <a class="skip" href="#content">Bỏ qua menu</a>
379
+
380
+ <header class="topbar">
381
+ <span class="doc-type">SPEC</span>
382
+ <span class="feature">user-auth</span>
383
+ <span class="meta">
384
+ <span>v3</span>
385
+ <span class="sep">·</span>
386
+ <span>updated 2026-05-14</span>
387
+ <span class="sep">·</span>
388
+ <span>4 stories</span>
389
+ <span class="sep">·</span>
390
+ <span>9 AS</span>
391
+ </span>
392
+ <span class="status active">Active</span>
393
+ <span class="spacer"></span>
394
+ <button class="icon-btn" id="theme-toggle" title="Đổi theme" aria-label="Đổi theme">
395
+ <svg viewBox="0 0 16 16"><path d="M8 12.5a4.5 4.5 0 1 0 0-9 4.5 4.5 0 0 0 0 9zM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0zm0 13a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13zM3.05 3.05a.75.75 0 0 1 1.06 0l1.06 1.06a.75.75 0 1 1-1.06 1.06L3.05 4.11a.75.75 0 0 1 0-1.06zm7.78 7.78a.75.75 0 0 1 1.06 0l1.06 1.06a.75.75 0 1 1-1.06 1.06l-1.06-1.06a.75.75 0 0 1 0-1.06zM0 8a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 8zm13 0a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5A.75.75 0 0 1 13 8zM3.05 12.95a.75.75 0 0 1 0-1.06l1.06-1.06a.75.75 0 1 1 1.06 1.06L4.11 12.95a.75.75 0 0 1-1.06 0zm7.78-7.78a.75.75 0 0 1 0-1.06l1.06-1.06a.75.75 0 1 1 1.06 1.06l-1.06 1.06a.75.75 0 0 1-1.06 0z"/></svg>
396
+ </button>
397
+ </header>
398
+
399
+ <div class="layout">
400
+ <aside class="sidebar" aria-label="Mục lục">
401
+ <input class="toc-search" id="toc-search" placeholder="Tìm story…" type="search">
402
+ <nav class="toc" id="toc">
403
+ <div class="toc-group">Tổng quan</div>
404
+ <a href="#tldr" data-target="tldr">TL;DR</a>
405
+ <a href="#overview" data-target="overview">Overview</a>
406
+ <a href="#data-model" data-target="data-model">Data Model</a>
407
+
408
+ <div class="toc-group">Stories</div>
409
+ <a href="#s-001" data-target="s-001"><span class="p p0">P0</span> S-001 · Login email + password</a>
410
+ <a href="#s-002" data-target="s-002"><span class="p p0">P0</span> S-002 · Logout</a>
411
+ <a href="#s-003" data-target="s-003"><span class="p p1">P1</span> S-003 · Password reset</a>
412
+ <a href="#s-004" data-target="s-004"><span class="p p2">P2</span> S-004 · Remember-me</a>
413
+
414
+ <div class="toc-group">Tham chiếu</div>
415
+ <a href="#constraints" data-target="constraints">Constraints</a>
416
+ <a href="#existing" data-target="existing">What Already Exists</a>
417
+ <a href="#not-in-scope" data-target="not-in-scope">Not in Scope</a>
418
+ <a href="#changelog" data-target="changelog">Change Log</a>
419
+ <a href="#snapshots" data-target="snapshots">Snapshots</a>
420
+ </nav>
421
+ </aside>
422
+
423
+ <main id="content">
424
+ <h1>user-auth</h1>
425
+ <p class="subtitle">Email + password authentication with session cookies, password reset, and optional remember-me.</p>
426
+
427
+ <section class="tldr" id="tldr">
428
+ <div class="tldr-label">TL;DR</div>
429
+ <p>4 stories, 9 AS. Quyết định lớn: <b>session cookie</b> (không JWT), <b>bcrypt cost 12</b>, password tối thiểu 12 ký tự, rate limit 5 lần / 15 phút theo IP+email.</p>
430
+ <ul>
431
+ <li><b>S-001</b> Login — happy path + sai password + rate-limited (3 AS, P0)</li>
432
+ <li><b>S-002</b> Logout — server-side session invalidation + cookie clear (2 AS, P0)</li>
433
+ <li><b>S-003</b> Password reset — email link, token 1h TTL (3 AS, P1)</li>
434
+ <li><b>S-004</b> Remember-me — 30d rolling cookie (1 AS, P2)</li>
435
+ </ul>
436
+ </section>
437
+
438
+ <h2 id="overview">Overview</h2>
439
+ <p>Hệ thống auth cho khu vực dashboard. Đăng nhập bằng email + password, session lưu server-side, cookie HTTP-only. Mục tiêu: replace middleware cũ (legacy token storage không meet compliance).</p>
440
+
441
+ <h2 id="data-model">Data Model</h2>
442
+ <p>Entities: <code>User</code>, <code>Session</code>, <code>PasswordResetToken</code>. <code>Session</code> liên kết 1-N với <code>User</code>, TTL 30 ngày, rolling khi có activity. <code>PasswordResetToken</code> single-use, TTL 1h, invalidate hết session khác khi reset thành công.</p>
443
+
444
+ <h2 id="stories" style="margin-top:36px">Stories</h2>
445
+
446
+ <!-- S-001 -->
447
+ <article class="story" id="s-001">
448
+ <header class="story-head">
449
+ <span class="badge p0">P0</span>
450
+ <span class="id">S-001</span>
451
+ <span class="title">Login với email + password</span>
452
+ <span class="badge count">3 AS</span>
453
+ </header>
454
+ <div class="story-body">
455
+ <p class="story-desc">User nhập email + password → nhận session cookie, redirect tới <code>/dashboard</code>. Sai password hiện lỗi generic. Quá 5 lần sai trong 15 phút → tạm khoá.</p>
456
+
457
+ <div class="as-list">
458
+ <details class="as" open>
459
+ <summary class="as-head">
460
+ <span class="id">AS-001</span>
461
+ <span class="desc">Login thành công với credential đúng</span>
462
+ <span class="chev">›</span>
463
+ </summary>
464
+ <div class="as-body">
465
+ <dl class="gwt">
466
+ <dt>Given</dt><dd>User <code>jane@acme.com</code> tồn tại, password đúng, không có session active</dd>
467
+ <dt>When</dt><dd>POST <code>/api/login</code> với <code>{email, password}</code></dd>
468
+ <dt>Then</dt><dd>Response 200, set cookie <code>sid</code> (HTTP-only, Secure, SameSite=Lax), session row được tạo trong DB</dd>
469
+ </dl>
470
+ <div class="as-data"><b>Data:</b> email=<code>jane@acme.com</code>, password=<code>correct-horse-battery-staple</code></div>
471
+ </div>
472
+ </details>
473
+
474
+ <details class="as">
475
+ <summary class="as-head">
476
+ <span class="id">AS-002</span>
477
+ <span class="desc">Sai password trả lỗi generic, không leak user tồn tại</span>
478
+ <span class="chev">›</span>
479
+ </summary>
480
+ <div class="as-body">
481
+ <dl class="gwt">
482
+ <dt>Given</dt><dd>User tồn tại nhưng password sai (hoặc user không tồn tại)</dd>
483
+ <dt>When</dt><dd>POST <code>/api/login</code></dd>
484
+ <dt>Then</dt><dd>Response 401 với message <code>"Invalid email or password"</code>. Không phân biệt 2 trường hợp ở message hay timing.</dd>
485
+ </dl>
486
+ </div>
487
+ </details>
488
+
489
+ <details class="as">
490
+ <summary class="as-head">
491
+ <span class="id">AS-003</span>
492
+ <span class="desc">Quá 5 lần sai → 429 trong 15 phút</span>
493
+ <span class="chev">›</span>
494
+ </summary>
495
+ <div class="as-body">
496
+ <dl class="gwt">
497
+ <dt>Given</dt><dd>5 lần login fail liên tiếp cho cùng email từ cùng IP trong 15 phút</dd>
498
+ <dt>When</dt><dd>Lần thứ 6 POST <code>/api/login</code></dd>
499
+ <dt>Then</dt><dd>Response 429 với header <code>Retry-After: 900</code>, dù password đúng cũng từ chối</dd>
500
+ </dl>
501
+ <div class="as-data"><b>Edge:</b> đếm theo cặp (email, IP) — tránh attacker spam từ IP khác khoá user thật</div>
502
+ </div>
503
+ </details>
504
+ </div>
505
+ </div>
506
+ </article>
507
+
508
+ <!-- S-002 -->
509
+ <article class="story" id="s-002">
510
+ <header class="story-head">
511
+ <span class="badge p0">P0</span>
512
+ <span class="id">S-002</span>
513
+ <span class="title">Logout</span>
514
+ <span class="badge count">2 AS</span>
515
+ </header>
516
+ <div class="story-body">
517
+ <p class="story-desc">User click logout → session xoá khỏi DB, cookie clear, redirect <code>/login</code>.</p>
518
+ <div class="as-list">
519
+ <details class="as">
520
+ <summary class="as-head">
521
+ <span class="id">AS-004</span>
522
+ <span class="desc">Logout xoá session DB + clear cookie</span>
523
+ <span class="chev">›</span>
524
+ </summary>
525
+ <div class="as-body">
526
+ <dl class="gwt">
527
+ <dt>Given</dt><dd>Session active, cookie <code>sid</code> hợp lệ</dd>
528
+ <dt>When</dt><dd>POST <code>/api/logout</code></dd>
529
+ <dt>Then</dt><dd>Session row deleted, <code>Set-Cookie: sid=; Max-Age=0</code>, 302 → <code>/login</code></dd>
530
+ </dl>
531
+ </div>
532
+ </details>
533
+ <details class="as">
534
+ <summary class="as-head">
535
+ <span class="id">AS-005</span>
536
+ <span class="desc">Logout khi không có session vẫn 200 (idempotent)</span>
537
+ <span class="chev">›</span>
538
+ </summary>
539
+ <div class="as-body">
540
+ <dl class="gwt">
541
+ <dt>Given</dt><dd>Không có cookie <code>sid</code> hoặc session đã expire</dd>
542
+ <dt>When</dt><dd>POST <code>/api/logout</code></dd>
543
+ <dt>Then</dt><dd>Response 200, không lỗi. Idempotent (gloss: gọi nhiều lần kết quả như gọi 1 lần).</dd>
544
+ </dl>
545
+ </div>
546
+ </details>
547
+ </div>
548
+ </div>
549
+ </article>
550
+
551
+ <!-- S-003 -->
552
+ <article class="story" id="s-003">
553
+ <header class="story-head">
554
+ <span class="badge p1">P1</span>
555
+ <span class="id">S-003</span>
556
+ <span class="title">Password reset qua email</span>
557
+ <span class="badge count">3 AS</span>
558
+ </header>
559
+ <div class="story-body">
560
+ <p class="story-desc">User nhập email → nhận link reset (TTL 1h, single-use) → đặt password mới → invalidate tất cả session khác.</p>
561
+ <div class="as-list">
562
+ <details class="as">
563
+ <summary class="as-head">
564
+ <span class="id">AS-006</span>
565
+ <span class="desc">Request reset link gửi email (luôn 200 dù email tồn tại hay không)</span>
566
+ <span class="chev">›</span>
567
+ </summary>
568
+ <div class="as-body">
569
+ <dl class="gwt">
570
+ <dt>Given</dt><dd>Form reset password</dd>
571
+ <dt>When</dt><dd>POST <code>/api/password-reset/request</code> với <code>{email}</code></dd>
572
+ <dt>Then</dt><dd>Response 200 với message generic <code>"Check your email"</code>. Nếu email tồn tại → gửi link, không tồn tại → no-op (không leak).</dd>
573
+ </dl>
574
+ </div>
575
+ </details>
576
+ <details class="as">
577
+ <summary class="as-head">
578
+ <span class="id">AS-007</span>
579
+ <span class="desc">Token expired (>1h) trả lỗi</span>
580
+ <span class="chev">›</span>
581
+ </summary>
582
+ <div class="as-body">
583
+ <dl class="gwt">
584
+ <dt>Given</dt><dd>Token tạo cách đây 65 phút</dd>
585
+ <dt>When</dt><dd>POST <code>/api/password-reset/confirm</code> với token + new password</dd>
586
+ <dt>Then</dt><dd>Response 410 <code>"Token expired"</code></dd>
587
+ </dl>
588
+ </div>
589
+ </details>
590
+ <details class="as">
591
+ <summary class="as-head">
592
+ <span class="id">AS-008</span>
593
+ <span class="desc">Reset thành công invalidate tất cả session khác</span>
594
+ <span class="chev">›</span>
595
+ </summary>
596
+ <div class="as-body">
597
+ <dl class="gwt">
598
+ <dt>Given</dt><dd>User có 3 session active (laptop, phone, tablet), token còn hạn</dd>
599
+ <dt>When</dt><dd>Reset password thành công</dd>
600
+ <dt>Then</dt><dd>3 session bị xoá, user phải login lại trên cả 3 thiết bị, token reset bị mark used.</dd>
601
+ </dl>
602
+ </div>
603
+ </details>
604
+ </div>
605
+ </div>
606
+ </article>
607
+
608
+ <!-- S-004 -->
609
+ <article class="story" id="s-004">
610
+ <header class="story-head">
611
+ <span class="badge p2">P2</span>
612
+ <span class="id">S-004</span>
613
+ <span class="title">Remember-me cookie</span>
614
+ <span class="badge count">1 AS</span>
615
+ </header>
616
+ <div class="story-body">
617
+ <p class="story-desc">Checkbox "Remember me" trong form login → session TTL 30 ngày rolling thay vì 24h.</p>
618
+ <div class="as-list">
619
+ <details class="as">
620
+ <summary class="as-head">
621
+ <span class="id">AS-009</span>
622
+ <span class="desc">Tick remember → cookie Max-Age 30 ngày</span>
623
+ <span class="chev">›</span>
624
+ </summary>
625
+ <div class="as-body">
626
+ <dl class="gwt">
627
+ <dt>Given</dt><dd>Form login có checkbox "Remember me" được tick</dd>
628
+ <dt>When</dt><dd>Login thành công</dd>
629
+ <dt>Then</dt><dd>Cookie <code>sid</code> có <code>Max-Age=2592000</code> (30 ngày), session row có <code>extended=true</code>.</dd>
630
+ </dl>
631
+ </div>
632
+ </details>
633
+ </div>
634
+ </div>
635
+ </article>
636
+
637
+ <h2 id="constraints">Constraints &amp; Invariants</h2>
638
+ <div class="constraints">
639
+ <div class="callout">
640
+ <svg class="ico" viewBox="0 0 16 16" fill="currentColor"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/></svg>
641
+ <div>
642
+ <p class="callout-title">Password storage</p>
643
+ <p>Bcrypt cost 12 cho mọi password. Không dùng SHA*, không dùng PBKDF2. Migration từ legacy phải re-hash lúc login lần đầu.</p>
644
+ </div>
645
+ </div>
646
+ <div class="callout">
647
+ <svg class="ico" viewBox="0 0 16 16" fill="currentColor"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/></svg>
648
+ <div>
649
+ <p class="callout-title">Session cookie</p>
650
+ <p>Luôn <code>HttpOnly</code>, <code>Secure</code>, <code>SameSite=Lax</code>. Domain scope tới apex, không subdomain. Cookie name: <code>sid</code>.</p>
651
+ </div>
652
+ </div>
653
+ <div class="callout">
654
+ <svg class="ico" viewBox="0 0 16 16" fill="currentColor"><path d="M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/></svg>
655
+ <div>
656
+ <p class="callout-title">Password policy</p>
657
+ <p>Tối thiểu 12 ký tự, kiểm tra zxcvbn score ≥ 3. Không bắt buộc ký tự đặc biệt (NIST 800-63B).</p>
658
+ </div>
659
+ </div>
660
+ </div>
661
+
662
+ <h2 id="existing">What Already Exists</h2>
663
+ <p>Legacy auth middleware ở <code>src/middleware/auth-legacy.ts</code> đang dùng JWT lưu trong localStorage — đây là cái cần thay (compliance flagged). Bcrypt utility đã có ở <code>src/lib/crypto/hash.ts</code>, reuse. Rate limit helper <code>src/lib/rate-limit.ts</code> đã có cho API publicly-facing — reuse cho login.</p>
664
+
665
+ <h2 id="not-in-scope">Not in Scope</h2>
666
+ <ul>
667
+ <li><b>OAuth / SSO</b> — defer phase 2, hiện ưu tiên migrate khỏi legacy trước.</li>
668
+ <li><b>2FA / TOTP</b> — separate spec, planned Q3.</li>
669
+ <li><b>Email change flow</b> — sẽ làm khi có account settings page.</li>
670
+ <li><b>Audit log UI</b> — log có ghi DB nhưng không expose UI trong spec này.</li>
671
+ </ul>
672
+
673
+ <h2 id="changelog">Change Log</h2>
674
+ <details class="collapsible">
675
+ <summary>3 entries <span class="count">expand để xem</span></summary>
676
+ <div>
677
+ <table class="changelog">
678
+ <thead><tr><th>Date</th><th>Change</th><th>Ref</th></tr></thead>
679
+ <tbody>
680
+ <tr><td class="date">2026-05-14</td><td>Add S-004 Remember-me (P2)</td><td>—</td></tr>
681
+ <tr><td class="date">2026-05-08</td><td>S-003 priority P2 → P1 sau review compliance</td><td>JIRA-1428</td></tr>
682
+ <tr><td class="date">2026-04-30</td><td>Initial creation</td><td>—</td></tr>
683
+ </tbody>
684
+ </table>
685
+ </div>
686
+ </details>
687
+
688
+ <h2 id="snapshots">Snapshots</h2>
689
+ <details class="collapsible">
690
+ <summary>2 snapshots <span class="count">history</span></summary>
691
+ <div>
692
+ <div class="snapshots-list">
693
+ <div class="snapshot-row">
694
+ <span class="date">2026-05-08</span>
695
+ <a href="#">snapshots/2026-05-08-JIRA-1428.md</a>
696
+ <span class="reason">M3: priority change</span>
697
+ </div>
698
+ <div class="snapshot-row">
699
+ <span class="date">2026-04-30</span>
700
+ <a href="#">snapshots/2026-04-30.md</a>
701
+ <span class="reason">M1: initial</span>
702
+ </div>
703
+ </div>
704
+ </div>
705
+ </details>
706
+ </main>
707
+ </div>
708
+
709
+ <script>
710
+ // Theme toggle
711
+ const root = document.documentElement;
712
+ const themeBtn = document.getElementById('theme-toggle');
713
+ const stored = localStorage.getItem('spec-theme');
714
+ if (stored) root.setAttribute('data-theme', stored);
715
+ themeBtn.addEventListener('click', () => {
716
+ const cur = root.getAttribute('data-theme');
717
+ const next = cur === 'dark' ? 'light' : (cur === 'light' ? 'auto' : 'dark');
718
+ root.setAttribute('data-theme', next);
719
+ localStorage.setItem('spec-theme', next);
720
+ });
721
+
722
+ // TOC scroll-spy
723
+ const tocLinks = document.querySelectorAll('#toc a');
724
+ const tocById = new Map();
725
+ tocLinks.forEach(a => tocById.set(a.dataset.target, a));
726
+ const targets = [...tocById.keys()].map(id => document.getElementById(id)).filter(Boolean);
727
+ const setActive = (id) => {
728
+ tocLinks.forEach(a => a.classList.remove('active'));
729
+ const a = tocById.get(id);
730
+ if (a) a.classList.add('active');
731
+ };
732
+ const obs = new IntersectionObserver((entries) => {
733
+ const visible = entries.filter(e => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
734
+ if (visible[0]) setActive(visible[0].target.id);
735
+ }, { rootMargin: '-70px 0px -60% 0px', threshold: 0 });
736
+ targets.forEach(t => obs.observe(t));
737
+
738
+ // TOC search filter
739
+ const search = document.getElementById('toc-search');
740
+ search.addEventListener('input', () => {
741
+ const q = search.value.trim().toLowerCase();
742
+ tocLinks.forEach(a => {
743
+ if (!q) { a.classList.remove('toc-hidden'); return; }
744
+ a.classList.toggle('toc-hidden', !a.textContent.toLowerCase().includes(q));
745
+ });
746
+ });
747
+ </script>
748
+ </body>
749
+ </html>