@tollwyn/baukasten 1.2.3 → 1.3.1
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 +4 -1
package/dist/server/catalog.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { WEBHOOK_PATH } from './stripe.js';
|
|
2
2
|
export function pricesOf(catalog) {
|
|
3
|
-
const currency = catalog
|
|
3
|
+
const { currency } = catalog;
|
|
4
4
|
return catalog.products.flatMap((product) => {
|
|
5
5
|
switch (product.kind) {
|
|
6
6
|
case 'subscription':
|
|
@@ -41,9 +41,10 @@ export function priceFits(found, wanted) {
|
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
43
|
* The default configuration of the customer portal names the product and links its own pages: an
|
|
44
|
-
* account copied from another one brings that one's along.
|
|
44
|
+
* account copied from another one brings that one's along. `ids` holds the price at Stripe of each
|
|
45
|
+
* lookup key that has one; null while a price the portal offers has none yet.
|
|
45
46
|
*/
|
|
46
|
-
export function portalConfiguration(catalog, shop,
|
|
47
|
+
export function portalConfiguration(catalog, shop, ids) {
|
|
47
48
|
const subscriptions = catalog.products.flatMap((product) => product.kind === 'subscription' ? [product] : []);
|
|
48
49
|
const prices = subscriptions.reduce((count, product) => count + product.prices.length, 0);
|
|
49
50
|
const allowed = [
|
|
@@ -52,6 +53,25 @@ export function portalConfiguration(catalog, shop, priceId) {
|
|
|
52
53
|
? ['quantity']
|
|
53
54
|
: [])
|
|
54
55
|
];
|
|
56
|
+
const products = [];
|
|
57
|
+
if (allowed.length > 0) {
|
|
58
|
+
for (const product of subscriptions) {
|
|
59
|
+
const offered = [];
|
|
60
|
+
for (const price of product.prices) {
|
|
61
|
+
const id = ids.get(price.lookupKey);
|
|
62
|
+
if (id === undefined)
|
|
63
|
+
return null;
|
|
64
|
+
offered.push(id);
|
|
65
|
+
}
|
|
66
|
+
products.push({
|
|
67
|
+
product: product.id,
|
|
68
|
+
prices: offered,
|
|
69
|
+
adjustable_quantity: product.quantity === undefined
|
|
70
|
+
? { enabled: false }
|
|
71
|
+
: { enabled: true, minimum: 1, maximum: product.quantity.max }
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
55
75
|
return {
|
|
56
76
|
business_profile: {
|
|
57
77
|
headline: shop.name,
|
|
@@ -70,13 +90,7 @@ export function portalConfiguration(catalog, shop, priceId) {
|
|
|
70
90
|
enabled: true,
|
|
71
91
|
default_allowed_updates: allowed,
|
|
72
92
|
proration_behavior: 'create_prorations',
|
|
73
|
-
products
|
|
74
|
-
product: product.id,
|
|
75
|
-
prices: product.prices.map((price) => priceId(price.lookupKey)),
|
|
76
|
-
adjustable_quantity: product.quantity === undefined
|
|
77
|
-
? { enabled: false }
|
|
78
|
-
: { enabled: true, minimum: 1, maximum: product.quantity.max }
|
|
79
|
-
}))
|
|
93
|
+
products
|
|
80
94
|
}
|
|
81
95
|
}
|
|
82
96
|
};
|
|
@@ -105,80 +119,85 @@ export function contains(found, wanted) {
|
|
|
105
119
|
}
|
|
106
120
|
/** Only the Dashboard creates the default configuration of the customer portal. */
|
|
107
121
|
export const NO_DEFAULT_PORTAL = 'the Stripe account has no default configuration of the customer portal in this mode: save the settings of the customer portal once in the Stripe Dashboard, then migrate again';
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
await stripe.createProduct(wanted);
|
|
115
|
-
steps.push({ subject: 'product', id: product.id, action: 'created' });
|
|
116
|
-
}
|
|
117
|
-
else if (!found.active || found.name !== product.name) {
|
|
118
|
-
await stripe.updateProduct(wanted);
|
|
119
|
-
steps.push({ subject: 'product', id: product.id, action: 'updated' });
|
|
120
|
-
}
|
|
121
|
-
else {
|
|
122
|
-
steps.push({ subject: 'product', id: product.id, action: 'exists' });
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
const ids = new Map();
|
|
126
|
-
for (const wanted of pricesOf(catalog)) {
|
|
127
|
-
const found = await stripe.activePrice(wanted.lookupKey);
|
|
128
|
-
if (found !== null && priceFits(found, wanted)) {
|
|
129
|
-
ids.set(wanted.lookupKey, found.id);
|
|
130
|
-
steps.push({ subject: 'price', id: wanted.lookupKey, action: 'exists' });
|
|
131
|
-
}
|
|
132
|
-
else {
|
|
133
|
-
ids.set(wanted.lookupKey, await stripe.createPrice(wanted));
|
|
134
|
-
steps.push({
|
|
135
|
-
subject: 'price',
|
|
136
|
-
id: wanted.lookupKey,
|
|
137
|
-
action: found === null ? 'created' : 'updated'
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
const portal = await stripe.defaultPortal();
|
|
142
|
-
if (portal === null)
|
|
143
|
-
throw new Error(NO_DEFAULT_PORTAL);
|
|
144
|
-
const wanted = portalConfiguration(catalog, shop, (key) => {
|
|
145
|
-
const id = ids.get(key);
|
|
146
|
-
if (id === undefined)
|
|
147
|
-
throw new Error(`the price ${key} was not set up`);
|
|
148
|
-
return id;
|
|
149
|
-
});
|
|
150
|
-
if (contains(portal.configuration, wanted)) {
|
|
151
|
-
steps.push({ subject: 'portal', id: portal.id, action: 'exists' });
|
|
152
|
-
}
|
|
153
|
-
else {
|
|
154
|
-
await stripe.updatePortal(portal.id, wanted);
|
|
155
|
-
steps.push({ subject: 'portal', id: portal.id, action: 'updated' });
|
|
156
|
-
}
|
|
157
|
-
return steps;
|
|
158
|
-
}
|
|
159
|
-
/** What the migration would change at Stripe, as `product <id>`, `price <key>` or `portal`. */
|
|
160
|
-
export async function catalogDifferences(stripe, catalog, shop) {
|
|
122
|
+
/**
|
|
123
|
+
* The catalog compared with Stripe: products first, then prices, then the portal, which offers the
|
|
124
|
+
* prices. A price still to be made changes a portal that offers it: its new id is in no
|
|
125
|
+
* configuration yet.
|
|
126
|
+
*/
|
|
127
|
+
export async function catalogPlan(stripe, catalog, shop) {
|
|
161
128
|
const [products, prices, portal] = await Promise.all([
|
|
162
129
|
Promise.all(catalog.products.map(async (product) => [product, await stripe.product(product.id)])),
|
|
163
130
|
Promise.all(pricesOf(catalog).map(async (price) => [price, await stripe.activePrice(price.lookupKey)])),
|
|
164
131
|
stripe.defaultPortal()
|
|
165
132
|
]);
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
133
|
+
const productSteps = products.map(([product, found]) => ({
|
|
134
|
+
subject: 'product',
|
|
135
|
+
id: product.id,
|
|
136
|
+
name: product.name,
|
|
137
|
+
kind: found === null ? 'create' : !found.active || found.name !== product.name ? 'update' : 'exists'
|
|
138
|
+
}));
|
|
139
|
+
const priceSteps = prices.map(([price, found]) => {
|
|
140
|
+
const step = { subject: 'price', id: price.lookupKey, price };
|
|
141
|
+
if (found === null)
|
|
142
|
+
return { ...step, kind: 'create' };
|
|
143
|
+
return priceFits(found, price)
|
|
144
|
+
? { ...step, kind: 'exists', stripeId: found.id }
|
|
145
|
+
: { ...step, kind: 'update' };
|
|
146
|
+
});
|
|
147
|
+
const ids = new Map(priceSteps.flatMap((step) => step.kind === 'exists' ? [[step.id, step.stripeId]] : []));
|
|
148
|
+
const wanted = portalConfiguration(catalog, shop, ids);
|
|
149
|
+
const portalStep = portal === null
|
|
150
|
+
? { subject: 'portal', id: null, kind: 'missing' }
|
|
151
|
+
: {
|
|
152
|
+
subject: 'portal',
|
|
153
|
+
id: portal.id,
|
|
154
|
+
kind: wanted !== null && contains(portal.configuration, wanted) ? 'exists' : 'update'
|
|
155
|
+
};
|
|
156
|
+
return [...productSteps, ...priceSteps, portalStep];
|
|
157
|
+
}
|
|
158
|
+
/** What the migration would change at Stripe, as `product <id>`, `price <key>` or `portal`. */
|
|
159
|
+
export function catalogChanges(plan) {
|
|
160
|
+
return plan.flatMap((step) => {
|
|
161
|
+
if (step.kind === 'exists')
|
|
162
|
+
return [];
|
|
163
|
+
return [step.subject === 'portal' ? 'portal' : `${step.subject} ${step.id}`];
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const DONE = { create: 'created', update: 'updated', exists: 'exists' };
|
|
167
|
+
/** Carries the plan out: a second run finds everything as it exists and changes nothing. */
|
|
168
|
+
export async function setUpCatalog(stripe, catalog, shop) {
|
|
169
|
+
const done = [];
|
|
170
|
+
const ids = new Map();
|
|
171
|
+
for (const step of await catalogPlan(stripe, catalog, shop)) {
|
|
172
|
+
switch (step.subject) {
|
|
173
|
+
case 'product': {
|
|
174
|
+
const product = { id: step.id, name: step.name };
|
|
175
|
+
if (step.kind === 'create')
|
|
176
|
+
await stripe.createProduct(product);
|
|
177
|
+
if (step.kind === 'update')
|
|
178
|
+
await stripe.updateProduct(product);
|
|
179
|
+
done.push({ subject: 'product', id: step.id, action: DONE[step.kind] });
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case 'price':
|
|
183
|
+
ids.set(step.id, step.kind === 'exists' ? step.stripeId : await stripe.createPrice(step.price));
|
|
184
|
+
done.push({ subject: 'price', id: step.id, action: DONE[step.kind] });
|
|
185
|
+
break;
|
|
186
|
+
case 'portal': {
|
|
187
|
+
if (step.kind === 'missing')
|
|
188
|
+
throw new Error(NO_DEFAULT_PORTAL);
|
|
189
|
+
if (step.kind === 'update') {
|
|
190
|
+
const wanted = portalConfiguration(catalog, shop, ids);
|
|
191
|
+
if (wanted === null)
|
|
192
|
+
throw new Error('a price of the portal was not set up');
|
|
193
|
+
await stripe.updatePortal(step.id, wanted);
|
|
194
|
+
}
|
|
195
|
+
done.push({ subject: 'portal', id: step.id, action: DONE[step.kind] });
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return done;
|
|
182
201
|
}
|
|
183
202
|
/**
|
|
184
203
|
* The enabled webhook endpoints of the Stripe account that are not this product's: an account copied
|
package/dist/server/config.d.ts
CHANGED
|
@@ -85,17 +85,60 @@ export declare const SHAPE: {
|
|
|
85
85
|
OPERATOR_REGISTER: z.ZodUnion<readonly [z.ZodLiteral<"none">, z.ZodString]>;
|
|
86
86
|
OPERATOR_VAT: z.ZodLiteral<"none">;
|
|
87
87
|
OPERATOR_JURISDICTION: z.ZodString;
|
|
88
|
-
HOSTING_LOCATION: z.ZodString;
|
|
89
88
|
STRIPE_SECRET_KEY: z.ZodString;
|
|
90
89
|
STRIPE_WEBHOOK_SECRET: z.ZodString;
|
|
91
90
|
VERTRIEB_TOKEN: z.ZodString;
|
|
92
91
|
};
|
|
93
92
|
/** What a value must be; the problem never repeats the value, which may be a secret. */
|
|
94
93
|
export declare const MUST: Record<StandardVariable, string>;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
94
|
+
/** What the standard variables of both arts share, as the schema of either mode reads them. */
|
|
95
|
+
interface Shared {
|
|
96
|
+
PUBLIC_ORIGIN: string;
|
|
97
|
+
PORT?: number | undefined;
|
|
98
|
+
DATABASE_URL: string;
|
|
99
|
+
SMTP_HOST?: string | undefined;
|
|
100
|
+
SMTP_PORT?: number | undefined;
|
|
101
|
+
MAIL_FROM?: string | undefined;
|
|
102
|
+
MAIL_FROM_NAME: string;
|
|
103
|
+
VERTRIEB_TOKEN: string;
|
|
104
|
+
}
|
|
105
|
+
/** The part of the configuration that both arts read alike. */
|
|
106
|
+
type SharedConfig<N extends string, E extends ProductEnv> = Pick<Config<E>, 'mode' | 'publicOrigin' | 'port' | 'databaseUrl' | 'name' | 'mail' | 'salesToken' | 'product'> & {
|
|
107
|
+
missing: readonly N[];
|
|
108
|
+
};
|
|
109
|
+
type Read<T> = {
|
|
110
|
+
kind: 'ok';
|
|
111
|
+
value: T;
|
|
112
|
+
} | {
|
|
113
|
+
kind: 'problems';
|
|
114
|
+
problems: string[];
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Reads the standard variables `names` of an art with the schema of its mode, the variables of the
|
|
118
|
+
* product and the address of the double of the tests, and names every problem at once. Locally
|
|
119
|
+
* each of `groups` is set in full or not at all, and what is unset of them runs without.
|
|
120
|
+
*/
|
|
121
|
+
export declare function readVariables<N extends string, L extends z.ZodType<Shared>, E extends ProductEnv>(env: Env, spec: {
|
|
122
|
+
names: readonly N[];
|
|
123
|
+
production: z.ZodType<z.output<L>>;
|
|
124
|
+
local: L;
|
|
125
|
+
/** What the value of each must be; a problem never repeats the value, which may be a secret. */
|
|
126
|
+
must: Record<N, string>;
|
|
127
|
+
groups: readonly {
|
|
128
|
+
label: string;
|
|
129
|
+
names: readonly N[];
|
|
130
|
+
}[];
|
|
131
|
+
/** The variable of the double, which takes the keys, and what is wrong with it elsewhere. */
|
|
132
|
+
double: {
|
|
133
|
+
name: string;
|
|
134
|
+
problem: string;
|
|
135
|
+
};
|
|
136
|
+
product: E;
|
|
137
|
+
}): Read<{
|
|
138
|
+
config: SharedConfig<N, E>;
|
|
139
|
+
data: z.output<L>;
|
|
140
|
+
double: string | null;
|
|
141
|
+
}>;
|
|
99
142
|
/** A variable that is empty or blank counts as missing. */
|
|
100
143
|
export declare function valueOf(env: Env, name: string): string | undefined;
|
|
101
144
|
/** The port of the server by the rule of loadConfig, which has checked it before the server listens. */
|
package/dist/server/config.js
CHANGED
|
@@ -43,18 +43,15 @@ export const SHAPE = {
|
|
|
43
43
|
OPERATOR_REGISTER: z.union([z.literal(NONE), z.string().refine(isUid)]),
|
|
44
44
|
OPERATOR_VAT: z.literal(NONE),
|
|
45
45
|
OPERATOR_JURISDICTION: text,
|
|
46
|
-
// Required like every standard variable, yet unread: the legal pages name the server of the
|
|
47
|
-
// fleet (legal/fleet.ts).
|
|
48
|
-
HOSTING_LOCATION: text,
|
|
49
46
|
STRIPE_SECRET_KEY: z.string().regex(/^(?:sk|rk)_(?:test|live)_[A-Za-z0-9]+$/),
|
|
50
47
|
STRIPE_WEBHOOK_SECRET: z.string().regex(/^whsec_[A-Za-z0-9]+$/),
|
|
51
48
|
VERTRIEB_TOKEN: z.string().min(32)
|
|
52
49
|
};
|
|
53
50
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
51
|
+
* A double of the tests, which takes the keys and moves no money: only on this machine, so that no
|
|
52
|
+
* setting sends a key anywhere else.
|
|
56
53
|
*/
|
|
57
|
-
const
|
|
54
|
+
const ON_THIS_MACHINE = z
|
|
58
55
|
.url({ protocol: /^http$/, hostname: /^(?:127\.0\.0\.1|localhost|\[::1\])$/ })
|
|
59
56
|
.transform((value) => new URL(value).origin);
|
|
60
57
|
/** What a value must be; the problem never repeats the value, which may be a secret. */
|
|
@@ -71,12 +68,11 @@ export const MUST = {
|
|
|
71
68
|
OPERATOR_REGISTER: 'none or the company identification number of the entry with its check digit, like CHE-123.456.788',
|
|
72
69
|
OPERATOR_VAT: 'none: with a VAT number the product would have to charge VAT through Stripe Tax, which this version of the baukasten does not support',
|
|
73
70
|
OPERATOR_JURISDICTION: 'a place in Switzerland',
|
|
74
|
-
HOSTING_LOCATION: 'a place',
|
|
75
71
|
STRIPE_SECRET_KEY: 'a secret or restricted key of Stripe (sk_ or rk_, then test_ or live_)',
|
|
76
72
|
STRIPE_WEBHOOK_SECRET: 'a signing secret of Stripe (whsec_)',
|
|
77
73
|
VERTRIEB_TOKEN: 'at least 32 characters'
|
|
78
74
|
};
|
|
79
|
-
|
|
75
|
+
const DEFAULT_PORT = 3000;
|
|
80
76
|
const MAIL = ['SMTP_HOST', 'SMTP_PORT', 'MAIL_FROM'];
|
|
81
77
|
const STRIPE = ['STRIPE_SECRET_KEY', 'STRIPE_WEBHOOK_SECRET'];
|
|
82
78
|
const PRODUCTION = z.object(SHAPE);
|
|
@@ -90,7 +86,7 @@ const LOCAL = z.object({
|
|
|
90
86
|
STRIPE_WEBHOOK_SECRET: SHAPE.STRIPE_WEBHOOK_SECRET.optional()
|
|
91
87
|
});
|
|
92
88
|
/** One problem per variable, in the order of `names`: missing, or what its value must be. */
|
|
93
|
-
|
|
89
|
+
function problemsOf(issues, names, input, invalid) {
|
|
94
90
|
const first = new Map();
|
|
95
91
|
for (const issue of issues) {
|
|
96
92
|
const name = names.find((n) => n === issue.path[0]);
|
|
@@ -107,57 +103,6 @@ export function problemsOf(issues, names, input, invalid) {
|
|
|
107
103
|
return [input[name] === undefined ? `${name} is missing` : invalid(name, issue)];
|
|
108
104
|
});
|
|
109
105
|
}
|
|
110
|
-
function read(schema, input, build) {
|
|
111
|
-
const result = schema.safeParse(input);
|
|
112
|
-
if (result.success)
|
|
113
|
-
return { kind: 'ok', value: build(result.data) };
|
|
114
|
-
return {
|
|
115
|
-
kind: 'problems',
|
|
116
|
-
problems: problemsOf(result.error.issues, STANDARD_VARIABLES, input, (name) => `${name} must be ${MUST[name]}`)
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
function common(data, rest) {
|
|
120
|
-
return {
|
|
121
|
-
publicOrigin: data.PUBLIC_ORIGIN,
|
|
122
|
-
databaseUrl: data.DATABASE_URL,
|
|
123
|
-
name: data.MAIL_FROM_NAME,
|
|
124
|
-
operator: {
|
|
125
|
-
name: data.OPERATOR_NAME,
|
|
126
|
-
address: data.OPERATOR_ADDRESS,
|
|
127
|
-
register: data.OPERATOR_REGISTER === NONE
|
|
128
|
-
? { kind: 'none' }
|
|
129
|
-
: { kind: 'entered', uid: data.OPERATOR_REGISTER },
|
|
130
|
-
vat: data.OPERATOR_VAT,
|
|
131
|
-
jurisdiction: data.OPERATOR_JURISDICTION
|
|
132
|
-
},
|
|
133
|
-
salesToken: data.VERTRIEB_TOKEN,
|
|
134
|
-
...rest
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
function production(data) {
|
|
138
|
-
return common(data, {
|
|
139
|
-
port: data.PORT,
|
|
140
|
-
mail: { kind: 'smtp', host: data.SMTP_HOST, port: data.SMTP_PORT, from: data.MAIL_FROM },
|
|
141
|
-
stripe: {
|
|
142
|
-
kind: 'stripe',
|
|
143
|
-
secretKey: data.STRIPE_SECRET_KEY,
|
|
144
|
-
webhookSecret: data.STRIPE_WEBHOOK_SECRET
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
function local(data) {
|
|
149
|
-
const { SMTP_HOST: host, SMTP_PORT: port, MAIL_FROM: from } = data;
|
|
150
|
-
const { STRIPE_SECRET_KEY: secretKey, STRIPE_WEBHOOK_SECRET: webhookSecret } = data;
|
|
151
|
-
return common(data, {
|
|
152
|
-
port: data.PORT ?? DEFAULT_PORT,
|
|
153
|
-
mail: host !== undefined && port !== undefined && from !== undefined
|
|
154
|
-
? { kind: 'smtp', host, port, from }
|
|
155
|
-
: { kind: 'log' },
|
|
156
|
-
stripe: secretKey !== undefined && webhookSecret !== undefined
|
|
157
|
-
? { kind: 'stripe', secretKey, webhookSecret }
|
|
158
|
-
: { kind: 'none' }
|
|
159
|
-
});
|
|
160
|
-
}
|
|
161
106
|
/** "A, B and C" */
|
|
162
107
|
function list(names) {
|
|
163
108
|
return names.length < 2 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
|
|
@@ -171,6 +116,57 @@ function partly(label, names, input) {
|
|
|
171
116
|
`${label} is set only in part: ${list(absent)} ${absent.length === 1 ? 'is' : 'are'} missing`
|
|
172
117
|
];
|
|
173
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Reads the standard variables `names` of an art with the schema of its mode, the variables of the
|
|
121
|
+
* product and the address of the double of the tests, and names every problem at once. Locally
|
|
122
|
+
* each of `groups` is set in full or not at all, and what is unset of them runs without.
|
|
123
|
+
*/
|
|
124
|
+
export function readVariables(env, spec) {
|
|
125
|
+
const inProduction = env.NODE_ENV === 'production';
|
|
126
|
+
const input = Object.fromEntries(spec.names.map((name) => [name, valueOf(env, name)]));
|
|
127
|
+
const names = Object.keys(spec.product);
|
|
128
|
+
const ownInput = Object.fromEntries(names.map((name) => [name, valueOf(env, name)]));
|
|
129
|
+
const base = (inProduction ? spec.production : spec.local).safeParse(input);
|
|
130
|
+
const own = z.object(spec.product).safeParse(ownInput);
|
|
131
|
+
const address = valueOf(env, spec.double.name);
|
|
132
|
+
const double = address === undefined ? null : ON_THIS_MACHINE.safeParse(address);
|
|
133
|
+
const problems = [
|
|
134
|
+
...(base.success
|
|
135
|
+
? []
|
|
136
|
+
: problemsOf(base.error.issues, spec.names, input, (name) => `${name} must be ${spec.must[name]}`)),
|
|
137
|
+
...(double === null || double.success ? [] : [spec.double.problem]),
|
|
138
|
+
...(own.success
|
|
139
|
+
? []
|
|
140
|
+
: problemsOf(own.error.issues, names, ownInput, (name, issue) => `${name} is invalid: ${issue.message}`)),
|
|
141
|
+
...(inProduction ? [] : spec.groups.flatMap((group) => partly(group.label, group.names, input)))
|
|
142
|
+
];
|
|
143
|
+
if (!base.success || !own.success || problems.length > 0)
|
|
144
|
+
return { kind: 'problems', problems };
|
|
145
|
+
const data = base.data;
|
|
146
|
+
const { SMTP_HOST: host, SMTP_PORT: port, MAIL_FROM: from } = data;
|
|
147
|
+
return {
|
|
148
|
+
kind: 'ok',
|
|
149
|
+
value: {
|
|
150
|
+
config: {
|
|
151
|
+
mode: inProduction ? 'production' : 'local',
|
|
152
|
+
publicOrigin: data.PUBLIC_ORIGIN,
|
|
153
|
+
port: data.PORT ?? DEFAULT_PORT,
|
|
154
|
+
databaseUrl: data.DATABASE_URL,
|
|
155
|
+
name: data.MAIL_FROM_NAME,
|
|
156
|
+
mail: host !== undefined && port !== undefined && from !== undefined
|
|
157
|
+
? { kind: 'smtp', host, port, from }
|
|
158
|
+
: { kind: 'log' },
|
|
159
|
+
salesToken: data.VERTRIEB_TOKEN,
|
|
160
|
+
missing: inProduction
|
|
161
|
+
? []
|
|
162
|
+
: spec.groups.flatMap((group) => group.names.filter((name) => input[name] === undefined)),
|
|
163
|
+
product: own.data
|
|
164
|
+
},
|
|
165
|
+
data,
|
|
166
|
+
double: double === null || !double.success ? null : double.data
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
174
170
|
/** A variable that is empty or blank counts as missing. */
|
|
175
171
|
export function valueOf(env, name) {
|
|
176
172
|
const raw = env[name]?.trim();
|
|
@@ -202,43 +198,45 @@ export function stripeModeOf(secretKey) {
|
|
|
202
198
|
}
|
|
203
199
|
/** Reads the configuration; ConfigError names every problem at once. */
|
|
204
200
|
export function loadConfig(env, product) {
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (base.kind === 'problems' || !own.success || problems.length > 0) {
|
|
225
|
-
throw new ConfigError(problems);
|
|
226
|
-
}
|
|
227
|
-
const stripe = base.value.stripe;
|
|
201
|
+
const read = readVariables(env, {
|
|
202
|
+
names: STANDARD_VARIABLES,
|
|
203
|
+
production: PRODUCTION,
|
|
204
|
+
local: LOCAL,
|
|
205
|
+
must: MUST,
|
|
206
|
+
groups: [
|
|
207
|
+
{ label: 'mail', names: MAIL },
|
|
208
|
+
{ label: 'Stripe', names: STRIPE }
|
|
209
|
+
],
|
|
210
|
+
double: {
|
|
211
|
+
name: 'STRIPE_API_BASE',
|
|
212
|
+
problem: 'STRIPE_API_BASE must be an http origin on this machine, for the Stripe double'
|
|
213
|
+
},
|
|
214
|
+
product: product.env
|
|
215
|
+
});
|
|
216
|
+
if (read.kind === 'problems')
|
|
217
|
+
throw new ConfigError(read.problems);
|
|
218
|
+
const { config, data, double } = read.value;
|
|
219
|
+
const { STRIPE_SECRET_KEY: secretKey, STRIPE_WEBHOOK_SECRET: webhookSecret } = data;
|
|
228
220
|
return {
|
|
229
|
-
|
|
221
|
+
...config,
|
|
230
222
|
language: product.language,
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
223
|
+
operator: {
|
|
224
|
+
name: data.OPERATOR_NAME,
|
|
225
|
+
address: data.OPERATOR_ADDRESS,
|
|
226
|
+
register: data.OPERATOR_REGISTER === NONE
|
|
227
|
+
? { kind: 'none' }
|
|
228
|
+
: { kind: 'entered', uid: data.OPERATOR_REGISTER },
|
|
229
|
+
vat: data.OPERATOR_VAT,
|
|
230
|
+
jurisdiction: data.OPERATOR_JURISDICTION
|
|
231
|
+
},
|
|
232
|
+
stripe: secretKey !== undefined && webhookSecret !== undefined
|
|
233
|
+
? {
|
|
234
|
+
kind: 'stripe',
|
|
235
|
+
secretKey,
|
|
236
|
+
webhookSecret,
|
|
237
|
+
mode: stripeModeOf(secretKey),
|
|
238
|
+
api: double === null ? { kind: 'stripe' } : { kind: 'double', url: double }
|
|
239
|
+
}
|
|
240
|
+
: { kind: 'none' }
|
|
243
241
|
};
|
|
244
242
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { Handle, HandleServerError, RequestEvent, ServerInit } from '@sveltejs/kit';
|
|
2
|
+
import type { Language } from '../product.js';
|
|
3
|
+
import { type Art } from './adapter/migrate.js';
|
|
4
|
+
import { type Mail } from './config.js';
|
|
5
|
+
import { type Check, type HealthReport } from './health.js';
|
|
6
|
+
import { type Limits } from './limits.js';
|
|
7
|
+
import type { Log } from './log.js';
|
|
8
|
+
import type { Db, Mailer } from './ports.js';
|
|
9
|
+
/** What the core reads of the configuration of either art. */
|
|
10
|
+
export interface CoreConfig {
|
|
11
|
+
mode: 'production' | 'local';
|
|
12
|
+
language: Language;
|
|
13
|
+
publicOrigin: string;
|
|
14
|
+
databaseUrl: string;
|
|
15
|
+
name: string;
|
|
16
|
+
mail: Mail;
|
|
17
|
+
/** What runs without locally; empty in production. */
|
|
18
|
+
missing: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
/** Pool, configuration, log, mail and limits of what runs, for the hooks of the product. */
|
|
21
|
+
export interface CoreContext<C> {
|
|
22
|
+
config: C;
|
|
23
|
+
db: Db;
|
|
24
|
+
log: Log;
|
|
25
|
+
mail: Mailer;
|
|
26
|
+
limits: Limits;
|
|
27
|
+
}
|
|
28
|
+
/** The hooks of the product the core calls; the art calls the others. */
|
|
29
|
+
export interface CoreHooks<C> {
|
|
30
|
+
checks?: Record<string, (context: CoreContext<C>) => Promise<void>>;
|
|
31
|
+
start?: (context: CoreContext<C>) => Promise<void> | void;
|
|
32
|
+
stop?: (context: CoreContext<C>) => Promise<void> | void;
|
|
33
|
+
}
|
|
34
|
+
/** What an art runs with once its configuration is read. */
|
|
35
|
+
export interface Opened<S> {
|
|
36
|
+
services: S;
|
|
37
|
+
/** Its own check in /health/deep, beside database and mail. */
|
|
38
|
+
check: Check;
|
|
39
|
+
/** Its own runs: they start before the start hook of the product and stop before its stop hook. */
|
|
40
|
+
runs?: {
|
|
41
|
+
start(): void;
|
|
42
|
+
stop(): Promise<void>;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
export interface Running<C, S> {
|
|
46
|
+
context: CoreContext<C>;
|
|
47
|
+
services: S;
|
|
48
|
+
health: () => Promise<HealthReport>;
|
|
49
|
+
}
|
|
50
|
+
type Resolve = Parameters<Handle>[0]['resolve'];
|
|
51
|
+
export interface CoreSpec<C extends CoreConfig, S> {
|
|
52
|
+
/** What runs, as the log and the errors name it. */
|
|
53
|
+
noun: 'product' | 'app';
|
|
54
|
+
art: Art;
|
|
55
|
+
/** The name of the art's own check in /health/deep. */
|
|
56
|
+
check: string;
|
|
57
|
+
hooks: CoreHooks<C>;
|
|
58
|
+
/** Reads the configuration in the root of the product; ConfigError names every problem. */
|
|
59
|
+
load(root: string): C;
|
|
60
|
+
open(context: CoreContext<C>): Opened<S>;
|
|
61
|
+
/** Answers every request but /health and /health/deep. */
|
|
62
|
+
respond(running: Running<C, S>, event: RequestEvent, resolve: Resolve): Promise<Response>;
|
|
63
|
+
/** The security headers of every answer. */
|
|
64
|
+
headers: Readonly<Record<string, string>>;
|
|
65
|
+
/** Fields of the request log beyond method, path, status and time. */
|
|
66
|
+
logged?: (event: RequestEvent) => Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
export interface Core<C, S> {
|
|
69
|
+
/**
|
|
70
|
+
* The `init` of the hooks. SvelteKit runs it in `vite build` too, to prerender pages, with
|
|
71
|
+
* `building` of `$app/environment` set: then it reads nothing and opens nothing, since the image is
|
|
72
|
+
* built without a single variable.
|
|
73
|
+
*/
|
|
74
|
+
init: (building: boolean) => ServerInit;
|
|
75
|
+
handle: Handle;
|
|
76
|
+
handleError: HandleServerError;
|
|
77
|
+
/** What runs; only after `init`, and never in the build. */
|
|
78
|
+
running: () => Running<C, S>;
|
|
79
|
+
}
|
|
80
|
+
export declare function json(body: unknown, status: number): Response;
|
|
81
|
+
/**
|
|
82
|
+
* A page without its content security policy does not go out: the policy comes from `option` in
|
|
83
|
+
* sveltekit() of the product's vite.config.ts, and without it every page is a 500 that the delivery
|
|
84
|
+
* service sees, not a product that runs without the policy unnoticed. The log says it once.
|
|
85
|
+
*/
|
|
86
|
+
export declare function policyGuard(option: string, noun: 'product' | 'app'): (response: Response, path: string) => Response;
|
|
87
|
+
export declare function serverCore<C extends CoreConfig, S>(spec: CoreSpec<C, S>): Core<C, S>;
|
|
88
|
+
export {};
|