@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.
@@ -1,6 +1,6 @@
1
1
  import { WEBHOOK_PATH } from './stripe.js';
2
2
  export function pricesOf(catalog) {
3
- const currency = catalog.currency.toLowerCase();
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, priceId) {
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: subscriptions.map((product) => ({
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
- export async function setUpCatalog(stripe, catalog, shop) {
109
- const steps = [];
110
- for (const product of catalog.products) {
111
- const found = await stripe.product(product.id);
112
- const wanted = { id: product.id, name: product.name };
113
- if (found === null) {
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 differences = [
167
- ...products.flatMap(([product, found]) => found !== null && found.active && found.name === product.name ? [] : [`product ${product.id}`]),
168
- ...prices.flatMap(([wanted, found]) => found !== null && priceFits(found, wanted) ? [] : [`price ${wanted.lookupKey}`])
169
- ];
170
- // With a price missing, the portal would point at a price the migration has yet to create.
171
- if (differences.some((difference) => difference.startsWith('price ')))
172
- return differences;
173
- const ids = new Map(prices.flatMap(([wanted, found]) => found === null ? [] : [[wanted.lookupKey, found.id]]));
174
- const fits = portal !== null &&
175
- contains(portal.configuration, portalConfiguration(catalog, shop, (key) => {
176
- const id = ids.get(key);
177
- if (id === undefined)
178
- throw new Error(`the price ${key} is not at Stripe`);
179
- return id;
180
- }));
181
- return fits ? differences : [...differences, 'portal'];
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
@@ -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
- export declare const DEFAULT_PORT = 3000;
96
- type Input = Record<string, string | undefined>;
97
- /** One problem per variable, in the order of `names`: missing, or what its value must be. */
98
- export declare function problemsOf<N extends string>(issues: readonly z.core.$ZodIssue[], names: readonly N[], input: Input, invalid: (name: N, issue: z.core.$ZodIssue) => string): string[];
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. */
@@ -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
- * The Stripe double of the tests, which takes the key and moves no money: only on this machine, so
55
- * that no setting sends the key anywhere else.
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 STRIPE_API_BASE = z
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
- export const DEFAULT_PORT = 3000;
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
- export function problemsOf(issues, names, input, invalid) {
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 value = (name) => valueOf(env, name);
206
- const inProduction = env.NODE_ENV === 'production';
207
- const input = Object.fromEntries(STANDARD_VARIABLES.map((name) => [name, value(name)]));
208
- const names = Object.keys(product.env);
209
- const ownInput = Object.fromEntries(names.map((name) => [name, value(name)]));
210
- const base = inProduction ? read(PRODUCTION, input, production) : read(LOCAL, input, local);
211
- const own = z.object(product.env).safeParse(ownInput);
212
- const apiBase = value('STRIPE_API_BASE');
213
- const double = apiBase === undefined ? null : STRIPE_API_BASE.safeParse(apiBase);
214
- const problems = [
215
- ...(base.kind === 'problems' ? base.problems : []),
216
- ...(double === null || double.success
217
- ? []
218
- : ['STRIPE_API_BASE must be an http origin on this machine, for the Stripe double']),
219
- ...(own.success
220
- ? []
221
- : problemsOf(own.error.issues, names, ownInput, (name, issue) => `${name} is invalid: ${issue.message}`)),
222
- ...(inProduction ? [] : [...partly('mail', MAIL, input), ...partly('Stripe', STRIPE, input)])
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
- mode: inProduction ? 'production' : 'local',
221
+ ...config,
230
222
  language: product.language,
231
- ...base.value,
232
- stripe: stripe.kind === 'none'
233
- ? stripe
234
- : {
235
- ...stripe,
236
- mode: stripeModeOf(stripe.secretKey),
237
- api: double === null || !double.success
238
- ? { kind: 'stripe' }
239
- : { kind: 'double', url: double.data }
240
- },
241
- missing: inProduction ? [] : [...MAIL, ...STRIPE].filter((name) => input[name] === undefined),
242
- product: own.data
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 {};