@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,159 @@
1
+ import { z } from 'zod';
2
+ import { EmailTaken } from './accounts.js';
3
+ import { ApiError, bearerKey, readBody, respond } from './api.js';
4
+ import { emailField } from './fields.js';
5
+ import { LIMITS } from './limits.js';
6
+ import { sendLink, signUp } from './services.js';
7
+ import { VIA_MAX, VIA_PATTERN } from './via.js';
8
+ export const API_PATHS = {
9
+ signup: '/api/v1/signup',
10
+ account: '/api/v1/account',
11
+ checkout: '/api/v1/billing/checkout',
12
+ portal: '/api/v1/billing/portal'
13
+ };
14
+ export const API_METHODS = {
15
+ [API_PATHS.signup]: 'POST',
16
+ [API_PATHS.account]: 'GET',
17
+ [API_PATHS.checkout]: 'POST',
18
+ [API_PATHS.portal]: 'POST'
19
+ };
20
+ const isApiPath = (path) => Object.hasOwn(API_METHODS, path);
21
+ const BACK = '/account';
22
+ export function accountJson(account) {
23
+ return {
24
+ id: account.id,
25
+ email: account.email,
26
+ email_verified: account.emailVerified,
27
+ created_at: account.createdAt.toISOString()
28
+ };
29
+ }
30
+ function tooMany(s, seconds) {
31
+ return new ApiError(429, 'rate_limited', s.messages.api.rateLimited(seconds), {
32
+ headers: { 'retry-after': String(seconds) }
33
+ });
34
+ }
35
+ /** The account of the bearer key, counted against the limit of the key. */
36
+ export async function apiAccount(s, event) {
37
+ const prefix = s.product.accounts.keyPrefix;
38
+ const key = bearerKey(event.request.headers, prefix);
39
+ const found = key === null ? null : await s.accounts.forApiKey(key);
40
+ if (found === null) {
41
+ throw new ApiError(401, 'unauthorized', s.messages.api.unauthorized(`${prefix}_…`));
42
+ }
43
+ const seconds = s.limits.take(`api:${found.keyId}`, LIMITS.api);
44
+ if (seconds > 0)
45
+ throw tooMany(s, seconds);
46
+ return found;
47
+ }
48
+ /** The body of the sign-up, every problem in the language of the product. */
49
+ export function signupBody(m, terms) {
50
+ return z.object({
51
+ email: emailField(m),
52
+ accept_terms: z.literal(true, m.api.acceptTerms(terms)),
53
+ acting_for_business: z.literal(true, m.api.actingForBusiness),
54
+ via: z.string(m.api.via).max(VIA_MAX, m.api.via).regex(VIA_PATTERN, m.api.via).optional()
55
+ }, m.api.notAnObject);
56
+ }
57
+ async function signup(s, event) {
58
+ const m = s.messages;
59
+ const body = await readBody(event.request, signupBody(m, `${s.config.publicOrigin}/terms`), m);
60
+ const seconds = s.limits.take(`signup:${s.address(event)}`, LIMITS.signup);
61
+ if (seconds > 0)
62
+ throw tooMany(s, seconds);
63
+ let created;
64
+ try {
65
+ created = await s.accounts.createWithKey({
66
+ // An agent that signs up names the referral tag of the link it came by, or none.
67
+ ...signUp(s, body.email, body.via ?? null),
68
+ keyName: m.api.firstKey,
69
+ keyPrefix: s.product.accounts.keyPrefix
70
+ });
71
+ }
72
+ catch (error) {
73
+ if (error instanceof EmailTaken)
74
+ throw new ApiError(409, 'email_taken', m.api.emailTaken);
75
+ throw error;
76
+ }
77
+ // A failed mail is in the log and does not fail the sign-up: the key works without it. Whoever
78
+ // holds the address still gets in with «Forgot your password?», which confirms the address and
79
+ // revokes the keys made before.
80
+ await sendLink(s, created.account, { purpose: 'verify' });
81
+ return Response.json({ api_key: created.key, account: accountJson(created.account) }, { status: 201 });
82
+ }
83
+ /**
84
+ * The body of a Checkout over the API: a price of the catalog by its lookup key, and a quantity,
85
+ * which the price decides on (checkout below).
86
+ */
87
+ export function checkoutBody(m) {
88
+ return z.object({ price: z.string(m.api.price), quantity: z.unknown().optional() }, m.api.notAnObject);
89
+ }
90
+ function invalid(m, path, message) {
91
+ return new ApiError(422, 'invalid_request', m.api.invalidRequest, {
92
+ details: [{ path, message }]
93
+ });
94
+ }
95
+ function withStripe(s) {
96
+ if (s.config.stripe.kind === 'none') {
97
+ throw new ApiError(503, 'not_configured', s.messages.stripe.notConfigured);
98
+ }
99
+ }
100
+ async function checkout(s, event) {
101
+ const m = s.messages;
102
+ const { account } = await apiAccount(s, event);
103
+ const body = await readBody(event.request, checkoutBody(m), m);
104
+ const product = s.product.catalog.products.find((p) => p.prices.some((price) => price.lookupKey === body.price));
105
+ if (product === undefined)
106
+ throw invalid(m, 'price', m.api.price);
107
+ const max = product.kind === 'subscription' && product.quantity !== undefined ? product.quantity.max : 1;
108
+ const quantity = body.quantity;
109
+ if (quantity === undefined)
110
+ return opened(s, account, { price: body.price });
111
+ if (typeof quantity !== 'number' ||
112
+ !Number.isInteger(quantity) ||
113
+ quantity < 1 ||
114
+ quantity > max) {
115
+ throw invalid(m, 'quantity', m.api.quantity(max));
116
+ }
117
+ return opened(s, account, { price: body.price, quantity });
118
+ }
119
+ async function opened(s, account, order) {
120
+ withStripe(s);
121
+ const page = await s.billing.checkout(account, { ...order, success: BACK, cancel: BACK });
122
+ switch (page.kind) {
123
+ case 'subscribed':
124
+ throw new ApiError(409, 'subscribed', s.messages.api.subscribed);
125
+ case 'open':
126
+ return Response.json({ url: page.url });
127
+ }
128
+ }
129
+ async function portal(s, event) {
130
+ const { account } = await apiAccount(s, event);
131
+ withStripe(s);
132
+ if (!(await s.billing.hasCustomer(account.id))) {
133
+ throw new ApiError(409, 'no_customer', s.messages.api.noCustomer);
134
+ }
135
+ return Response.json({ url: await s.billing.portal(account, BACK) });
136
+ }
137
+ /** The answer of a path of the baukasten's API, or null for any other path. */
138
+ export function serveApi(s, event) {
139
+ const path = event.url.pathname;
140
+ if (!isApiPath(path))
141
+ return null;
142
+ return respond(s.log, s.messages, async () => {
143
+ if (event.request.method !== API_METHODS[path]) {
144
+ throw new ApiError(405, 'method_not_allowed', s.messages.api.methodNotAllowed, {
145
+ headers: { allow: API_METHODS[path] }
146
+ });
147
+ }
148
+ switch (path) {
149
+ case API_PATHS.signup:
150
+ return signup(s, event);
151
+ case API_PATHS.account:
152
+ return Response.json(accountJson((await apiAccount(s, event)).account));
153
+ case API_PATHS.checkout:
154
+ return checkout(s, event);
155
+ case API_PATHS.portal:
156
+ return portal(s, event);
157
+ }
158
+ });
159
+ }
@@ -0,0 +1,7 @@
1
+ import { z } from 'zod';
2
+ import type { Messages } from './messages.js';
3
+ export declare const emailField: (m: Messages) => z.ZodPipe<z.ZodString, z.ZodEmail>;
4
+ export declare const passwordField: (m: Messages) => z.ZodString;
5
+ /** A text field of a submitted form; a missing one is empty, as an empty field. */
6
+ export declare function field(form: FormData, name: string): string;
7
+ export declare function problem(schema: z.ZodType, value: unknown): string | null;
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+ // Every rule names its message: what Zod says by itself is English, whatever the product speaks.
3
+ export const emailField = (m) => z.string(m.email.invalid).trim().max(254, m.email.tooLong).pipe(z.email(m.email.invalid));
4
+ export const passwordField = (m) => z.string(m.password.tooShort).min(10, m.password.tooShort).max(200, m.password.tooLong);
5
+ /** A text field of a submitted form; a missing one is empty, as an empty field. */
6
+ export function field(form, name) {
7
+ const value = form.get(name);
8
+ return typeof value === 'string' ? value : '';
9
+ }
10
+ export function problem(schema, value) {
11
+ const result = schema.safeParse(value);
12
+ if (result.success)
13
+ return null;
14
+ const [first] = result.error.issues;
15
+ if (first === undefined)
16
+ throw new Error('a failed parse without an issue');
17
+ return first.message;
18
+ }
@@ -0,0 +1 @@
1
+ export declare const SECURITY_HEADERS: Readonly<Record<string, string>>;
@@ -0,0 +1,9 @@
1
+ // The security headers of every response. The server of a product (start.ts) sets them before any
2
+ // handler, so that the static files carry them too; the handle sets them as well, for the dev
3
+ // server. The content security policy comes from SvelteKit (SVELTEKIT_OPTIONS), per page.
4
+ export const SECURITY_HEADERS = {
5
+ 'x-content-type-options': 'nosniff',
6
+ 'x-frame-options': 'DENY',
7
+ 'referrer-policy': 'strict-origin-when-cross-origin',
8
+ 'cross-origin-opener-policy': 'same-origin'
9
+ };
@@ -0,0 +1,34 @@
1
+ import type { Catalog } from '../product.js';
2
+ import { type Shop } from './catalog.js';
3
+ import { type Log } from './log.js';
4
+ import type { MigrationStatus } from './migrations.js';
5
+ import type { Mailer, Queryable, StripeApi } from './ports.js';
6
+ /** A check resolves when all is well; CheckFailed carries a short reason without hosts or secrets. */
7
+ export type Check = () => Promise<void>;
8
+ export declare class CheckFailed extends Error {
9
+ name: string;
10
+ }
11
+ /**
12
+ * Per check `ok` or `fail: <reason>`, never a `status` key of its own: the delivery service looks
13
+ * for the overall `"status":"ok"`.
14
+ */
15
+ export interface HealthReport {
16
+ status: 'ok' | 'fail';
17
+ checks: Record<string, string>;
18
+ }
19
+ export declare const CHECK_TIMEOUT_MS = 5000;
20
+ export declare function deepHealth(options: {
21
+ checks: Record<string, Check>;
22
+ now: () => number;
23
+ log: Log;
24
+ timeoutMs?: number;
25
+ }): () => Promise<HealthReport>;
26
+ /** The database answers, and neither layer has a migration pending or a problem. */
27
+ export declare function databaseCheck(db: Queryable, status: () => Promise<MigrationStatus>, log: Log): Check;
28
+ /** The relay of the fleet greets; its time limit is that of every check. */
29
+ export declare function mailCheck(mailer: Mailer, log: Log): Check;
30
+ /**
31
+ * Stripe takes the key, the catalog and the default portal are as the migration sets them up, and
32
+ * no webhook endpoint of another product gets the events of this one.
33
+ */
34
+ export declare function stripeCheck(stripe: StripeApi, catalog: Catalog, shop: Shop, log: Log): Check;
@@ -0,0 +1,116 @@
1
+ import { StripeFailed } from './billing.js';
2
+ import { catalogDifferences, foreignWebhooks } from './catalog.js';
3
+ import { describeError } from './log.js';
4
+ import { MailFailed } from './mail.js';
5
+ export class CheckFailed extends Error {
6
+ name = 'CheckFailed';
7
+ }
8
+ export const CHECK_TIMEOUT_MS = 5_000;
9
+ const OK_FOR_MS = 60_000;
10
+ const FAIL_FOR_MS = 5_000;
11
+ async function within(check, timeoutMs) {
12
+ let timer;
13
+ const timeout = new Promise((_, reject) => {
14
+ timer = setTimeout(() => reject(new CheckFailed('timeout')), timeoutMs);
15
+ });
16
+ try {
17
+ await Promise.race([check(), timeout]);
18
+ }
19
+ finally {
20
+ clearTimeout(timer);
21
+ }
22
+ }
23
+ export function deepHealth(options) {
24
+ const { checks, now, log } = options;
25
+ const timeoutMs = options.timeoutMs ?? CHECK_TIMEOUT_MS;
26
+ let running = null;
27
+ let remembered = null;
28
+ async function one(name, check) {
29
+ try {
30
+ await within(check, timeoutMs);
31
+ return 'ok';
32
+ }
33
+ catch (error) {
34
+ if (error instanceof CheckFailed)
35
+ return `fail: ${error.message}`;
36
+ log.error('health check failed', { check: name, error });
37
+ return 'fail: error';
38
+ }
39
+ }
40
+ async function run() {
41
+ const results = await Promise.all(Object.entries(checks).map(async ([name, check]) => [name, await one(name, check)]));
42
+ const report = {
43
+ status: results.every(([, result]) => result === 'ok') ? 'ok' : 'fail',
44
+ checks: Object.fromEntries(results)
45
+ };
46
+ remembered = { report, until: now() + (report.status === 'ok' ? OK_FOR_MS : FAIL_FOR_MS) };
47
+ return report;
48
+ }
49
+ return () => {
50
+ if (remembered !== null && now() < remembered.until)
51
+ return Promise.resolve(remembered.report);
52
+ running ??= run().finally(() => {
53
+ running = null;
54
+ });
55
+ return running;
56
+ };
57
+ }
58
+ /** The database answers, and neither layer has a migration pending or a problem. */
59
+ export function databaseCheck(db, status, log) {
60
+ return async () => {
61
+ try {
62
+ await db.query('SELECT 1');
63
+ }
64
+ catch (error) {
65
+ log.error('database unreachable', { error });
66
+ const code = describeError(error).code;
67
+ // A SQLSTATE such as 28P01 names the kind of failure and carries no secret.
68
+ throw new CheckFailed(typeof code === 'string' && /^[0-9A-Z]{5}$/.test(code) ? `error ${code}` : 'unreachable');
69
+ }
70
+ const { pending, problems } = await status();
71
+ if (problems.length > 0)
72
+ throw new CheckFailed(problems.join('; '));
73
+ if (pending.length > 0)
74
+ throw new CheckFailed(`migrations pending: ${pending.join(', ')}`);
75
+ };
76
+ }
77
+ /** The relay of the fleet greets; its time limit is that of every check. */
78
+ export function mailCheck(mailer, log) {
79
+ return async () => {
80
+ try {
81
+ await mailer.check();
82
+ }
83
+ catch (error) {
84
+ log.error('mail relay unreachable', { error });
85
+ // The code of SMTP, such as ECONNECTION, names the kind of failure and no host.
86
+ throw new CheckFailed(error instanceof MailFailed ? `error ${error.code}` : 'unreachable');
87
+ }
88
+ };
89
+ }
90
+ /**
91
+ * Stripe takes the key, the catalog and the default portal are as the migration sets them up, and
92
+ * no webhook endpoint of another product gets the events of this one.
93
+ */
94
+ export function stripeCheck(stripe, catalog, shop, log) {
95
+ return async () => {
96
+ let differences;
97
+ let foreign;
98
+ try {
99
+ [differences, foreign] = await Promise.all([
100
+ catalogDifferences(stripe, catalog, shop),
101
+ foreignWebhooks(stripe, shop.origin)
102
+ ]);
103
+ }
104
+ catch (error) {
105
+ log.error('stripe unreachable', { error });
106
+ // What Stripe called the failure, such as authentication_error, names no key.
107
+ throw new CheckFailed(error instanceof StripeFailed ? `error ${error.kind}` : 'unreachable');
108
+ }
109
+ if (foreign.length > 0) {
110
+ log.error('stripe: webhook endpoints of another product', { endpoints: foreign });
111
+ throw new CheckFailed(`foreign webhook endpoints: ${foreign.length}`);
112
+ }
113
+ if (differences.length > 0)
114
+ throw new CheckFailed(`catalog differs: ${differences.join(', ')}`);
115
+ };
116
+ }
@@ -0,0 +1,92 @@
1
+ import type { Handle, HandleServerError, RequestEvent, ServerInit } from '@sveltejs/kit';
2
+ import type { z } from 'zod';
3
+ import type { Product, ProductEnv } from '../product.js';
4
+ import { type Account } from './accounts.js';
5
+ import { type CheckoutPage, type Order, type Purchase, type SubscriptionChange } from './billing.js';
6
+ import { type Config } from './config.js';
7
+ import { type Limits } from './limits.js';
8
+ import type { Log } from './log.js';
9
+ import { type OpenApiDocument } from './openapi.js';
10
+ import { type Pages } from './pages.js';
11
+ import type { Db, Mailer, Queryable } from './ports.js';
12
+ import type { ProductNumbers } from './sales.js';
13
+ export interface Context<E extends ProductEnv = ProductEnv> {
14
+ config: Config<E>;
15
+ db: Db;
16
+ log: Log;
17
+ /** The sending of the baukasten, for the product's own mails. */
18
+ mail: Mailer;
19
+ /** The counters of the baukasten, for the limits of the product's own routes. */
20
+ limits: Limits;
21
+ }
22
+ /** What the baukasten calls in the product. */
23
+ export interface Hooks<E extends ProductEnv = ProductEnv> {
24
+ /**
25
+ * The checks of the product in GET /health/deep, by name; `database`, `mail` and `stripe` are the
26
+ * baukasten's.
27
+ */
28
+ checks?: Record<string, (context: Context<E>) => Promise<void>>;
29
+ /** Once the configuration is read, before the first request: the product starts its runs. */
30
+ start?: (context: Context<E>) => Promise<void> | void;
31
+ /** On SIGTERM, after the last request and before the pool closes: it stops its runs. */
32
+ stop?: (context: Context<E>) => Promise<void> | void;
33
+ /**
34
+ * Deletes the product's data of an account, in the transaction that deletes the account; the
35
+ * baukasten then deletes what it keeps of it, and the row stays without the address.
36
+ */
37
+ deleteAccount?: (tx: Queryable, accountId: string) => Promise<void>;
38
+ /**
39
+ * Stripe reported a change of a subscription, and this is its state now, read back from Stripe. In
40
+ * the transaction of the webhook, which applies each event once; a failure rolls it back, and
41
+ * Stripe sends the event again. The product derives its plans and entitlements here.
42
+ */
43
+ subscriptionChanged?: (tx: Queryable, change: SubscriptionChange) => Promise<void>;
44
+ /**
45
+ * A purchase was paid, once per purchase, in the transaction of the webhook. A product with a
46
+ * balance books it here.
47
+ */
48
+ purchasePaid?: (tx: Queryable, purchase: Purchase) => Promise<void>;
49
+ /**
50
+ * Further numbers for GET /vertrieb/zahlen, by name, each counted per account id; the baukasten
51
+ * adds them up per referral tag. An account the product does not name has none of that number.
52
+ */
53
+ salesNumbers?: (db: Queryable) => Promise<ProductNumbers>;
54
+ }
55
+ export interface Kit<E extends ProductEnv = ProductEnv> {
56
+ /**
57
+ * The `init` of the hooks. SvelteKit runs it in `vite build` too, to prerender pages, with
58
+ * `building` of `$app/environment` set: then it reads nothing and opens nothing, since the image is
59
+ * built without a single variable.
60
+ */
61
+ init(building: boolean): ServerInit;
62
+ handle: Handle;
63
+ handleError: HandleServerError;
64
+ /** Pool, configuration, log, mail and limits of the running product; only after `init`. */
65
+ context(): Context<E>;
66
+ /** `load` and `actions` of the pages of the baukasten, for the stubs under src/routes. */
67
+ pages: Pages;
68
+ /** The address of the client of a request; the product counts its own limits by it. */
69
+ clientAddress(event: RequestEvent): string;
70
+ /** The helpers of the product's own API, in the shape of the baukasten's. */
71
+ api: {
72
+ /** Runs a handler: an ApiError becomes its answer, anything else a logged 500. */
73
+ respond(handler: () => Promise<Response>): Promise<Response>;
74
+ /** The JSON body of a request, parsed with `schema`: else 400 or 422 with each problem. */
75
+ body<S extends z.ZodType>(request: Request, schema: S): Promise<z.output<S>>;
76
+ /** The account of the bearer key: else 401, and 429 past the limit of the key. */
77
+ account(event: RequestEvent): Promise<Account>;
78
+ };
79
+ /** The OpenAPI document of the product with the paths of the baukasten merged in. */
80
+ openApi(document: OpenApiDocument): OpenApiDocument;
81
+ /** Payment through Stripe, for the pages of the product. */
82
+ billing: {
83
+ /**
84
+ * A Checkout page for a price of the catalog, by its lookup key: a subscription, or a purchase.
85
+ * An account with a running subscription gets none; the customer portal changes it.
86
+ */
87
+ checkout(account: Account, order: Order): Promise<CheckoutPage>;
88
+ /** The customer portal of the account, which leads back to the path `back`. */
89
+ portal(account: Account, back: string): Promise<string>;
90
+ };
91
+ }
92
+ export declare function baukasten<E extends ProductEnv>(product: Product<E>, hooks?: Hooks<E>): Kit<E>;