@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,273 @@
1
+ import type pg from 'pg';
2
+ /** Anything that runs SQL: the pool, or one transaction. */
3
+ export interface Queryable {
4
+ query<T extends pg.QueryResultRow = pg.QueryResultRow>(sql: string, params?: unknown[]): Promise<T[]>;
5
+ }
6
+ /**
7
+ * The pool of a product, shared by the baukasten and the product. Neon suspends when no connection
8
+ * is open, so a connection goes back after a short idle time, and nothing opens one without work.
9
+ */
10
+ export interface Db extends Queryable {
11
+ /** Runs `fn` in one transaction: it commits when `fn` resolves and rolls back when it throws. */
12
+ transaction<T>(fn: (tx: Queryable) => Promise<T>): Promise<T>;
13
+ /**
14
+ * The same pool for queries that must end within `ms`: a query that runs out fails, and its
15
+ * connection is dropped instead of waiting for a database that hangs.
16
+ */
17
+ withTimeout(ms: number): Queryable;
18
+ /** Physical connections the pool has opened since it was created. */
19
+ connectionsOpened(): number;
20
+ close(): Promise<void>;
21
+ }
22
+ /** Randomness and password hashing for accounts; SHA-256 of a token needs no port. */
23
+ export interface Secrets {
24
+ /** A fresh random secret for a session, a link or a key: 32 bytes, base64url, 43 characters. */
25
+ token(): string;
26
+ /** A slow, salted hash of a password, with its parameters, for the database. */
27
+ hashPassword(password: string): Promise<string>;
28
+ /** Whether the password fits a hash of hashPassword; as slow as hashing, whatever the answer. */
29
+ verifyPassword(password: string, stored: string): Promise<boolean>;
30
+ }
31
+ /** A mail of the product: plain text, from MAIL_FROM with MAIL_FROM_NAME. */
32
+ export interface Mail {
33
+ to: string;
34
+ subject: string;
35
+ text: string;
36
+ }
37
+ export interface Mailer {
38
+ /**
39
+ * Sends one mail through the relay of the fleet, or, locally without SMTP, writes it with its
40
+ * links to the log. A failure throws MailFailed (mail.ts), whose message never names an address.
41
+ */
42
+ send(mail: Mail): Promise<void>;
43
+ /**
44
+ * Whether the relay greets: a connection and EHLO, without a login and without a mail, for
45
+ * /health/deep; MailFailed as `send`. Without SMTP there is nothing to check.
46
+ */
47
+ check(): Promise<void>;
48
+ }
49
+ /** A product at Stripe as the catalog compares it. */
50
+ export interface StripeProduct {
51
+ id: string;
52
+ name: string;
53
+ active: boolean;
54
+ }
55
+ /** An active price at Stripe: once, or per interval. */
56
+ export type StripePrice = {
57
+ id: string;
58
+ product: string;
59
+ /** In the smallest unit of the currency; null for a price that is not a fixed amount. */
60
+ amount: number | null;
61
+ /** Small letters, as Stripe writes it. */
62
+ currency: string;
63
+ } & ({
64
+ type: 'one_time';
65
+ } | {
66
+ type: 'recurring';
67
+ interval: string;
68
+ intervalCount: number;
69
+ });
70
+ /** A new price that takes over the lookup key from the price that held it. */
71
+ export type NewPrice = {
72
+ product: string;
73
+ lookupKey: string;
74
+ amount: number;
75
+ currency: string;
76
+ } & ({
77
+ type: 'one_time';
78
+ } | {
79
+ type: 'recurring';
80
+ interval: 'month' | 'year';
81
+ });
82
+ /** What the baukasten sets of the default configuration of the customer portal, as the API takes it. */
83
+ export interface PortalConfiguration {
84
+ business_profile: {
85
+ headline: string;
86
+ privacy_policy_url: string;
87
+ terms_of_service_url: string;
88
+ };
89
+ default_return_url: string;
90
+ features: {
91
+ customer_update: {
92
+ enabled: true;
93
+ allowed_updates: ('address' | 'name' | 'tax_id')[];
94
+ };
95
+ invoice_history: {
96
+ enabled: true;
97
+ };
98
+ payment_method_update: {
99
+ enabled: true;
100
+ };
101
+ subscription_cancel: {
102
+ enabled: true;
103
+ mode: 'at_period_end';
104
+ proration_behavior: 'none';
105
+ };
106
+ subscription_update: {
107
+ enabled: false;
108
+ } | {
109
+ enabled: true;
110
+ default_allowed_updates: ('price' | 'quantity')[];
111
+ proration_behavior: 'create_prorations';
112
+ products: {
113
+ product: string;
114
+ prices: string[];
115
+ adjustable_quantity: {
116
+ enabled: false;
117
+ } | {
118
+ enabled: true;
119
+ minimum: number;
120
+ maximum: number;
121
+ };
122
+ }[];
123
+ };
124
+ };
125
+ }
126
+ export type SubscriptionStatus = 'incomplete' | 'incomplete_expired' | 'trialing' | 'active' | 'past_due' | 'canceled' | 'unpaid' | 'paused';
127
+ /** A subscription at Stripe with its one item. */
128
+ export interface StripeSubscription {
129
+ id: string;
130
+ customerId: string;
131
+ status: SubscriptionStatus;
132
+ product: string;
133
+ /** The price, and its lookup key; a price that gave its key to a newer one has none. */
134
+ price: {
135
+ id: string;
136
+ lookupKey: string | null;
137
+ };
138
+ quantity: number;
139
+ /** The last invoice of the subscription; a new one has its first. */
140
+ latestInvoice: string | null;
141
+ }
142
+ /** A Checkout page for one price; the customer accepts the terms there. */
143
+ export type CheckoutRequest = {
144
+ customerId: string;
145
+ accountId: string;
146
+ priceId: string;
147
+ successUrl: string;
148
+ cancelUrl: string;
149
+ /** The sentence by the box for the terms, in Markdown with the link to them. */
150
+ termsMessage: string;
151
+ expiresAt: Date;
152
+ } & ({
153
+ mode: 'subscription';
154
+ quantity: number;
155
+ /** Whether the customer may change the quantity in Checkout, and up to how many. */
156
+ adjustable: {
157
+ kind: 'fixed';
158
+ } | {
159
+ kind: 'up to';
160
+ max: number;
161
+ };
162
+ } | {
163
+ mode: 'payment';
164
+ product: string;
165
+ lookupKey: string;
166
+ });
167
+ /** A Checkout session of the webhook: of a subscription, of a purchase of this product, or other. */
168
+ export type CheckoutSession = {
169
+ mode: 'subscription';
170
+ id: string;
171
+ customerId: string;
172
+ subscriptionId: string;
173
+ } | {
174
+ mode: 'payment';
175
+ id: string;
176
+ customerId: string;
177
+ paid: boolean;
178
+ amount: number;
179
+ currency: string;
180
+ /** The product and the lookup key of the price, as the Checkout of the baukasten names them. */
181
+ product: string;
182
+ lookupKey: string;
183
+ } | {
184
+ mode: 'other';
185
+ id: string;
186
+ };
187
+ export type StripeEvent = {
188
+ id: string;
189
+ type: string;
190
+ created: Date;
191
+ } & ({
192
+ kind: 'checkout';
193
+ session: CheckoutSession;
194
+ } | {
195
+ kind: 'subscription';
196
+ subscriptionId: string;
197
+ customerId: string;
198
+ } | {
199
+ kind: 'invoice';
200
+ invoice: {
201
+ id: string;
202
+ customerId: string;
203
+ amount: number;
204
+ currency: string;
205
+ paidAt: Date;
206
+ };
207
+ } | {
208
+ kind: 'other';
209
+ });
210
+ /**
211
+ * Stripe, with the key of the product. A failure throws StripeFailed (billing.ts), whose message
212
+ * names what Stripe called it and never the key.
213
+ */
214
+ export interface StripeApi {
215
+ /** The product of this id, archived or not; null when there is none. */
216
+ product(id: string): Promise<StripeProduct | null>;
217
+ createProduct(product: {
218
+ id: string;
219
+ name: string;
220
+ }): Promise<void>;
221
+ /** Sets the name and makes the product active again. */
222
+ updateProduct(product: {
223
+ id: string;
224
+ name: string;
225
+ }): Promise<void>;
226
+ /** The active price of the lookup key; null when no active price holds it. */
227
+ activePrice(lookupKey: string): Promise<StripePrice | null>;
228
+ /** Returns the id of the new price. */
229
+ createPrice(price: NewPrice): Promise<string>;
230
+ /**
231
+ * The default configuration of the customer portal as Stripe shows it, with the products of its
232
+ * subscription updates; null while the Dashboard never saved one in this mode.
233
+ */
234
+ defaultPortal(): Promise<{
235
+ id: string;
236
+ configuration: unknown;
237
+ } | null>;
238
+ updatePortal(id: string, configuration: PortalConfiguration): Promise<void>;
239
+ webhookEndpoints(): Promise<{
240
+ id: string;
241
+ url: string;
242
+ enabled: boolean;
243
+ }[]>;
244
+ /** Creates a customer with this address, marked with the account; returns its id. */
245
+ createCustomer(customer: {
246
+ email: string;
247
+ accountId: string;
248
+ }): Promise<string>;
249
+ updateCustomerEmail(customerId: string, email: string): Promise<void>;
250
+ /** Every subscription of the customer, ended or not, with its status. */
251
+ subscriptionsOf(customerId: string): Promise<{
252
+ id: string;
253
+ status: SubscriptionStatus;
254
+ }[]>;
255
+ subscription(id: string): Promise<StripeSubscription>;
256
+ /** Ends a subscription at once; `missing` when Stripe does not know it. */
257
+ cancelSubscription(id: string): Promise<'canceled' | 'missing'>;
258
+ /** Returns the address of the Checkout page. */
259
+ createCheckout(request: CheckoutRequest): Promise<string>;
260
+ /** Returns the address of the customer portal. */
261
+ createPortalSession(session: {
262
+ customerId: string;
263
+ configurationId: string;
264
+ returnUrl: string;
265
+ }): Promise<string>;
266
+ /** The event of a webhook request, when its signature fits the secret of the webhook. */
267
+ event(payload: string, signature: string | null): {
268
+ kind: 'event';
269
+ event: StripeEvent;
270
+ } | {
271
+ kind: 'invalid';
272
+ };
273
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ import type { Log } from './log.js';
2
+ import type { Db } from './ports.js';
3
+ export interface Run {
4
+ /** There is work: the next beat calls the work of the run. */
5
+ mark(): void;
6
+ /** Beats every `everyMs` from now on; the timer keeps no process alive. */
7
+ start(context: {
8
+ db: Db;
9
+ log: Log;
10
+ }): void;
11
+ /** Stops the beats and waits for a beat that is still running. */
12
+ stop(): Promise<void>;
13
+ }
14
+ export interface RunOptions {
15
+ name: string;
16
+ everyMs: number;
17
+ /** The work of a beat with the pool and the log of the product. */
18
+ work: (context: {
19
+ db: Db;
20
+ log: Log;
21
+ }) => Promise<void>;
22
+ }
23
+ export declare function periodic(options: RunOptions): Run;
24
+ /**
25
+ * The run with its single beat, which the timer calls: without a mark, or while the last beat still
26
+ * runs, a beat returns at once. The tests beat by hand.
27
+ */
28
+ export declare function beating(options: RunOptions): Run & {
29
+ tick(): Promise<void>;
30
+ };
@@ -0,0 +1,55 @@
1
+ export function periodic(options) {
2
+ return beating(options);
3
+ }
4
+ /**
5
+ * The run with its single beat, which the timer calls: without a mark, or while the last beat still
6
+ * runs, a beat returns at once. The tests beat by hand.
7
+ */
8
+ export function beating(options) {
9
+ const { name, everyMs, work } = options;
10
+ let state = { kind: 'waiting' };
11
+ let marked = false;
12
+ let running = null;
13
+ async function tick() {
14
+ if (state.kind === 'waiting')
15
+ throw new Error(`run ${name} beats before it was started`);
16
+ if (state.kind === 'stopped')
17
+ throw new Error(`run ${name} beats after it was stopped`);
18
+ if (running !== null || !marked)
19
+ return;
20
+ const { db, log } = state;
21
+ // Cleared before the work: a mark set while it runs calls it again at the next beat.
22
+ marked = false;
23
+ running = work({ db, log }).catch((error) => {
24
+ marked = true;
25
+ log.error('run failed, it tries again', { run: name, error });
26
+ });
27
+ try {
28
+ await running;
29
+ }
30
+ finally {
31
+ running = null;
32
+ }
33
+ }
34
+ return {
35
+ mark() {
36
+ marked = true;
37
+ },
38
+ tick,
39
+ start({ db, log }) {
40
+ if (state.kind !== 'waiting')
41
+ throw new Error(`run ${name} was started before`);
42
+ const timer = setInterval(() => {
43
+ void tick();
44
+ }, everyMs);
45
+ timer.unref();
46
+ state = { kind: 'started', db, log, timer };
47
+ },
48
+ async stop() {
49
+ if (state.kind === 'started')
50
+ clearInterval(state.timer);
51
+ state = { kind: 'stopped' };
52
+ await running;
53
+ }
54
+ };
55
+ }
@@ -0,0 +1,38 @@
1
+ import type { Log } from './log.js';
2
+ import type { Queryable } from './ports.js';
3
+ export declare const SALES_PATH = "/vertrieb/zahlen";
4
+ /** The numbers of every referral tag, in this order. */
5
+ export declare const SALES_NUMBERS: readonly ["Konten", "Zahlende"];
6
+ export declare const KENNUNGEN_MAX = 500;
7
+ export interface SalesNumbers {
8
+ stand: string;
9
+ je_kennung: {
10
+ kennung: string;
11
+ werte: {
12
+ name: string;
13
+ wert: number;
14
+ }[];
15
+ /** In the smallest unit of the currency; null without revenue. */
16
+ umsatz: {
17
+ cent: number;
18
+ waehrung: string;
19
+ } | null;
20
+ }[];
21
+ }
22
+ /** Numbers of the product by their name, each per account id. */
23
+ export type ProductNumbers = Readonly<Record<string, Readonly<Record<string, number>>>>;
24
+ /**
25
+ * Whether the request carries the bearer token. Both sides are hashed to the same length first, so
26
+ * the comparison takes the same time whatever was sent.
27
+ */
28
+ export declare function bearerFits(authorization: string | null, token: string): boolean;
29
+ /** The numbers of the product, checked: what does not fit would spoil the whole answer. */
30
+ export declare function productNumbers(numbers: unknown): ProductNumbers;
31
+ export declare function salesNumbers(db: Queryable, options: {
32
+ currency: string;
33
+ now: Date;
34
+ own: ProductNumbers;
35
+ /** With the live key only the accounts of the live mode count: the others are tests. */
36
+ count: 'all' | 'live';
37
+ log: Log;
38
+ }): Promise<SalesNumbers>;
@@ -0,0 +1,132 @@
1
+ // GET /vertrieb/zahlen answers in the form every product promises the delivery service, which
2
+ // sales.db.test.ts holds.
3
+ import { createHash, timingSafeEqual } from 'node:crypto';
4
+ import { z } from 'zod';
5
+ import { NO_VIA } from './via.js';
6
+ export const SALES_PATH = '/vertrieb/zahlen';
7
+ /** The numbers of every referral tag, in this order. */
8
+ export const SALES_NUMBERS = ['Konten', 'Zahlende'];
9
+ /** The delivery service takes at most this many numbers per referral tag. */
10
+ const NUMBERS_MAX = 20;
11
+ export const KENNUNGEN_MAX = 500;
12
+ const reserved = new Set(SALES_NUMBERS);
13
+ /** A name as it is shown; Postgres takes neither NUL nor a lone surrogate in JSONB. */
14
+ const NAME = z
15
+ .string()
16
+ .min(1, 'is empty')
17
+ .max(60, 'is longer than 60 characters')
18
+ .regex(/^\S(?:.*\S)?$/u, 'starts or ends with a blank')
19
+ .regex(/^[^\p{Cc}\p{Cs}]+$/u, 'carries a control character')
20
+ .refine((name) => !reserved.has(name), 'is a number of the baukasten');
21
+ const PRODUCT_NUMBERS = z
22
+ .record(z.string(), z.record(z.uuid(), z.int().min(0)))
23
+ .superRefine((numbers, ctx) => {
24
+ const names = Object.keys(numbers);
25
+ if (names.length > NUMBERS_MAX - SALES_NUMBERS.length) {
26
+ ctx.addIssue({
27
+ code: 'custom',
28
+ message: `names more than ${NUMBERS_MAX - SALES_NUMBERS.length} numbers`
29
+ });
30
+ }
31
+ for (const name of names) {
32
+ for (const issue of NAME.safeParse(name).error?.issues ?? []) {
33
+ ctx.addIssue({ code: 'custom', path: [name], message: issue.message });
34
+ }
35
+ }
36
+ });
37
+ /**
38
+ * Whether the request carries the bearer token. Both sides are hashed to the same length first, so
39
+ * the comparison takes the same time whatever was sent.
40
+ */
41
+ export function bearerFits(authorization, token) {
42
+ const given = /^Bearer (\S+)$/.exec(authorization ?? '')?.[1] ?? '';
43
+ const digest = (value) => createHash('sha256').update(value).digest();
44
+ return timingSafeEqual(digest(given), digest(token));
45
+ }
46
+ /** The numbers of the product, checked: what does not fit would spoil the whole answer. */
47
+ export function productNumbers(numbers) {
48
+ const read = PRODUCT_NUMBERS.safeParse(numbers);
49
+ if (!read.success) {
50
+ const problems = read.error.issues.map((issue) => `${issue.path.join('.')} ${issue.message}`);
51
+ throw new Error(`the sales numbers of the product: ${problems.join('; ')}`);
52
+ }
53
+ return read.data;
54
+ }
55
+ /** First what brings money, then by the referral tag. */
56
+ function byYield(a, b) {
57
+ const [aKonten = 0, aZahlende = 0] = a.values;
58
+ const [bKonten = 0, bZahlende = 0] = b.values;
59
+ return (bZahlende - aZahlende ||
60
+ b.cent - a.cent ||
61
+ bKonten - aKonten ||
62
+ (a.kennung < b.kennung ? -1 : a.kennung > b.kennung ? 1 : 0));
63
+ }
64
+ /**
65
+ * At most KENNUNGEN_MAX referral tags: the delivery service takes no more in one answer, and a tag
66
+ * is what a visitor writes into a link. The rest counts under ohne, so that every account and every
67
+ * franc is still in the answer.
68
+ */
69
+ function withinLimit(counted, log) {
70
+ if (counted.length <= KENNUNGEN_MAX)
71
+ return counted;
72
+ const others = counted.filter((entry) => entry.kennung !== NO_VIA);
73
+ const kept = others.slice(0, KENNUNGEN_MAX - 1);
74
+ const folded = others.slice(KENNUNGEN_MAX - 1);
75
+ const without = counted.find((entry) => entry.kennung === NO_VIA);
76
+ const width = counted[0]?.values.length ?? 0;
77
+ const merged = [...folded, ...(without === undefined ? [] : [without])].reduce((sum, entry) => ({
78
+ kennung: NO_VIA,
79
+ values: sum.values.map((value, i) => value + (entry.values[i] ?? 0)),
80
+ cent: sum.cent + entry.cent
81
+ }), { kennung: NO_VIA, values: Array(width).fill(0), cent: 0 });
82
+ log.warn('sales: more Kennungen than the delivery service takes; the rest counted under ohne', {
83
+ folded: folded.length
84
+ });
85
+ return [...kept, merged].sort(byYield);
86
+ }
87
+ export async function salesNumbers(db, options) {
88
+ const names = Object.keys(options.own);
89
+ const own = Object.entries(options.own).flatMap(([name, perAccount]) => Object.entries(perAccount).map(([account, value]) => ({ name, account, value })));
90
+ const live = options.count === 'live';
91
+ const [accounts, revenue, counted] = await Promise.all([
92
+ db.query(`SELECT a.via, count(*)::int AS konten,
93
+ count(*) FILTER (WHERE
94
+ EXISTS (SELECT 1 FROM baukasten.invoices i WHERE i.account_id = a.id AND i.amount > 0)
95
+ OR EXISTS (SELECT 1 FROM baukasten.purchases p WHERE p.account_id = a.id AND p.amount > 0)
96
+ )::int AS zahlende
97
+ FROM baukasten.accounts a WHERE (NOT $1 OR a.stripe_mode = 'live') GROUP BY a.via`, [live]),
98
+ db.query(`SELECT a.via, sum(paid.amount)::text AS cent FROM (
99
+ SELECT account_id, amount FROM baukasten.invoices WHERE currency = $1
100
+ UNION ALL SELECT account_id, amount FROM baukasten.purchases WHERE currency = $1
101
+ ) paid JOIN baukasten.accounts a ON a.id = paid.account_id
102
+ WHERE (NOT $2 OR a.stripe_mode = 'live') GROUP BY a.via`, [options.currency.toLowerCase(), live]),
103
+ db.query(`SELECT own.name, a.via, sum(own.value)::text AS value
104
+ FROM unnest($1::text[], $2::uuid[], $3::bigint[]) AS own (name, account, value)
105
+ JOIN baukasten.accounts a ON a.id = own.account
106
+ WHERE (NOT $4 OR a.stripe_mode = 'live') GROUP BY own.name, a.via`, [own.map((n) => n.name), own.map((n) => n.account), own.map((n) => n.value), live])
107
+ ]);
108
+ const cents = new Map(revenue.map((row) => [row.via, Number(row.cent)]));
109
+ const values = new Map(counted.map((row) => [`${row.name}\n${row.via}`, Number(row.value)]));
110
+ const all = accounts
111
+ .map((row) => ({
112
+ kennung: row.via,
113
+ values: [
114
+ row.konten,
115
+ row.zahlende,
116
+ // The product counted each of its accounts: one it does not name has none.
117
+ ...names.map((name) => values.get(`${name}\n${row.via}`) ?? 0)
118
+ ],
119
+ // A referral tag without a payment in the currency has no row of revenue.
120
+ cent: cents.get(row.via) ?? 0
121
+ }))
122
+ .sort(byYield);
123
+ const numbers = [...SALES_NUMBERS, ...names];
124
+ return {
125
+ stand: options.now.toISOString(),
126
+ je_kennung: withinLimit(all, options.log).map((entry) => ({
127
+ kennung: entry.kennung,
128
+ werte: numbers.map((name, i) => ({ name, wert: entry.values[i] ?? 0 })),
129
+ umsatz: entry.cent > 0 ? { cent: entry.cent, waehrung: options.currency } : null
130
+ }))
131
+ };
132
+ }
@@ -0,0 +1,57 @@
1
+ import type { Product } from '../product.js';
2
+ import { type Accounts, type DeleteHook, type SignUp } from './accounts.js';
3
+ import type { Billing } from './billing.js';
4
+ import type { Config } from './config.js';
5
+ import type { LegalPages } from './legal/index.js';
6
+ import { type Limits } from './limits.js';
7
+ import type { Log } from './log.js';
8
+ import type { Messages } from './messages.js';
9
+ import type { Db, Mailer, Queryable } from './ports.js';
10
+ export interface Services {
11
+ config: Config;
12
+ product: Product;
13
+ db: Db;
14
+ log: Log;
15
+ mail: Mailer;
16
+ limits: Limits;
17
+ accounts: Accounts;
18
+ billing: Billing;
19
+ messages: Messages;
20
+ /** The name of the terms a sign-up accepts. */
21
+ termsVersion: string;
22
+ /** Imprint, privacy policy and terms, written once at the start. */
23
+ legal: LegalPages;
24
+ deleteHook: DeleteHook;
25
+ /** The numbers of the product for GET /vertrieb/zahlen, unchecked: they come from its code. */
26
+ salesNumbers: (db: Queryable) => Promise<unknown>;
27
+ address(event: {
28
+ request: Request;
29
+ getClientAddress(): string;
30
+ }): string;
31
+ now: () => Date;
32
+ }
33
+ export declare function signUp(s: Services, email: string, via: string | null): SignUp;
34
+ export type Sent = {
35
+ kind: 'sent';
36
+ } | {
37
+ kind: 'failed';
38
+ } | {
39
+ kind: 'limited';
40
+ seconds: number;
41
+ };
42
+ /**
43
+ * Sends the link to confirm the address, to set a new password, or to make `newEmail` the address,
44
+ * which gets the mail. Each recipient gets a few mails of the accounts an hour, whoever asks; the
45
+ * link is made only when the mail may go.
46
+ */
47
+ export declare function sendLink(s: Services, account: {
48
+ id: string;
49
+ email: string;
50
+ }, link: {
51
+ purpose: 'verify';
52
+ } | {
53
+ purpose: 'reset';
54
+ } | {
55
+ purpose: 'change_email';
56
+ newEmail: string;
57
+ }): Promise<Sent>;
@@ -0,0 +1,44 @@
1
+ import { LINK_SECONDS, sha256, normalizeEmail } from './accounts.js';
2
+ import { LIMITS } from './limits.js';
3
+ import { deliver } from './mail.js';
4
+ import { NO_VIA } from './via.js';
5
+ export function signUp(s, email, via) {
6
+ return {
7
+ email,
8
+ termsVersion: s.termsVersion,
9
+ via: via ?? NO_VIA,
10
+ // Without Stripe nothing moves money, as in the test mode.
11
+ stripeMode: s.config.stripe.kind === 'stripe' ? s.config.stripe.mode : 'test'
12
+ };
13
+ }
14
+ const PAGES = {
15
+ verify: '/verify-email',
16
+ reset: '/reset-password',
17
+ change_email: '/confirm-email'
18
+ };
19
+ /**
20
+ * Sends the link to confirm the address, to set a new password, or to make `newEmail` the address,
21
+ * which gets the mail. Each recipient gets a few mails of the accounts an hour, whoever asks; the
22
+ * link is made only when the mail may go.
23
+ */
24
+ export async function sendLink(s, account, link) {
25
+ const to = link.purpose === 'change_email' ? normalizeEmail(link.newEmail) : account.email;
26
+ const seconds = s.limits.take(`mails-to:${sha256(to).toString('hex')}`, LIMITS.mailsTo);
27
+ if (seconds > 0)
28
+ return { kind: 'limited', seconds };
29
+ const token = link.purpose === 'change_email'
30
+ ? await s.accounts.issueEmailChange(account.id, to)
31
+ : await s.accounts.issueLink(account.id, link.purpose);
32
+ const mail = {
33
+ verify: s.messages.mails.verify,
34
+ reset: s.messages.mails.reset,
35
+ change_email: s.messages.mails.change
36
+ }[link.purpose]({
37
+ name: s.config.name,
38
+ origin: s.config.publicOrigin,
39
+ link: `${s.config.publicOrigin}${PAGES[link.purpose]}?token=${token}`,
40
+ hours: LINK_SECONDS[link.purpose] / 3_600
41
+ });
42
+ const sent = await deliver(s.mail, s.log, { to, ...mail }, { kind: link.purpose, account: account.id });
43
+ return sent ? { kind: 'sent' } : { kind: 'failed' };
44
+ }
@@ -0,0 +1,14 @@
1
+ import { type IncomingMessage, type ServerResponse } from 'node:http';
2
+ export type NodeHandler = (req: IncomingMessage, res: ServerResponse) => void;
3
+ /**
4
+ * Open requests get this long after SIGTERM, the stop hooks of the product then this long; both end
5
+ * before the fixed start of the delivery service kills the product (stop-grace.test.ts).
6
+ */
7
+ export declare const DRAIN_MS = 15000;
8
+ export declare const STOP_MS = 10000;
9
+ export declare function start(load: () => Promise<{
10
+ handler: NodeHandler;
11
+ }>): Promise<void>;
12
+ export declare function serve(load: () => Promise<{
13
+ handler: NodeHandler;
14
+ }>, headers: Readonly<Record<string, string>>): Promise<void>;