@jarwizz/create-jarshop 0.1.1 → 0.1.2

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 (100) hide show
  1. package/README.md +8 -0
  2. package/dist/generator.js +6 -3
  3. package/dist/generator.js.map +1 -1
  4. package/dist/template/CONTEXT.md +10 -0
  5. package/dist/template/README.md +20 -3
  6. package/dist/template/docker-compose.yml +5 -0
  7. package/dist/template/docs/smtp-smoke.md +37 -0
  8. package/dist/template/env.example.template +18 -0
  9. package/dist/template/gitignore.template +2 -0
  10. package/dist/template/package.json +13 -5
  11. package/dist/template/pnpm-lock.yaml +38 -7
  12. package/dist/template/src/dev-storefront.ts +58 -0
  13. package/dist/template/src/dev.ts +67 -4
  14. package/dist/template/src/email-smoke.ts +27 -0
  15. package/dist/template/src/environment.d.ts +6 -0
  16. package/dist/template/src/migrate.ts +16 -0
  17. package/dist/template/src/migrations/1786024108434-jarshop-checkout-attempt.ts +28 -0
  18. package/dist/template/src/migrations/1786024108435-jarshop-legal-and-confirmation.ts +60 -0
  19. package/dist/template/src/migrations/1786024108436-jarshop-order-email-outbox.ts +37 -0
  20. package/dist/template/src/migrations/1786030000000-jarshop-order-email-delivery-lease.ts +27 -0
  21. package/dist/template/src/plugins/jarshop-checkout/checkout-attempt.entity.ts +42 -0
  22. package/dist/template/src/plugins/jarshop-checkout/confirmation-token.entity.ts +31 -0
  23. package/dist/template/src/plugins/jarshop-checkout/confirmation.ts +26 -0
  24. package/dist/template/src/plugins/jarshop-checkout/dashboard/index.tsx +107 -0
  25. package/dist/template/src/plugins/jarshop-checkout/dashboard/order-email-delivery-status.ts +43 -0
  26. package/dist/template/src/plugins/jarshop-checkout/email-smoke-core.ts +56 -0
  27. package/dist/template/src/plugins/jarshop-checkout/index.ts +47 -0
  28. package/dist/template/src/plugins/jarshop-checkout/jarshop-checkout.plugin.ts +813 -0
  29. package/dist/template/src/plugins/jarshop-checkout/legal.entity.ts +39 -0
  30. package/dist/template/src/plugins/jarshop-checkout/order-consent.entity.ts +32 -0
  31. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-core.ts +337 -0
  32. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-status.ts +39 -0
  33. package/dist/template/src/plugins/jarshop-checkout/order-email-delivery.entity.ts +66 -0
  34. package/dist/template/src/plugins/jarshop-checkout/order-email-outbox.ts +108 -0
  35. package/dist/template/src/plugins/jarshop-checkout/order-email-transport.ts +35 -0
  36. package/dist/template/src/plugins/jarshop-checkout/order-email-worker.ts +281 -0
  37. package/dist/template/src/plugins/jarshop-checkout/shipping.test.ts +41 -0
  38. package/dist/template/src/plugins/jarshop-checkout/shipping.ts +162 -0
  39. package/dist/template/src/seed.ts +149 -0
  40. package/dist/template/src/vendure-config.ts +68 -5
  41. package/dist/template/static/email/templates/email-verification/body.hbs +4 -4
  42. package/dist/template/storefront/app/[locale]/[slug]/add-to-cart-form.tsx +44 -0
  43. package/dist/template/storefront/app/[locale]/[slug]/page.tsx +35 -26
  44. package/dist/template/storefront/app/[locale]/account/account-mutation-form.tsx +44 -0
  45. package/dist/template/storefront/app/[locale]/account/change-password-form.tsx +65 -0
  46. package/dist/template/storefront/app/[locale]/account/login/login-form.tsx +60 -0
  47. package/dist/template/storefront/app/[locale]/account/login/page.tsx +23 -0
  48. package/dist/template/storefront/app/[locale]/account/page.tsx +215 -0
  49. package/dist/template/storefront/app/[locale]/account/password-reset/callback/route.ts +33 -0
  50. package/dist/template/storefront/app/[locale]/account/password-reset/complete/page.tsx +27 -0
  51. package/dist/template/storefront/app/[locale]/account/password-reset/page.tsx +37 -0
  52. package/dist/template/storefront/app/[locale]/account/password-reset/password-reset-forms.tsx +99 -0
  53. package/dist/template/storefront/app/[locale]/account/register/page.tsx +23 -0
  54. package/dist/template/storefront/app/[locale]/account/register/register-form.tsx +67 -0
  55. package/dist/template/storefront/app/[locale]/account/verify/complete/route.ts +33 -0
  56. package/dist/template/storefront/app/[locale]/account/verify/page.tsx +41 -0
  57. package/dist/template/storefront/app/[locale]/account/verify/refresh-form.tsx +35 -0
  58. package/dist/template/storefront/app/[locale]/cart/page.tsx +78 -0
  59. package/dist/template/storefront/app/[locale]/checkout/checkout-form.tsx +112 -0
  60. package/dist/template/storefront/app/[locale]/checkout/page.tsx +44 -0
  61. package/dist/template/storefront/app/[locale]/checkout/success/page.tsx +23 -0
  62. package/dist/template/storefront/app/[locale]/layout.tsx +48 -0
  63. package/dist/template/storefront/app/[locale]/page.tsx +10 -11
  64. package/dist/template/storefront/app/globals.css +372 -4
  65. package/dist/template/storefront/app/layout.tsx +13 -0
  66. package/dist/template/storefront/eslint.config.mjs +1 -1
  67. package/dist/template/storefront/gitignore.template +1 -0
  68. package/dist/template/storefront/lib/account-security.test.ts +74 -0
  69. package/dist/template/storefront/lib/account-security.ts +81 -0
  70. package/dist/template/storefront/lib/account.ts +366 -0
  71. package/dist/template/storefront/lib/cart-contract.ts +52 -0
  72. package/dist/template/storefront/lib/cart.test.ts +30 -0
  73. package/dist/template/storefront/lib/cart.ts +130 -0
  74. package/dist/template/storefront/lib/checkout-contract.test.ts +20 -0
  75. package/dist/template/storefront/lib/checkout-contract.ts +16 -0
  76. package/dist/template/storefront/lib/checkout.ts +143 -0
  77. package/dist/template/storefront/lib/commerce-session.test.ts +77 -0
  78. package/dist/template/storefront/lib/commerce-session.ts +85 -0
  79. package/dist/template/storefront/lib/commerce.ts +122 -1
  80. package/dist/template/storefront/lib/confirmation-cookie.ts +9 -0
  81. package/dist/template/storefront/lib/confirmation.ts +47 -0
  82. package/dist/template/storefront/lib/form-values.test.ts +14 -0
  83. package/dist/template/storefront/lib/form-values.ts +7 -0
  84. package/dist/template/storefront/lib/i18n.test.ts +50 -0
  85. package/dist/template/storefront/lib/i18n.ts +44 -0
  86. package/dist/template/storefront/lib/password-reset-token.test.ts +31 -0
  87. package/dist/template/storefront/lib/password-reset-token.ts +17 -0
  88. package/dist/template/storefront/lib/valkey-rate-limit.ts +72 -0
  89. package/dist/template/storefront/messages/en.json +89 -0
  90. package/dist/template/storefront/messages/sk.json +89 -0
  91. package/dist/template/storefront/next.config.ts +3 -1
  92. package/dist/template/storefront/package.json +4 -1
  93. package/dist/template/storefront/pnpm-lock.yaml +756 -2
  94. package/dist/template/tsconfig.json +2 -1
  95. package/dist/template/turbo.json +1 -0
  96. package/dist/template/vite.config.mts +4 -1
  97. package/dist/template-manifest.json +1 -1
  98. package/dist/template.js +3 -0
  99. package/dist/template.js.map +1 -1
  100. package/package.json +1 -1
@@ -0,0 +1,48 @@
1
+ import Link from "next/link";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { getCart } from "../../lib/cart";
5
+ import { formatMessage, parseLocale } from "../../lib/i18n";
6
+
7
+ interface LocaleLayoutProps {
8
+ readonly children: ReactNode;
9
+ readonly params: Promise<{ locale: string }>;
10
+ }
11
+
12
+ export default async function LocaleLayout({
13
+ children,
14
+ params,
15
+ }: LocaleLayoutProps) {
16
+ const { locale } = await params;
17
+ const language = parseLocale(locale);
18
+ const cart = await getCart(language);
19
+ const itemCount =
20
+ cart?.lines.reduce((total, line) => total + line.quantity, 0) ?? 0;
21
+
22
+ return (
23
+ <>
24
+ <header className="site-header">
25
+ <div className="site-header__inner">
26
+ <Link className="brand" href={`/${language}`}>
27
+ JarShop <span>Starter</span>
28
+ </Link>
29
+ <nav aria-label="Storefront navigation" className="site-nav">
30
+ <Link href={`/${language}`}>
31
+ {formatMessage(language, "nav.catalog", {})}
32
+ </Link>
33
+ <Link className="cart-link" href={`/${language}/cart`}>
34
+ {formatMessage(language, "nav.cart", {})}
35
+ <span>
36
+ {formatMessage(language, "nav.cartItems", { count: itemCount })}
37
+ </span>
38
+ </Link>
39
+ <Link href={`/${language}/account`}>
40
+ {formatMessage(language, "nav.account", {})}
41
+ </Link>
42
+ </nav>
43
+ </div>
44
+ </header>
45
+ {children}
46
+ </>
47
+ );
48
+ }
@@ -1,27 +1,26 @@
1
1
  import { getCatalog } from "../../lib/commerce";
2
+ import { createTranslator, parseLocale } from "../../lib/i18n";
2
3
 
3
4
  interface LocalePageProps {
4
5
  params: Promise<{ locale: string }>;
5
6
  }
6
7
 
7
- const labels = {
8
- sk: { title: "Produkty", empty: "Produkty sa nepodarilo načítať." },
9
- en: { title: "Products", empty: "Products are not available." },
10
- } as const;
11
-
12
8
  export default async function LocalePage({ params }: LocalePageProps) {
13
9
  const { locale } = await params;
14
- const language = locale === "en" ? "en" : "sk";
10
+ const language = parseLocale(locale);
15
11
  const result = await getCatalog(language);
16
- const copy = labels[language];
12
+ const t = createTranslator(language);
17
13
 
18
14
  return (
19
- <main lang={language}>
20
- <h1>{copy.title}</h1>
15
+ <main className="page" lang={language}>
16
+ <div className="page-heading">
17
+ <p className="eyebrow">JarShop starter</p>
18
+ <h1>{t("catalog.title")}</h1>
19
+ </div>
21
20
  {!result.ok || result.data.length === 0 ? (
22
- <p>{copy.empty}</p>
21
+ <p>{t("catalog.empty")}</p>
23
22
  ) : (
24
- <ul>
23
+ <ul className="product-grid">
25
24
  {result.data.map((product) => (
26
25
  <li key={product.id}>
27
26
  <a href={`/${language}/${product.slug}`}>{product.name}</a>
@@ -1,16 +1,384 @@
1
1
  :root {
2
2
  color-scheme: light;
3
- font-family: system-ui, sans-serif;
3
+ font-family:
4
+ Inter,
5
+ ui-sans-serif,
6
+ system-ui,
7
+ -apple-system,
8
+ BlinkMacSystemFont,
9
+ "Segoe UI",
10
+ sans-serif;
11
+ background: #f6f7fb;
12
+ color: #182033;
4
13
  }
5
14
 
6
15
  body {
7
16
  margin: 0;
8
- background: #fff;
9
- color: #111;
17
+ min-height: 100vh;
18
+ background: #f6f7fb;
19
+ }
20
+
21
+ .site-header {
22
+ position: sticky;
23
+ top: 0;
24
+ z-index: 10;
25
+ border-bottom: 1px solid #e5e7ef;
26
+ background: rgb(255 255 255 / 92%);
27
+ backdrop-filter: blur(10px);
10
28
  }
11
29
 
12
- main {
30
+ .site-header__inner,
31
+ .page {
13
32
  max-width: 72rem;
14
33
  margin: 0 auto;
34
+ }
35
+
36
+ .site-header__inner {
37
+ display: flex;
38
+ align-items: center;
39
+ justify-content: space-between;
40
+ min-height: 4.5rem;
41
+ padding: 0 2rem;
42
+ }
43
+
44
+ .brand,
45
+ .site-nav a,
46
+ .product-grid a,
47
+ .back-link,
48
+ .cart-lines a {
49
+ color: inherit;
50
+ text-decoration: none;
51
+ }
52
+
53
+ .brand {
54
+ font-weight: 800;
55
+ letter-spacing: -0.04em;
56
+ }
57
+
58
+ .brand span {
59
+ color: #6d7390;
60
+ font-size: 0.75rem;
61
+ font-weight: 700;
62
+ letter-spacing: 0;
63
+ text-transform: uppercase;
64
+ }
65
+
66
+ .site-nav {
67
+ display: flex;
68
+ align-items: center;
69
+ gap: 0.4rem;
70
+ }
71
+
72
+ .site-nav a {
73
+ padding: 0.55rem 0.7rem;
74
+ border-radius: 0.55rem;
75
+ font-size: 0.92rem;
76
+ font-weight: 650;
77
+ }
78
+
79
+ .site-nav a:hover,
80
+ .site-nav a:focus-visible {
81
+ background: #eff1f7;
82
+ }
83
+
84
+ .cart-link span {
85
+ margin-left: 0.35rem;
86
+ padding: 0.15rem 0.38rem;
87
+ border-radius: 999px;
88
+ background: #182033;
89
+ color: #fff;
90
+ font-size: 0.72rem;
91
+ }
92
+
93
+ .page {
15
94
  padding: 2rem;
16
95
  }
96
+
97
+ .page-heading {
98
+ margin-bottom: 1.5rem;
99
+ }
100
+
101
+ h1,
102
+ h2,
103
+ p {
104
+ margin-top: 0;
105
+ }
106
+
107
+ h1 {
108
+ margin-bottom: 0.4rem;
109
+ font-size: clamp(2rem, 5vw, 3.3rem);
110
+ letter-spacing: -0.055em;
111
+ }
112
+
113
+ .eyebrow {
114
+ margin-bottom: 0.45rem;
115
+ color: #69708b;
116
+ font-size: 0.75rem;
117
+ font-weight: 800;
118
+ letter-spacing: 0.08em;
119
+ text-transform: uppercase;
120
+ }
121
+
122
+ .product-grid,
123
+ .variant-list,
124
+ .cart-lines {
125
+ display: grid;
126
+ gap: 0.85rem;
127
+ padding: 0;
128
+ margin: 0;
129
+ list-style: none;
130
+ }
131
+
132
+ .product-grid {
133
+ grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
134
+ }
135
+
136
+ .product-grid li,
137
+ .variant-list li,
138
+ .cart-lines li,
139
+ .cart-summary,
140
+ .checkout-form {
141
+ border: 1px solid #e4e7f0;
142
+ border-radius: 0.9rem;
143
+ background: #fff;
144
+ box-shadow: 0 0.5rem 1.25rem rgb(29 38 61 / 5%);
145
+ }
146
+
147
+ .product-grid li {
148
+ padding: 1.25rem;
149
+ }
150
+
151
+ .product-grid a {
152
+ display: block;
153
+ margin-bottom: 0.55rem;
154
+ font-weight: 750;
155
+ }
156
+
157
+ .product-grid p {
158
+ margin: 0;
159
+ color: #626a82;
160
+ font-size: 0.93rem;
161
+ line-height: 1.55;
162
+ }
163
+
164
+ .back-link {
165
+ display: inline-block;
166
+ margin-bottom: 1.35rem;
167
+ color: #59627b;
168
+ font-weight: 650;
169
+ }
170
+
171
+ .product-page__intro {
172
+ max-width: 42rem;
173
+ margin-bottom: 1.5rem;
174
+ }
175
+
176
+ .product-page__intro p {
177
+ color: #626a82;
178
+ line-height: 1.6;
179
+ }
180
+
181
+ .variant-list {
182
+ max-width: 44rem;
183
+ }
184
+
185
+ .variant-list li {
186
+ display: grid;
187
+ grid-template-columns: 1fr auto auto;
188
+ gap: 1rem;
189
+ align-items: center;
190
+ padding: 1rem 1.1rem;
191
+ }
192
+
193
+ .price {
194
+ display: block;
195
+ margin-top: 0.2rem;
196
+ color: #626a82;
197
+ font-size: 0.9rem;
198
+ }
199
+
200
+ .stock {
201
+ font-size: 0.85rem;
202
+ font-weight: 700;
203
+ }
204
+
205
+ .stock--in {
206
+ color: #18724c;
207
+ }
208
+ .stock--out {
209
+ color: #ad3845;
210
+ }
211
+
212
+ button,
213
+ .button-link,
214
+ .cart-toast a {
215
+ display: inline-flex;
216
+ align-items: center;
217
+ justify-content: center;
218
+ min-height: 2.45rem;
219
+ padding: 0.55rem 0.85rem;
220
+ border: 0;
221
+ border-radius: 0.6rem;
222
+ background: #2453eb;
223
+ color: #fff;
224
+ cursor: pointer;
225
+ font: inherit;
226
+ font-size: 0.9rem;
227
+ font-weight: 750;
228
+ text-decoration: none;
229
+ }
230
+
231
+ button:hover,
232
+ button:focus-visible,
233
+ .button-link:hover,
234
+ .button-link:focus-visible {
235
+ background: #173fc2;
236
+ }
237
+
238
+ button:disabled {
239
+ cursor: wait;
240
+ opacity: 0.6;
241
+ }
242
+
243
+ .add-to-cart {
244
+ min-width: max-content;
245
+ }
246
+
247
+ .cart-toast {
248
+ display: flex;
249
+ gap: 0.65rem;
250
+ align-items: center;
251
+ margin-top: 0.7rem;
252
+ padding: 0.65rem 0.75rem;
253
+ border: 1px solid #9cdcc0;
254
+ border-radius: 0.65rem;
255
+ background: #effaf4;
256
+ color: #155c3d;
257
+ font-size: 0.85rem;
258
+ font-weight: 650;
259
+ }
260
+
261
+ .cart-toast a {
262
+ min-height: auto;
263
+ padding: 0.35rem 0.55rem;
264
+ background: #167047;
265
+ font-size: 0.78rem;
266
+ }
267
+
268
+ .form-error {
269
+ color: #b32637;
270
+ font-size: 0.88rem;
271
+ }
272
+
273
+ .cart-layout {
274
+ display: grid;
275
+ grid-template-columns: minmax(0, 1fr) minmax(16rem, 22rem);
276
+ gap: 1.5rem;
277
+ align-items: start;
278
+ }
279
+
280
+ .cart-lines li {
281
+ padding: 1rem 1.1rem;
282
+ }
283
+
284
+ .cart-lines form {
285
+ display: inline-flex;
286
+ gap: 0.5rem;
287
+ align-items: end;
288
+ margin-right: 0.5rem;
289
+ }
290
+
291
+ .cart-lines label,
292
+ .checkout-form label {
293
+ display: grid;
294
+ gap: 0.35rem;
295
+ font-size: 0.88rem;
296
+ font-weight: 650;
297
+ }
298
+
299
+ input,
300
+ select {
301
+ min-height: 2.35rem;
302
+ box-sizing: border-box;
303
+ padding: 0.45rem 0.6rem;
304
+ border: 1px solid #cbd0df;
305
+ border-radius: 0.55rem;
306
+ background: #fff;
307
+ color: inherit;
308
+ font: inherit;
309
+ }
310
+
311
+ input:focus,
312
+ select:focus {
313
+ outline: 3px solid rgb(36 83 235 / 20%);
314
+ border-color: #2453eb;
315
+ }
316
+
317
+ .cart-summary {
318
+ padding: 1.2rem;
319
+ }
320
+
321
+ .cart-summary dl {
322
+ display: grid;
323
+ gap: 0.7rem;
324
+ margin: 0 0 1.2rem;
325
+ }
326
+
327
+ .cart-summary dl div {
328
+ display: flex;
329
+ justify-content: space-between;
330
+ gap: 1rem;
331
+ }
332
+
333
+ .cart-summary dd {
334
+ margin: 0;
335
+ font-weight: 750;
336
+ }
337
+ .cart-summary dl div:last-child {
338
+ padding-top: 0.7rem;
339
+ border-top: 1px solid #e6e8f0;
340
+ }
341
+
342
+ .checkout-form {
343
+ display: grid;
344
+ gap: 1rem;
345
+ max-width: 44rem;
346
+ padding: 1.25rem;
347
+ }
348
+
349
+ .checkout-form fieldset {
350
+ display: grid;
351
+ gap: 0.65rem;
352
+ margin: 0;
353
+ padding: 1rem;
354
+ border: 1px solid #e4e7f0;
355
+ border-radius: 0.7rem;
356
+ }
357
+
358
+ .checkout-form fieldset label {
359
+ display: flex;
360
+ align-items: center;
361
+ gap: 0.55rem;
362
+ }
363
+ .checkout-form fieldset input {
364
+ min-height: auto;
365
+ }
366
+
367
+ @media (max-width: 42rem) {
368
+ .site-header__inner,
369
+ .page {
370
+ padding-right: 1rem;
371
+ padding-left: 1rem;
372
+ }
373
+ .site-header__inner {
374
+ min-height: 4rem;
375
+ }
376
+ .variant-list li,
377
+ .cart-layout {
378
+ grid-template-columns: 1fr;
379
+ }
380
+ .cart-lines form {
381
+ display: flex;
382
+ margin-top: 0.65rem;
383
+ }
384
+ }
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import "./globals.css";
4
+
5
+ export default function RootLayout({
6
+ children,
7
+ }: Readonly<{ children: ReactNode }>) {
8
+ return (
9
+ <html lang="sk">
10
+ <body>{children}</body>
11
+ </html>
12
+ );
13
+ }
@@ -5,5 +5,5 @@ import nextTypeScript from "eslint-config-next/typescript";
5
5
  export default defineConfig([
6
6
  ...nextVitals,
7
7
  ...nextTypeScript,
8
- globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
8
+ globalIgnores([".next/**", "dist/**", "out/**", "build/**", "next-env.d.ts"]),
9
9
  ]);
@@ -0,0 +1 @@
1
+ src/gql/
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ createAccountActionGuard,
5
+ type AccountRateLimitStore,
6
+ } from "./account-security";
7
+
8
+ function store(allowed = true): AccountRateLimitStore & { keys: string[][] } {
9
+ const keys: string[][] = [];
10
+ return {
11
+ keys,
12
+ async consume(buckets) {
13
+ keys.push(buckets.map((bucket) => bucket.key));
14
+ return allowed;
15
+ },
16
+ };
17
+ }
18
+
19
+ describe("account action security guard", () => {
20
+ it("rejects a cross-origin action before it reaches commerce", async () => {
21
+ const rateLimits = store();
22
+ const guard = createAccountActionGuard(rateLimits);
23
+
24
+ await expect(
25
+ guard.check({
26
+ scope: "login",
27
+ subject: "customer@example.com",
28
+ origin: "https://attacker.example",
29
+ host: "shop.example",
30
+ clientAddress: "203.0.113.10",
31
+ now: 0,
32
+ }),
33
+ ).resolves.toEqual({ allowed: false, reason: "origin" });
34
+ expect(rateLimits.keys).toEqual([]);
35
+ });
36
+
37
+ it("uses independent client and subject buckets without retaining personal data", async () => {
38
+ const rateLimits = store();
39
+ const guard = createAccountActionGuard(rateLimits);
40
+ const request = {
41
+ scope: "password-reset",
42
+ subject: "customer@example.com",
43
+ origin: "https://shop.example",
44
+ host: "shop.example",
45
+ clientAddress: "203.0.113.10",
46
+ };
47
+
48
+ await expect(guard.check({ ...request, now: 0 })).resolves.toEqual({
49
+ allowed: true,
50
+ });
51
+ expect(rateLimits.keys).toHaveLength(1);
52
+ expect(rateLimits.keys[0]).toHaveLength(2);
53
+ expect(rateLimits.keys[0]?.join(" ")).not.toContain("customer@example.com");
54
+ });
55
+
56
+ it("fails closed when the shared rate-limit store is unavailable", async () => {
57
+ const guard = createAccountActionGuard({
58
+ async consume() {
59
+ throw new Error("Valkey unavailable");
60
+ },
61
+ });
62
+
63
+ await expect(
64
+ guard.check({
65
+ scope: "login",
66
+ subject: "customer@example.com",
67
+ origin: "https://shop.example",
68
+ host: "shop.example",
69
+ clientAddress: "203.0.113.10",
70
+ now: 0,
71
+ }),
72
+ ).resolves.toEqual({ allowed: false, reason: "rateLimit" });
73
+ });
74
+ });
@@ -0,0 +1,81 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export interface AccountActionRequest {
4
+ scope: string;
5
+ subject: string;
6
+ origin: string | null;
7
+ host: string | null;
8
+ clientAddress: string | null;
9
+ now: number;
10
+ }
11
+
12
+ export type AccountActionSecurityResult =
13
+ { allowed: true } | { allowed: false; reason: "origin" | "rateLimit" };
14
+
15
+ export interface AccountRateLimitBucket {
16
+ key: string;
17
+ maxAttempts: number;
18
+ }
19
+
20
+ export interface AccountRateLimitStore {
21
+ consume(
22
+ buckets: readonly AccountRateLimitBucket[],
23
+ windowMs: number,
24
+ ): Promise<boolean>;
25
+ }
26
+
27
+ interface AccountActionGuardOptions {
28
+ subjectMaxAttempts?: number;
29
+ clientMaxAttempts?: number;
30
+ windowMs?: number;
31
+ }
32
+
33
+ function digest(parts: readonly string[]): string {
34
+ return createHash("sha256").update(parts.join("\u0000")).digest("hex");
35
+ }
36
+
37
+ export function createAccountActionGuard(
38
+ store: AccountRateLimitStore,
39
+ options: AccountActionGuardOptions = {},
40
+ ) {
41
+ const subjectMaxAttempts = options.subjectMaxAttempts ?? 5;
42
+ const clientMaxAttempts = options.clientMaxAttempts ?? 20;
43
+ const windowMs = options.windowMs ?? 15 * 60 * 1000;
44
+
45
+ return {
46
+ async check(
47
+ request: AccountActionRequest,
48
+ ): Promise<AccountActionSecurityResult> {
49
+ if (request.origin && request.host) {
50
+ try {
51
+ if (new URL(request.origin).host !== request.host) {
52
+ return { allowed: false, reason: "origin" };
53
+ }
54
+ } catch {
55
+ return { allowed: false, reason: "origin" };
56
+ }
57
+ }
58
+
59
+ const scope = request.scope.trim().toLowerCase();
60
+ const client = request.clientAddress?.trim() || "unknown-client";
61
+ const subject = request.subject.trim().toLowerCase();
62
+ const buckets = [
63
+ {
64
+ key: `jarshop:account:client:${digest([scope, client])}`,
65
+ maxAttempts: clientMaxAttempts,
66
+ },
67
+ {
68
+ key: `jarshop:account:subject:${digest([scope, client, subject])}`,
69
+ maxAttempts: subjectMaxAttempts,
70
+ },
71
+ ];
72
+
73
+ try {
74
+ if (await store.consume(buckets, windowMs)) return { allowed: true };
75
+ return { allowed: false, reason: "rateLimit" };
76
+ } catch {
77
+ return { allowed: false, reason: "rateLimit" };
78
+ }
79
+ },
80
+ };
81
+ }