@tollwyn/baukasten 0.0.2 → 1.2.2
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/README.md +254 -1
- package/dist/bin/check.d.ts +2 -0
- package/dist/bin/check.js +38 -0
- package/dist/bin/migrate.d.ts +2 -0
- package/dist/bin/migrate.js +111 -0
- package/dist/bin/stubs.d.ts +2 -0
- package/dist/bin/stubs.js +38 -0
- package/dist/bin/test-accounts.d.ts +2 -0
- package/dist/bin/test-accounts.js +62 -0
- package/dist/check.d.ts +20 -0
- package/dist/check.js +285 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +9 -0
- package/dist/product.d.ts +161 -0
- package/dist/product.js +152 -0
- package/dist/server/accounts.d.ts +128 -0
- package/dist/server/accounts.js +282 -0
- package/dist/server/adapter/db.d.ts +5 -0
- package/dist/server/adapter/db.js +63 -0
- package/dist/server/adapter/log.d.ts +3 -0
- package/dist/server/adapter/log.js +12 -0
- package/dist/server/adapter/mailer.d.ts +4 -0
- package/dist/server/adapter/mailer.js +56 -0
- package/dist/server/adapter/migrate.d.ts +21 -0
- package/dist/server/adapter/migrate.js +113 -0
- package/dist/server/adapter/secrets.d.ts +2 -0
- package/dist/server/adapter/secrets.js +40 -0
- package/dist/server/adapter/stripe.d.ts +11 -0
- package/dist/server/adapter/stripe.js +333 -0
- package/dist/server/api.d.ts +25 -0
- package/dist/server/api.js +98 -0
- package/dist/server/baukasten-migrations.d.ts +4 -0
- package/dist/server/baukasten-migrations.js +126 -0
- package/dist/server/billing.d.ts +94 -0
- package/dist/server/billing.js +349 -0
- package/dist/server/catalog.d.ts +39 -0
- package/dist/server/catalog.js +193 -0
- package/dist/server/config.d.ts +108 -0
- package/dist/server/config.js +244 -0
- package/dist/server/endpoints.d.ts +44 -0
- package/dist/server/endpoints.js +159 -0
- package/dist/server/fields.d.ts +7 -0
- package/dist/server/fields.js +18 -0
- package/dist/server/headers.d.ts +1 -0
- package/dist/server/headers.js +9 -0
- package/dist/server/health.d.ts +34 -0
- package/dist/server/health.js +116 -0
- package/dist/server/hook.d.ts +92 -0
- package/dist/server/hook.js +293 -0
- package/dist/server/index.d.ts +18 -0
- package/dist/server/index.js +11 -0
- package/dist/server/legal/de.d.ts +2 -0
- package/dist/server/legal/de.js +251 -0
- package/dist/server/legal/document.d.ts +37 -0
- package/dist/server/legal/document.js +30 -0
- package/dist/server/legal/en.d.ts +2 -0
- package/dist/server/legal/en.js +251 -0
- package/dist/server/legal/fleet.d.ts +114 -0
- package/dist/server/legal/fleet.js +89 -0
- package/dist/server/legal/index.d.ts +3 -0
- package/dist/server/legal/index.js +11 -0
- package/dist/server/limits.d.ts +58 -0
- package/dist/server/limits.js +76 -0
- package/dist/server/log.d.ts +26 -0
- package/dist/server/log.js +61 -0
- package/dist/server/machine.d.ts +4 -0
- package/dist/server/machine.js +57 -0
- package/dist/server/mail.d.ts +20 -0
- package/dist/server/mail.js +29 -0
- package/dist/server/messages.d.ts +105 -0
- package/dist/server/messages.js +232 -0
- package/dist/server/migrations.d.ts +28 -0
- package/dist/server/migrations.js +59 -0
- package/dist/server/openapi.d.ts +24 -0
- package/dist/server/openapi.js +173 -0
- package/dist/server/pages.d.ts +158 -0
- package/dist/server/pages.js +491 -0
- package/dist/server/ports.d.ts +273 -0
- package/dist/server/ports.js +1 -0
- package/dist/server/runs.d.ts +30 -0
- package/dist/server/runs.js +55 -0
- package/dist/server/sales.d.ts +38 -0
- package/dist/server/sales.js +132 -0
- package/dist/server/services.d.ts +57 -0
- package/dist/server/services.js +44 -0
- package/dist/server/start.d.ts +14 -0
- package/dist/server/start.js +65 -0
- package/dist/server/stripe.d.ts +33 -0
- package/dist/server/stripe.js +29 -0
- package/dist/server/terms.d.ts +7 -0
- package/dist/server/terms.js +9 -0
- package/dist/server/test-accounts.d.ts +28 -0
- package/dist/server/test-accounts.js +62 -0
- package/dist/server/via.d.ts +9 -0
- package/dist/server/via.js +15 -0
- package/dist/server/web.d.ts +48 -0
- package/dist/server/web.js +105 -0
- package/dist/shopify/adapter/shopify.d.ts +8 -0
- package/dist/shopify/adapter/shopify.js +232 -0
- package/dist/shopify/admin.d.ts +33 -0
- package/dist/shopify/admin.js +47 -0
- package/dist/shopify/app.d.ts +60 -0
- package/dist/shopify/app.js +67 -0
- package/dist/shopify/config.d.ts +53 -0
- package/dist/shopify/config.js +133 -0
- package/dist/shopify/constants.d.ts +27 -0
- package/dist/shopify/constants.js +27 -0
- package/dist/shopify/headers.d.ts +1 -0
- package/dist/shopify/headers.js +8 -0
- package/dist/shopify/hmac.d.ts +1 -0
- package/dist/shopify/hmac.js +9 -0
- package/dist/shopify/hook.d.ts +60 -0
- package/dist/shopify/hook.js +508 -0
- package/dist/shopify/id-token.d.ts +12 -0
- package/dist/shopify/id-token.js +66 -0
- package/dist/shopify/index.d.ts +19 -0
- package/dist/shopify/index.js +15 -0
- package/dist/shopify/legal/de.d.ts +3 -0
- package/dist/shopify/legal/de.js +123 -0
- package/dist/shopify/legal/en.d.ts +3 -0
- package/dist/shopify/legal/en.js +121 -0
- package/dist/shopify/legal/index.d.ts +19 -0
- package/dist/shopify/legal/index.js +8 -0
- package/dist/shopify/legal/shopify.d.ts +16 -0
- package/dist/shopify/legal/shopify.js +21 -0
- package/dist/shopify/migrations.d.ts +4 -0
- package/dist/shopify/migrations.js +67 -0
- package/dist/shopify/pages.d.ts +45 -0
- package/dist/shopify/pages.js +146 -0
- package/dist/shopify/ports.d.ts +77 -0
- package/dist/shopify/ports.js +1 -0
- package/dist/shopify/sales.d.ts +4 -0
- package/dist/shopify/sales.js +27 -0
- package/dist/shopify/scopes.d.ts +2 -0
- package/dist/shopify/scopes.js +13 -0
- package/dist/shopify/seal.d.ts +12 -0
- package/dist/shopify/seal.js +60 -0
- package/dist/shopify/shop.d.ts +4 -0
- package/dist/shopify/shop.js +15 -0
- package/dist/shopify/shops.d.ts +76 -0
- package/dist/shopify/shops.js +256 -0
- package/dist/shopify/sveltekit.d.ts +19 -0
- package/dist/shopify/sveltekit.js +18 -0
- package/dist/shopify/texts.d.ts +28 -0
- package/dist/shopify/texts.js +49 -0
- package/dist/shopify/toml.d.ts +22 -0
- package/dist/shopify/toml.js +125 -0
- package/dist/shopify/webhooks.d.ts +65 -0
- package/dist/shopify/webhooks.js +220 -0
- package/dist/stubs.d.ts +15 -0
- package/dist/stubs.js +287 -0
- package/dist/sveltekit.d.ts +20 -0
- package/dist/sveltekit.js +18 -0
- package/dist/testing/app.d.ts +36 -0
- package/dist/testing/app.js +155 -0
- package/dist/testing/browser.d.ts +21 -0
- package/dist/testing/browser.js +21 -0
- package/dist/testing/connection.d.ts +7 -0
- package/dist/testing/connection.js +303 -0
- package/dist/testing/database.d.ts +21 -0
- package/dist/testing/database.js +54 -0
- package/dist/testing/env-example.d.ts +4 -0
- package/dist/testing/env-example.js +33 -0
- package/dist/testing/index.d.ts +9 -0
- package/dist/testing/index.js +9 -0
- package/dist/testing/shopify-connection.d.ts +8 -0
- package/dist/testing/shopify-connection.js +313 -0
- package/dist/testing/shopify.d.ts +71 -0
- package/dist/testing/shopify.js +278 -0
- package/dist/testing/smtp.d.ts +38 -0
- package/dist/testing/smtp.js +165 -0
- package/dist/testing/stripe.d.ts +128 -0
- package/dist/testing/stripe.js +511 -0
- package/dist/texts.d.ts +139 -0
- package/dist/texts.js +265 -0
- package/dist/ui/AccountPage.svelte +143 -0
- package/dist/ui/AccountPage.svelte.d.ts +10 -0
- package/dist/ui/ConfirmEmailPage.svelte +21 -0
- package/dist/ui/ConfirmEmailPage.svelte.d.ts +10 -0
- package/dist/ui/ErrorPage.svelte +14 -0
- package/dist/ui/ErrorPage.svelte.d.ts +3 -0
- package/dist/ui/Field.svelte +12 -0
- package/dist/ui/Field.svelte.d.ts +10 -0
- package/dist/ui/ForgotPasswordPage.svelte +32 -0
- package/dist/ui/ForgotPasswordPage.svelte.d.ts +7 -0
- package/dist/ui/Form.svelte +30 -0
- package/dist/ui/Form.svelte.d.ts +11 -0
- package/dist/ui/Frame.svelte +84 -0
- package/dist/ui/Frame.svelte.d.ts +12 -0
- package/dist/ui/LegalPage.svelte +38 -0
- package/dist/ui/LegalPage.svelte.d.ts +9 -0
- package/dist/ui/Linked.svelte +5 -0
- package/dist/ui/Linked.svelte.d.ts +8 -0
- package/dist/ui/LoginPage.svelte +41 -0
- package/dist/ui/LoginPage.svelte.d.ts +10 -0
- package/dist/ui/LogoutPage.svelte +19 -0
- package/dist/ui/LogoutPage.svelte.d.ts +9 -0
- package/dist/ui/ResetPasswordPage.svelte +30 -0
- package/dist/ui/ResetPasswordPage.svelte.d.ts +10 -0
- package/dist/ui/SignupPage.svelte +52 -0
- package/dist/ui/SignupPage.svelte.d.ts +10 -0
- package/dist/ui/VerifyEmailPage.svelte +50 -0
- package/dist/ui/VerifyEmailPage.svelte.d.ts +10 -0
- package/dist/ui/base.css +287 -0
- package/dist/ui/data.d.ts +167 -0
- package/dist/ui/data.js +1 -0
- package/dist/ui/frame.d.ts +17 -0
- package/dist/ui/frame.js +27 -0
- package/dist/ui/index.d.ts +15 -0
- package/dist/ui/index.js +15 -0
- package/dist/variables.d.ts +20 -0
- package/dist/variables.js +62 -0
- package/package.json +88 -4
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Log } from '../server/log.js';
|
|
2
|
+
import type { ShopifyApi } from './ports.js';
|
|
3
|
+
import type { Shops } from './shops.js';
|
|
4
|
+
/** The shop gave no token that works: it removed the app, or only the merchant can get a new one. */
|
|
5
|
+
export declare class ShopGone extends Error {
|
|
6
|
+
name: string;
|
|
7
|
+
}
|
|
8
|
+
/** The Admin API refused the query; the errors as Shopify named them. */
|
|
9
|
+
export declare class AdminRefused extends Error {
|
|
10
|
+
name: string;
|
|
11
|
+
readonly errors: readonly {
|
|
12
|
+
message: string;
|
|
13
|
+
code: string | null;
|
|
14
|
+
}[];
|
|
15
|
+
constructor(errors: readonly {
|
|
16
|
+
message: string;
|
|
17
|
+
code: string | null;
|
|
18
|
+
}[]);
|
|
19
|
+
}
|
|
20
|
+
export interface AdminApi {
|
|
21
|
+
/**
|
|
22
|
+
* The data of a GraphQL query, unparsed: the app parses what it asked for. Over the shop's limit
|
|
23
|
+
* it waits and tries again a few times, then fails; ShopGone and AdminRefused name the other
|
|
24
|
+
* failures.
|
|
25
|
+
*/
|
|
26
|
+
graphql(query: string, variables?: Readonly<Record<string, unknown>>): Promise<unknown>;
|
|
27
|
+
}
|
|
28
|
+
export declare function adminApi(shop: string, deps: {
|
|
29
|
+
api: ShopifyApi;
|
|
30
|
+
shops: Shops;
|
|
31
|
+
log: Log;
|
|
32
|
+
wait: (ms: number) => Promise<void>;
|
|
33
|
+
}): AdminApi;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Over its limit, a query waits and tries again this often before it fails. */
|
|
2
|
+
const THROTTLE_TRIES = 5;
|
|
3
|
+
/** The shop gave no token that works: it removed the app, or only the merchant can get a new one. */
|
|
4
|
+
export class ShopGone extends Error {
|
|
5
|
+
name = 'ShopGone';
|
|
6
|
+
}
|
|
7
|
+
/** The Admin API refused the query; the errors as Shopify named them. */
|
|
8
|
+
export class AdminRefused extends Error {
|
|
9
|
+
name = 'AdminRefused';
|
|
10
|
+
errors;
|
|
11
|
+
constructor(errors) {
|
|
12
|
+
super(`the Admin API refused the query: ${errors.map((e) => e.code ?? e.message).join(', ')}`);
|
|
13
|
+
this.errors = errors;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function adminApi(shop, deps) {
|
|
17
|
+
return {
|
|
18
|
+
async graphql(query, variables = {}) {
|
|
19
|
+
for (let attempt = 1;; attempt += 1) {
|
|
20
|
+
const token = await deps.shops.accessToken(shop);
|
|
21
|
+
if (token === null)
|
|
22
|
+
throw new ShopGone(`${shop} gave the app no token that works`);
|
|
23
|
+
const answer = await deps.api.graphql(shop, token, query, variables);
|
|
24
|
+
switch (answer.kind) {
|
|
25
|
+
case 'data':
|
|
26
|
+
return answer.data;
|
|
27
|
+
case 'errors':
|
|
28
|
+
throw new AdminRefused(answer.errors);
|
|
29
|
+
case 'unauthorized':
|
|
30
|
+
// A refresh meanwhile ended the token this query took: the next try takes the new one.
|
|
31
|
+
if (!(await deps.shops.forgetTokens(shop, token)) && attempt < THROTTLE_TRIES)
|
|
32
|
+
break;
|
|
33
|
+
throw new ShopGone(`${shop} no longer takes the token of the app`);
|
|
34
|
+
case 'throttled':
|
|
35
|
+
if (attempt >= THROTTLE_TRIES) {
|
|
36
|
+
throw new Error(`the Admin API of ${shop} stayed over its limit`);
|
|
37
|
+
}
|
|
38
|
+
deps.log.debug('shopify: over the limit of a shop, waiting', {
|
|
39
|
+
shop,
|
|
40
|
+
ms: answer.waitMs
|
|
41
|
+
});
|
|
42
|
+
await deps.wait(answer.waitMs);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type Language, type ProductEnv, type Role, type Transfer } from '../product.js';
|
|
2
|
+
/** A plan of the app, by the handle Shopify App Pricing gives it in the Partner Dashboard. */
|
|
3
|
+
export interface ShopifyPlan {
|
|
4
|
+
handle: string;
|
|
5
|
+
/** Whether it costs money: a shop on it past its trial counts as paying in the sales numbers. */
|
|
6
|
+
paid: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* What the privacy text says of the app, in its language, and true. The sentences are whole, each
|
|
10
|
+
* ending with a full stop; the parts of a sentence have none at their end.
|
|
11
|
+
*/
|
|
12
|
+
export interface ShopifyPrivacy {
|
|
13
|
+
/** What the app does for the merchant, in whole sentences: the purpose of all it processes. */
|
|
14
|
+
service: string;
|
|
15
|
+
/**
|
|
16
|
+
* The personal data it processes for the merchant besides the shop of the baukasten, like the
|
|
17
|
+
* customers on the orders it reads: the merchant is their controller, the operator its processor.
|
|
18
|
+
*/
|
|
19
|
+
data: readonly {
|
|
20
|
+
/** A short name, like «Customers on orders». */
|
|
21
|
+
name: string;
|
|
22
|
+
/** What they are, like «the name, address and email address on each order». */
|
|
23
|
+
what: string;
|
|
24
|
+
/** Where they come from, like «your shop at Shopify». */
|
|
25
|
+
source: string;
|
|
26
|
+
/** Why, like «to write the invoice of each order to your accounting». */
|
|
27
|
+
purpose: string;
|
|
28
|
+
/** How long, like «until the invoice is written; then only its number stays». */
|
|
29
|
+
kept: string;
|
|
30
|
+
}[];
|
|
31
|
+
/** Who gets personal data from it besides Shopify and the providers of the fleet. */
|
|
32
|
+
recipients: readonly {
|
|
33
|
+
/** Like «Pennylane SAS, your accounting». */
|
|
34
|
+
name: string;
|
|
35
|
+
/** The country, like «France». */
|
|
36
|
+
place: string;
|
|
37
|
+
transfer: Transfer;
|
|
38
|
+
/** A processor is a sub-processor of the operator, which the merchant approves. */
|
|
39
|
+
role: Role;
|
|
40
|
+
/** What it gets and why, like «gets the invoices of your orders, in your own account». */
|
|
41
|
+
purpose: string;
|
|
42
|
+
}[];
|
|
43
|
+
}
|
|
44
|
+
export interface ShopifyApp<E extends ProductEnv = ProductEnv> {
|
|
45
|
+
/** The privacy text and the few pages of the baukasten come in this language. */
|
|
46
|
+
language: Language;
|
|
47
|
+
env: E;
|
|
48
|
+
/** Every plan of the app, as it stands in the Partner Dashboard. */
|
|
49
|
+
plans: readonly ShopifyPlan[];
|
|
50
|
+
/**
|
|
51
|
+
* The day the app's part of its privacy text (`privacy`) last changed, as YYYY-MM-DD; the text is
|
|
52
|
+
* dated by the later of this day and that of the baukasten's part.
|
|
53
|
+
*/
|
|
54
|
+
privacyDate: string;
|
|
55
|
+
privacy: ShopifyPrivacy;
|
|
56
|
+
}
|
|
57
|
+
/** Whether `value` is an app that defineShopifyApp accepted, such as baukasten.config.ts exports. */
|
|
58
|
+
export declare function isShopifyApp(value: unknown): value is ShopifyApp;
|
|
59
|
+
/** Declares the configuration of a Shopify app; anything wrong with it stops the app loudly. */
|
|
60
|
+
export declare function defineShopifyApp<const E extends ProductEnv>(app: ShopifyApp<E>): ShopifyApp<E>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { LANGUAGES, ROLES, TRANSFERS } from '../product.js';
|
|
3
|
+
import { BAUKASTEN_VARIABLES } from '../variables.js';
|
|
4
|
+
const NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
5
|
+
const OWN = new Set(BAUKASTEN_VARIABLES);
|
|
6
|
+
const text = z
|
|
7
|
+
.string('must not be empty')
|
|
8
|
+
.trim()
|
|
9
|
+
.min(1, { error: 'must not be empty', abort: true });
|
|
10
|
+
const sentences = text.regex(/[.!?]$/, 'must be whole sentences, ending with a full stop');
|
|
11
|
+
const part = text.regex(/[^.!?;:,]$/, 'must be part of a sentence, without a full stop at the end');
|
|
12
|
+
const SETTINGS = z.object({
|
|
13
|
+
language: z.enum(LANGUAGES, 'must be en or de'),
|
|
14
|
+
plans: z
|
|
15
|
+
.array(z.object({
|
|
16
|
+
handle: z
|
|
17
|
+
.string()
|
|
18
|
+
.regex(/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/, 'must be a handle like basic or pro-yearly'),
|
|
19
|
+
paid: z.boolean('must be true or false')
|
|
20
|
+
}))
|
|
21
|
+
.min(1, 'must name a plan')
|
|
22
|
+
.superRefine((plans, ctx) => {
|
|
23
|
+
const handles = plans.map((plan) => plan.handle);
|
|
24
|
+
for (const handle of handles.filter((h, i) => handles.indexOf(h) !== i)) {
|
|
25
|
+
ctx.addIssue({ code: 'custom', message: `names the handle ${handle} twice` });
|
|
26
|
+
}
|
|
27
|
+
}),
|
|
28
|
+
privacyDate: z.iso.date('must be a day like 2026-10-08'),
|
|
29
|
+
privacy: z.object({
|
|
30
|
+
service: sentences,
|
|
31
|
+
data: z.array(z.object({ name: part, what: part, source: part, purpose: part, kept: part })),
|
|
32
|
+
recipients: z.array(z.object({
|
|
33
|
+
name: part,
|
|
34
|
+
place: part,
|
|
35
|
+
transfer: z.enum(TRANSFERS, 'must be adequate, dpf or scc'),
|
|
36
|
+
role: z.enum(ROLES, 'must be processor or controller'),
|
|
37
|
+
purpose: part
|
|
38
|
+
}))
|
|
39
|
+
}, 'must be given')
|
|
40
|
+
});
|
|
41
|
+
/** What defineShopifyApp accepted; the migration command finds the app by it. */
|
|
42
|
+
const DEFINED = new WeakSet();
|
|
43
|
+
/** Whether `value` is an app that defineShopifyApp accepted, such as baukasten.config.ts exports. */
|
|
44
|
+
export function isShopifyApp(value) {
|
|
45
|
+
return typeof value === 'object' && value !== null && DEFINED.has(value);
|
|
46
|
+
}
|
|
47
|
+
/** Declares the configuration of a Shopify app; anything wrong with it stops the app loudly. */
|
|
48
|
+
export function defineShopifyApp(app) {
|
|
49
|
+
const names = Object.keys(app.env);
|
|
50
|
+
const problems = [];
|
|
51
|
+
const taken = names.filter((name) => OWN.has(name));
|
|
52
|
+
if (taken.length > 0) {
|
|
53
|
+
problems.push(`variables ${taken.join(', ')} ${taken.length === 1 ? 'is' : 'are'} read by the baukasten itself`);
|
|
54
|
+
}
|
|
55
|
+
const malformed = names.filter((name) => !NAME.test(name));
|
|
56
|
+
if (malformed.length > 0) {
|
|
57
|
+
problems.push(`variables ${malformed.join(', ')} must look like NAME_IN_CAPITALS`);
|
|
58
|
+
}
|
|
59
|
+
const settings = SETTINGS.safeParse(app);
|
|
60
|
+
if (!settings.success) {
|
|
61
|
+
problems.push(...settings.error.issues.map((issue) => `${issue.path.join('.')} ${issue.message}`));
|
|
62
|
+
}
|
|
63
|
+
if (problems.length > 0)
|
|
64
|
+
throw new Error(`the app: ${problems.join('; ')}`);
|
|
65
|
+
DEFINED.add(app);
|
|
66
|
+
return app;
|
|
67
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { Language, ProductEnv } from '../product.js';
|
|
3
|
+
import { type Env, type Mail } from '../server/config.js';
|
|
4
|
+
import { type ShopifyStandardVariable } from '../variables.js';
|
|
5
|
+
import type { ShopifyApp } from './app.js';
|
|
6
|
+
import { type SealKey } from './seal.js';
|
|
7
|
+
export interface ShopifyConfig<E extends ProductEnv = ProductEnv> {
|
|
8
|
+
mode: 'production' | 'local';
|
|
9
|
+
language: Language;
|
|
10
|
+
/** Every address of the app starts with it. */
|
|
11
|
+
publicOrigin: string;
|
|
12
|
+
port: number;
|
|
13
|
+
databaseUrl: string;
|
|
14
|
+
/** The name of the app in its mails and pages: MAIL_FROM_NAME. */
|
|
15
|
+
name: string;
|
|
16
|
+
mail: Mail;
|
|
17
|
+
/** Who runs the app, for its privacy text. */
|
|
18
|
+
operator: {
|
|
19
|
+
name: string;
|
|
20
|
+
address: string;
|
|
21
|
+
};
|
|
22
|
+
/** The bearer token of GET /vertrieb/zahlen. */
|
|
23
|
+
salesToken: string;
|
|
24
|
+
shopify: {
|
|
25
|
+
/** Null until the app is linked at Shopify: then /health/deep fails and no shop gets in. */
|
|
26
|
+
clientId: string | null;
|
|
27
|
+
/** The handle of the app in the admin. */
|
|
28
|
+
handle: string;
|
|
29
|
+
/** The scopes of shopify.app.toml, which the tokens of a shop must cover. */
|
|
30
|
+
scopes: string;
|
|
31
|
+
/** The client secret: it signs ID tokens and webhooks and takes the tokens of the shops. */
|
|
32
|
+
secret: string;
|
|
33
|
+
/** Seals the tokens of the shops in the database. */
|
|
34
|
+
tokenKey: SealKey;
|
|
35
|
+
/** The Partner API, which alone says the plan of a shop. */
|
|
36
|
+
partner: {
|
|
37
|
+
token: string;
|
|
38
|
+
organization: string;
|
|
39
|
+
};
|
|
40
|
+
/** Shopify itself, or in the tests of an app its double on this machine. */
|
|
41
|
+
api: {
|
|
42
|
+
kind: 'shopify';
|
|
43
|
+
} | {
|
|
44
|
+
kind: 'double';
|
|
45
|
+
url: string;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
/** What runs without locally; empty in production. */
|
|
49
|
+
missing: readonly ShopifyStandardVariable[];
|
|
50
|
+
product: z.output<z.ZodObject<E>>;
|
|
51
|
+
}
|
|
52
|
+
/** Reads the configuration; ConfigError names every problem at once. `toml` is the text of the file. */
|
|
53
|
+
export declare function loadShopifyConfig<E extends ProductEnv>(env: Env, app: ShopifyApp<E>, toml: string | undefined): ShopifyConfig<E>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Locally mail may be missing (mails go to the log); Shopify is needed in either mode, since
|
|
2
|
+
// without it no page of the app opens.
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { ConfigError, DEFAULT_PORT, MUST, problemsOf, SHAPE, valueOf } from '../server/config.js';
|
|
5
|
+
import { SHOPIFY_STANDARD_VARIABLES } from '../variables.js';
|
|
6
|
+
import { sealKey } from './seal.js';
|
|
7
|
+
import { APP_TOML, appToml } from './toml.js';
|
|
8
|
+
const SECRET = z.string().regex(/^\S{16,}$/);
|
|
9
|
+
const OWN_SHAPE = {
|
|
10
|
+
SHOPIFY_API_SECRET: SECRET,
|
|
11
|
+
SHOPIFY_TOKEN_KEY: z.string().regex(/^[0-9a-f]{64}$/),
|
|
12
|
+
SHOPIFY_PARTNER_TOKEN: SECRET,
|
|
13
|
+
SHOPIFY_PARTNER_ORG: z.string().regex(/^\d{1,20}$/)
|
|
14
|
+
};
|
|
15
|
+
const OWN_MUST = {
|
|
16
|
+
SHOPIFY_API_SECRET: 'the client secret of the app, at least 16 characters without a blank',
|
|
17
|
+
SHOPIFY_TOKEN_KEY: '64 hex digits, the key that seals the tokens of the shops',
|
|
18
|
+
SHOPIFY_PARTNER_TOKEN: 'a token of the Partner API, at least 16 characters without a blank',
|
|
19
|
+
SHOPIFY_PARTNER_ORG: 'the number of the Partner organization'
|
|
20
|
+
};
|
|
21
|
+
const SHARED = {
|
|
22
|
+
PUBLIC_ORIGIN: SHAPE.PUBLIC_ORIGIN,
|
|
23
|
+
PORT: SHAPE.PORT,
|
|
24
|
+
DATABASE_URL: SHAPE.DATABASE_URL,
|
|
25
|
+
SMTP_HOST: SHAPE.SMTP_HOST,
|
|
26
|
+
SMTP_PORT: SHAPE.SMTP_PORT,
|
|
27
|
+
MAIL_FROM: SHAPE.MAIL_FROM,
|
|
28
|
+
MAIL_FROM_NAME: SHAPE.MAIL_FROM_NAME,
|
|
29
|
+
OPERATOR_NAME: SHAPE.OPERATOR_NAME,
|
|
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
|
+
VERTRIEB_TOKEN: SHAPE.VERTRIEB_TOKEN
|
|
34
|
+
};
|
|
35
|
+
const FULL_SHAPE = { ...SHARED, ...OWN_SHAPE };
|
|
36
|
+
const MUST_OF = {
|
|
37
|
+
PUBLIC_ORIGIN: MUST.PUBLIC_ORIGIN,
|
|
38
|
+
PORT: MUST.PORT,
|
|
39
|
+
DATABASE_URL: MUST.DATABASE_URL,
|
|
40
|
+
SMTP_HOST: MUST.SMTP_HOST,
|
|
41
|
+
SMTP_PORT: MUST.SMTP_PORT,
|
|
42
|
+
MAIL_FROM: MUST.MAIL_FROM,
|
|
43
|
+
MAIL_FROM_NAME: MUST.MAIL_FROM_NAME,
|
|
44
|
+
OPERATOR_NAME: MUST.OPERATOR_NAME,
|
|
45
|
+
OPERATOR_ADDRESS: MUST.OPERATOR_ADDRESS,
|
|
46
|
+
HOSTING_LOCATION: MUST.HOSTING_LOCATION,
|
|
47
|
+
VERTRIEB_TOKEN: MUST.VERTRIEB_TOKEN,
|
|
48
|
+
...OWN_MUST
|
|
49
|
+
};
|
|
50
|
+
const MAIL = ['SMTP_HOST', 'SMTP_PORT', 'MAIL_FROM'];
|
|
51
|
+
const PRODUCTION = z.object(FULL_SHAPE);
|
|
52
|
+
const LOCAL = z.object({
|
|
53
|
+
...FULL_SHAPE,
|
|
54
|
+
PORT: FULL_SHAPE.PORT.optional(),
|
|
55
|
+
SMTP_HOST: FULL_SHAPE.SMTP_HOST.optional(),
|
|
56
|
+
SMTP_PORT: FULL_SHAPE.SMTP_PORT.optional(),
|
|
57
|
+
MAIL_FROM: FULL_SHAPE.MAIL_FROM.optional()
|
|
58
|
+
});
|
|
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
|
+
/** Reads the configuration; ConfigError names every problem at once. `toml` is the text of the file. */
|
|
70
|
+
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);
|
|
79
|
+
// The double gives the app a client ID of its own: until the app is linked, its file has none.
|
|
80
|
+
const testClientId = valueOf(env, 'SHOPIFY_CLIENT_ID');
|
|
81
|
+
const file = toml === undefined
|
|
82
|
+
? { kind: 'problems', problems: [`${APP_TOML} is missing in the root of the app`] }
|
|
83
|
+
: appToml(toml);
|
|
84
|
+
const absent = MAIL.filter((name) => input[name] === undefined);
|
|
85
|
+
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
|
|
93
|
+
? [
|
|
94
|
+
`SHOPIFY_CLIENT_ID is only for the Shopify double of the tests; the client ID comes from ${APP_TOML}`
|
|
95
|
+
]
|
|
96
|
+
: []),
|
|
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
|
+
: [])
|
|
104
|
+
];
|
|
105
|
+
if (!base.success || !own.success || file.kind === 'problems' || problems.length > 0) {
|
|
106
|
+
throw new ConfigError(problems);
|
|
107
|
+
}
|
|
108
|
+
const data = base.data;
|
|
109
|
+
return {
|
|
110
|
+
mode: inProduction ? 'production' : 'local',
|
|
111
|
+
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
|
+
operator: { name: data.OPERATOR_NAME, address: data.OPERATOR_ADDRESS },
|
|
118
|
+
salesToken: data.VERTRIEB_TOKEN,
|
|
119
|
+
shopify: {
|
|
120
|
+
clientId: testClientId ?? file.toml.clientId,
|
|
121
|
+
handle: file.toml.handle,
|
|
122
|
+
scopes: file.toml.scopes,
|
|
123
|
+
secret: data.SHOPIFY_API_SECRET,
|
|
124
|
+
tokenKey: sealKey(data.SHOPIFY_TOKEN_KEY),
|
|
125
|
+
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
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version of the Admin API, for every request of the baukasten and the app and for the webhooks.
|
|
3
|
+
* Shopify supports a version for twelve months: a newer one comes with a new version of the baukasten.
|
|
4
|
+
*/
|
|
5
|
+
export declare const SHOPIFY_API_VERSION = "2026-10";
|
|
6
|
+
/** Every webhook of the app comes here, those of the baukasten and those of the app alike. */
|
|
7
|
+
export declare const SHOPIFY_WEBHOOK_PATH = "/shopify/webhooks";
|
|
8
|
+
/** The webhooks about privacy, which every app in the App Store answers. */
|
|
9
|
+
export declare const COMPLIANCE_TOPICS: string[];
|
|
10
|
+
/**
|
|
11
|
+
* The redirect URL of the app configuration. With managed installation Shopify never sends a merchant
|
|
12
|
+
* there, but the App Store wants one; it shows where the app is opened.
|
|
13
|
+
*/
|
|
14
|
+
export declare const SHOPIFY_AUTH_PATH = "/shopify/auth";
|
|
15
|
+
/** The page with App Bridge alone, which fetches an ID token and opens the page it came for again. */
|
|
16
|
+
export declare const SHOPIFY_BOUNCE_PATH = "/shopify/session-token";
|
|
17
|
+
/** The CDN of Shopify, with App Bridge and Polaris, which the App Store wants in their latest version. */
|
|
18
|
+
export declare const SHOPIFY_CDN = "https://cdn.shopify.com";
|
|
19
|
+
/**
|
|
20
|
+
* Where on the CDN the scripts of Shopify lie: a page takes scripts from there alone, since the rest
|
|
21
|
+
* of the CDN serves what any merchant uploads.
|
|
22
|
+
*/
|
|
23
|
+
export declare const SHOPIFY_SCRIPTS = "https://cdn.shopify.com/shopifycloud/";
|
|
24
|
+
export declare const APP_BRIDGE_URL = "https://cdn.shopify.com/shopifycloud/app-bridge.js";
|
|
25
|
+
export declare const POLARIS_URL = "https://cdn.shopify.com/shopifycloud/polaris.js";
|
|
26
|
+
/** The admin that frames the app, besides the domain of the shop itself. */
|
|
27
|
+
export declare const SHOPIFY_ADMIN = "https://admin.shopify.com";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version of the Admin API, for every request of the baukasten and the app and for the webhooks.
|
|
3
|
+
* Shopify supports a version for twelve months: a newer one comes with a new version of the baukasten.
|
|
4
|
+
*/
|
|
5
|
+
export const SHOPIFY_API_VERSION = '2026-10';
|
|
6
|
+
/** Every webhook of the app comes here, those of the baukasten and those of the app alike. */
|
|
7
|
+
export const SHOPIFY_WEBHOOK_PATH = '/shopify/webhooks';
|
|
8
|
+
/** The webhooks about privacy, which every app in the App Store answers. */
|
|
9
|
+
export const COMPLIANCE_TOPICS = ['customers/data_request', 'customers/redact', 'shop/redact'];
|
|
10
|
+
/**
|
|
11
|
+
* The redirect URL of the app configuration. With managed installation Shopify never sends a merchant
|
|
12
|
+
* there, but the App Store wants one; it shows where the app is opened.
|
|
13
|
+
*/
|
|
14
|
+
export const SHOPIFY_AUTH_PATH = '/shopify/auth';
|
|
15
|
+
/** The page with App Bridge alone, which fetches an ID token and opens the page it came for again. */
|
|
16
|
+
export const SHOPIFY_BOUNCE_PATH = '/shopify/session-token';
|
|
17
|
+
/** The CDN of Shopify, with App Bridge and Polaris, which the App Store wants in their latest version. */
|
|
18
|
+
export const SHOPIFY_CDN = 'https://cdn.shopify.com';
|
|
19
|
+
/**
|
|
20
|
+
* Where on the CDN the scripts of Shopify lie: a page takes scripts from there alone, since the rest
|
|
21
|
+
* of the CDN serves what any merchant uploads.
|
|
22
|
+
*/
|
|
23
|
+
export const SHOPIFY_SCRIPTS = `${SHOPIFY_CDN}/shopifycloud/`;
|
|
24
|
+
export const APP_BRIDGE_URL = `${SHOPIFY_SCRIPTS}app-bridge.js`;
|
|
25
|
+
export const POLARIS_URL = `${SHOPIFY_SCRIPTS}polaris.js`;
|
|
26
|
+
/** The admin that frames the app, besides the domain of the shop itself. */
|
|
27
|
+
export const SHOPIFY_ADMIN = 'https://admin.shopify.com';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SHOPIFY_SECURITY_HEADERS: Readonly<Record<string, string>>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The security headers of every response of a Shopify app, like those of a web app
|
|
2
|
+
// (server/headers.ts), but without X-Frame-Options and Cross-Origin-Opener-Policy: the admin of
|
|
3
|
+
// Shopify frames the app, and App Bridge opens the top window from it. Who may frame a page, its
|
|
4
|
+
// content security policy says, per shop (hook.ts).
|
|
5
|
+
export const SHOPIFY_SECURITY_HEADERS = {
|
|
6
|
+
'x-content-type-options': 'nosniff',
|
|
7
|
+
'referrer-policy': 'strict-origin-when-cross-origin'
|
|
8
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function webhookSigned(body: string | Uint8Array, header: string | null, secret: string): boolean;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The signature of a webhook of Shopify: X-Shopify-Hmac-Sha256 is the HMAC-SHA256 of the raw body
|
|
2
|
+
// with the client secret, in base64. Both sides are hashed to the same length before the comparison,
|
|
3
|
+
// so that it takes the same time whatever was sent.
|
|
4
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
5
|
+
export function webhookSigned(body, header, secret) {
|
|
6
|
+
const expected = createHmac('sha256', secret).update(body).digest('base64');
|
|
7
|
+
const digest = (value) => createHash('sha256').update(value).digest();
|
|
8
|
+
return timingSafeEqual(digest(header ?? ''), digest(expected));
|
|
9
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Handle, HandleServerError, ServerInit } from '@sveltejs/kit';
|
|
2
|
+
import type { ProductEnv } from '../product.js';
|
|
3
|
+
import { type Limits } from '../server/limits.js';
|
|
4
|
+
import type { Log } from '../server/log.js';
|
|
5
|
+
import type { Db, Mailer } from '../server/ports.js';
|
|
6
|
+
import { type AdminApi } from './admin.js';
|
|
7
|
+
import type { ShopifyApp } from './app.js';
|
|
8
|
+
import { type ShopifyConfig } from './config.js';
|
|
9
|
+
import { type Plan } from './shops.js';
|
|
10
|
+
import { type WebhookHooks } from './webhooks.js';
|
|
11
|
+
/** The shop of a request that got in. */
|
|
12
|
+
export interface ShopSession {
|
|
13
|
+
/** Its permanent domain, like example.myshopify.com. */
|
|
14
|
+
domain: string;
|
|
15
|
+
plan: Exclude<Plan, {
|
|
16
|
+
kind: 'none';
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
19
|
+
export interface ShopifyContext<E extends ProductEnv = ProductEnv> {
|
|
20
|
+
config: ShopifyConfig<E>;
|
|
21
|
+
db: Db;
|
|
22
|
+
log: Log;
|
|
23
|
+
/** The sending of the baukasten, for the app's own mails. */
|
|
24
|
+
mail: Mailer;
|
|
25
|
+
/** The counters of the baukasten, for the limits of the app's own routes. */
|
|
26
|
+
limits: Limits;
|
|
27
|
+
}
|
|
28
|
+
/** What the baukasten calls in the app. */
|
|
29
|
+
export interface ShopifyHooks<E extends ProductEnv = ProductEnv> extends WebhookHooks {
|
|
30
|
+
/**
|
|
31
|
+
* The checks of the app in GET /health/deep, by name; `database`, `mail` and `shopify` are the
|
|
32
|
+
* baukasten's.
|
|
33
|
+
*/
|
|
34
|
+
checks?: Record<string, (context: ShopifyContext<E>) => Promise<void>>;
|
|
35
|
+
/** Once the configuration is read, before the first request: the app starts its runs. */
|
|
36
|
+
start?: (context: ShopifyContext<E>) => Promise<void> | void;
|
|
37
|
+
/** On SIGTERM, after the last request and before the pool closes: it stops its runs. */
|
|
38
|
+
stop?: (context: ShopifyContext<E>) => Promise<void> | void;
|
|
39
|
+
}
|
|
40
|
+
export interface ShopifyKit<E extends ProductEnv = ProductEnv> {
|
|
41
|
+
/**
|
|
42
|
+
* The `init` of the hooks. SvelteKit runs it in `vite build` too, with `building` of
|
|
43
|
+
* `$app/environment` set: then it reads nothing and opens nothing.
|
|
44
|
+
*/
|
|
45
|
+
init(building: boolean): ServerInit;
|
|
46
|
+
handle: Handle;
|
|
47
|
+
handleError: HandleServerError;
|
|
48
|
+
/** Pool, configuration, log, mail and limits of the running app; only after `init`. */
|
|
49
|
+
context(): ShopifyContext<E>;
|
|
50
|
+
/**
|
|
51
|
+
* The shop of a request of a page or endpoint of the app: every one is opened by a shop that got
|
|
52
|
+
* in. Not in `locals`, whose type a web app of the same package shapes otherwise.
|
|
53
|
+
*/
|
|
54
|
+
shop(event: {
|
|
55
|
+
request: Request;
|
|
56
|
+
}): ShopSession;
|
|
57
|
+
/** The Admin API of a shop, for the app's own queries, in a request or in a run. */
|
|
58
|
+
admin(shop: string): AdminApi;
|
|
59
|
+
}
|
|
60
|
+
export declare function shopifyBaukasten<E extends ProductEnv>(app: ShopifyApp<E>, hooks?: ShopifyHooks<E>): ShopifyKit<E>;
|