@tollwyn/baukasten 0.0.2 → 1.2.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 (213) hide show
  1. package/README.md +254 -1
  2. package/dist/bin/check.d.ts +2 -0
  3. package/dist/bin/check.js +38 -0
  4. package/dist/bin/migrate.d.ts +2 -0
  5. package/dist/bin/migrate.js +111 -0
  6. package/dist/bin/stubs.d.ts +2 -0
  7. package/dist/bin/stubs.js +38 -0
  8. package/dist/bin/test-accounts.d.ts +2 -0
  9. package/dist/bin/test-accounts.js +62 -0
  10. package/dist/check.d.ts +20 -0
  11. package/dist/check.js +285 -0
  12. package/dist/index.d.ts +7 -0
  13. package/dist/index.js +9 -0
  14. package/dist/product.d.ts +161 -0
  15. package/dist/product.js +152 -0
  16. package/dist/server/accounts.d.ts +128 -0
  17. package/dist/server/accounts.js +282 -0
  18. package/dist/server/adapter/db.d.ts +5 -0
  19. package/dist/server/adapter/db.js +63 -0
  20. package/dist/server/adapter/log.d.ts +3 -0
  21. package/dist/server/adapter/log.js +12 -0
  22. package/dist/server/adapter/mailer.d.ts +4 -0
  23. package/dist/server/adapter/mailer.js +56 -0
  24. package/dist/server/adapter/migrate.d.ts +21 -0
  25. package/dist/server/adapter/migrate.js +113 -0
  26. package/dist/server/adapter/secrets.d.ts +2 -0
  27. package/dist/server/adapter/secrets.js +40 -0
  28. package/dist/server/adapter/stripe.d.ts +11 -0
  29. package/dist/server/adapter/stripe.js +333 -0
  30. package/dist/server/api.d.ts +25 -0
  31. package/dist/server/api.js +98 -0
  32. package/dist/server/baukasten-migrations.d.ts +4 -0
  33. package/dist/server/baukasten-migrations.js +126 -0
  34. package/dist/server/billing.d.ts +94 -0
  35. package/dist/server/billing.js +349 -0
  36. package/dist/server/catalog.d.ts +39 -0
  37. package/dist/server/catalog.js +193 -0
  38. package/dist/server/config.d.ts +108 -0
  39. package/dist/server/config.js +244 -0
  40. package/dist/server/endpoints.d.ts +44 -0
  41. package/dist/server/endpoints.js +159 -0
  42. package/dist/server/fields.d.ts +7 -0
  43. package/dist/server/fields.js +18 -0
  44. package/dist/server/headers.d.ts +1 -0
  45. package/dist/server/headers.js +9 -0
  46. package/dist/server/health.d.ts +34 -0
  47. package/dist/server/health.js +116 -0
  48. package/dist/server/hook.d.ts +92 -0
  49. package/dist/server/hook.js +293 -0
  50. package/dist/server/index.d.ts +18 -0
  51. package/dist/server/index.js +11 -0
  52. package/dist/server/legal/de.d.ts +2 -0
  53. package/dist/server/legal/de.js +251 -0
  54. package/dist/server/legal/document.d.ts +37 -0
  55. package/dist/server/legal/document.js +30 -0
  56. package/dist/server/legal/en.d.ts +2 -0
  57. package/dist/server/legal/en.js +251 -0
  58. package/dist/server/legal/fleet.d.ts +114 -0
  59. package/dist/server/legal/fleet.js +89 -0
  60. package/dist/server/legal/index.d.ts +3 -0
  61. package/dist/server/legal/index.js +11 -0
  62. package/dist/server/limits.d.ts +58 -0
  63. package/dist/server/limits.js +76 -0
  64. package/dist/server/log.d.ts +26 -0
  65. package/dist/server/log.js +61 -0
  66. package/dist/server/machine.d.ts +4 -0
  67. package/dist/server/machine.js +57 -0
  68. package/dist/server/mail.d.ts +20 -0
  69. package/dist/server/mail.js +29 -0
  70. package/dist/server/messages.d.ts +105 -0
  71. package/dist/server/messages.js +232 -0
  72. package/dist/server/migrations.d.ts +28 -0
  73. package/dist/server/migrations.js +59 -0
  74. package/dist/server/openapi.d.ts +24 -0
  75. package/dist/server/openapi.js +173 -0
  76. package/dist/server/pages.d.ts +158 -0
  77. package/dist/server/pages.js +491 -0
  78. package/dist/server/ports.d.ts +273 -0
  79. package/dist/server/ports.js +1 -0
  80. package/dist/server/runs.d.ts +30 -0
  81. package/dist/server/runs.js +55 -0
  82. package/dist/server/sales.d.ts +38 -0
  83. package/dist/server/sales.js +132 -0
  84. package/dist/server/services.d.ts +57 -0
  85. package/dist/server/services.js +44 -0
  86. package/dist/server/start.d.ts +14 -0
  87. package/dist/server/start.js +65 -0
  88. package/dist/server/stripe.d.ts +33 -0
  89. package/dist/server/stripe.js +29 -0
  90. package/dist/server/terms.d.ts +7 -0
  91. package/dist/server/terms.js +9 -0
  92. package/dist/server/test-accounts.d.ts +28 -0
  93. package/dist/server/test-accounts.js +62 -0
  94. package/dist/server/via.d.ts +9 -0
  95. package/dist/server/via.js +15 -0
  96. package/dist/server/web.d.ts +48 -0
  97. package/dist/server/web.js +105 -0
  98. package/dist/shopify/adapter/shopify.d.ts +8 -0
  99. package/dist/shopify/adapter/shopify.js +232 -0
  100. package/dist/shopify/admin.d.ts +33 -0
  101. package/dist/shopify/admin.js +47 -0
  102. package/dist/shopify/app.d.ts +60 -0
  103. package/dist/shopify/app.js +67 -0
  104. package/dist/shopify/config.d.ts +53 -0
  105. package/dist/shopify/config.js +133 -0
  106. package/dist/shopify/constants.d.ts +27 -0
  107. package/dist/shopify/constants.js +27 -0
  108. package/dist/shopify/headers.d.ts +1 -0
  109. package/dist/shopify/headers.js +8 -0
  110. package/dist/shopify/hmac.d.ts +1 -0
  111. package/dist/shopify/hmac.js +9 -0
  112. package/dist/shopify/hook.d.ts +60 -0
  113. package/dist/shopify/hook.js +508 -0
  114. package/dist/shopify/id-token.d.ts +12 -0
  115. package/dist/shopify/id-token.js +66 -0
  116. package/dist/shopify/index.d.ts +19 -0
  117. package/dist/shopify/index.js +15 -0
  118. package/dist/shopify/legal/de.d.ts +3 -0
  119. package/dist/shopify/legal/de.js +123 -0
  120. package/dist/shopify/legal/en.d.ts +3 -0
  121. package/dist/shopify/legal/en.js +121 -0
  122. package/dist/shopify/legal/index.d.ts +19 -0
  123. package/dist/shopify/legal/index.js +8 -0
  124. package/dist/shopify/legal/shopify.d.ts +16 -0
  125. package/dist/shopify/legal/shopify.js +21 -0
  126. package/dist/shopify/migrations.d.ts +4 -0
  127. package/dist/shopify/migrations.js +67 -0
  128. package/dist/shopify/pages.d.ts +45 -0
  129. package/dist/shopify/pages.js +146 -0
  130. package/dist/shopify/ports.d.ts +77 -0
  131. package/dist/shopify/ports.js +1 -0
  132. package/dist/shopify/sales.d.ts +4 -0
  133. package/dist/shopify/sales.js +27 -0
  134. package/dist/shopify/scopes.d.ts +2 -0
  135. package/dist/shopify/scopes.js +13 -0
  136. package/dist/shopify/seal.d.ts +12 -0
  137. package/dist/shopify/seal.js +60 -0
  138. package/dist/shopify/shop.d.ts +4 -0
  139. package/dist/shopify/shop.js +15 -0
  140. package/dist/shopify/shops.d.ts +76 -0
  141. package/dist/shopify/shops.js +256 -0
  142. package/dist/shopify/sveltekit.d.ts +19 -0
  143. package/dist/shopify/sveltekit.js +18 -0
  144. package/dist/shopify/texts.d.ts +28 -0
  145. package/dist/shopify/texts.js +49 -0
  146. package/dist/shopify/toml.d.ts +22 -0
  147. package/dist/shopify/toml.js +125 -0
  148. package/dist/shopify/webhooks.d.ts +65 -0
  149. package/dist/shopify/webhooks.js +220 -0
  150. package/dist/stubs.d.ts +15 -0
  151. package/dist/stubs.js +287 -0
  152. package/dist/sveltekit.d.ts +20 -0
  153. package/dist/sveltekit.js +18 -0
  154. package/dist/testing/app.d.ts +36 -0
  155. package/dist/testing/app.js +155 -0
  156. package/dist/testing/browser.d.ts +21 -0
  157. package/dist/testing/browser.js +21 -0
  158. package/dist/testing/connection.d.ts +7 -0
  159. package/dist/testing/connection.js +303 -0
  160. package/dist/testing/database.d.ts +21 -0
  161. package/dist/testing/database.js +54 -0
  162. package/dist/testing/env-example.d.ts +4 -0
  163. package/dist/testing/env-example.js +33 -0
  164. package/dist/testing/index.d.ts +9 -0
  165. package/dist/testing/index.js +9 -0
  166. package/dist/testing/shopify-connection.d.ts +8 -0
  167. package/dist/testing/shopify-connection.js +313 -0
  168. package/dist/testing/shopify.d.ts +71 -0
  169. package/dist/testing/shopify.js +278 -0
  170. package/dist/testing/smtp.d.ts +38 -0
  171. package/dist/testing/smtp.js +165 -0
  172. package/dist/testing/stripe.d.ts +128 -0
  173. package/dist/testing/stripe.js +511 -0
  174. package/dist/texts.d.ts +139 -0
  175. package/dist/texts.js +265 -0
  176. package/dist/ui/AccountPage.svelte +143 -0
  177. package/dist/ui/AccountPage.svelte.d.ts +10 -0
  178. package/dist/ui/ConfirmEmailPage.svelte +21 -0
  179. package/dist/ui/ConfirmEmailPage.svelte.d.ts +10 -0
  180. package/dist/ui/ErrorPage.svelte +14 -0
  181. package/dist/ui/ErrorPage.svelte.d.ts +3 -0
  182. package/dist/ui/Field.svelte +12 -0
  183. package/dist/ui/Field.svelte.d.ts +10 -0
  184. package/dist/ui/ForgotPasswordPage.svelte +32 -0
  185. package/dist/ui/ForgotPasswordPage.svelte.d.ts +7 -0
  186. package/dist/ui/Form.svelte +30 -0
  187. package/dist/ui/Form.svelte.d.ts +11 -0
  188. package/dist/ui/Frame.svelte +84 -0
  189. package/dist/ui/Frame.svelte.d.ts +12 -0
  190. package/dist/ui/LegalPage.svelte +38 -0
  191. package/dist/ui/LegalPage.svelte.d.ts +9 -0
  192. package/dist/ui/Linked.svelte +5 -0
  193. package/dist/ui/Linked.svelte.d.ts +8 -0
  194. package/dist/ui/LoginPage.svelte +41 -0
  195. package/dist/ui/LoginPage.svelte.d.ts +10 -0
  196. package/dist/ui/LogoutPage.svelte +19 -0
  197. package/dist/ui/LogoutPage.svelte.d.ts +9 -0
  198. package/dist/ui/ResetPasswordPage.svelte +30 -0
  199. package/dist/ui/ResetPasswordPage.svelte.d.ts +10 -0
  200. package/dist/ui/SignupPage.svelte +52 -0
  201. package/dist/ui/SignupPage.svelte.d.ts +10 -0
  202. package/dist/ui/VerifyEmailPage.svelte +50 -0
  203. package/dist/ui/VerifyEmailPage.svelte.d.ts +10 -0
  204. package/dist/ui/base.css +287 -0
  205. package/dist/ui/data.d.ts +167 -0
  206. package/dist/ui/data.js +1 -0
  207. package/dist/ui/frame.d.ts +17 -0
  208. package/dist/ui/frame.js +27 -0
  209. package/dist/ui/index.d.ts +15 -0
  210. package/dist/ui/index.js +15 -0
  211. package/dist/variables.d.ts +20 -0
  212. package/dist/variables.js +62 -0
  213. package/package.json +88 -4
@@ -0,0 +1,491 @@
1
+ import { fail, redirect } from '@sveltejs/kit';
2
+ import { z } from 'zod';
3
+ import { EmailTaken, normalizeEmail, sha256 } from './accounts.js';
4
+ import { emailField, field, passwordField, problem } from './fields.js';
5
+ import { LIMITS } from './limits.js';
6
+ import { deliver } from './mail.js';
7
+ import { sendLink, signUp } from './services.js';
8
+ import { parseVia } from './via.js';
9
+ import { clearSessionCookie, requireAccount, safeNext, SESSION_COOKIE, setSessionCookie, tooMany } from './web.js';
10
+ /** The marks of the product as the frame shows them: the picture of a shared link at its whole address. */
11
+ export function frameBrand(brand, origin) {
12
+ if (brand === undefined)
13
+ return null;
14
+ return {
15
+ logo: brand.logo,
16
+ icon: { href: brand.icon, type: brand.icon.endsWith('.svg') ? 'image/svg+xml' : 'image/png' },
17
+ image: new URL(brand.image, origin).href
18
+ };
19
+ }
20
+ const TO = {
21
+ verifyUnsent: '/verify-email?unsent',
22
+ accountVerified: '/account?verified',
23
+ accountEmailChanged: '/account?email-changed',
24
+ loginVerified: '/login?verified',
25
+ loginEmailChanged: '/login?email-changed',
26
+ loggedOut: '/logout',
27
+ deleted: '/logout?deleted'
28
+ };
29
+ export function createPages(services) {
30
+ async function logIn(event, s, accountId) {
31
+ setSessionCookie(event.cookies, await s.accounts.createSession(accountId));
32
+ }
33
+ /**
34
+ * A check of the current password of a logged-in account counts like a login, per account and
35
+ * before the check, so that a session cannot guess the password; a right one clears the count.
36
+ */
37
+ function passwordAttempt(s, account) {
38
+ const key = `password:${account.id}`;
39
+ return { seconds: s.limits.take(key, LIMITS.login), passed: () => s.limits.reset(key) };
40
+ }
41
+ return {
42
+ layout: {
43
+ load: ({ locals, url }) => {
44
+ const s = services();
45
+ return {
46
+ frame: {
47
+ language: s.config.language,
48
+ name: s.config.name,
49
+ navigation: s.product.navigation,
50
+ via: parseVia(url.searchParams.get('via')),
51
+ account: locals.account === null
52
+ ? null
53
+ : { email: locals.account.email, emailVerified: locals.account.emailVerified },
54
+ brand: frameBrand(s.product.brand, s.config.publicOrigin)
55
+ }
56
+ };
57
+ }
58
+ },
59
+ imprint: { load: () => ({ legal: services().legal.imprint }) },
60
+ privacy: { load: () => ({ legal: services().legal.privacy }) },
61
+ terms: { load: () => ({ legal: services().legal.terms }) },
62
+ signup: {
63
+ load: ({ locals, url }) => {
64
+ const s = services();
65
+ if (locals.account !== null)
66
+ redirect(303, s.product.accounts.home);
67
+ return {
68
+ signup: { ...s.product.accounts.signup, via: parseVia(url.searchParams.get('via')) }
69
+ };
70
+ },
71
+ actions: {
72
+ default: async (event) => {
73
+ const s = services();
74
+ const m = s.messages;
75
+ const form = await event.request.formData();
76
+ const email = field(form, 'email').trim();
77
+ const password = field(form, 'password');
78
+ const error = problem(emailField(m), email) ??
79
+ problem(passwordField(m), password) ??
80
+ (field(form, 'terms') === 'accepted' ? null : m.signup.acceptTerms) ??
81
+ (field(form, 'business') === 'confirmed' ? null : m.signup.confirmBusiness);
82
+ if (error !== null)
83
+ return fail(400, { email, error });
84
+ const seconds = s.limits.take(`signup:${s.address(event)}`, LIMITS.signup);
85
+ if (seconds > 0)
86
+ return tooMany(event, seconds, { email, error: m.tooMany(seconds) });
87
+ let account;
88
+ try {
89
+ account = await s.accounts.create({
90
+ // The form carries the referral tag of the page; one that is none counts as without.
91
+ ...signUp(s, email, parseVia(field(form, 'via'))),
92
+ password
93
+ });
94
+ }
95
+ catch (caught) {
96
+ if (caught instanceof EmailTaken) {
97
+ return fail(409, { email, error: m.signup.taken, taken: true });
98
+ }
99
+ throw caught;
100
+ }
101
+ await logIn(event, s, account.id);
102
+ const sent = await sendLink(s, account, { purpose: 'verify' });
103
+ redirect(303, sent.kind === 'sent' ? s.product.accounts.home : TO.verifyUnsent);
104
+ }
105
+ }
106
+ },
107
+ login: {
108
+ load: ({ locals, url }) => {
109
+ const s = services();
110
+ if (locals.account !== null) {
111
+ redirect(303, safeNext(url.searchParams.get('next'), s.product.accounts.home));
112
+ }
113
+ const notice = url.searchParams.has('verified')
114
+ ? 'verified'
115
+ : url.searchParams.has('email-changed')
116
+ ? 'emailChanged'
117
+ : null;
118
+ return { login: { notice } };
119
+ },
120
+ actions: {
121
+ default: async (event) => {
122
+ const s = services();
123
+ const m = s.messages;
124
+ const form = await event.request.formData();
125
+ const email = field(form, 'email').trim();
126
+ const password = field(form, 'password');
127
+ if (problem(emailField(m), email) !== null || password === '' || password.length > 200) {
128
+ return fail(400, { email, error: m.login.wrong });
129
+ }
130
+ // Every try counts before the password is checked: parallel guesses cannot slip by. The
131
+ // address counts on its own, against one password tried on many accounts and against
132
+ // a flood of scrypt.
133
+ const address = s.address(event);
134
+ const key = `login:${address}:${sha256(normalizeEmail(email)).toString('hex')}`;
135
+ const seconds = Math.max(s.limits.take(`login-address:${address}`, LIMITS.loginAddress), s.limits.take(key, LIMITS.login));
136
+ if (seconds > 0)
137
+ return tooMany(event, seconds, { email, error: m.tooMany(seconds) });
138
+ const account = await s.accounts.authenticate(email, password);
139
+ if (account === null)
140
+ return fail(400, { email, error: m.login.wrong });
141
+ s.limits.reset(key);
142
+ await s.accounts.removeExpired();
143
+ await logIn(event, s, account.id);
144
+ redirect(303, safeNext(event.url.searchParams.get('next'), s.product.accounts.home));
145
+ }
146
+ }
147
+ },
148
+ logout: {
149
+ load: ({ locals, url }) => ({
150
+ logout: { loggedIn: locals.account !== null, deleted: url.searchParams.has('deleted') }
151
+ }),
152
+ actions: {
153
+ default: async ({ cookies }) => {
154
+ const token = cookies.get(SESSION_COOKIE);
155
+ if (token !== undefined)
156
+ await services().accounts.endSession(token);
157
+ clearSessionCookie(cookies);
158
+ redirect(303, TO.loggedOut);
159
+ }
160
+ }
161
+ },
162
+ forgotPassword: {
163
+ actions: {
164
+ default: async (event) => {
165
+ const s = services();
166
+ const m = s.messages;
167
+ const email = field(await event.request.formData(), 'email').trim();
168
+ const error = problem(emailField(m), email);
169
+ if (error !== null)
170
+ return fail(400, { email, error });
171
+ const seconds = s.limits.take(`forgot:${s.address(event)}`, LIMITS.forgotPassword);
172
+ if (seconds > 0)
173
+ return tooMany(event, seconds, { email, error: m.tooMany(seconds) });
174
+ // The answer is the same with or without an account, as fast, and when the mail did
175
+ // not go: the link goes out after the answer, and a failure is in the log.
176
+ const account = await s.accounts.findByEmail(email);
177
+ if (account !== null) {
178
+ sendLink(s, account, { purpose: 'reset' }).catch((error) => {
179
+ s.log.error('mail failed', { kind: 'reset', account: account.id, error });
180
+ });
181
+ }
182
+ return { email, sent: true };
183
+ }
184
+ }
185
+ },
186
+ resetPassword: {
187
+ load: async ({ url }) => {
188
+ const token = url.searchParams.get('token');
189
+ if (token === null)
190
+ return { reset: { token: '', valid: false } };
191
+ const valid = (await services().accounts.peekLink(token, 'reset')) !== null;
192
+ return { reset: { token, valid } };
193
+ },
194
+ actions: {
195
+ default: async (event) => {
196
+ const s = services();
197
+ const m = s.messages;
198
+ const form = await event.request.formData();
199
+ const password = field(form, 'password');
200
+ const error = problem(passwordField(m), password);
201
+ if (error !== null)
202
+ return fail(400, { error });
203
+ const accountId = await s.accounts.resetPassword(field(form, 'token'), password);
204
+ if (accountId === null)
205
+ return fail(400, { error: m.link.invalid });
206
+ await s.accounts.removeExpired();
207
+ await logIn(event, s, accountId);
208
+ redirect(303, s.product.accounts.home);
209
+ }
210
+ }
211
+ },
212
+ /**
213
+ * The link of the mail opens a page with a button instead of confirming on GET, so that a mail
214
+ * scanner that opens links does not use it up. Confirming never logs into an account that has
215
+ * a password: whoever holds a mistyped address must not get in. An account from the API has
216
+ * none; it may set one here and is logged in.
217
+ */
218
+ verifyEmail: {
219
+ load: async ({ locals, url }) => {
220
+ const token = url.searchParams.get('token');
221
+ if (token !== null) {
222
+ const use = await services().accounts.peekLink(token, 'verify');
223
+ return {
224
+ verify: {
225
+ kind: 'link',
226
+ token,
227
+ valid: use !== null,
228
+ needsPassword: use !== null && !use.hasPassword
229
+ }
230
+ };
231
+ }
232
+ if (locals.account === null)
233
+ return { verify: { kind: 'none' } };
234
+ if (locals.account.emailVerified)
235
+ return { verify: { kind: 'confirmed' } };
236
+ return {
237
+ verify: {
238
+ kind: 'pending',
239
+ email: locals.account.email,
240
+ unsent: url.searchParams.has('unsent')
241
+ }
242
+ };
243
+ },
244
+ actions: {
245
+ confirm: async (event) => {
246
+ const s = services();
247
+ const m = s.messages;
248
+ const form = await event.request.formData();
249
+ const password = field(form, 'password');
250
+ if (password !== '') {
251
+ const error = problem(passwordField(m), password);
252
+ if (error !== null)
253
+ return fail(400, { error });
254
+ }
255
+ const use = await s.accounts.confirmEmail(field(form, 'token'), password === '' ? null : password);
256
+ if (use === null)
257
+ return fail(400, { error: m.link.invalid });
258
+ if (event.locals.account !== null && event.locals.account.id === use.accountId) {
259
+ redirect(303, TO.accountVerified);
260
+ }
261
+ if (use.hasPassword)
262
+ redirect(303, TO.loginVerified);
263
+ await logIn(event, s, use.accountId);
264
+ redirect(303, TO.accountVerified);
265
+ },
266
+ resend: async (event) => {
267
+ const s = services();
268
+ const account = requireAccount(event);
269
+ if (account.emailVerified)
270
+ redirect(303, '/verify-email');
271
+ const sent = await sendLink(s, account, { purpose: 'verify' });
272
+ switch (sent.kind) {
273
+ case 'limited':
274
+ return tooMany(event, sent.seconds, { error: s.messages.tooMany(sent.seconds) });
275
+ case 'failed':
276
+ return fail(502, { error: s.messages.mailFailed });
277
+ case 'sent':
278
+ return { resent: account.email };
279
+ }
280
+ }
281
+ }
282
+ },
283
+ confirmEmail: {
284
+ load: async ({ url }) => {
285
+ const token = url.searchParams.get('token');
286
+ if (token === null)
287
+ return { confirm: { token: '', newEmail: null } };
288
+ const use = await services().accounts.peekLink(token, 'change_email');
289
+ return { confirm: { token, newEmail: use === null ? null : use.newEmail } };
290
+ },
291
+ actions: {
292
+ default: async (event) => {
293
+ const s = services();
294
+ const result = await s.accounts.confirmEmailChange(field(await event.request.formData(), 'token'));
295
+ switch (result.kind) {
296
+ case 'invalid':
297
+ return fail(400, { error: s.messages.link.invalid });
298
+ case 'taken':
299
+ return fail(409, { error: s.messages.account.taken });
300
+ case 'changed': {
301
+ // The address has changed; a notice that did not go is in the log.
302
+ const notice = s.messages.mails.changed({
303
+ name: s.config.name,
304
+ origin: s.config.publicOrigin,
305
+ newEmail: result.newEmail
306
+ });
307
+ await deliver(s.mail, s.log, { to: result.oldEmail, ...notice }, { kind: 'email_changed', account: result.accountId });
308
+ // The address has changed; receipts that still go to the old one are in the log.
309
+ await s.billing
310
+ .followEmail(result.accountId, result.newEmail)
311
+ .catch((error) => {
312
+ s.log.error('stripe: the customer kept the old address', {
313
+ account: result.accountId,
314
+ error
315
+ });
316
+ });
317
+ const own = event.locals.account !== null && event.locals.account.id === result.accountId;
318
+ redirect(303, own ? TO.accountEmailChanged : TO.loginEmailChanged);
319
+ }
320
+ }
321
+ }
322
+ }
323
+ },
324
+ account: {
325
+ load: async (event) => {
326
+ const s = services();
327
+ const account = requireAccount(event);
328
+ const keys = await s.accounts.listApiKeys(account.id);
329
+ const customer = await s.billing.hasCustomer(account.id);
330
+ const notice = event.url.searchParams.has('verified')
331
+ ? 'verified'
332
+ : event.url.searchParams.has('email-changed')
333
+ ? 'emailChanged'
334
+ : null;
335
+ return {
336
+ account: {
337
+ email: account.email,
338
+ emailVerified: account.emailVerified,
339
+ hasPassword: account.hasPassword,
340
+ keyExample: `${s.product.accounts.keyPrefix}_…`,
341
+ customer,
342
+ keys: keys.map((key) => ({
343
+ id: key.id,
344
+ name: key.name,
345
+ prefix: key.prefix,
346
+ created: key.createdAt.toISOString().slice(0, 10)
347
+ })),
348
+ notice
349
+ }
350
+ };
351
+ },
352
+ actions: {
353
+ // Only a confirmed address makes keys: a key of an address not one's own would outlast
354
+ // the day its owner takes the account.
355
+ createKey: async (event) => {
356
+ const s = services();
357
+ const account = requireAccount(event);
358
+ if (!account.emailVerified) {
359
+ return fail(403, {
360
+ action: 'createKey',
361
+ error: s.messages.account.confirmFirst
362
+ });
363
+ }
364
+ const typed = field(await event.request.formData(), 'name')
365
+ .trim()
366
+ .slice(0, 80);
367
+ const name = typed === '' ? s.messages.account.keyName : typed;
368
+ const { key } = await s.accounts.createApiKey(account.id, name, s.product.accounts.keyPrefix);
369
+ return { action: 'createKey', key };
370
+ },
371
+ revokeKey: async (event) => {
372
+ const s = services();
373
+ const account = requireAccount(event);
374
+ const id = field(await event.request.formData(), 'id');
375
+ if (z.uuid().safeParse(id).success)
376
+ await s.accounts.revokeApiKey(account.id, id);
377
+ return { action: 'revokeKey' };
378
+ },
379
+ changePassword: async (event) => {
380
+ const s = services();
381
+ const m = s.messages;
382
+ const account = requireAccount(event);
383
+ const form = await event.request.formData();
384
+ const next = field(form, 'next');
385
+ const error = problem(passwordField(m), next);
386
+ if (error !== null)
387
+ return fail(400, { action: 'changePassword', error });
388
+ const attempt = passwordAttempt(s, account);
389
+ if (attempt.seconds > 0) {
390
+ return tooMany(event, attempt.seconds, {
391
+ action: 'changePassword',
392
+ error: m.tooMany(attempt.seconds)
393
+ });
394
+ }
395
+ const current = account.hasPassword ? field(form, 'current') : null;
396
+ if (!(await s.accounts.changePassword(account.id, current, next))) {
397
+ return fail(400, { action: 'changePassword', error: m.account.wrongCurrent });
398
+ }
399
+ attempt.passed();
400
+ const token = event.cookies.get(SESSION_COOKIE);
401
+ if (token === undefined)
402
+ throw new Error('an account without the cookie of its session');
403
+ await s.accounts.endOtherSessions(account.id, token);
404
+ return { action: 'changePassword', done: true };
405
+ },
406
+ changeEmail: async (event) => {
407
+ const s = services();
408
+ const m = s.messages;
409
+ const account = requireAccount(event);
410
+ const form = await event.request.formData();
411
+ const email = field(form, 'email').trim();
412
+ const failed = (status, error) => fail(status, { action: 'changeEmail', email, error });
413
+ const error = problem(emailField(m), email);
414
+ if (error !== null)
415
+ return failed(400, error);
416
+ if (normalizeEmail(email) === account.email)
417
+ return failed(400, m.account.sameEmail);
418
+ if (account.hasPassword) {
419
+ const attempt = passwordAttempt(s, account);
420
+ if (attempt.seconds > 0) {
421
+ return tooMany(event, attempt.seconds, {
422
+ action: 'changeEmail',
423
+ email,
424
+ error: m.tooMany(attempt.seconds)
425
+ });
426
+ }
427
+ if ((await s.accounts.authenticate(account.email, field(form, 'password'))) === null) {
428
+ return failed(400, m.account.wrongPassword);
429
+ }
430
+ attempt.passed();
431
+ }
432
+ const seconds = s.limits.take(`email-change:${account.id}`, LIMITS.emailChange);
433
+ if (seconds > 0) {
434
+ return tooMany(event, seconds, {
435
+ action: 'changeEmail',
436
+ email,
437
+ error: m.tooMany(seconds)
438
+ });
439
+ }
440
+ if ((await s.accounts.findByEmail(email)) !== null)
441
+ return failed(409, m.account.taken);
442
+ const sent = await sendLink(s, account, { purpose: 'change_email', newEmail: email });
443
+ switch (sent.kind) {
444
+ case 'limited':
445
+ return tooMany(event, sent.seconds, {
446
+ action: 'changeEmail',
447
+ email,
448
+ error: m.tooMany(sent.seconds)
449
+ });
450
+ case 'failed':
451
+ return failed(502, m.mailFailed);
452
+ case 'sent':
453
+ return {
454
+ action: 'changeEmail',
455
+ email: '',
456
+ sentTo: normalizeEmail(email)
457
+ };
458
+ }
459
+ },
460
+ /**
461
+ * Only a customer has a portal; opening it must not make one, which with the live key
462
+ * makes the account live.
463
+ */
464
+ portal: async (event) => {
465
+ const s = services();
466
+ const account = requireAccount(event);
467
+ if (!(await s.billing.hasCustomer(account.id)))
468
+ redirect(303, '/account');
469
+ redirect(303, await s.billing.portal(account, '/account'));
470
+ },
471
+ delete: async (event) => {
472
+ const s = services();
473
+ const account = requireAccount(event);
474
+ const typed = normalizeEmail(field(await event.request.formData(), 'confirm'));
475
+ if (typed !== account.email) {
476
+ return fail(400, {
477
+ action: 'delete',
478
+ error: s.messages.account.confirmDelete
479
+ });
480
+ }
481
+ // A deleted account pays for nothing: its subscriptions end first, and a failure at
482
+ // Stripe leaves the account as it is.
483
+ await s.billing.endSubscriptions(account.id);
484
+ await s.accounts.delete(account.id, s.deleteHook);
485
+ clearSessionCookie(event.cookies);
486
+ redirect(303, TO.deleted);
487
+ }
488
+ }
489
+ }
490
+ };
491
+ }