@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
|
@@ -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) {
|
package/dist/shopify/config.js
CHANGED
|
@@ -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,
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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 (
|
|
84
|
+
if (read.kind === 'problems' || file.kind === 'problems' || problems.length > 0) {
|
|
106
85
|
throw new ConfigError(problems);
|
|
107
86
|
}
|
|
108
|
-
const data =
|
|
87
|
+
const { config, data, double } = read.value;
|
|
109
88
|
return {
|
|
110
|
-
|
|
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
|
|
127
|
-
|
|
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
|
}
|
package/dist/shopify/hook.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Handle, HandleServerError, ServerInit } from '@sveltejs/kit';
|
|
2
2
|
import type { ProductEnv } from '../product.js';
|
|
3
|
-
import {
|
|
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';
|
package/dist/shopify/hook.js
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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({
|
|
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
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
413
|
-
return response;
|
|
331
|
+
return guarded;
|
|
414
332
|
}
|
|
415
|
-
async function
|
|
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
|
|
505
|
-
return adminApi(shop, { api
|
|
381
|
+
const { api, shops } = core.running().services;
|
|
382
|
+
return adminApi(shop, { api, shops, log, wait: (ms) => wait(ms) });
|
|
506
383
|
}
|
|
507
384
|
};
|
|
508
385
|
}
|
|
@@ -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
|
|
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';
|
package/dist/testing/stripe.d.ts
CHANGED
|
@@ -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;
|
package/dist/testing/stripe.js
CHANGED
|
@@ -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
|
-
|
|
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');
|