@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,61 @@
1
+ // The operations log of a product: one JSON object per line, time, level and message first. A field
2
+ // that is an Error becomes `{ name, message, code }`, never its other properties, which may carry
3
+ // data of a customer. What a field holds is the caller's choice; the baukasten never logs a client
4
+ // address, an email address, a password or a key.
5
+ const LEVELS = ['debug', 'info', 'warn', 'error', 'silent'];
6
+ /** An error as the log shows it; an AggregateError (pg tries every address) lists its messages. */
7
+ export function describeError(error) {
8
+ if (!(error instanceof Error))
9
+ return { name: typeof error, message: String(error) };
10
+ const described = { name: error.name, message: error.message };
11
+ const code = Reflect.get(error, 'code');
12
+ if (typeof code === 'string' || typeof code === 'number')
13
+ described.code = code;
14
+ if (error instanceof AggregateError) {
15
+ described.errors = error.errors.map((inner) => describeError(inner).message);
16
+ }
17
+ return described;
18
+ }
19
+ const isLevel = (name) => LEVELS.some((level) => level === name);
20
+ export function createLog(options) {
21
+ const { write, now } = options;
22
+ const name = options.level ?? 'info';
23
+ const threshold = LEVELS.indexOf(isLevel(name) ? name : 'info');
24
+ function line(level, msg, fields) {
25
+ if (LEVELS.indexOf(level) < threshold)
26
+ return;
27
+ const entry = { time: now().toISOString(), level, msg };
28
+ for (const [key, value] of Object.entries(fields)) {
29
+ entry[key] = value instanceof Error ? describeError(value) : value;
30
+ }
31
+ write(serialize(entry, Object.keys(fields)) + '\n');
32
+ }
33
+ if (!isLevel(name)) {
34
+ line('warn', 'LOG_LEVEL is not a level, logging at info', { levels: [...LEVELS] });
35
+ }
36
+ return {
37
+ debug: (msg, fields = {}) => line('debug', msg, fields),
38
+ info: (msg, fields = {}) => line('info', msg, fields),
39
+ warn: (msg, fields = {}) => line('warn', msg, fields),
40
+ error: (msg, fields = {}) => line('error', msg, fields)
41
+ };
42
+ }
43
+ /** A field JSON cannot write (a cycle, a BigInt) is named instead of losing the whole line. */
44
+ function serialize(entry, fields) {
45
+ try {
46
+ return JSON.stringify(entry);
47
+ }
48
+ catch {
49
+ const unserializable = fields.filter((key) => {
50
+ try {
51
+ JSON.stringify(entry[key]);
52
+ return false;
53
+ }
54
+ catch {
55
+ return true;
56
+ }
57
+ });
58
+ const kept = Object.entries(entry).filter(([key]) => !unserializable.includes(key));
59
+ return JSON.stringify({ ...Object.fromEntries(kept), unserializable });
60
+ }
61
+ }
@@ -0,0 +1,4 @@
1
+ import type { RequestEvent } from '@sveltejs/kit';
2
+ import type { Services } from './services.js';
3
+ /** The answer of a machine endpoint of the baukasten, or null for any other path. */
4
+ export declare function serveMachine(s: Services, event: RequestEvent): Promise<Response> | null;
@@ -0,0 +1,57 @@
1
+ import { ApiError, respond } from './api.js';
2
+ import { productNumbers, SALES_PATH, salesNumbers, bearerFits } from './sales.js';
3
+ import { WEBHOOK_PATH } from './stripe.js';
4
+ function only(s, event, method) {
5
+ if (event.request.method !== method) {
6
+ throw new ApiError(405, 'method_not_allowed', s.messages.api.methodNotAllowed, {
7
+ headers: { allow: method }
8
+ });
9
+ }
10
+ }
11
+ /**
12
+ * Applies an event of Stripe once. A wrong signature is a 400; an event that fails is a 500, so that
13
+ * Stripe sends it again later.
14
+ */
15
+ async function webhook(s, event) {
16
+ only(s, event, 'POST');
17
+ if (s.config.stripe.kind === 'none') {
18
+ s.log.warn('stripe: a webhook without Stripe configured');
19
+ throw new ApiError(503, 'not_configured', s.messages.stripe.notConfigured);
20
+ }
21
+ const payload = await event.request.text();
22
+ const received = await s.billing.receive(payload, event.request.headers.get('stripe-signature'));
23
+ switch (received.kind) {
24
+ case 'invalid':
25
+ s.log.warn('stripe: a webhook with a wrong signature');
26
+ throw new ApiError(400, 'invalid_signature', s.messages.stripe.signature);
27
+ case 'applied':
28
+ case 'repeated':
29
+ return Response.json({ received: true });
30
+ }
31
+ }
32
+ async function sales(s, event) {
33
+ only(s, event, 'GET');
34
+ if (!bearerFits(event.request.headers.get('authorization'), s.config.salesToken)) {
35
+ throw new ApiError(401, 'unauthorized', s.messages.sales.unauthorized);
36
+ }
37
+ const own = productNumbers(await s.salesNumbers(s.db));
38
+ const numbers = await salesNumbers(s.db, {
39
+ currency: s.product.catalog.currency,
40
+ now: s.now(),
41
+ own,
42
+ count: s.config.stripe.kind === 'stripe' && s.config.stripe.mode === 'live' ? 'live' : 'all',
43
+ log: s.log
44
+ });
45
+ return Response.json(numbers, { headers: { 'cache-control': 'no-store' } });
46
+ }
47
+ /** The answer of a machine endpoint of the baukasten, or null for any other path. */
48
+ export function serveMachine(s, event) {
49
+ switch (event.url.pathname) {
50
+ case WEBHOOK_PATH:
51
+ return respond(s.log, s.messages, () => webhook(s, event));
52
+ case SALES_PATH:
53
+ return respond(s.log, s.messages, () => sales(s, event));
54
+ default:
55
+ return null;
56
+ }
57
+ }
@@ -0,0 +1,20 @@
1
+ import type { Log } from './log.js';
2
+ import type { Mail, Mailer } from './ports.js';
3
+ /**
4
+ * A mail the relay did not take. Only what SMTP calls the failure is kept; the reply of a server can
5
+ * quote the address of the recipient.
6
+ */
7
+ export declare class MailFailed extends Error {
8
+ name: string;
9
+ readonly code: string;
10
+ constructor(details: {
11
+ code: string;
12
+ responseCode?: number;
13
+ command?: string;
14
+ });
15
+ }
16
+ /** Sends a mail of the accounts: true when it went out; a failure is logged with its kind. */
17
+ export declare function deliver(mailer: Mailer, log: Log, mail: Mail, about: {
18
+ kind: string;
19
+ account: string;
20
+ }): Promise<boolean>;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A mail the relay did not take. Only what SMTP calls the failure is kept; the reply of a server can
3
+ * quote the address of the recipient.
4
+ */
5
+ export class MailFailed extends Error {
6
+ name = 'MailFailed';
7
+ code;
8
+ constructor(details) {
9
+ const parts = [
10
+ details.code,
11
+ ...(details.responseCode === undefined ? [] : [`reply ${details.responseCode}`]),
12
+ ...(details.command === undefined ? [] : [`at ${details.command}`])
13
+ ];
14
+ super(`the mail was not sent: ${parts.join(', ')}`);
15
+ this.code = details.code;
16
+ }
17
+ }
18
+ /** Sends a mail of the accounts: true when it went out; a failure is logged with its kind. */
19
+ export async function deliver(mailer, log, mail, about) {
20
+ try {
21
+ await mailer.send(mail);
22
+ log.info('mail sent', about);
23
+ return true;
24
+ }
25
+ catch (error) {
26
+ log.error('mail failed', { ...about, error });
27
+ return false;
28
+ }
29
+ }
@@ -0,0 +1,105 @@
1
+ import type { Language } from '../product.js';
2
+ export interface MailText {
3
+ subject: string;
4
+ text: string;
5
+ }
6
+ /** A link in a mail and what it is about: the product, its address and how long the link holds. */
7
+ interface LinkMail {
8
+ name: string;
9
+ origin: string;
10
+ link: string;
11
+ hours: number;
12
+ }
13
+ export interface Messages {
14
+ tooMany: (seconds: number) => string;
15
+ crossSite: string;
16
+ email: {
17
+ invalid: string;
18
+ tooLong: string;
19
+ };
20
+ password: {
21
+ tooShort: string;
22
+ tooLong: string;
23
+ };
24
+ signup: {
25
+ acceptTerms: string;
26
+ confirmBusiness: string;
27
+ taken: string;
28
+ };
29
+ login: {
30
+ wrong: string;
31
+ };
32
+ link: {
33
+ invalid: string;
34
+ };
35
+ mailFailed: string;
36
+ account: {
37
+ sameEmail: string;
38
+ wrongPassword: string;
39
+ wrongCurrent: string;
40
+ taken: string;
41
+ confirmDelete: string;
42
+ keyName: string;
43
+ confirmFirst: string;
44
+ };
45
+ api: {
46
+ unauthorized: (example: string) => string;
47
+ rateLimited: (seconds: number) => string;
48
+ invalidJson: string;
49
+ notAnObject: string;
50
+ invalidRequest: string;
51
+ emailTaken: string;
52
+ notFound: string;
53
+ methodNotAllowed: string;
54
+ payloadTooLarge: string;
55
+ failed: string;
56
+ forbidden: string;
57
+ internal: string;
58
+ acceptTerms: (terms: string) => string;
59
+ actingForBusiness: string;
60
+ via: string;
61
+ firstKey: string;
62
+ price: string;
63
+ quantity: (max: number) => string;
64
+ subscribed: string;
65
+ noCustomer: string;
66
+ };
67
+ stripe: {
68
+ /** By the box for the terms in Checkout, in Markdown with their link. */
69
+ terms: (name: string, url: string) => string;
70
+ signature: string;
71
+ notConfigured: string;
72
+ };
73
+ sales: {
74
+ unauthorized: string;
75
+ };
76
+ mails: {
77
+ verify: (mail: LinkMail) => MailText;
78
+ reset: (mail: LinkMail) => MailText;
79
+ change: (mail: LinkMail) => MailText;
80
+ changed: (mail: {
81
+ name: string;
82
+ origin: string;
83
+ newEmail: string;
84
+ }) => MailText;
85
+ };
86
+ openApi: {
87
+ signup: string;
88
+ signupCreated: string;
89
+ account: string;
90
+ email: string;
91
+ acceptTerms: string;
92
+ actingForBusiness: string;
93
+ via: string;
94
+ apiKey: string;
95
+ checkout: string;
96
+ checkoutBody: string;
97
+ portal: string;
98
+ url: string;
99
+ price: string;
100
+ quantity: string;
101
+ errors: Record<'invalidJson' | 'invalidRequest' | 'emailTaken' | 'unauthorized' | 'rateLimited' | 'forbidden' | 'subscribed' | 'noCustomer' | 'notConfigured', string>;
102
+ };
103
+ }
104
+ export declare const MESSAGES: Record<Language, Messages>;
105
+ export {};
@@ -0,0 +1,232 @@
1
+ const signature = (name, origin) => `\n\n— ${name}\n${origin}`;
2
+ const en = {
3
+ tooMany: (seconds) => {
4
+ const minutes = Math.max(1, Math.ceil(seconds / 60));
5
+ return `Too many tries. Try again in ${minutes} minute${minutes === 1 ? '' : 's'}.`;
6
+ },
7
+ crossSite: 'Cross-site form submissions are forbidden.',
8
+ email: {
9
+ invalid: 'Enter a valid email address.',
10
+ tooLong: 'The email address is too long.'
11
+ },
12
+ password: {
13
+ tooShort: 'Use at least 10 characters for the password.',
14
+ tooLong: 'Use at most 200 characters for the password.'
15
+ },
16
+ signup: {
17
+ acceptTerms: 'Please accept the terms to sign up.',
18
+ confirmBusiness: 'Please confirm that you sign up for your business: we serve businesses only.',
19
+ taken: 'An account with this email address already exists.'
20
+ },
21
+ login: { wrong: 'Email or password is wrong.' },
22
+ link: { invalid: 'This link is invalid, used or expired.' },
23
+ mailFailed: 'We could not send the email. Please try again in a moment.',
24
+ account: {
25
+ sameEmail: 'This is your address already.',
26
+ wrongPassword: 'The password is wrong.',
27
+ wrongCurrent: 'The current password is wrong.',
28
+ taken: 'Another account uses this address.',
29
+ confirmDelete: 'Type your email address to confirm.',
30
+ keyName: 'API key',
31
+ confirmFirst: 'Confirm your email address first; then you can create keys.'
32
+ },
33
+ api: {
34
+ unauthorized: (example) => `Send a valid API key as "Authorization: Bearer ${example}".`,
35
+ rateLimited: (seconds) => `Too many requests. Try again in ${seconds} seconds.`,
36
+ invalidJson: 'The request body must be JSON.',
37
+ notAnObject: 'The request body must be a JSON object.',
38
+ invalidRequest: 'The request body is not valid.',
39
+ emailTaken: 'An account with this email address already exists.',
40
+ notFound: 'There is no such API endpoint.',
41
+ methodNotAllowed: 'This endpoint does not support the method.',
42
+ payloadTooLarge: 'The request body is too large.',
43
+ failed: 'The request failed.',
44
+ forbidden: 'Cross-site form submissions are forbidden. Send JSON with "Content-Type: application/json".',
45
+ internal: 'Something went wrong on our side.',
46
+ acceptTerms: (terms) => `Send "accept_terms": true to accept the terms (${terms}) on behalf of your business.`,
47
+ actingForBusiness: 'Send "acting_for_business": true to state that you sign up for your business: we serve businesses only.',
48
+ via: 'via is the referral tag of the link you came by: small letters and digits, joined by single hyphens, at most 32 characters.',
49
+ firstKey: 'Created at sign-up',
50
+ price: 'price must be the lookup key of a price of the product.',
51
+ quantity: (max) => `quantity must be a whole number from 1 to ${max}.`,
52
+ subscribed: 'The account has a running subscription; change it in the customer portal (POST /api/v1/billing/portal).',
53
+ noCustomer: 'The account has paid for nothing yet, so it has no customer portal.'
54
+ },
55
+ stripe: {
56
+ terms: (name, url) => `I accept the [terms of ${name}](${url}) on behalf of my business.`,
57
+ signature: 'The signature of the webhook does not fit.',
58
+ notConfigured: 'Stripe is not configured.'
59
+ },
60
+ sales: { unauthorized: 'Send the token for the sales numbers as "Authorization: Bearer …".' },
61
+ mails: {
62
+ verify: ({ name, origin, link, hours }) => ({
63
+ subject: `Confirm your email address for ${name}`,
64
+ text: `Please confirm your email address for your account at ${name}:\n\n${link}\n\n` +
65
+ `The link is valid for ${hours} hours. If you did not create this account, ignore this email.` +
66
+ signature(name, origin)
67
+ }),
68
+ reset: ({ name, origin, link, hours }) => ({
69
+ subject: `Set a new password for ${name}`,
70
+ text: `Set a new password for your account at ${name}:\n\n${link}\n\n` +
71
+ `The link is valid for ${hours === 1 ? 'one hour' : `${hours} hours`} and works once. If you did not ask for it, ` +
72
+ 'ignore this email; your password stays as it is.' +
73
+ signature(name, origin)
74
+ }),
75
+ change: ({ name, origin, link, hours }) => ({
76
+ subject: `Confirm your new email address for ${name}`,
77
+ text: `Confirm that this address becomes the login address of your account at ${name}:\n\n` +
78
+ `${link}\n\nThe link is valid for ${hours} hours. If you did not ask for it, ignore this email.` +
79
+ signature(name, origin)
80
+ }),
81
+ changed: ({ name, origin, newEmail }) => ({
82
+ subject: `Your email address for ${name} was changed`,
83
+ text: `The login address of your account at ${name} is now ${newEmail}.\n\n` +
84
+ 'If you did not do this, reply to this email right away.' +
85
+ signature(name, origin)
86
+ })
87
+ },
88
+ openApi: {
89
+ signup: 'Create an account and get its first API key at once',
90
+ signupCreated: 'The account and its first API key, shown only here. A link to confirm the email address goes to it.',
91
+ account: 'The account of the API key',
92
+ email: 'The email address of the account.',
93
+ acceptTerms: 'Accepts the terms on behalf of the business that signs up.',
94
+ actingForBusiness: 'States that the account is for a business, not a consumer.',
95
+ via: 'The referral tag of the link the business came by, if any (?via=…).',
96
+ apiKey: 'An API key of the account as a bearer token.',
97
+ checkout: 'A Checkout page at Stripe for a price of the product',
98
+ checkoutBody: 'A subscription or a purchase; whoever pays accepts the terms there on behalf of the business. Back to /account.',
99
+ portal: 'The customer portal of the account at Stripe: payment method, invoices, subscription',
100
+ url: 'The address to open in a browser; it holds for a short while.',
101
+ price: 'The lookup key of a price of the product.',
102
+ quantity: 'For a subscription that counts a quantity, like seats; 1 without.',
103
+ errors: {
104
+ invalidJson: 'The body is not JSON (invalid_json).',
105
+ invalidRequest: 'The body is not valid (invalid_request); details name each problem.',
106
+ emailTaken: 'An account with this email address exists (email_taken).',
107
+ unauthorized: 'No valid API key (unauthorized).',
108
+ rateLimited: 'Too many requests (rate_limited); see the header Retry-After.',
109
+ forbidden: 'A form from another site (forbidden).',
110
+ subscribed: 'The account has a running subscription (subscribed).',
111
+ noCustomer: 'The account has paid for nothing yet (no_customer).',
112
+ notConfigured: 'Stripe is not configured (not_configured).'
113
+ }
114
+ }
115
+ };
116
+ const de = {
117
+ tooMany: (seconds) => {
118
+ const minutes = Math.max(1, Math.ceil(seconds / 60));
119
+ return `Zu viele Versuche. Versuchen Sie es in ${minutes} Minute${minutes === 1 ? '' : 'n'} wieder.`;
120
+ },
121
+ crossSite: 'Formulare von anderen Seiten sind nicht erlaubt.',
122
+ email: {
123
+ invalid: 'Geben Sie eine gültige E-Mail-Adresse ein.',
124
+ tooLong: 'Die E-Mail-Adresse ist zu lang.'
125
+ },
126
+ password: {
127
+ tooShort: 'Das Passwort braucht mindestens 10 Zeichen.',
128
+ tooLong: 'Das Passwort darf höchstens 200 Zeichen haben.'
129
+ },
130
+ signup: {
131
+ acceptTerms: 'Bitte akzeptieren Sie die Bedingungen, um sich zu registrieren.',
132
+ confirmBusiness: 'Bitte bestätigen Sie, dass Sie sich für Ihr Unternehmen registrieren: wir bedienen nur Unternehmen.',
133
+ taken: 'Zu dieser E-Mail-Adresse besteht schon ein Konto.'
134
+ },
135
+ login: { wrong: 'E-Mail-Adresse oder Passwort ist falsch.' },
136
+ link: { invalid: 'Dieser Link ist ungültig, schon benutzt oder abgelaufen.' },
137
+ mailFailed: 'Wir konnten die E-Mail nicht senden. Bitte versuchen Sie es gleich nochmals.',
138
+ account: {
139
+ sameEmail: 'Das ist schon Ihre Adresse.',
140
+ wrongPassword: 'Das Passwort ist falsch.',
141
+ wrongCurrent: 'Das aktuelle Passwort ist falsch.',
142
+ taken: 'Ein anderes Konto benutzt diese Adresse.',
143
+ confirmDelete: 'Geben Sie zur Bestätigung Ihre E-Mail-Adresse ein.',
144
+ keyName: 'API-Schlüssel',
145
+ confirmFirst: 'Bestätigen Sie zuerst Ihre E-Mail-Adresse; dann können Sie Schlüssel anlegen.'
146
+ },
147
+ api: {
148
+ unauthorized: (example) => `Senden Sie einen gültigen API-Schlüssel als "Authorization: Bearer ${example}".`,
149
+ rateLimited: (seconds) => `Zu viele Anfragen. Versuchen Sie es in ${seconds} Sekunden wieder.`,
150
+ invalidJson: 'Der Inhalt der Anfrage muss JSON sein.',
151
+ notAnObject: 'Der Inhalt der Anfrage muss ein JSON-Objekt sein.',
152
+ invalidRequest: 'Der Inhalt der Anfrage ist nicht gültig.',
153
+ emailTaken: 'Zu dieser E-Mail-Adresse besteht schon ein Konto.',
154
+ notFound: 'Diesen API-Endpunkt gibt es nicht.',
155
+ methodNotAllowed: 'Dieser Endpunkt kennt die Methode nicht.',
156
+ payloadTooLarge: 'Der Inhalt der Anfrage ist zu gross.',
157
+ failed: 'Die Anfrage ist fehlgeschlagen.',
158
+ forbidden: 'Formulare von anderen Seiten sind nicht erlaubt. Senden Sie JSON mit "Content-Type: application/json".',
159
+ internal: 'Bei uns ist etwas schiefgegangen.',
160
+ acceptTerms: (terms) => `Senden Sie "accept_terms": true, um die Bedingungen (${terms}) für Ihr Unternehmen zu akzeptieren.`,
161
+ actingForBusiness: 'Senden Sie "acting_for_business": true, um zu bestätigen, dass Sie sich für Ihr Unternehmen registrieren: wir bedienen nur Unternehmen.',
162
+ via: 'via ist die Kennung des Links, über den Sie kamen: kleine Buchstaben und Ziffern, mit einzelnen Bindestrichen verbunden, höchstens 32 Zeichen.',
163
+ firstKey: 'Bei der Registrierung angelegt',
164
+ price: 'price muss der lookup_key eines Preises des Produkts sein.',
165
+ quantity: (max) => `quantity muss eine ganze Zahl von 1 bis ${max} sein.`,
166
+ subscribed: 'Das Konto hat ein laufendes Abo; ändern Sie es im Kundenportal (POST /api/v1/billing/portal).',
167
+ noCustomer: 'Das Konto hat noch nichts bezahlt und darum kein Kundenportal.'
168
+ },
169
+ stripe: {
170
+ terms: (name, url) => `Ich akzeptiere die [Bedingungen von ${name}](${url}) im Namen meines Unternehmens.`,
171
+ signature: 'Die Signatur des Webhooks passt nicht.',
172
+ notConfigured: 'Stripe ist nicht eingerichtet.'
173
+ },
174
+ sales: {
175
+ unauthorized: 'Senden Sie das Token für die Zahlen des Vertriebs als "Authorization: Bearer …".'
176
+ },
177
+ mails: {
178
+ verify: ({ name, origin, link, hours }) => ({
179
+ subject: `Bestätigen Sie Ihre E-Mail-Adresse für ${name}`,
180
+ text: `Bitte bestätigen Sie Ihre E-Mail-Adresse für Ihr Konto bei ${name}:\n\n${link}\n\n` +
181
+ `Der Link gilt ${hours} Stunden. Falls Sie dieses Konto nicht eröffnet haben, ignorieren Sie diese E-Mail.` +
182
+ signature(name, origin)
183
+ }),
184
+ reset: ({ name, origin, link, hours }) => ({
185
+ subject: `Neues Passwort für ${name}`,
186
+ text: `Setzen Sie ein neues Passwort für Ihr Konto bei ${name}:\n\n${link}\n\n` +
187
+ `Der Link gilt ${hours === 1 ? 'eine Stunde' : `${hours} Stunden`} und funktioniert einmal. Falls Sie ihn nicht ` +
188
+ 'angefordert haben, ignorieren Sie diese E-Mail; Ihr Passwort bleibt, wie es ist.' +
189
+ signature(name, origin)
190
+ }),
191
+ change: ({ name, origin, link, hours }) => ({
192
+ subject: `Bestätigen Sie Ihre neue E-Mail-Adresse für ${name}`,
193
+ text: `Bestätigen Sie, dass diese Adresse die Anmeldeadresse Ihres Kontos bei ${name} wird:\n\n` +
194
+ `${link}\n\nDer Link gilt ${hours} Stunden. Falls Sie ihn nicht angefordert haben, ignorieren Sie diese E-Mail.` +
195
+ signature(name, origin)
196
+ }),
197
+ changed: ({ name, origin, newEmail }) => ({
198
+ subject: `Ihre E-Mail-Adresse für ${name} wurde geändert`,
199
+ text: `Die Anmeldeadresse Ihres Kontos bei ${name} ist jetzt ${newEmail}.\n\n` +
200
+ 'Falls Sie das nicht waren, antworten Sie sofort auf diese E-Mail.' +
201
+ signature(name, origin)
202
+ })
203
+ },
204
+ openApi: {
205
+ signup: 'Ein Konto eröffnen und gleich seinen ersten API-Schlüssel erhalten',
206
+ signupCreated: 'Das Konto und sein erster API-Schlüssel, der nur hier erscheint. Ein Link zur Bestätigung der E-Mail-Adresse geht an sie.',
207
+ account: 'Das Konto des API-Schlüssels',
208
+ email: 'Die E-Mail-Adresse des Kontos.',
209
+ acceptTerms: 'Akzeptiert die Bedingungen für das Unternehmen, das sich registriert.',
210
+ actingForBusiness: 'Bestätigt, dass das Konto einem Unternehmen gehört, nicht einem Konsumenten.',
211
+ via: 'Die Kennung des Links, über den das Unternehmen kam, falls es eine gibt (?via=…).',
212
+ apiKey: 'Ein API-Schlüssel des Kontos als Bearer-Token.',
213
+ checkout: 'Eine Checkout-Seite bei Stripe für einen Preis des Produkts',
214
+ checkoutBody: 'Ein Abo oder ein Einmalkauf; wer zahlt, akzeptiert dort die Bedingungen für das Unternehmen. Zurück nach /account.',
215
+ portal: 'Das Kundenportal des Kontos bei Stripe: Zahlungsmittel, Rechnungen, Abo',
216
+ url: 'Die Adresse zum Öffnen im Browser; sie gilt eine kurze Weile.',
217
+ price: 'Der lookup_key eines Preises des Produkts.',
218
+ quantity: 'Für ein Abo mit einer Menge, etwa Plätzen; ohne 1.',
219
+ errors: {
220
+ invalidJson: 'Der Inhalt ist kein JSON (invalid_json).',
221
+ invalidRequest: 'Der Inhalt ist nicht gültig (invalid_request); details nennt jedes Problem.',
222
+ emailTaken: 'Zu dieser E-Mail-Adresse besteht ein Konto (email_taken).',
223
+ unauthorized: 'Kein gültiger API-Schlüssel (unauthorized).',
224
+ rateLimited: 'Zu viele Anfragen (rate_limited); siehe die Kopfzeile Retry-After.',
225
+ forbidden: 'Ein Formular von einer anderen Seite (forbidden).',
226
+ subscribed: 'Das Konto hat ein laufendes Abo (subscribed).',
227
+ noCustomer: 'Das Konto hat noch nichts bezahlt (no_customer).',
228
+ notConfigured: 'Stripe ist nicht eingerichtet (not_configured).'
229
+ }
230
+ }
231
+ };
232
+ export const MESSAGES = { en, de };
@@ -0,0 +1,28 @@
1
+ export type Layer = 'baukasten' | 'product';
2
+ export interface MigrationFile {
3
+ name: string;
4
+ sql: string;
5
+ checksum: string;
6
+ }
7
+ export interface AppliedMigration {
8
+ name: string;
9
+ checksum: string;
10
+ }
11
+ export interface Plan {
12
+ pending: MigrationFile[];
13
+ /** Applied migrations of a newer version, all after the files of this one. */
14
+ ahead: string[];
15
+ problems: string[];
16
+ }
17
+ /** What a database still needs of this version, as `layer/name`, and why it must not be migrated. */
18
+ export interface MigrationStatus {
19
+ pending: string[];
20
+ problems: string[];
21
+ }
22
+ /** The migrations among the files of a folder, in order; other files are no concern of theirs. */
23
+ export declare function migrationFiles(layer: Layer, entries: readonly {
24
+ name: string;
25
+ sql: string;
26
+ }[]): MigrationFile[];
27
+ /** What to apply, what the database has ahead, and the problems that forbid applying anything. */
28
+ export declare function plan(layer: Layer, files: readonly MigrationFile[], applied: readonly AppliedMigration[]): Plan;
@@ -0,0 +1,59 @@
1
+ // Forward-only SQL migrations in two layers: first that of the baukasten (schema `baukasten`), then
2
+ // that of the product (its `migrations/`, schema `public`). A file changed after it was applied
3
+ // stops the run, and so does a gap. A database ahead of this version, with later migrations of a
4
+ // newer one, is fine: the delivery service falls back to the previous version when a newer one
5
+ // starts unhealthy, so a migration only adds, and the previous version runs against the newer
6
+ // schema.
7
+ import { createHash } from 'node:crypto';
8
+ const NAME = /^(\d{3})_[a-z0-9_]+\.sql$/;
9
+ /** The migrations among the files of a folder, in order; other files are no concern of theirs. */
10
+ export function migrationFiles(layer, entries) {
11
+ const numbers = new Set();
12
+ const files = entries
13
+ .filter((entry) => entry.name.endsWith('.sql'))
14
+ .map((entry) => {
15
+ const number = NAME.exec(entry.name)?.[1];
16
+ if (number === undefined) {
17
+ throw new Error(`${layer} migrations are named like 001_name.sql: ${entry.name}`);
18
+ }
19
+ if (numbers.has(number))
20
+ throw new Error(`two ${layer} migrations share the number ${number}`);
21
+ numbers.add(number);
22
+ return {
23
+ name: entry.name,
24
+ sql: entry.sql,
25
+ checksum: createHash('sha256').update(entry.sql).digest('hex')
26
+ };
27
+ });
28
+ return files.sort((a, b) => (a.name < b.name ? -1 : 1));
29
+ }
30
+ /** What to apply, what the database has ahead, and the problems that forbid applying anything. */
31
+ export function plan(layer, files, applied) {
32
+ const done = new Map(applied.map((row) => [row.name, row.checksum]));
33
+ const known = new Set(files.map((file) => file.name));
34
+ const names = applied.map((row) => row.name).sort();
35
+ const changed = files
36
+ .filter((file) => done.has(file.name) && done.get(file.name) !== file.checksum)
37
+ .map((file) => `${layer} migration ${file.name} was changed after it was applied`);
38
+ const pending = files.filter((file) => !done.has(file.name));
39
+ const gaps = pending.flatMap((file) => {
40
+ const later = names.find((name) => name > file.name);
41
+ if (later === undefined)
42
+ return [];
43
+ return [`${layer} migration ${file.name} is not applied, but the later ${later} is`];
44
+ });
45
+ const unknown = names.filter((name) => !known.has(name));
46
+ const diverged = unknown.flatMap((name) => {
47
+ const own = files.find((file) => file.name > name);
48
+ if (own === undefined)
49
+ return [];
50
+ return [
51
+ `the database carries the ${layer} migration ${name}, which this version does not know, before ${own.name}, which it does`
52
+ ];
53
+ });
54
+ return {
55
+ pending,
56
+ ahead: unknown.filter((name) => files.every((file) => file.name < name)),
57
+ problems: [...changed, ...gaps, ...diverged]
58
+ };
59
+ }
@@ -0,0 +1,24 @@
1
+ import type { Messages } from './messages.js';
2
+ export interface OpenApiPart {
3
+ paths: Record<string, unknown>;
4
+ components: {
5
+ schemas: Record<string, unknown>;
6
+ securitySchemes: Record<string, unknown>;
7
+ };
8
+ }
9
+ /** An OpenAPI document as far as the merge reads it; the rest is the product's. */
10
+ export interface OpenApiDocument {
11
+ paths?: Record<string, unknown>;
12
+ components?: {
13
+ schemas?: Record<string, unknown>;
14
+ securitySchemes?: Record<string, unknown>;
15
+ [key: string]: unknown;
16
+ };
17
+ [key: string]: unknown;
18
+ }
19
+ export declare function openApiPart(origin: string, messages: Messages): OpenApiPart;
20
+ /**
21
+ * `document` with the part of the baukasten. The product's own paths use its security scheme
22
+ * `apiKey` and its schema `Error`; a name both define is an error, not an override.
23
+ */
24
+ export declare function mergeOpenApi(document: OpenApiDocument, part: OpenApiPart): OpenApiDocument;