@tollwyn/baukasten 1.2.2 → 1.3.0
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.
- package/dist/server/accounts.d.ts +6 -5
- package/dist/server/accounts.js +8 -6
- package/dist/server/adapter/migrate.js +16 -3
- package/dist/server/adapter/stripe.js +35 -12
- package/dist/server/baukasten-migrations.js +17 -0
- package/dist/server/billing.d.ts +2 -2
- package/dist/server/billing.js +83 -34
- package/dist/server/catalog.d.ts +44 -6
- package/dist/server/catalog.js +98 -79
- package/dist/server/config.d.ts +48 -5
- package/dist/server/config.js +94 -96
- package/dist/server/core.d.ts +88 -0
- package/dist/server/core.js +168 -0
- package/dist/server/health.js +2 -2
- package/dist/server/hook.d.ts +1 -1
- package/dist/server/hook.js +42 -177
- package/dist/server/mail.d.ts +4 -1
- package/dist/server/mail.js +8 -3
- package/dist/server/messages.d.ts +1 -0
- package/dist/server/messages.js +2 -0
- package/dist/server/pages.js +15 -10
- package/dist/server/ports.d.ts +22 -15
- package/dist/server/sales.js +4 -3
- package/dist/server/test-accounts.js +1 -1
- package/dist/shopify/config.js +21 -52
- package/dist/shopify/hook.d.ts +1 -1
- package/dist/shopify/hook.js +65 -188
- package/dist/testing/connection.js +0 -1
- package/dist/testing/env-example.js +3 -2
- package/dist/testing/shopify-connection.js +0 -1
- package/dist/testing/stripe.d.ts +2 -0
- package/dist/testing/stripe.js +35 -1
- package/dist/variables.d.ts +18 -9
- package/dist/variables.js +16 -7
- package/package.json +2 -2
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createDb } from './adapter/db.js';
|
|
2
|
+
import { exitAfterLog, log } from './adapter/log.js';
|
|
3
|
+
import { createMailer } from './adapter/mailer.js';
|
|
4
|
+
import { migrationSources, migrationStatus } from './adapter/migrate.js';
|
|
5
|
+
import { ConfigError } from './config.js';
|
|
6
|
+
import { CHECK_TIMEOUT_MS, databaseCheck, deepHealth, mailCheck } from './health.js';
|
|
7
|
+
import { createLimits } from './limits.js';
|
|
8
|
+
const HEALTH = '/health';
|
|
9
|
+
const DEEP = '/health/deep';
|
|
10
|
+
/** The queries of the database check end before the check does, and give their connection back. */
|
|
11
|
+
const PROBE_TIMEOUT_MS = CHECK_TIMEOUT_MS - 1_000;
|
|
12
|
+
export function json(body, status) {
|
|
13
|
+
return new Response(JSON.stringify(body), {
|
|
14
|
+
status,
|
|
15
|
+
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A page without its content security policy does not go out: the policy comes from `option` in
|
|
20
|
+
* sveltekit() of the product's vite.config.ts, and without it every page is a 500 that the delivery
|
|
21
|
+
* service sees, not a product that runs without the policy unnoticed. The log says it once.
|
|
22
|
+
*/
|
|
23
|
+
export function policyGuard(option, noun) {
|
|
24
|
+
let said = false;
|
|
25
|
+
return (response, path) => {
|
|
26
|
+
const page = response.headers.get('content-type')?.startsWith('text/html') === true;
|
|
27
|
+
if (!page || response.status >= 500 || response.headers.has('content-security-policy')) {
|
|
28
|
+
return response;
|
|
29
|
+
}
|
|
30
|
+
if (!said) {
|
|
31
|
+
said = true;
|
|
32
|
+
log.error(`a page without its content security policy: ${option} of the baukasten is missing in sveltekit() of vite.config.ts, or an endpoint of the ${noun} answers HTML of its own; every such answer is a 500`, { path });
|
|
33
|
+
}
|
|
34
|
+
return new Response('Internal Error', {
|
|
35
|
+
status: 500,
|
|
36
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function serverCore(spec) {
|
|
41
|
+
const own = spec.hooks.checks ?? {};
|
|
42
|
+
if ('database' in own || 'mail' in own || spec.check in own) {
|
|
43
|
+
throw new Error(`the checks database, mail and ${spec.check} belong to the baukasten`);
|
|
44
|
+
}
|
|
45
|
+
let state = { kind: 'waiting' };
|
|
46
|
+
function running() {
|
|
47
|
+
switch (state.kind) {
|
|
48
|
+
case 'running':
|
|
49
|
+
return state;
|
|
50
|
+
case 'building':
|
|
51
|
+
throw new Error(`the baukasten is used in the build: its pages read the configuration of the running ${spec.noun} and cannot be prerendered`);
|
|
52
|
+
case 'waiting':
|
|
53
|
+
throw new Error('the baukasten is used before init ran');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function stop(context, opened) {
|
|
57
|
+
await opened.runs?.stop();
|
|
58
|
+
try {
|
|
59
|
+
await spec.hooks.stop?.(context);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
log.error(`the stop hook of the ${spec.noun} failed`, { error });
|
|
64
|
+
}
|
|
65
|
+
await context.db.close();
|
|
66
|
+
log.info('stopped');
|
|
67
|
+
}
|
|
68
|
+
async function start() {
|
|
69
|
+
const root = process.cwd();
|
|
70
|
+
let config;
|
|
71
|
+
try {
|
|
72
|
+
config = spec.load(root);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (!(error instanceof ConfigError))
|
|
76
|
+
throw error;
|
|
77
|
+
log.error('configuration incomplete, not starting', { problems: error.problems });
|
|
78
|
+
return exitAfterLog(1);
|
|
79
|
+
}
|
|
80
|
+
if (config.missing.length > 0) {
|
|
81
|
+
log.warn('running without what production needs', { missing: config.missing });
|
|
82
|
+
}
|
|
83
|
+
const db = createDb(config.databaseUrl, log);
|
|
84
|
+
const mail = createMailer(config.mail, config.name, log);
|
|
85
|
+
const limits = createLimits({ now: () => Date.now() });
|
|
86
|
+
const context = { config, db, log, mail, limits };
|
|
87
|
+
const opened = spec.open(context);
|
|
88
|
+
const sources = migrationSources(root, spec.art);
|
|
89
|
+
const probe = db.withTimeout(PROBE_TIMEOUT_MS);
|
|
90
|
+
const checks = Object.fromEntries(Object.entries(own).map(([name, check]) => [name, () => check(context)]));
|
|
91
|
+
const health = deepHealth({
|
|
92
|
+
checks: {
|
|
93
|
+
database: databaseCheck(probe, () => migrationStatus(probe, sources), log),
|
|
94
|
+
mail: mailCheck(mail, log),
|
|
95
|
+
[spec.check]: opened.check,
|
|
96
|
+
...checks
|
|
97
|
+
},
|
|
98
|
+
now: () => Date.now(),
|
|
99
|
+
log
|
|
100
|
+
});
|
|
101
|
+
state = { kind: 'running', context, services: opened.services, health };
|
|
102
|
+
log.info('starting', {
|
|
103
|
+
mode: config.mode,
|
|
104
|
+
origin: config.publicOrigin,
|
|
105
|
+
language: config.language
|
|
106
|
+
});
|
|
107
|
+
opened.runs?.start();
|
|
108
|
+
await spec.hooks.start?.(context);
|
|
109
|
+
process.once('sveltekit:shutdown', () => void stop(context, opened));
|
|
110
|
+
}
|
|
111
|
+
const init = (building) => () => {
|
|
112
|
+
if (!building)
|
|
113
|
+
return start();
|
|
114
|
+
state = { kind: 'building' };
|
|
115
|
+
};
|
|
116
|
+
async function health(event) {
|
|
117
|
+
if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
|
|
118
|
+
return new Response(null, { status: 405, headers: { allow: 'GET, HEAD' } });
|
|
119
|
+
}
|
|
120
|
+
// Plain health answers without the database: Neon may sleep, the process still lives.
|
|
121
|
+
if (event.url.pathname === HEALTH)
|
|
122
|
+
return json({ status: 'ok' }, 200);
|
|
123
|
+
const report = await running().health();
|
|
124
|
+
return json(report, report.status === 'ok' ? 200 : 503);
|
|
125
|
+
}
|
|
126
|
+
const handle = async ({ event, resolve }) => {
|
|
127
|
+
// Prerendering in the build is no request of anyone.
|
|
128
|
+
if (state.kind === 'building')
|
|
129
|
+
return resolve(event);
|
|
130
|
+
const started = performance.now();
|
|
131
|
+
let status = 500;
|
|
132
|
+
try {
|
|
133
|
+
const path = event.url.pathname;
|
|
134
|
+
const response = path === HEALTH || path === DEEP
|
|
135
|
+
? await health(event)
|
|
136
|
+
: await spec.respond(running(), event, resolve);
|
|
137
|
+
for (const [name, value] of Object.entries(spec.headers)) {
|
|
138
|
+
response.headers.set(name, value);
|
|
139
|
+
}
|
|
140
|
+
status = response.status;
|
|
141
|
+
return response;
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
// The container polls /health; it is logged only at debug. The path goes without its query,
|
|
145
|
+
// which may carry a token.
|
|
146
|
+
(event.url.pathname === HEALTH ? log.debug : log.info)('request', {
|
|
147
|
+
method: event.request.method,
|
|
148
|
+
path: event.url.pathname,
|
|
149
|
+
status,
|
|
150
|
+
ms: Math.round(performance.now() - started),
|
|
151
|
+
...spec.logged?.(event)
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const handleError = ({ error, event, status, message }) => {
|
|
156
|
+
// An unknown path is already in the request log.
|
|
157
|
+
if (status === 404)
|
|
158
|
+
return { message };
|
|
159
|
+
(status >= 500 ? log.error : log.warn)('request failed', {
|
|
160
|
+
method: event.request.method,
|
|
161
|
+
path: event.url.pathname,
|
|
162
|
+
status,
|
|
163
|
+
error
|
|
164
|
+
});
|
|
165
|
+
return { message: status >= 500 ? 'Internal Error' : message };
|
|
166
|
+
};
|
|
167
|
+
return { init, handle, handleError, running };
|
|
168
|
+
}
|
package/dist/server/health.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { StripeFailed } from './billing.js';
|
|
2
|
-
import {
|
|
2
|
+
import { catalogChanges, catalogPlan, foreignWebhooks } from './catalog.js';
|
|
3
3
|
import { describeError } from './log.js';
|
|
4
4
|
import { MailFailed } from './mail.js';
|
|
5
5
|
export class CheckFailed extends Error {
|
|
@@ -97,7 +97,7 @@ export function stripeCheck(stripe, catalog, shop, log) {
|
|
|
97
97
|
let foreign;
|
|
98
98
|
try {
|
|
99
99
|
[differences, foreign] = await Promise.all([
|
|
100
|
-
|
|
100
|
+
catalogPlan(stripe, catalog, shop).then(catalogChanges),
|
|
101
101
|
foreignWebhooks(stripe, shop.origin)
|
|
102
102
|
]);
|
|
103
103
|
}
|
package/dist/server/hook.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { Product, ProductEnv } from '../product.js';
|
|
|
4
4
|
import { type Account } from './accounts.js';
|
|
5
5
|
import { type CheckoutPage, type Order, type Purchase, type SubscriptionChange } from './billing.js';
|
|
6
6
|
import { type Config } from './config.js';
|
|
7
|
-
import {
|
|
7
|
+
import type { Limits } from './limits.js';
|
|
8
8
|
import type { Log } from './log.js';
|
|
9
9
|
import { type OpenApiDocument } from './openapi.js';
|
|
10
10
|
import { type Pages } from './pages.js';
|
package/dist/server/hook.js
CHANGED
|
@@ -1,26 +1,21 @@
|
|
|
1
1
|
import { createAccounts } from './accounts.js';
|
|
2
|
-
import {
|
|
3
|
-
import { exitAfterLog, log } from './adapter/log.js';
|
|
4
|
-
import { createMailer } from './adapter/mailer.js';
|
|
5
|
-
import { migrationSources, migrationStatus } from './adapter/migrate.js';
|
|
2
|
+
import { log } from './adapter/log.js';
|
|
6
3
|
import { secrets } from './adapter/secrets.js';
|
|
7
4
|
import { createStripe } from './adapter/stripe.js';
|
|
8
|
-
import { asApiError, readBody, respond } from './api.js';
|
|
5
|
+
import { asApiError, readBody, respond as respondApi } from './api.js';
|
|
9
6
|
import { createBilling, noBilling } from './billing.js';
|
|
10
|
-
import {
|
|
7
|
+
import { loadConfig } from './config.js';
|
|
8
|
+
import { policyGuard, serverCore } from './core.js';
|
|
11
9
|
import { apiAccount, serveApi } from './endpoints.js';
|
|
12
|
-
import {
|
|
10
|
+
import { stripeCheck } from './health.js';
|
|
13
11
|
import { SECURITY_HEADERS } from './headers.js';
|
|
14
12
|
import { legalPages } from './legal/index.js';
|
|
15
|
-
import { createLimits } from './limits.js';
|
|
16
13
|
import { serveMachine } from './machine.js';
|
|
17
14
|
import { MESSAGES } from './messages.js';
|
|
18
15
|
import { mergeOpenApi, openApiPart } from './openapi.js';
|
|
19
16
|
import { createPages } from './pages.js';
|
|
20
17
|
import { BAUKASTEN_TERMS_DATE, termsVersion } from './terms.js';
|
|
21
18
|
import { clearSessionCookie, clientAddress, isCrossSite, isMachinePath, SESSION_COOKIE } from './web.js';
|
|
22
|
-
const HEALTH = '/health';
|
|
23
|
-
const DEEP = '/health/deep';
|
|
24
19
|
/**
|
|
25
20
|
* The product's src/app.html writes `<html lang="%baukasten.lang%" %baukasten.theme%>`; the handle
|
|
26
21
|
* fills in its language and, with a theme in its marks, `data-theme="…"`, else nothing.
|
|
@@ -31,59 +26,11 @@ const THEME = '%baukasten.theme%';
|
|
|
31
26
|
function themeAttribute(theme) {
|
|
32
27
|
return theme === undefined ? '' : `data-theme="${theme}"`;
|
|
33
28
|
}
|
|
34
|
-
/** The queries of the database check end before the check does, and give their connection back. */
|
|
35
|
-
const PROBE_TIMEOUT_MS = CHECK_TIMEOUT_MS - 1_000;
|
|
36
|
-
function json(body, status) {
|
|
37
|
-
return new Response(JSON.stringify(body), {
|
|
38
|
-
status,
|
|
39
|
-
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' }
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
29
|
export function baukasten(product, hooks = {}) {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
let state = { kind: 'waiting' };
|
|
48
|
-
function running() {
|
|
49
|
-
switch (state.kind) {
|
|
50
|
-
case 'running':
|
|
51
|
-
return state;
|
|
52
|
-
case 'building':
|
|
53
|
-
throw new Error('the baukasten is used in the build: its pages read the configuration of the running product and cannot be prerendered');
|
|
54
|
-
case 'waiting':
|
|
55
|
-
throw new Error('the baukasten is used before init ran');
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
async function stop(context) {
|
|
59
|
-
try {
|
|
60
|
-
await hooks.stop?.(context);
|
|
61
|
-
}
|
|
62
|
-
catch (error) {
|
|
63
|
-
process.exitCode = 1;
|
|
64
|
-
log.error('the stop hook of the product failed', { error });
|
|
65
|
-
}
|
|
66
|
-
await context.db.close();
|
|
67
|
-
log.info('stopped');
|
|
68
|
-
}
|
|
69
|
-
async function start() {
|
|
70
|
-
let config;
|
|
71
|
-
try {
|
|
72
|
-
config = loadConfig(process.env, product);
|
|
73
|
-
}
|
|
74
|
-
catch (error) {
|
|
75
|
-
if (!(error instanceof ConfigError))
|
|
76
|
-
throw error;
|
|
77
|
-
log.error('configuration incomplete, not starting', { problems: error.problems });
|
|
78
|
-
return exitAfterLog(1);
|
|
79
|
-
}
|
|
80
|
-
if (config.missing.length > 0) {
|
|
81
|
-
log.warn('running without what production needs', { missing: config.missing });
|
|
82
|
-
}
|
|
83
|
-
const db = createDb(config.databaseUrl, log);
|
|
84
|
-
const mail = createMailer(config.mail, config.name, log);
|
|
85
|
-
const limits = createLimits({ now: () => Date.now() });
|
|
86
|
-
const context = { config, db, log, mail, limits };
|
|
30
|
+
const withPolicy = policyGuard('SVELTEKIT_OPTIONS', 'product');
|
|
31
|
+
/** Billing, accounts, legal pages and the rest, on the pool, mail and limits of the core. */
|
|
32
|
+
function open(context) {
|
|
33
|
+
const { config, db, mail, limits } = context;
|
|
87
34
|
const version = termsVersion(BAUKASTEN_TERMS_DATE, product.termsDate);
|
|
88
35
|
const messages = MESSAGES[config.language];
|
|
89
36
|
const shop = { name: config.name, origin: config.publicOrigin };
|
|
@@ -133,52 +80,17 @@ export function baukasten(product, hooks = {}) {
|
|
|
133
80
|
address: (event) => clientAddress(event.request.headers, config.mode, () => event.getClientAddress()),
|
|
134
81
|
now: () => new Date()
|
|
135
82
|
};
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const health = deepHealth({
|
|
144
|
-
checks: {
|
|
145
|
-
database: databaseCheck(probe, () => migrationStatus(probe, sources), log),
|
|
146
|
-
mail: mailCheck(mail, log),
|
|
147
|
-
stripe: stripeHealth,
|
|
148
|
-
...checks
|
|
149
|
-
},
|
|
150
|
-
now: () => Date.now(),
|
|
151
|
-
log
|
|
152
|
-
});
|
|
153
|
-
state = { kind: 'running', context, services, health };
|
|
154
|
-
log.info('starting', {
|
|
155
|
-
mode: config.mode,
|
|
156
|
-
origin: config.publicOrigin,
|
|
157
|
-
language: config.language
|
|
158
|
-
});
|
|
159
|
-
await hooks.start?.(context);
|
|
160
|
-
process.once('sveltekit:shutdown', () => void stop(context));
|
|
161
|
-
}
|
|
162
|
-
const init = (building) => () => {
|
|
163
|
-
if (!building)
|
|
164
|
-
return start();
|
|
165
|
-
state = { kind: 'building' };
|
|
166
|
-
};
|
|
167
|
-
async function health(event) {
|
|
168
|
-
if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
|
|
169
|
-
return new Response(null, { status: 405, headers: { allow: 'GET, HEAD' } });
|
|
170
|
-
}
|
|
171
|
-
// Plain health answers without the database: Neon may sleep, the process still lives.
|
|
172
|
-
if (event.url.pathname === HEALTH)
|
|
173
|
-
return json({ status: 'ok' }, 200);
|
|
174
|
-
const report = await running().health();
|
|
175
|
-
return json(report, report.status === 'ok' ? 200 : 503);
|
|
83
|
+
return {
|
|
84
|
+
services,
|
|
85
|
+
// Without Stripe, locally, there is nothing to check.
|
|
86
|
+
check: paid === null
|
|
87
|
+
? () => Promise.resolve()
|
|
88
|
+
: stripeCheck(paid.stripe, product.catalog, shop, log)
|
|
89
|
+
};
|
|
176
90
|
}
|
|
177
|
-
async function
|
|
91
|
+
async function respond(r, event, resolve) {
|
|
178
92
|
const path = event.url.pathname;
|
|
179
|
-
|
|
180
|
-
return health(event);
|
|
181
|
-
const s = running().services;
|
|
93
|
+
const s = r.services;
|
|
182
94
|
event.locals.account = null;
|
|
183
95
|
if (isCrossSite(event.request, s.config.publicOrigin)) {
|
|
184
96
|
log.warn('request refused: from another origin', { path });
|
|
@@ -208,86 +120,39 @@ export function baukasten(product, hooks = {}) {
|
|
|
208
120
|
});
|
|
209
121
|
return path.startsWith('/api/') ? asApiError(response, s.messages) : withPolicy(response, path);
|
|
210
122
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
if (!policyMissing) {
|
|
224
|
-
policyMissing = true;
|
|
225
|
-
log.error('a page without its content security policy: SVELTEKIT_OPTIONS of the baukasten is missing in sveltekit() of vite.config.ts, or an endpoint of the product answers HTML of its own; every such answer is a 500', { path });
|
|
226
|
-
}
|
|
227
|
-
return new Response('Internal Error', {
|
|
228
|
-
status: 500,
|
|
229
|
-
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
const handle = async ({ event, resolve }) => {
|
|
233
|
-
// Prerendering in the build is no request of anyone.
|
|
234
|
-
if (state.kind === 'building')
|
|
235
|
-
return resolve(event);
|
|
236
|
-
const started = performance.now();
|
|
237
|
-
let status = 500;
|
|
238
|
-
try {
|
|
239
|
-
const response = await respondTo(event, resolve);
|
|
240
|
-
for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
|
|
241
|
-
response.headers.set(name, value);
|
|
242
|
-
}
|
|
243
|
-
status = response.status;
|
|
244
|
-
return response;
|
|
245
|
-
}
|
|
246
|
-
finally {
|
|
247
|
-
// The container polls /health; it is logged only at debug. The path goes without its query,
|
|
248
|
-
// which may carry a token.
|
|
249
|
-
(event.url.pathname === HEALTH ? log.debug : log.info)('request', {
|
|
250
|
-
method: event.request.method,
|
|
251
|
-
path: event.url.pathname,
|
|
252
|
-
status,
|
|
253
|
-
ms: Math.round(performance.now() - started)
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
};
|
|
257
|
-
const handleError = ({ error, event, status, message }) => {
|
|
258
|
-
// An unknown path is already in the request log.
|
|
259
|
-
if (status === 404)
|
|
260
|
-
return { message };
|
|
261
|
-
(status >= 500 ? log.error : log.warn)('request failed', {
|
|
262
|
-
method: event.request.method,
|
|
263
|
-
path: event.url.pathname,
|
|
264
|
-
status,
|
|
265
|
-
error
|
|
266
|
-
});
|
|
267
|
-
return { message: status >= 500 ? 'Internal Error' : message };
|
|
268
|
-
};
|
|
123
|
+
const core = serverCore({
|
|
124
|
+
noun: 'product',
|
|
125
|
+
art: 'webapp',
|
|
126
|
+
check: 'stripe',
|
|
127
|
+
hooks,
|
|
128
|
+
load: () => loadConfig(process.env, product),
|
|
129
|
+
open,
|
|
130
|
+
respond,
|
|
131
|
+
headers: SECURITY_HEADERS
|
|
132
|
+
});
|
|
133
|
+
const services = () => core.running().services;
|
|
269
134
|
return {
|
|
270
|
-
init,
|
|
271
|
-
handle,
|
|
272
|
-
handleError,
|
|
273
|
-
context: () => running().context,
|
|
274
|
-
pages: createPages(
|
|
275
|
-
clientAddress: (event) =>
|
|
135
|
+
init: core.init,
|
|
136
|
+
handle: core.handle,
|
|
137
|
+
handleError: core.handleError,
|
|
138
|
+
context: () => core.running().context,
|
|
139
|
+
pages: createPages(services),
|
|
140
|
+
clientAddress: (event) => services().address(event),
|
|
276
141
|
api: {
|
|
277
142
|
respond: (handler) => {
|
|
278
|
-
const s =
|
|
279
|
-
return
|
|
143
|
+
const s = services();
|
|
144
|
+
return respondApi(s.log, s.messages, handler);
|
|
280
145
|
},
|
|
281
|
-
body: (request, schema) => readBody(request, schema,
|
|
282
|
-
account: async (event) => (await apiAccount(
|
|
146
|
+
body: (request, schema) => readBody(request, schema, services().messages),
|
|
147
|
+
account: async (event) => (await apiAccount(services(), event)).account
|
|
283
148
|
},
|
|
284
149
|
openApi: (document) => {
|
|
285
|
-
const s =
|
|
150
|
+
const s = services();
|
|
286
151
|
return mergeOpenApi(document, openApiPart(s.config.publicOrigin, s.messages));
|
|
287
152
|
},
|
|
288
153
|
billing: {
|
|
289
|
-
checkout: (account, order) =>
|
|
290
|
-
portal: (account, back) =>
|
|
154
|
+
checkout: (account, order) => services().billing.checkout(account, order),
|
|
155
|
+
portal: (account, back) => services().billing.portal(account, back)
|
|
291
156
|
}
|
|
292
157
|
};
|
|
293
158
|
}
|
package/dist/server/mail.d.ts
CHANGED
|
@@ -13,7 +13,10 @@ export declare class MailFailed extends Error {
|
|
|
13
13
|
command?: string;
|
|
14
14
|
});
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Sends a mail of the accounts: true when it went out, false when the relay did not take it, which
|
|
18
|
+
* is logged with its kind. Any other failure is a fault and goes on to the caller.
|
|
19
|
+
*/
|
|
17
20
|
export declare function deliver(mailer: Mailer, log: Log, mail: Mail, about: {
|
|
18
21
|
kind: string;
|
|
19
22
|
account: string;
|
package/dist/server/mail.js
CHANGED
|
@@ -15,15 +15,20 @@ export class MailFailed extends Error {
|
|
|
15
15
|
this.code = details.code;
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Sends a mail of the accounts: true when it went out, false when the relay did not take it, which
|
|
20
|
+
* is logged with its kind. Any other failure is a fault and goes on to the caller.
|
|
21
|
+
*/
|
|
19
22
|
export async function deliver(mailer, log, mail, about) {
|
|
20
23
|
try {
|
|
21
24
|
await mailer.send(mail);
|
|
22
|
-
log.info('mail sent', about);
|
|
23
|
-
return true;
|
|
24
25
|
}
|
|
25
26
|
catch (error) {
|
|
27
|
+
if (!(error instanceof MailFailed))
|
|
28
|
+
throw error;
|
|
26
29
|
log.error('mail failed', { ...about, error });
|
|
27
30
|
return false;
|
|
28
31
|
}
|
|
32
|
+
log.info('mail sent', about);
|
|
33
|
+
return true;
|
|
29
34
|
}
|
package/dist/server/messages.js
CHANGED
|
@@ -23,6 +23,7 @@ const en = {
|
|
|
23
23
|
mailFailed: 'We could not send the email. Please try again in a moment.',
|
|
24
24
|
account: {
|
|
25
25
|
sameEmail: 'This is your address already.',
|
|
26
|
+
notChanged: 'We could not change your address just now; it stays as it was. Please try again in a few minutes.',
|
|
26
27
|
wrongPassword: 'The password is wrong.',
|
|
27
28
|
wrongCurrent: 'The current password is wrong.',
|
|
28
29
|
taken: 'Another account uses this address.',
|
|
@@ -137,6 +138,7 @@ const de = {
|
|
|
137
138
|
mailFailed: 'Wir konnten die E-Mail nicht senden. Bitte versuchen Sie es gleich nochmals.',
|
|
138
139
|
account: {
|
|
139
140
|
sameEmail: 'Das ist schon Ihre Adresse.',
|
|
141
|
+
notChanged: 'Wir konnten Ihre Adresse gerade nicht ändern; sie bleibt, wie sie war. Bitte versuchen Sie es in einigen Minuten nochmals.',
|
|
140
142
|
wrongPassword: 'Das Passwort ist falsch.',
|
|
141
143
|
wrongCurrent: 'Das aktuelle Passwort ist falsch.',
|
|
142
144
|
taken: 'Ein anderes Konto benutzt diese Adresse.',
|
package/dist/server/pages.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fail, redirect } from '@sveltejs/kit';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { EmailTaken, normalizeEmail, sha256 } from './accounts.js';
|
|
4
|
+
import { StripeFailed } from './billing.js';
|
|
4
5
|
import { emailField, field, passwordField, problem } from './fields.js';
|
|
5
6
|
import { LIMITS } from './limits.js';
|
|
6
7
|
import { deliver } from './mail.js';
|
|
@@ -291,7 +292,20 @@ export function createPages(services) {
|
|
|
291
292
|
actions: {
|
|
292
293
|
default: async (event) => {
|
|
293
294
|
const s = services();
|
|
294
|
-
const
|
|
295
|
+
const token = field(await event.request.formData(), 'token');
|
|
296
|
+
let result;
|
|
297
|
+
try {
|
|
298
|
+
// Receipts and invoices follow the address, or the address does not change.
|
|
299
|
+
result = await s.accounts.confirmEmailChange(token, (tx, accountId, email) => s.billing.followEmail(tx, accountId, email));
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
if (!(error instanceof StripeFailed))
|
|
303
|
+
throw error;
|
|
304
|
+
s.log.error('stripe: the customer could not take the new address; it did not change', {
|
|
305
|
+
error
|
|
306
|
+
});
|
|
307
|
+
return fail(503, { error: s.messages.account.notChanged });
|
|
308
|
+
}
|
|
295
309
|
switch (result.kind) {
|
|
296
310
|
case 'invalid':
|
|
297
311
|
return fail(400, { error: s.messages.link.invalid });
|
|
@@ -305,15 +319,6 @@ export function createPages(services) {
|
|
|
305
319
|
newEmail: result.newEmail
|
|
306
320
|
});
|
|
307
321
|
await deliver(s.mail, s.log, { to: result.oldEmail, ...notice }, { kind: 'email_changed', account: result.accountId });
|
|
308
|
-
// The address has changed; receipts that still go to the old one are in the log.
|
|
309
|
-
await s.billing
|
|
310
|
-
.followEmail(result.accountId, result.newEmail)
|
|
311
|
-
.catch((error) => {
|
|
312
|
-
s.log.error('stripe: the customer kept the old address', {
|
|
313
|
-
account: result.accountId,
|
|
314
|
-
error
|
|
315
|
-
});
|
|
316
|
-
});
|
|
317
322
|
const own = event.locals.account !== null && event.locals.account.id === result.accountId;
|
|
318
323
|
redirect(303, own ? TO.accountEmailChanged : TO.loginEmailChanged);
|
|
319
324
|
}
|
package/dist/server/ports.d.ts
CHANGED
|
@@ -58,7 +58,7 @@ export type StripePrice = {
|
|
|
58
58
|
product: string;
|
|
59
59
|
/** In the smallest unit of the currency; null for a price that is not a fixed amount. */
|
|
60
60
|
amount: number | null;
|
|
61
|
-
/**
|
|
61
|
+
/** Its code, like CHF. */
|
|
62
62
|
currency: string;
|
|
63
63
|
} & ({
|
|
64
64
|
type: 'one_time';
|
|
@@ -79,6 +79,18 @@ export type NewPrice = {
|
|
|
79
79
|
type: 'recurring';
|
|
80
80
|
interval: 'month' | 'year';
|
|
81
81
|
});
|
|
82
|
+
/** A product whose subscription a customer may change in the customer portal, and to which prices. */
|
|
83
|
+
export interface PortalProduct {
|
|
84
|
+
product: string;
|
|
85
|
+
prices: string[];
|
|
86
|
+
adjustable_quantity: {
|
|
87
|
+
enabled: false;
|
|
88
|
+
} | {
|
|
89
|
+
enabled: true;
|
|
90
|
+
minimum: number;
|
|
91
|
+
maximum: number;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
82
94
|
/** What the baukasten sets of the default configuration of the customer portal, as the API takes it. */
|
|
83
95
|
export interface PortalConfiguration {
|
|
84
96
|
business_profile: {
|
|
@@ -109,17 +121,7 @@ export interface PortalConfiguration {
|
|
|
109
121
|
enabled: true;
|
|
110
122
|
default_allowed_updates: ('price' | 'quantity')[];
|
|
111
123
|
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
|
-
}[];
|
|
124
|
+
products: PortalProduct[];
|
|
123
125
|
};
|
|
124
126
|
};
|
|
125
127
|
}
|
|
@@ -209,7 +211,7 @@ export type StripeEvent = {
|
|
|
209
211
|
});
|
|
210
212
|
/**
|
|
211
213
|
* 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.
|
|
214
|
+
* names what Stripe called it and never the key. A currency is its code, like CHF, both ways.
|
|
213
215
|
*/
|
|
214
216
|
export interface StripeApi {
|
|
215
217
|
/** The product of this id, archived or not; null when there is none. */
|
|
@@ -255,8 +257,13 @@ export interface StripeApi {
|
|
|
255
257
|
subscription(id: string): Promise<StripeSubscription>;
|
|
256
258
|
/** Ends a subscription at once; `missing` when Stripe does not know it. */
|
|
257
259
|
cancelSubscription(id: string): Promise<'canceled' | 'missing'>;
|
|
258
|
-
/** Returns the address of
|
|
259
|
-
createCheckout(request: CheckoutRequest): Promise<
|
|
260
|
+
/** Returns the session and the address of its Checkout page. */
|
|
261
|
+
createCheckout(request: CheckoutRequest): Promise<{
|
|
262
|
+
id: string;
|
|
263
|
+
url: string;
|
|
264
|
+
}>;
|
|
265
|
+
/** Ends a Checkout that is still open, so that it can no longer be paid; `complete` if it was. */
|
|
266
|
+
retireCheckout(id: string): Promise<'expired' | 'complete'>;
|
|
260
267
|
/** Returns the address of the customer portal. */
|
|
261
268
|
createPortalSession(session: {
|
|
262
269
|
customerId: string;
|
package/dist/server/sales.js
CHANGED
|
@@ -95,11 +95,12 @@ export async function salesNumbers(db, options) {
|
|
|
95
95
|
OR EXISTS (SELECT 1 FROM baukasten.purchases p WHERE p.account_id = a.id AND p.amount > 0)
|
|
96
96
|
)::int AS zahlende
|
|
97
97
|
FROM baukasten.accounts a WHERE (NOT $1 OR a.stripe_mode = 'live') GROUP BY a.via`, [live]),
|
|
98
|
+
// The columns keep the currency in small letters, as Stripe writes it (003_stripe_and_sales.sql).
|
|
98
99
|
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
|
|
100
|
+
SELECT account_id, amount FROM baukasten.invoices WHERE currency = lower($1)
|
|
101
|
+
UNION ALL SELECT account_id, amount FROM baukasten.purchases WHERE currency = lower($1)
|
|
101
102
|
) 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
|
|
103
|
+
WHERE (NOT $2 OR a.stripe_mode = 'live') GROUP BY a.via`, [options.currency, live]),
|
|
103
104
|
db.query(`SELECT own.name, a.via, sum(own.value)::text AS value
|
|
104
105
|
FROM unnest($1::text[], $2::uuid[], $3::bigint[]) AS own (name, account, value)
|
|
105
106
|
JOIN baukasten.accounts a ON a.id = own.account
|