@tollwyn/baukasten 1.2.3 → 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.
@@ -1,5 +1,5 @@
1
1
  /** What else points at an account in the schema of the baukasten, deleted before the account. */
2
- const OF_ACCOUNTS = ['sessions', 'links', 'api_keys', 'subscriptions'];
2
+ const OF_ACCOUNTS = ['sessions', 'links', 'api_keys', 'subscriptions', 'checkouts'];
3
3
  const TEST = "a.stripe_mode = 'test'";
4
4
  /** The accounts from the test mode, oldest first. */
5
5
  export async function testAccounts(db) {
@@ -1,7 +1,7 @@
1
1
  // Locally mail may be missing (mails go to the log); Shopify is needed in either mode, since
2
2
  // without it no page of the app opens.
3
3
  import { z } from 'zod';
4
- import { ConfigError, DEFAULT_PORT, MUST, problemsOf, SHAPE, valueOf } from '../server/config.js';
4
+ import { ConfigError, MUST, readVariables, SHAPE, valueOf } from '../server/config.js';
5
5
  import { SHOPIFY_STANDARD_VARIABLES } from '../variables.js';
6
6
  import { sealKey } from './seal.js';
7
7
  import { APP_TOML, appToml } from './toml.js';
@@ -28,8 +28,6 @@ const SHARED = {
28
28
  MAIL_FROM_NAME: SHAPE.MAIL_FROM_NAME,
29
29
  OPERATOR_NAME: SHAPE.OPERATOR_NAME,
30
30
  OPERATOR_ADDRESS: SHAPE.OPERATOR_ADDRESS,
31
- // Required like every standard variable, yet unread: the privacy text names the server of the fleet.
32
- HOSTING_LOCATION: SHAPE.HOSTING_LOCATION,
33
31
  VERTRIEB_TOKEN: SHAPE.VERTRIEB_TOKEN
34
32
  };
35
33
  const FULL_SHAPE = { ...SHARED, ...OWN_SHAPE };
@@ -43,7 +41,6 @@ const MUST_OF = {
43
41
  MAIL_FROM_NAME: MUST.MAIL_FROM_NAME,
44
42
  OPERATOR_NAME: MUST.OPERATOR_NAME,
45
43
  OPERATOR_ADDRESS: MUST.OPERATOR_ADDRESS,
46
- HOSTING_LOCATION: MUST.HOSTING_LOCATION,
47
44
  VERTRIEB_TOKEN: MUST.VERTRIEB_TOKEN,
48
45
  ...OWN_MUST
49
46
  };
@@ -56,66 +53,42 @@ const LOCAL = z.object({
56
53
  SMTP_PORT: FULL_SHAPE.SMTP_PORT.optional(),
57
54
  MAIL_FROM: FULL_SHAPE.MAIL_FROM.optional()
58
55
  });
59
- /** The Shopify double of the tests: only on this machine, so that no setting sends a token elsewhere. */
60
- const SHOPIFY_API_BASE = z
61
- .url({ protocol: /^http$/, hostname: /^(?:127\.0\.0\.1|localhost|\[::1\])$/ })
62
- .transform((value) => new URL(value).origin);
63
- function mailOf(data) {
64
- const { SMTP_HOST: host, SMTP_PORT: port, MAIL_FROM: from } = data;
65
- return host !== undefined && port !== undefined && from !== undefined
66
- ? { kind: 'smtp', host, port, from }
67
- : { kind: 'log' };
68
- }
69
56
  /** Reads the configuration; ConfigError names every problem at once. `toml` is the text of the file. */
70
57
  export function loadShopifyConfig(env, app, toml) {
71
- const inProduction = env.NODE_ENV === 'production';
72
- const input = Object.fromEntries(SHOPIFY_STANDARD_VARIABLES.map((name) => [name, valueOf(env, name)]));
73
- const names = Object.keys(app.env);
74
- const ownInput = Object.fromEntries(names.map((name) => [name, valueOf(env, name)]));
75
- const base = (inProduction ? PRODUCTION : LOCAL).safeParse(input);
76
- const own = z.object(app.env).safeParse(ownInput);
77
- const apiBase = valueOf(env, 'SHOPIFY_API_BASE');
78
- const double = apiBase === undefined ? null : SHOPIFY_API_BASE.safeParse(apiBase);
58
+ const read = readVariables(env, {
59
+ names: SHOPIFY_STANDARD_VARIABLES,
60
+ production: PRODUCTION,
61
+ local: LOCAL,
62
+ must: MUST_OF,
63
+ groups: [{ label: 'mail', names: MAIL }],
64
+ double: {
65
+ name: 'SHOPIFY_API_BASE',
66
+ problem: 'SHOPIFY_API_BASE must be an http origin on this machine, for the Shopify double'
67
+ },
68
+ product: app.env
69
+ });
79
70
  // The double gives the app a client ID of its own: until the app is linked, its file has none.
80
71
  const testClientId = valueOf(env, 'SHOPIFY_CLIENT_ID');
81
72
  const file = toml === undefined
82
73
  ? { kind: 'problems', problems: [`${APP_TOML} is missing in the root of the app`] }
83
74
  : appToml(toml);
84
- const absent = MAIL.filter((name) => input[name] === undefined);
85
75
  const problems = [
86
- ...(base.success
87
- ? []
88
- : problemsOf(base.error.issues, SHOPIFY_STANDARD_VARIABLES, input, (name) => `${name} must be ${MUST_OF[name]}`)),
89
- ...(double === null || double.success
90
- ? []
91
- : ['SHOPIFY_API_BASE must be an http origin on this machine, for the Shopify double']),
92
- ...(testClientId !== undefined && double === null
76
+ ...(read.kind === 'problems' ? read.problems : []),
77
+ ...(testClientId !== undefined && valueOf(env, 'SHOPIFY_API_BASE') === undefined
93
78
  ? [
94
79
  `SHOPIFY_CLIENT_ID is only for the Shopify double of the tests; the client ID comes from ${APP_TOML}`
95
80
  ]
96
81
  : []),
97
- ...(own.success
98
- ? []
99
- : problemsOf(own.error.issues, names, ownInput, (name, issue) => `${name} is invalid: ${issue.message}`)),
100
- ...(file.kind === 'problems' ? file.problems : []),
101
- ...(!inProduction && absent.length > 0 && absent.length < MAIL.length
102
- ? [`mail is set only in part: ${absent.join(', ')} missing`]
103
- : [])
82
+ ...(file.kind === 'problems' ? file.problems : [])
104
83
  ];
105
- if (!base.success || !own.success || file.kind === 'problems' || problems.length > 0) {
84
+ if (read.kind === 'problems' || file.kind === 'problems' || problems.length > 0) {
106
85
  throw new ConfigError(problems);
107
86
  }
108
- const data = base.data;
87
+ const { config, data, double } = read.value;
109
88
  return {
110
- mode: inProduction ? 'production' : 'local',
89
+ ...config,
111
90
  language: app.language,
112
- publicOrigin: data.PUBLIC_ORIGIN,
113
- port: data.PORT ?? DEFAULT_PORT,
114
- databaseUrl: data.DATABASE_URL,
115
- name: data.MAIL_FROM_NAME,
116
- mail: mailOf(data),
117
91
  operator: { name: data.OPERATOR_NAME, address: data.OPERATOR_ADDRESS },
118
- salesToken: data.VERTRIEB_TOKEN,
119
92
  shopify: {
120
93
  clientId: testClientId ?? file.toml.clientId,
121
94
  handle: file.toml.handle,
@@ -123,11 +96,7 @@ export function loadShopifyConfig(env, app, toml) {
123
96
  secret: data.SHOPIFY_API_SECRET,
124
97
  tokenKey: sealKey(data.SHOPIFY_TOKEN_KEY),
125
98
  partner: { token: data.SHOPIFY_PARTNER_TOKEN, organization: data.SHOPIFY_PARTNER_ORG },
126
- api: double === null || !double.success
127
- ? { kind: 'shopify' }
128
- : { kind: 'double', url: double.data }
129
- },
130
- missing: inProduction ? [] : absent,
131
- product: own.data
99
+ api: double === null ? { kind: 'shopify' } : { kind: 'double', url: double }
100
+ }
132
101
  };
133
102
  }
@@ -1,6 +1,6 @@
1
1
  import type { Handle, HandleServerError, ServerInit } from '@sveltejs/kit';
2
2
  import type { ProductEnv } from '../product.js';
3
- import { type Limits } from '../server/limits.js';
3
+ import type { Limits } from '../server/limits.js';
4
4
  import type { Log } from '../server/log.js';
5
5
  import type { Db, Mailer } from '../server/ports.js';
6
6
  import { type AdminApi } from './admin.js';
@@ -1,13 +1,9 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { setTimeout as wait } from 'node:timers/promises';
4
- import { createDb } from '../server/adapter/db.js';
5
- import { exitAfterLog, log } from '../server/adapter/log.js';
6
- import { createMailer } from '../server/adapter/mailer.js';
7
- import { migrationSources, migrationStatus } from '../server/adapter/migrate.js';
8
- import { ConfigError } from '../server/config.js';
9
- import { CHECK_TIMEOUT_MS, CheckFailed, databaseCheck, deepHealth, mailCheck } from '../server/health.js';
10
- import { createLimits } from '../server/limits.js';
4
+ import { log } from '../server/adapter/log.js';
5
+ import { json, policyGuard, serverCore } from '../server/core.js';
6
+ import { CheckFailed } from '../server/health.js';
11
7
  import { periodic } from '../server/runs.js';
12
8
  import { bearerFits, SALES_PATH } from '../server/sales.js';
13
9
  import { ShopifyFailed, shopifyApi } from './adapter/shopify.js';
@@ -24,8 +20,6 @@ import { createShops } from './shops.js';
24
20
  import { SHOPIFY_TEXTS } from './texts.js';
25
21
  import { APP_TOML } from './toml.js';
26
22
  import { createWebhooks } from './webhooks.js';
27
- const HEALTH = '/health';
28
- const DEEP = '/health/deep';
29
23
  const PRIVACY = '/privacy';
30
24
  /** The app's src/app.html, a stub, takes the language and the client ID from these. */
31
25
  const LANG = '%baukasten.lang%';
@@ -34,77 +28,43 @@ const CLIENT_ID = '%baukasten.clientId%';
34
28
  const RETRY = { 'x-shopify-retry-invalid-session-request': '1' };
35
29
  /** The parameters of the admin that a page of the app is opened with, which the bounce page drops. */
36
30
  const ADMIN_PARAMS = ['id_token'];
37
- const PROBE_TIMEOUT_MS = CHECK_TIMEOUT_MS - 1_000;
38
31
  /** The run of the webhooks beats this often, and touches the database only when it is marked. */
39
32
  const WEBHOOK_BEAT_MS = 1_000;
40
33
  const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
41
- function json(body, status, headers = {}) {
42
- return new Response(JSON.stringify(body), {
43
- status,
44
- headers: { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers }
45
- });
46
- }
47
34
  function laterOf(a, b) {
48
35
  return a > b ? a : b;
49
36
  }
50
37
  export function shopifyBaukasten(app, hooks = {}) {
51
- const appChecks = hooks.checks ?? {};
52
- if ('database' in appChecks || 'mail' in appChecks || 'shopify' in appChecks) {
53
- throw new Error('the checks database, mail and shopify belong to the baukasten');
54
- }
38
+ /** The shop of each request that got in, by its request, which its loads and actions share. */
39
+ const sessions = new WeakMap();
40
+ const withPolicy = policyGuard('SHOPIFY_SVELTEKIT_OPTIONS', 'app');
41
+ const core = serverCore({
42
+ noun: 'app',
43
+ art: 'shopify',
44
+ check: 'shopify',
45
+ hooks,
46
+ load: (root) => {
47
+ const toml = `${root}/${APP_TOML}`;
48
+ return loadShopifyConfig(process.env, app, existsSync(toml) ? readFileSync(toml, 'utf8') : undefined);
49
+ },
50
+ open,
51
+ respond,
52
+ headers: SHOPIFY_SECURITY_HEADERS,
53
+ // The shop of a request that got in goes with it, the privacy text says so, and a request that
54
+ // did not get in has none.
55
+ logged: (event) => ({ shop: sessions.get(event.request)?.domain })
56
+ });
55
57
  // The privacy text promises a merchant what the app keeps of a customer, and its deletion.
56
58
  if (app.privacy.data.length > 0 &&
57
59
  (hooks.customerData === undefined || hooks.redactCustomer === undefined)) {
58
60
  throw new Error('the app keeps personal data for the merchant (privacy.data): it gives the hooks customerData and redactCustomer');
59
61
  }
60
- let state = { kind: 'waiting' };
61
- /** The shop of each request that got in, by its request, which its loads and actions share. */
62
- const sessions = new WeakMap();
63
- function running() {
64
- switch (state.kind) {
65
- case 'running':
66
- return state;
67
- case 'building':
68
- throw new Error('the baukasten is used in the build: its pages read the configuration of the running app and cannot be prerendered');
69
- case 'waiting':
70
- throw new Error('the baukasten is used before init ran');
71
- }
72
- }
73
- async function stop(applying, context) {
74
- await applying.stop();
75
- try {
76
- await hooks.stop?.(context);
77
- }
78
- catch (error) {
79
- process.exitCode = 1;
80
- log.error('the stop hook of the app failed', { error });
81
- }
82
- await context.db.close();
83
- log.info('stopped');
84
- }
85
- async function start() {
86
- const root = process.cwd();
87
- const toml = `${root}/${APP_TOML}`;
88
- let config;
89
- try {
90
- config = loadShopifyConfig(process.env, app, existsSync(toml) ? readFileSync(toml, 'utf8') : undefined);
91
- }
92
- catch (error) {
93
- if (!(error instanceof ConfigError))
94
- throw error;
95
- log.error('configuration incomplete, not starting', { problems: error.problems });
96
- return exitAfterLog(1);
97
- }
98
- if (config.missing.length > 0) {
99
- log.warn('running without what production needs', { missing: config.missing });
100
- }
62
+ /** Installation, shops, webhooks and the privacy text, on the pool and mail of the core. */
63
+ function open(context) {
64
+ const { config, db, mail } = context;
101
65
  if (config.shopify.clientId === null) {
102
66
  log.error(`the app is not linked at Shopify: ${APP_TOML} has no client_id, and no shop gets in`);
103
67
  }
104
- const db = createDb(config.databaseUrl, log);
105
- const mail = createMailer(config.mail, config.name, log);
106
- const limits = createLimits({ now: () => Date.now() });
107
- const context = { config, db, log, mail, limits };
108
68
  const api = shopifyApi(config.shopify, { now: () => new Date() });
109
69
  const shops = createShops({
110
70
  db,
@@ -145,10 +105,6 @@ export function shopifyBaukasten(app, hooks = {}) {
145
105
  retry?.unref();
146
106
  }
147
107
  });
148
- const stopRetry = () => {
149
- if (retry !== null)
150
- clearTimeout(retry);
151
- };
152
108
  const privacy = shopifyPrivacy({
153
109
  language: config.language,
154
110
  name: config.name,
@@ -157,10 +113,7 @@ export function shopifyBaukasten(app, hooks = {}) {
157
113
  privacy: app.privacy,
158
114
  version: laterOf(SHOPIFY_PRIVACY_DATE, app.privacyDate)
159
115
  });
160
- const probe = db.withTimeout(PROBE_TIMEOUT_MS);
161
- const sources = migrationSources(root, 'shopify');
162
- const checks = Object.fromEntries(Object.entries(appChecks).map(([name, check]) => [name, () => check(context)]));
163
- const shopifyHealth = async () => {
116
+ const check = async () => {
164
117
  if (config.shopify.clientId === null) {
165
118
  throw new CheckFailed(`no client_id in ${APP_TOML}: link the app at Shopify`);
166
119
  }
@@ -178,52 +131,29 @@ export function shopifyBaukasten(app, hooks = {}) {
178
131
  throw new CheckFailed(error instanceof ShopifyFailed ? 'the Partner API refused' : 'unreachable');
179
132
  }
180
133
  };
181
- const health = deepHealth({
182
- checks: {
183
- database: databaseCheck(probe, () => migrationStatus(probe, sources), log),
184
- mail: mailCheck(mail, log),
185
- shopify: shopifyHealth,
186
- ...checks
187
- },
188
- now: () => Date.now(),
189
- log
190
- });
191
- state = { kind: 'running', context, api, shops, webhooks, applying, privacy, health };
192
- log.info('starting', {
193
- mode: config.mode,
194
- origin: config.publicOrigin,
195
- language: config.language
196
- });
197
- applying.start({ db, log });
198
- // Webhooks that were kept before a restart are applied now.
199
- applying.mark();
200
- await hooks.start?.(context);
201
- process.once('sveltekit:shutdown', () => {
202
- stopRetry();
203
- void stop(applying, context);
204
- });
205
- }
206
- const init = (building) => () => {
207
- if (!building)
208
- return start();
209
- state = { kind: 'building' };
210
- };
211
- async function health(event) {
212
- if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
213
- return new Response(null, { status: 405, headers: { allow: 'GET, HEAD' } });
214
- }
215
- // Plain health answers without the database: Neon may sleep, the process still lives.
216
- if (event.url.pathname === HEALTH)
217
- return json({ status: 'ok' }, 200);
218
- const report = await running().health();
219
- return json(report, report.status === 'ok' ? 200 : 503);
134
+ return {
135
+ services: { api, shops, webhooks, applying, privacy },
136
+ check,
137
+ runs: {
138
+ start: () => {
139
+ applying.start({ db, log });
140
+ // Webhooks that were kept before a restart are applied now.
141
+ applying.mark();
142
+ },
143
+ stop: () => {
144
+ if (retry !== null)
145
+ clearTimeout(retry);
146
+ return applying.stop();
147
+ }
148
+ }
149
+ };
220
150
  }
221
151
  async function webhook(s, event) {
222
152
  if (event.request.method !== 'POST') {
223
153
  return new Response(null, { status: 405, headers: { allow: 'POST' } });
224
154
  }
225
155
  const body = new Uint8Array(await event.request.arrayBuffer());
226
- const received = await s.webhooks.receive(body, event.request.headers);
156
+ const received = await s.services.webhooks.receive(body, event.request.headers);
227
157
  switch (received.kind) {
228
158
  case 'unsigned':
229
159
  log.warn('shopify: a webhook without the signature of the app');
@@ -232,7 +162,7 @@ export function shopifyBaukasten(app, hooks = {}) {
232
162
  log.warn('shopify: a webhook of an unknown form', { reason: received.reason });
233
163
  return new Response(null, { status: 400 });
234
164
  case 'queued':
235
- s.applying.mark();
165
+ s.services.applying.mark();
236
166
  return new Response(null, { status: 200 });
237
167
  case 'repeated':
238
168
  return new Response(null, { status: 200 });
@@ -257,7 +187,11 @@ export function shopifyBaukasten(app, hooks = {}) {
257
187
  case SALES_PATH:
258
188
  return sales(s, event);
259
189
  case PRIVACY:
260
- return legalPage({ name: config.name, language: config.language, legal: s.privacy });
190
+ return legalPage({
191
+ name: config.name,
192
+ language: config.language,
193
+ legal: s.services.privacy
194
+ });
261
195
  case SHOPIFY_AUTH_PATH:
262
196
  return outsidePage({ name: config.name, language: config.language, texts });
263
197
  case SHOPIFY_BOUNCE_PATH: {
@@ -330,7 +264,7 @@ export function shopifyBaukasten(app, hooks = {}) {
330
264
  }
331
265
  async function entered(s, url, shop, idToken, document) {
332
266
  const { config } = s.context;
333
- const entry = await s.shops.enter(shop, idToken);
267
+ const entry = await s.services.shops.enter(shop, idToken);
334
268
  switch (entry.kind) {
335
269
  case 'in':
336
270
  return { kind: 'in', shop, document };
@@ -357,7 +291,7 @@ export function shopifyBaukasten(app, hooks = {}) {
357
291
  try {
358
292
  // Back from the plan page of Shopify, the page asks the plan again at once; its requests,
359
293
  // which may carry the parameter as well, take what it found.
360
- plan = await s.shops.plan(shop, document && event.url.searchParams.has('plan_handle'));
294
+ plan = await s.services.shops.plan(shop, document && event.url.searchParams.has('plan_handle'));
361
295
  }
362
296
  catch (error) {
363
297
  log.error('shopify: the plan of a shop could not be read', { shop, error });
@@ -386,37 +320,18 @@ export function shopifyBaukasten(app, hooks = {}) {
386
320
  })
387
321
  };
388
322
  }
389
- /**
390
- * A page without its content security policy does not go out: the policy comes from
391
- * SHOPIFY_SVELTEKIT_OPTIONS in the configuration of the app; with it, only the admin of the shop
392
- * may frame the page.
393
- */
394
- let policyMissing = false;
323
+ /** With its content security policy, a page of the app may be framed by the admin of its shop. */
395
324
  function framed(response, shop, path) {
396
- const page = response.headers.get('content-type')?.startsWith('text/html') === true;
397
- if (!page)
398
- return response;
399
- const policy = response.headers.get('content-security-policy');
400
- if (policy === null) {
401
- if (response.status >= 500)
402
- return response;
403
- if (!policyMissing) {
404
- policyMissing = true;
405
- log.error('a page without its content security policy: SHOPIFY_SVELTEKIT_OPTIONS of the baukasten is missing in sveltekit() of vite.config.ts, or an endpoint of the app answers HTML of its own; every such answer is a 500', { path });
406
- }
407
- return new Response('Internal Error', {
408
- status: 500,
409
- headers: { 'content-type': 'text/plain; charset=utf-8' }
410
- });
325
+ const guarded = withPolicy(response, path);
326
+ const page = guarded.headers.get('content-type')?.startsWith('text/html') === true;
327
+ const policy = guarded.headers.get('content-security-policy');
328
+ if (page && policy !== null) {
329
+ guarded.headers.set('content-security-policy', `${policy}; ${frameAncestors(shop)}`);
411
330
  }
412
- response.headers.set('content-security-policy', `${policy}; ${frameAncestors(shop)}`);
413
- return response;
331
+ return guarded;
414
332
  }
415
- async function respondTo(event, resolve) {
333
+ async function respond(s, event, resolve) {
416
334
  const path = event.url.pathname;
417
- if (path === HEALTH || path === DEEP)
418
- return health(event);
419
- const s = running();
420
335
  const { config } = s.context;
421
336
  const answer = own(s, event);
422
337
  if (answer !== null)
@@ -450,49 +365,11 @@ export function shopifyBaukasten(app, hooks = {}) {
450
365
  });
451
366
  return framed(response, gated.shop, path);
452
367
  }
453
- const handle = async ({ event, resolve }) => {
454
- // Prerendering in the build is no request of anyone.
455
- if (state.kind === 'building')
456
- return resolve(event);
457
- const started = performance.now();
458
- let status = 500;
459
- try {
460
- const response = await respondTo(event, resolve);
461
- for (const [name, value] of Object.entries(SHOPIFY_SECURITY_HEADERS)) {
462
- response.headers.set(name, value);
463
- }
464
- status = response.status;
465
- return response;
466
- }
467
- finally {
468
- // The container polls /health; it is logged only at debug. The path goes without its query,
469
- // which carries the ID token; the shop of a request that got in goes with it, the privacy
470
- // text says so, and a request that did not get in has none.
471
- (event.url.pathname === HEALTH ? log.debug : log.info)('request', {
472
- method: event.request.method,
473
- path: event.url.pathname,
474
- status,
475
- ms: Math.round(performance.now() - started),
476
- shop: sessions.get(event.request)?.domain
477
- });
478
- }
479
- };
480
- const handleError = ({ error, event, status, message }) => {
481
- if (status === 404)
482
- return { message };
483
- (status >= 500 ? log.error : log.warn)('request failed', {
484
- method: event.request.method,
485
- path: event.url.pathname,
486
- status,
487
- error
488
- });
489
- return { message: status >= 500 ? 'Internal Error' : message };
490
- };
491
368
  return {
492
- init,
493
- handle,
494
- handleError,
495
- context: () => running().context,
369
+ init: core.init,
370
+ handle: core.handle,
371
+ handleError: core.handleError,
372
+ context: () => core.running().context,
496
373
  shop: (event) => {
497
374
  const session = sessions.get(event.request);
498
375
  if (session === undefined) {
@@ -501,8 +378,8 @@ export function shopifyBaukasten(app, hooks = {}) {
501
378
  return session;
502
379
  },
503
380
  admin: (shop) => {
504
- const s = running();
505
- return adminApi(shop, { api: s.api, shops: s.shops, log, wait: (ms) => wait(ms) });
381
+ const { api, shops } = core.running().services;
382
+ return adminApi(shop, { api, shops, log, wait: (ms) => wait(ms) });
506
383
  }
507
384
  };
508
385
  }
@@ -24,7 +24,6 @@ const STANDARD = {
24
24
  OPERATOR_REGISTER: 'none',
25
25
  OPERATOR_VAT: 'none',
26
26
  OPERATOR_JURISDICTION: 'Baden',
27
- HOSTING_LOCATION: 'Example Hosting, Nuremberg',
28
27
  VERTRIEB_TOKEN: 'v'.repeat(64)
29
28
  };
30
29
  const VIA = 'connection-test';
@@ -1,5 +1,5 @@
1
1
  import { isShopifyApp } from '../shopify/app.js';
2
- import { SHOPIFY_VARIABLES, WEBAPP_VARIABLES } from '../variables.js';
2
+ import { RETIRED_VARIABLES, SHOPIFY_VARIABLES, WEBAPP_VARIABLES } from '../variables.js';
3
3
  const LINE = /^([A-Z][A-Z0-9_]*)=(.*)$/;
4
4
  /** What is wrong with the text of `.env.example`; empty when nothing is. */
5
5
  export function envExampleProblems(text, product) {
@@ -26,7 +26,8 @@ export function envExampleProblems(text, product) {
26
26
  const lacking = read.filter((name) => !named.includes(name));
27
27
  if (lacking.length > 0)
28
28
  problems.push(`.env.example lacks ${lacking.join(', ')}`);
29
- const unread = named.filter((name) => !read.includes(name));
29
+ const retired = RETIRED_VARIABLES;
30
+ const unread = named.filter((name) => !read.includes(name) && !retired.includes(name));
30
31
  if (unread.length > 0)
31
32
  problems.push(`.env.example names what nothing reads: ${unread.join(', ')}`);
32
33
  return problems;
@@ -19,7 +19,6 @@ const STANDARD = {
19
19
  MAIL_FROM_NAME: 'Connection Test',
20
20
  OPERATOR_NAME: 'Connection Test Operator',
21
21
  OPERATOR_ADDRESS: 'Street 1, 5400 Baden',
22
- HOSTING_LOCATION: 'Example Hosting, Nuremberg',
23
22
  VERTRIEB_TOKEN: 'v'.repeat(64)
24
23
  };
25
24
  const SHOP = 'connection-test.myshopify.com';
@@ -41,6 +41,8 @@ export interface DoubleSession {
41
41
  mode: string;
42
42
  customer: string;
43
43
  url: string;
44
+ /** Open until it is paid or expired; only an open one can be paid. */
45
+ status: 'open' | 'complete' | 'expired';
44
46
  subscription: string | null;
45
47
  payment_status: 'paid' | 'unpaid';
46
48
  amount_total: number | null;
@@ -181,6 +181,10 @@ export async function startStripe(options = {}) {
181
181
  const session = double.sessions.get(sessionId);
182
182
  if (session === undefined)
183
183
  throw new Error(`the double has no session ${sessionId}`);
184
+ if (session.status !== 'open') {
185
+ throw new Error(`the double cannot pay the session ${sessionId}: it is ${session.status}`);
186
+ }
187
+ session.status = 'complete';
184
188
  const price = double.prices.get(session.item.price);
185
189
  if (price === undefined)
186
190
  throw new Error(`the double has no price ${session.item.price}`);
@@ -292,7 +296,21 @@ export async function startStripe(options = {}) {
292
296
  };
293
297
  if (method !== 'GET' && path.startsWith('/v1/'))
294
298
  double.writes.push(`${method} ${path}`);
295
- const list = (data) => send(200, { object: 'list', data, has_more: false, url: path });
299
+ /** One page of a list, as Stripe gives it: `limit` items (10 without), after `starting_after`. */
300
+ const list = (data) => {
301
+ const limit = whole(query, 'limit') ?? 10;
302
+ const after = text(query, 'starting_after');
303
+ const at = after === null ? -1 : data.findIndex((item) => item.id === after);
304
+ if (after !== null && at < 0)
305
+ return missing(`object: '${after}'`);
306
+ const from = at + 1;
307
+ return send(200, {
308
+ object: 'list',
309
+ data: data.slice(from, from + limit),
310
+ has_more: from + limit < data.length,
311
+ url: path
312
+ });
313
+ };
296
314
  let match;
297
315
  if (path === '/v1/products' && method === 'POST') {
298
316
  const id = text(form, 'id') ?? next('prod');
@@ -423,6 +441,7 @@ export async function startStripe(options = {}) {
423
441
  mode: text(form, 'mode') ?? '',
424
442
  customer: text(form, 'customer') ?? '',
425
443
  url: `${base}/pay/${id}`,
444
+ status: 'open',
426
445
  subscription: null,
427
446
  payment_status: 'unpaid',
428
447
  amount_total: null,
@@ -440,6 +459,21 @@ export async function startStripe(options = {}) {
440
459
  double.sessions.set(id, session);
441
460
  return send(200, session);
442
461
  }
462
+ if ((match = /^\/v1\/checkout\/sessions\/([^/]+)(\/expire)?$/.exec(path)) !== null) {
463
+ const session = double.sessions.get(match[1] ?? '');
464
+ if (session === undefined || customerOf(session.customer) === undefined) {
465
+ return missing('checkout.session');
466
+ }
467
+ if (match[2] === undefined)
468
+ return send(200, session);
469
+ if (method !== 'POST')
470
+ return failed(405, `unknown ${method} ${path}`);
471
+ if (session.status !== 'open') {
472
+ return failed(400, `Only Checkout Sessions with a status in [open] can be expired.`);
473
+ }
474
+ session.status = 'expired';
475
+ return send(200, session);
476
+ }
443
477
  if (path === '/v1/billing_portal/sessions' && method === 'POST') {
444
478
  if (customerOf(text(form, 'customer')) === undefined)
445
479
  return missing('customer');