@tollwyn/baukasten 0.0.2 → 1.2.3

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.
Files changed (213) hide show
  1. package/README.md +254 -1
  2. package/dist/bin/check.d.ts +2 -0
  3. package/dist/bin/check.js +38 -0
  4. package/dist/bin/migrate.d.ts +2 -0
  5. package/dist/bin/migrate.js +111 -0
  6. package/dist/bin/stubs.d.ts +2 -0
  7. package/dist/bin/stubs.js +38 -0
  8. package/dist/bin/test-accounts.d.ts +2 -0
  9. package/dist/bin/test-accounts.js +62 -0
  10. package/dist/check.d.ts +20 -0
  11. package/dist/check.js +285 -0
  12. package/dist/index.d.ts +7 -0
  13. package/dist/index.js +9 -0
  14. package/dist/product.d.ts +161 -0
  15. package/dist/product.js +152 -0
  16. package/dist/server/accounts.d.ts +128 -0
  17. package/dist/server/accounts.js +282 -0
  18. package/dist/server/adapter/db.d.ts +5 -0
  19. package/dist/server/adapter/db.js +63 -0
  20. package/dist/server/adapter/log.d.ts +3 -0
  21. package/dist/server/adapter/log.js +12 -0
  22. package/dist/server/adapter/mailer.d.ts +4 -0
  23. package/dist/server/adapter/mailer.js +56 -0
  24. package/dist/server/adapter/migrate.d.ts +21 -0
  25. package/dist/server/adapter/migrate.js +113 -0
  26. package/dist/server/adapter/secrets.d.ts +2 -0
  27. package/dist/server/adapter/secrets.js +40 -0
  28. package/dist/server/adapter/stripe.d.ts +11 -0
  29. package/dist/server/adapter/stripe.js +333 -0
  30. package/dist/server/api.d.ts +25 -0
  31. package/dist/server/api.js +98 -0
  32. package/dist/server/baukasten-migrations.d.ts +4 -0
  33. package/dist/server/baukasten-migrations.js +126 -0
  34. package/dist/server/billing.d.ts +94 -0
  35. package/dist/server/billing.js +349 -0
  36. package/dist/server/catalog.d.ts +39 -0
  37. package/dist/server/catalog.js +193 -0
  38. package/dist/server/config.d.ts +108 -0
  39. package/dist/server/config.js +244 -0
  40. package/dist/server/endpoints.d.ts +44 -0
  41. package/dist/server/endpoints.js +159 -0
  42. package/dist/server/fields.d.ts +7 -0
  43. package/dist/server/fields.js +18 -0
  44. package/dist/server/headers.d.ts +1 -0
  45. package/dist/server/headers.js +9 -0
  46. package/dist/server/health.d.ts +34 -0
  47. package/dist/server/health.js +116 -0
  48. package/dist/server/hook.d.ts +92 -0
  49. package/dist/server/hook.js +293 -0
  50. package/dist/server/index.d.ts +18 -0
  51. package/dist/server/index.js +11 -0
  52. package/dist/server/legal/de.d.ts +2 -0
  53. package/dist/server/legal/de.js +251 -0
  54. package/dist/server/legal/document.d.ts +37 -0
  55. package/dist/server/legal/document.js +30 -0
  56. package/dist/server/legal/en.d.ts +2 -0
  57. package/dist/server/legal/en.js +251 -0
  58. package/dist/server/legal/fleet.d.ts +114 -0
  59. package/dist/server/legal/fleet.js +89 -0
  60. package/dist/server/legal/index.d.ts +3 -0
  61. package/dist/server/legal/index.js +11 -0
  62. package/dist/server/limits.d.ts +58 -0
  63. package/dist/server/limits.js +76 -0
  64. package/dist/server/log.d.ts +26 -0
  65. package/dist/server/log.js +61 -0
  66. package/dist/server/machine.d.ts +4 -0
  67. package/dist/server/machine.js +57 -0
  68. package/dist/server/mail.d.ts +20 -0
  69. package/dist/server/mail.js +29 -0
  70. package/dist/server/messages.d.ts +105 -0
  71. package/dist/server/messages.js +232 -0
  72. package/dist/server/migrations.d.ts +28 -0
  73. package/dist/server/migrations.js +59 -0
  74. package/dist/server/openapi.d.ts +24 -0
  75. package/dist/server/openapi.js +173 -0
  76. package/dist/server/pages.d.ts +158 -0
  77. package/dist/server/pages.js +491 -0
  78. package/dist/server/ports.d.ts +273 -0
  79. package/dist/server/ports.js +1 -0
  80. package/dist/server/runs.d.ts +30 -0
  81. package/dist/server/runs.js +55 -0
  82. package/dist/server/sales.d.ts +38 -0
  83. package/dist/server/sales.js +132 -0
  84. package/dist/server/services.d.ts +57 -0
  85. package/dist/server/services.js +44 -0
  86. package/dist/server/start.d.ts +14 -0
  87. package/dist/server/start.js +65 -0
  88. package/dist/server/stripe.d.ts +33 -0
  89. package/dist/server/stripe.js +29 -0
  90. package/dist/server/terms.d.ts +7 -0
  91. package/dist/server/terms.js +9 -0
  92. package/dist/server/test-accounts.d.ts +28 -0
  93. package/dist/server/test-accounts.js +62 -0
  94. package/dist/server/via.d.ts +9 -0
  95. package/dist/server/via.js +15 -0
  96. package/dist/server/web.d.ts +48 -0
  97. package/dist/server/web.js +105 -0
  98. package/dist/shopify/adapter/shopify.d.ts +8 -0
  99. package/dist/shopify/adapter/shopify.js +232 -0
  100. package/dist/shopify/admin.d.ts +33 -0
  101. package/dist/shopify/admin.js +47 -0
  102. package/dist/shopify/app.d.ts +60 -0
  103. package/dist/shopify/app.js +67 -0
  104. package/dist/shopify/config.d.ts +53 -0
  105. package/dist/shopify/config.js +133 -0
  106. package/dist/shopify/constants.d.ts +27 -0
  107. package/dist/shopify/constants.js +27 -0
  108. package/dist/shopify/headers.d.ts +1 -0
  109. package/dist/shopify/headers.js +8 -0
  110. package/dist/shopify/hmac.d.ts +1 -0
  111. package/dist/shopify/hmac.js +9 -0
  112. package/dist/shopify/hook.d.ts +60 -0
  113. package/dist/shopify/hook.js +508 -0
  114. package/dist/shopify/id-token.d.ts +12 -0
  115. package/dist/shopify/id-token.js +66 -0
  116. package/dist/shopify/index.d.ts +19 -0
  117. package/dist/shopify/index.js +15 -0
  118. package/dist/shopify/legal/de.d.ts +3 -0
  119. package/dist/shopify/legal/de.js +123 -0
  120. package/dist/shopify/legal/en.d.ts +3 -0
  121. package/dist/shopify/legal/en.js +121 -0
  122. package/dist/shopify/legal/index.d.ts +19 -0
  123. package/dist/shopify/legal/index.js +8 -0
  124. package/dist/shopify/legal/shopify.d.ts +16 -0
  125. package/dist/shopify/legal/shopify.js +21 -0
  126. package/dist/shopify/migrations.d.ts +4 -0
  127. package/dist/shopify/migrations.js +67 -0
  128. package/dist/shopify/pages.d.ts +45 -0
  129. package/dist/shopify/pages.js +146 -0
  130. package/dist/shopify/ports.d.ts +77 -0
  131. package/dist/shopify/ports.js +1 -0
  132. package/dist/shopify/sales.d.ts +4 -0
  133. package/dist/shopify/sales.js +27 -0
  134. package/dist/shopify/scopes.d.ts +2 -0
  135. package/dist/shopify/scopes.js +13 -0
  136. package/dist/shopify/seal.d.ts +12 -0
  137. package/dist/shopify/seal.js +60 -0
  138. package/dist/shopify/shop.d.ts +4 -0
  139. package/dist/shopify/shop.js +15 -0
  140. package/dist/shopify/shops.d.ts +76 -0
  141. package/dist/shopify/shops.js +256 -0
  142. package/dist/shopify/sveltekit.d.ts +19 -0
  143. package/dist/shopify/sveltekit.js +18 -0
  144. package/dist/shopify/texts.d.ts +28 -0
  145. package/dist/shopify/texts.js +49 -0
  146. package/dist/shopify/toml.d.ts +22 -0
  147. package/dist/shopify/toml.js +125 -0
  148. package/dist/shopify/webhooks.d.ts +65 -0
  149. package/dist/shopify/webhooks.js +220 -0
  150. package/dist/stubs.d.ts +15 -0
  151. package/dist/stubs.js +287 -0
  152. package/dist/sveltekit.d.ts +20 -0
  153. package/dist/sveltekit.js +18 -0
  154. package/dist/testing/app.d.ts +36 -0
  155. package/dist/testing/app.js +155 -0
  156. package/dist/testing/browser.d.ts +21 -0
  157. package/dist/testing/browser.js +21 -0
  158. package/dist/testing/connection.d.ts +7 -0
  159. package/dist/testing/connection.js +303 -0
  160. package/dist/testing/database.d.ts +21 -0
  161. package/dist/testing/database.js +54 -0
  162. package/dist/testing/env-example.d.ts +4 -0
  163. package/dist/testing/env-example.js +33 -0
  164. package/dist/testing/index.d.ts +9 -0
  165. package/dist/testing/index.js +9 -0
  166. package/dist/testing/shopify-connection.d.ts +8 -0
  167. package/dist/testing/shopify-connection.js +313 -0
  168. package/dist/testing/shopify.d.ts +71 -0
  169. package/dist/testing/shopify.js +278 -0
  170. package/dist/testing/smtp.d.ts +38 -0
  171. package/dist/testing/smtp.js +165 -0
  172. package/dist/testing/stripe.d.ts +128 -0
  173. package/dist/testing/stripe.js +511 -0
  174. package/dist/texts.d.ts +139 -0
  175. package/dist/texts.js +265 -0
  176. package/dist/ui/AccountPage.svelte +143 -0
  177. package/dist/ui/AccountPage.svelte.d.ts +10 -0
  178. package/dist/ui/ConfirmEmailPage.svelte +21 -0
  179. package/dist/ui/ConfirmEmailPage.svelte.d.ts +10 -0
  180. package/dist/ui/ErrorPage.svelte +14 -0
  181. package/dist/ui/ErrorPage.svelte.d.ts +3 -0
  182. package/dist/ui/Field.svelte +12 -0
  183. package/dist/ui/Field.svelte.d.ts +10 -0
  184. package/dist/ui/ForgotPasswordPage.svelte +32 -0
  185. package/dist/ui/ForgotPasswordPage.svelte.d.ts +7 -0
  186. package/dist/ui/Form.svelte +30 -0
  187. package/dist/ui/Form.svelte.d.ts +11 -0
  188. package/dist/ui/Frame.svelte +84 -0
  189. package/dist/ui/Frame.svelte.d.ts +12 -0
  190. package/dist/ui/LegalPage.svelte +38 -0
  191. package/dist/ui/LegalPage.svelte.d.ts +9 -0
  192. package/dist/ui/Linked.svelte +5 -0
  193. package/dist/ui/Linked.svelte.d.ts +8 -0
  194. package/dist/ui/LoginPage.svelte +41 -0
  195. package/dist/ui/LoginPage.svelte.d.ts +10 -0
  196. package/dist/ui/LogoutPage.svelte +19 -0
  197. package/dist/ui/LogoutPage.svelte.d.ts +9 -0
  198. package/dist/ui/ResetPasswordPage.svelte +30 -0
  199. package/dist/ui/ResetPasswordPage.svelte.d.ts +10 -0
  200. package/dist/ui/SignupPage.svelte +52 -0
  201. package/dist/ui/SignupPage.svelte.d.ts +10 -0
  202. package/dist/ui/VerifyEmailPage.svelte +50 -0
  203. package/dist/ui/VerifyEmailPage.svelte.d.ts +10 -0
  204. package/dist/ui/base.css +287 -0
  205. package/dist/ui/data.d.ts +167 -0
  206. package/dist/ui/data.js +1 -0
  207. package/dist/ui/frame.d.ts +17 -0
  208. package/dist/ui/frame.js +27 -0
  209. package/dist/ui/index.d.ts +15 -0
  210. package/dist/ui/index.js +15 -0
  211. package/dist/variables.d.ts +20 -0
  212. package/dist/variables.js +62 -0
  213. package/package.json +88 -4
@@ -0,0 +1,65 @@
1
+ // The stub server.js wraps the handler of adapter-node in a plain HTTP server of its own, so that
2
+ // every line on stdout is JSON and the end is ordered.
3
+ import { EventEmitter } from 'node:events';
4
+ import { createServer } from 'node:http';
5
+ import { exitAfterLog, log } from './adapter/log.js';
6
+ import { portOf } from './config.js';
7
+ import { SECURITY_HEADERS } from './headers.js';
8
+ /**
9
+ * Open requests get this long after SIGTERM, the stop hooks of the product then this long; both end
10
+ * before the fixed start of the delivery service kills the product (stop-grace.test.ts).
11
+ */
12
+ export const DRAIN_MS = 15_000;
13
+ export const STOP_MS = 10_000;
14
+ export function start(load) {
15
+ return serve(load, SECURITY_HEADERS);
16
+ }
17
+ export async function serve(load, headers) {
18
+ process.on('uncaughtException', (error) => {
19
+ log.error('uncaught exception', { error });
20
+ void exitAfterLog(1);
21
+ });
22
+ process.on('unhandledRejection', (error) => {
23
+ log.error('unhandled rejection', { error });
24
+ void exitAfterLog(1);
25
+ });
26
+ // adapter-node reads ORIGIN while it loads. An invalid PUBLIC_ORIGIN stays for the configuration,
27
+ // which names it with every other problem in one line.
28
+ const publicOrigin = process.env.PUBLIC_ORIGIN?.trim();
29
+ if (publicOrigin !== undefined && URL.canParse(publicOrigin)) {
30
+ process.env.ORIGIN = new URL(publicOrigin).origin;
31
+ }
32
+ let handler;
33
+ try {
34
+ // Loading runs `init`, which reads the configuration and starts the product.
35
+ ({ handler } = await load());
36
+ }
37
+ catch (error) {
38
+ log.error('cannot load the app', { error });
39
+ return exitAfterLog(1);
40
+ }
41
+ const port = portOf(process.env);
42
+ // The static files of adapter-node never reach the handle; they get the headers here.
43
+ const server = createServer((req, res) => {
44
+ for (const [name, value] of Object.entries(headers))
45
+ res.setHeader(name, value);
46
+ handler(req, res);
47
+ });
48
+ server.listen(port, () => log.info('listening', { port }));
49
+ const shutdown = (signal) => {
50
+ log.info('shutting down', { signal });
51
+ server.closeIdleConnections();
52
+ setTimeout(() => server.closeAllConnections(), DRAIN_MS).unref();
53
+ server.close(() => {
54
+ setTimeout(() => {
55
+ log.error('stopping took too long, ending the process');
56
+ void exitAfterLog(1);
57
+ }, STOP_MS).unref();
58
+ // The event adapter-node's own server emits after its last request; the types of Node know
59
+ // only its own events on process.
60
+ EventEmitter.prototype.emit.call(process, 'sveltekit:shutdown', signal);
61
+ });
62
+ };
63
+ process.once('SIGTERM', shutdown);
64
+ process.once('SIGINT', shutdown);
65
+ }
@@ -0,0 +1,33 @@
1
+ export declare const WEBHOOK_PATH = "/stripe/webhook";
2
+ /** The events the webhook applies (billing.ts), and no other. */
3
+ export declare const WEBHOOK_EVENTS: readonly ["checkout.session.completed", "checkout.session.async_payment_succeeded", "customer.subscription.created", "customer.subscription.updated", "customer.subscription.deleted", "invoice.paid"];
4
+ /**
5
+ * What the restricted key of a product may do: the catalog (products, prices, the default customer
6
+ * portal), customers, Checkout, the portal, subscriptions to end them, and webhook endpoints, which
7
+ * the delivery service creates and the baukasten reads for those it did not create.
8
+ */
9
+ export declare const KEY_RIGHTS: readonly [{
10
+ readonly ressource: "product";
11
+ readonly zugriff: "schreiben";
12
+ }, {
13
+ readonly ressource: "price";
14
+ readonly zugriff: "schreiben";
15
+ }, {
16
+ readonly ressource: "billing_portal.configuration";
17
+ readonly zugriff: "schreiben";
18
+ }, {
19
+ readonly ressource: "billing_portal.session";
20
+ readonly zugriff: "schreiben";
21
+ }, {
22
+ readonly ressource: "checkout.session";
23
+ readonly zugriff: "schreiben";
24
+ }, {
25
+ readonly ressource: "customer";
26
+ readonly zugriff: "schreiben";
27
+ }, {
28
+ readonly ressource: "subscription";
29
+ readonly zugriff: "schreiben";
30
+ }, {
31
+ readonly ressource: "webhook_endpoint";
32
+ readonly zugriff: "schreiben";
33
+ }];
@@ -0,0 +1,29 @@
1
+ // What every product names under `stripe` in its delivery manifest (`deploy/auslieferung.json`),
2
+ // in the manifest's words: the webhook the delivery service sets up, and the rights of the
3
+ // restricted key the product runs with. The contract test of the delivery service holds these lists
4
+ // against the schema of the manifest.
5
+ export const WEBHOOK_PATH = '/stripe/webhook';
6
+ /** The events the webhook applies (billing.ts), and no other. */
7
+ export const WEBHOOK_EVENTS = [
8
+ 'checkout.session.completed',
9
+ 'checkout.session.async_payment_succeeded',
10
+ 'customer.subscription.created',
11
+ 'customer.subscription.updated',
12
+ 'customer.subscription.deleted',
13
+ 'invoice.paid'
14
+ ];
15
+ /**
16
+ * What the restricted key of a product may do: the catalog (products, prices, the default customer
17
+ * portal), customers, Checkout, the portal, subscriptions to end them, and webhook endpoints, which
18
+ * the delivery service creates and the baukasten reads for those it did not create.
19
+ */
20
+ export const KEY_RIGHTS = [
21
+ { ressource: 'product', zugriff: 'schreiben' },
22
+ { ressource: 'price', zugriff: 'schreiben' },
23
+ { ressource: 'billing_portal.configuration', zugriff: 'schreiben' },
24
+ { ressource: 'billing_portal.session', zugriff: 'schreiben' },
25
+ { ressource: 'checkout.session', zugriff: 'schreiben' },
26
+ { ressource: 'customer', zugriff: 'schreiben' },
27
+ { ressource: 'subscription', zugriff: 'schreiben' },
28
+ { ressource: 'webhook_endpoint', zugriff: 'schreiben' }
29
+ ];
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The day the baukasten's part of the legal texts last changed (legal/, legal.test.ts holds their
3
+ * checksum with it); a change to that part sets it anew.
4
+ */
5
+ export declare const BAUKASTEN_TERMS_DATE = "2026-10-08";
6
+ /** The name of the terms from the two days, each YYYY-MM-DD, which sort like their dates. */
7
+ export declare function termsVersion(baukasten: string, product: string): string;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The day the baukasten's part of the legal texts last changed (legal/, legal.test.ts holds their
3
+ * checksum with it); a change to that part sets it anew.
4
+ */
5
+ export const BAUKASTEN_TERMS_DATE = '2026-10-08';
6
+ /** The name of the terms from the two days, each YYYY-MM-DD, which sort like their dates. */
7
+ export function termsVersion(baukasten, product) {
8
+ return baukasten > product ? baukasten : product;
9
+ }
@@ -0,0 +1,28 @@
1
+ import type { Db, Queryable } from './ports.js';
2
+ export interface TestAccount {
3
+ id: string;
4
+ /** Null for an account that was deleted: its row without the address stays for the numbers. */
5
+ email: string | null;
6
+ createdAt: Date;
7
+ via: string;
8
+ /** The states of its subscriptions, as Stripe last said. */
9
+ subscriptions: string[];
10
+ invoices: number;
11
+ purchases: number;
12
+ }
13
+ export interface TestData {
14
+ accounts: number;
15
+ invoices: number;
16
+ purchases: number;
17
+ }
18
+ /** The accounts from the test mode, oldest first. */
19
+ export declare function testAccounts(db: Queryable): Promise<TestAccount[]>;
20
+ export declare function testData(db: Queryable): Promise<TestData>;
21
+ /**
22
+ * Deletes the accounts from the test mode with everything on them, their invoices, purchases and
23
+ * anonymous rows included (no real money moved), in one transaction. The tables of the product refer
24
+ * to an account with ON DELETE CASCADE, so its data goes with the account; a table that does not
25
+ * stops the deletion, and nothing is deleted. Stripe is not touched: the live key does not reach the
26
+ * objects of the test mode.
27
+ */
28
+ export declare function deleteTestAccounts(db: Db): Promise<TestData>;
@@ -0,0 +1,62 @@
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'];
3
+ const TEST = "a.stripe_mode = 'test'";
4
+ /** The accounts from the test mode, oldest first. */
5
+ export async function testAccounts(db) {
6
+ const rows = await db.query(`SELECT a.id, a.email, a.created_at, a.via,
7
+ array(SELECT s.status FROM baukasten.subscriptions s WHERE s.account_id = a.id
8
+ ORDER BY s.updated_at) AS subscriptions,
9
+ (SELECT count(*)::int FROM baukasten.invoices i WHERE i.account_id = a.id) AS invoices,
10
+ (SELECT count(*)::int FROM baukasten.purchases p WHERE p.account_id = a.id) AS purchases
11
+ FROM baukasten.accounts a WHERE ${TEST} ORDER BY a.created_at, a.id`);
12
+ return rows.map((row) => ({
13
+ id: row.id,
14
+ email: row.email,
15
+ createdAt: row.created_at,
16
+ via: row.via,
17
+ subscriptions: row.subscriptions,
18
+ invoices: row.invoices,
19
+ purchases: row.purchases
20
+ }));
21
+ }
22
+ export async function testData(db) {
23
+ const [row] = await db.query(`SELECT count(*)::int AS accounts,
24
+ (SELECT count(*)::int FROM baukasten.invoices i JOIN baukasten.accounts a ON a.id = i.account_id
25
+ WHERE ${TEST}) AS invoices,
26
+ (SELECT count(*)::int FROM baukasten.purchases p JOIN baukasten.accounts a ON a.id = p.account_id
27
+ WHERE ${TEST}) AS purchases
28
+ FROM baukasten.accounts a WHERE ${TEST}`);
29
+ if (row === undefined)
30
+ throw new Error('counting the test accounts gave no row');
31
+ return row;
32
+ }
33
+ /**
34
+ * Deletes the accounts from the test mode with everything on them, their invoices, purchases and
35
+ * anonymous rows included (no real money moved), in one transaction. The tables of the product refer
36
+ * to an account with ON DELETE CASCADE, so its data goes with the account; a table that does not
37
+ * stops the deletion, and nothing is deleted. Stripe is not touched: the live key does not reach the
38
+ * objects of the test mode.
39
+ */
40
+ export function deleteTestAccounts(db) {
41
+ return db.transaction(async (tx) => {
42
+ // Locked first: a write that points at one of them now waits for this transaction.
43
+ const accounts = await tx.query(`SELECT a.id FROM baukasten.accounts a WHERE ${TEST} ORDER BY a.id FOR UPDATE`);
44
+ const ids = accounts.map((account) => account.id);
45
+ const remove = async (table) => (await tx.query(`DELETE FROM baukasten.${table} WHERE account_id = ANY($1::uuid[]) RETURNING account_id`, [ids])).length;
46
+ for (const table of OF_ACCOUNTS)
47
+ await remove(table);
48
+ const invoices = await remove('invoices');
49
+ const purchases = await remove('purchases');
50
+ try {
51
+ await tx.query('DELETE FROM baukasten.accounts WHERE id = ANY($1::uuid[])', [ids]);
52
+ }
53
+ catch (error) {
54
+ if (error instanceof Error && Reflect.get(error, 'code') === '23503') {
55
+ const table = String(Reflect.get(error, 'table'));
56
+ throw new Error(`the table ${table} of the product refers to accounts without ON DELETE CASCADE; nothing was deleted`, { cause: error });
57
+ }
58
+ throw error;
59
+ }
60
+ return { accounts: ids.length, invoices, purchases };
61
+ });
62
+ }
@@ -0,0 +1,9 @@
1
+ /** The referral tag of an account that came without one. */
2
+ export declare const NO_VIA = "ohne";
3
+ /**
4
+ * At most as long as the delivery service takes a referral tag in the sales numbers (sales.test.ts
5
+ * holds it): a longer one would spoil every answer.
6
+ */
7
+ export declare const VIA_MAX = 32;
8
+ export declare const VIA_PATTERN: RegExp;
9
+ export declare function parseVia(value: string | null): string | null;
@@ -0,0 +1,15 @@
1
+ // The referral tag of a link to the product, `?via=<tag>`: it travels in the links of the page that
2
+ // was opened with it until the sign-up, and then stays with the account. Nothing keeps it in the
3
+ // browser, neither a cookie nor web storage, which would need consent in the EU; whoever comes back
4
+ // without the link counts as `ohne`.
5
+ /** The referral tag of an account that came without one. */
6
+ export const NO_VIA = 'ohne';
7
+ /**
8
+ * At most as long as the delivery service takes a referral tag in the sales numbers (sales.test.ts
9
+ * holds it): a longer one would spoil every answer.
10
+ */
11
+ export const VIA_MAX = 32;
12
+ export const VIA_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
+ export function parseVia(value) {
14
+ return value !== null && value.length <= VIA_MAX && VIA_PATTERN.test(value) ? value : null;
15
+ }
@@ -0,0 +1,48 @@
1
+ import { type ActionFailure, type Cookies } from '@sveltejs/kit';
2
+ import { type Account } from './accounts.js';
3
+ declare global {
4
+ namespace App {
5
+ interface Locals {
6
+ /** The account of the session; null without one, and on the machine paths. */
7
+ account: Account | null;
8
+ }
9
+ }
10
+ }
11
+ /**
12
+ * The only cookie of a product. `__Host-` keeps it to this host: no other product under the same
13
+ * domain can set or shadow it. Browsers take it on http://localhost too.
14
+ */
15
+ export declare const SESSION_COOKIE = "__Host-session";
16
+ export declare function setSessionCookie(cookies: Cookies, token: string): void;
17
+ export declare function clearSessionCookie(cookies: Cookies): void;
18
+ export declare function isMachinePath(path: string): boolean;
19
+ /**
20
+ * The address of the client, for the limits. In production the edge of the fleet (Cloudflare) sets
21
+ * CF-Connecting-IP; locally a proxy appends to X-Forwarded-For. Without a usable header the limits
22
+ * count the socket, which is the proxy: that errs towards limiting. An IPv6 address counts by its
23
+ * /64, which one connection usually holds whole.
24
+ */
25
+ export declare function clientAddress(headers: Headers, mode: 'production' | 'local', socket: () => string): string;
26
+ /**
27
+ * Where a login may lead: a path of this site, or `home`. Browsers drop tabs and line breaks and read
28
+ * a backslash as a slash, so such a value is refused before it is resolved; dot segments could turn
29
+ * a path into `//host`, so the resolved path is checked again.
30
+ */
31
+ export declare function safeNext(value: string | null, home: string): string;
32
+ /**
33
+ * A request another origin made a browser send (CSRF). The products share the site tollwyn.com, so
34
+ * SameSite=Lax hands a sibling product the session cookie with its forms and fetches; only the
35
+ * origin tells them apart. On the pages every unsafe request needs the product's own origin. The
36
+ * machine paths serve software, which sends no origin, and read no cookie: there only what a
37
+ * browser sends across sites without asking first, a form, is refused. The check of SvelteKit is
38
+ * off (SVELTEKIT_OPTIONS), so that the API can answer it in JSON.
39
+ */
40
+ export declare function isCrossSite(request: Request, origin: string): boolean;
41
+ export declare function tooMany<T extends Record<string, unknown>>(event: {
42
+ setHeaders: (headers: Record<string, string>) => void;
43
+ }, seconds: number, data: T): ActionFailure<T>;
44
+ /** The account of the session, or off to the login, which leads back here. */
45
+ export declare function requireAccount(event: {
46
+ locals: App.Locals;
47
+ url: URL;
48
+ }): Account;
@@ -0,0 +1,105 @@
1
+ import { isIP } from 'node:net';
2
+ import { fail, redirect } from '@sveltejs/kit';
3
+ import { SESSION_DAYS } from './accounts.js';
4
+ /**
5
+ * The only cookie of a product. `__Host-` keeps it to this host: no other product under the same
6
+ * domain can set or shadow it. Browsers take it on http://localhost too.
7
+ */
8
+ export const SESSION_COOKIE = '__Host-session';
9
+ export function setSessionCookie(cookies, token) {
10
+ cookies.set(SESSION_COOKIE, token, {
11
+ path: '/',
12
+ httpOnly: true,
13
+ secure: true,
14
+ sameSite: 'lax',
15
+ maxAge: SESSION_DAYS * 24 * 60 * 60
16
+ });
17
+ }
18
+ export function clearSessionCookie(cookies) {
19
+ cookies.delete(SESSION_COOKIE, { path: '/', httpOnly: true, secure: true, sameSite: 'lax' });
20
+ }
21
+ /** Machine endpoints never read the session: health must not touch the database, and the others
22
+ * read nothing before their own key or token is checked. */
23
+ const MACHINE = ['/health', '/api/', '/stripe/', '/vertrieb/'];
24
+ export function isMachinePath(path) {
25
+ return MACHINE.some((prefix) => prefix.endsWith('/')
26
+ ? path.startsWith(prefix)
27
+ : path === prefix || path.startsWith(`${prefix}/`));
28
+ }
29
+ /**
30
+ * The address of the client, for the limits. In production the edge of the fleet (Cloudflare) sets
31
+ * CF-Connecting-IP; locally a proxy appends to X-Forwarded-For. Without a usable header the limits
32
+ * count the socket, which is the proxy: that errs towards limiting. An IPv6 address counts by its
33
+ * /64, which one connection usually holds whole.
34
+ */
35
+ export function clientAddress(headers, mode, socket) {
36
+ const raw = mode === 'production'
37
+ ? headers.get('cf-connecting-ip')
38
+ : (headers.get('x-forwarded-for')?.split(',').at(-1) ?? null);
39
+ const address = raw?.trim() ?? '';
40
+ return isIP(address) === 0 ? network(socket()) : network(address);
41
+ }
42
+ /** An IPv6 address as its /64, an IPv4 address mapped into IPv6 as that; IPv4 as it is. */
43
+ function network(address) {
44
+ if (isIP(address) !== 6)
45
+ return address;
46
+ const lower = address.toLowerCase();
47
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower)?.[1];
48
+ if (mapped !== undefined)
49
+ return mapped;
50
+ // A dotted quad at the end is the last two groups.
51
+ const hex = lower.replace(/(\d+)\.(\d+)\.(\d+)\.(\d+)$/, (_, a, b, c, d) => `${((Number(a) << 8) | Number(b)).toString(16)}:${((Number(c) << 8) | Number(d)).toString(16)}`);
52
+ const [head = '', tail] = hex.split('::');
53
+ const left = head === '' ? [] : head.split(':');
54
+ const right = tail === undefined || tail === '' ? [] : tail.split(':');
55
+ const groups = tail === undefined
56
+ ? left
57
+ : [...left, ...Array(8 - left.length - right.length).fill('0'), ...right];
58
+ const prefix = groups.slice(0, 4).map((group) => parseInt(group, 16).toString(16));
59
+ return `${prefix.join(':')}::/64`;
60
+ }
61
+ /**
62
+ * Where a login may lead: a path of this site, or `home`. Browsers drop tabs and line breaks and read
63
+ * a backslash as a slash, so such a value is refused before it is resolved; dot segments could turn
64
+ * a path into `//host`, so the resolved path is checked again.
65
+ */
66
+ export function safeNext(value, home) {
67
+ if (value === null || !value.startsWith('/') || /[\s\\\p{Cc}]/u.test(value))
68
+ return home;
69
+ const here = 'http://product.invalid';
70
+ const url = new URL(value, here);
71
+ if (url.origin !== here || url.pathname.startsWith('//'))
72
+ return home;
73
+ return `${url.pathname}${url.search}${url.hash}`;
74
+ }
75
+ const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];
76
+ const FORM_TYPES = ['application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain'];
77
+ /**
78
+ * A request another origin made a browser send (CSRF). The products share the site tollwyn.com, so
79
+ * SameSite=Lax hands a sibling product the session cookie with its forms and fetches; only the
80
+ * origin tells them apart. On the pages every unsafe request needs the product's own origin. The
81
+ * machine paths serve software, which sends no origin, and read no cookie: there only what a
82
+ * browser sends across sites without asking first, a form, is refused. The check of SvelteKit is
83
+ * off (SVELTEKIT_OPTIONS), so that the API can answer it in JSON.
84
+ */
85
+ export function isCrossSite(request, origin) {
86
+ if (SAFE_METHODS.includes(request.method) || request.headers.get('origin') === origin) {
87
+ return false;
88
+ }
89
+ if (!isMachinePath(new URL(request.url).pathname))
90
+ return true;
91
+ const type = (request.headers.get('content-type') ?? '').split(';', 1)[0]?.trim().toLowerCase();
92
+ return FORM_TYPES.some((form) => form === type);
93
+ }
94
+ export function tooMany(event, seconds, data) {
95
+ event.setHeaders({ 'retry-after': String(seconds) });
96
+ return fail(429, data);
97
+ }
98
+ /** The account of the session, or off to the login, which leads back here. */
99
+ export function requireAccount(event) {
100
+ const account = event.locals.account;
101
+ if (account === null) {
102
+ redirect(303, `/login?next=${encodeURIComponent(event.url.pathname + event.url.search)}`);
103
+ }
104
+ return account;
105
+ }
@@ -0,0 +1,8 @@
1
+ import type { ShopifyConfig } from '../config.js';
2
+ import type { ShopifyApi } from '../ports.js';
3
+ export declare class ShopifyFailed extends Error {
4
+ name: string;
5
+ }
6
+ export declare function shopifyApi(config: ShopifyConfig['shopify'], options: {
7
+ now: () => Date;
8
+ }): ShopifyApi;
@@ -0,0 +1,232 @@
1
+ // In the tests of an app the same requests go to the double on this machine (testing/shopify.ts),
2
+ // under /shops/<shop>/… and /partners/<org>/…. An error names the status and what Shopify called it,
3
+ // never a token.
4
+ import { z } from 'zod';
5
+ import { SHOPIFY_API_VERSION } from '../constants.js';
6
+ export class ShopifyFailed extends Error {
7
+ name = 'ShopifyFailed';
8
+ }
9
+ /** Each call waits this long at most; the request of a merchant waits on it. */
10
+ const TIMEOUT_MS = 10_000;
11
+ const TOKENS = z.object({
12
+ access_token: z.string().min(1),
13
+ scope: z.string(),
14
+ expires_in: z.number().int().positive(),
15
+ refresh_token: z.string().min(1),
16
+ refresh_token_expires_in: z.number().int().positive()
17
+ });
18
+ const TOKEN_ERROR = z.object({ error: z.string() });
19
+ const GRAPHQL = z.object({
20
+ data: z.unknown().optional(),
21
+ errors: z
22
+ .array(z.object({
23
+ message: z.string(),
24
+ extensions: z.object({ code: z.string().optional() }).optional()
25
+ }))
26
+ .optional(),
27
+ extensions: z
28
+ .object({
29
+ cost: z
30
+ .object({
31
+ requestedQueryCost: z.number(),
32
+ throttleStatus: z.object({ currentlyAvailable: z.number(), restoreRate: z.number() })
33
+ })
34
+ .optional()
35
+ })
36
+ .optional()
37
+ });
38
+ const SHOP_INFO = z.object({
39
+ shop: z.object({
40
+ id: z.string().startsWith('gid://shopify/Shop/'),
41
+ email: z.string(),
42
+ plan: z.object({ partnerDevelopment: z.boolean() })
43
+ }),
44
+ currentAppInstallation: z.object({
45
+ app: z.object({ id: z.string().startsWith('gid://shopify/App/') })
46
+ })
47
+ });
48
+ const SUBSCRIPTION = z.object({
49
+ activeSubscription: z
50
+ .object({
51
+ trialEndsAt: z.iso.datetime({ offset: true }).nullable(),
52
+ items: z.array(z.object({ handle: z.string().nullable() }))
53
+ })
54
+ .nullable()
55
+ });
56
+ const SHOP_INFO_QUERY = `query BaukastenShop {
57
+ shop { id email plan { partnerDevelopment } }
58
+ currentAppInstallation { app { id } }
59
+ }`;
60
+ const SUBSCRIPTION_QUERY = `query BaukastenSubscription($appId: ID!, $shopId: ID!) {
61
+ activeSubscription(appId: $appId, shopId: $shopId) { trialEndsAt items { handle } }
62
+ }`;
63
+ const PARTNER_CHECK_QUERY = 'query BaukastenCheck { publicApiVersions { handle } }';
64
+ /** A token of the token endpoint in the shape of the port; `expires_in` counts seconds. */
65
+ function tokensOf(body, now) {
66
+ const at = (seconds) => new Date(now.getTime() + seconds * 1_000);
67
+ return {
68
+ accessToken: body.access_token,
69
+ accessExpiresAt: at(body.expires_in),
70
+ refreshToken: body.refresh_token,
71
+ refreshExpiresAt: at(body.refresh_token_expires_in),
72
+ scope: body.scope
73
+ };
74
+ }
75
+ /** How long to wait before the next query, from the cost Shopify reports, at least a second. */
76
+ function throttleWait(extensions) {
77
+ const cost = extensions?.cost;
78
+ if (cost === undefined || cost.throttleStatus.restoreRate <= 0)
79
+ return 1_000;
80
+ const lacking = cost.requestedQueryCost - cost.throttleStatus.currentlyAvailable;
81
+ return Math.max(1_000, Math.ceil(lacking / cost.throttleStatus.restoreRate) * 1_000);
82
+ }
83
+ export function shopifyApi(config, options) {
84
+ const shopUrl = (shop, path) => config.api.kind === 'double'
85
+ ? `${config.api.url}/shops/${shop}${path}`
86
+ : `https://${shop}${path}`;
87
+ const partnerUrl = () => {
88
+ const path = `/${config.partner.organization}/api/${SHOPIFY_API_VERSION}/graphql.json`;
89
+ return config.api.kind === 'double'
90
+ ? `${config.api.url}/partners${path}`
91
+ : `https://partners.shopify.com${path}`;
92
+ };
93
+ async function post(url, headers, body) {
94
+ let response;
95
+ try {
96
+ response = await fetch(url, {
97
+ method: 'POST',
98
+ headers: { 'content-type': 'application/json', accept: 'application/json', ...headers },
99
+ body: JSON.stringify(body),
100
+ signal: AbortSignal.timeout(TIMEOUT_MS)
101
+ });
102
+ }
103
+ catch (error) {
104
+ throw new ShopifyFailed('Shopify unreachable', { cause: error });
105
+ }
106
+ const text = await response.text();
107
+ let json = null;
108
+ try {
109
+ json = text === '' ? null : JSON.parse(text);
110
+ }
111
+ catch {
112
+ // An answer that is no JSON is judged by its status alone.
113
+ }
114
+ return { status: response.status, headers: response.headers, json };
115
+ }
116
+ async function token(shop, grant) {
117
+ // No shop gets in before the app is linked (respondTo in shopify/hook.ts); this is the last guard.
118
+ if (config.clientId === null)
119
+ throw new ShopifyFailed('the app has no client ID yet');
120
+ const answer = await post(shopUrl(shop, '/admin/oauth/access_token'), {}, { client_id: config.clientId, client_secret: config.secret, ...grant });
121
+ if (answer.status === 200) {
122
+ const tokens = TOKENS.safeParse(answer.json);
123
+ if (tokens.success)
124
+ return { kind: 'tokens', tokens: tokensOf(tokens.data, options.now()) };
125
+ return { kind: 'failed', reason: 'tokens of an unknown form' };
126
+ }
127
+ const error = TOKEN_ERROR.safeParse(answer.json);
128
+ const named = error.success ? error.data.error : null;
129
+ // Shopify no longer takes the ID token or the refresh token: only the merchant can get a new one.
130
+ if (answer.status === 401 ||
131
+ (answer.status === 400 && (named === 'invalid_subject_token' || named === 'invalid_grant'))) {
132
+ return { kind: 'invalid' };
133
+ }
134
+ return {
135
+ kind: 'failed',
136
+ reason: `status ${answer.status}${named === null ? '' : ` (${named})`}`
137
+ };
138
+ }
139
+ async function graphql(shop, accessToken, query, variables) {
140
+ const answer = await post(shopUrl(shop, `/admin/api/${SHOPIFY_API_VERSION}/graphql.json`), { 'x-shopify-access-token': accessToken }, { query, variables });
141
+ if (answer.status === 401)
142
+ return { kind: 'unauthorized' };
143
+ if (answer.status === 429) {
144
+ const seconds = Number(answer.headers.get('retry-after'));
145
+ return {
146
+ kind: 'throttled',
147
+ waitMs: Number.isFinite(seconds) && seconds > 0 ? seconds * 1_000 : 1_000
148
+ };
149
+ }
150
+ if (answer.status !== 200)
151
+ throw new ShopifyFailed(`the Admin API answered ${answer.status}`);
152
+ const body = GRAPHQL.safeParse(answer.json);
153
+ if (!body.success)
154
+ throw new ShopifyFailed('the Admin API answered in an unknown form');
155
+ const errors = body.data.errors ?? [];
156
+ if (errors.some((error) => error.extensions?.code === 'THROTTLED')) {
157
+ return { kind: 'throttled', waitMs: throttleWait(body.data.extensions) };
158
+ }
159
+ if (errors.length > 0) {
160
+ return {
161
+ kind: 'errors',
162
+ errors: errors.map((error) => ({
163
+ message: error.message,
164
+ code: error.extensions?.code ?? null
165
+ }))
166
+ };
167
+ }
168
+ return { kind: 'data', data: body.data.data };
169
+ }
170
+ async function partner(query, variables) {
171
+ const answer = await post(partnerUrl(), { 'x-shopify-access-token': config.partner.token }, { query, variables });
172
+ if (answer.status !== 200)
173
+ throw new ShopifyFailed(`the Partner API answered ${answer.status}`);
174
+ const body = GRAPHQL.safeParse(answer.json);
175
+ if (!body.success)
176
+ throw new ShopifyFailed('the Partner API answered in an unknown form');
177
+ const errors = body.data.errors ?? [];
178
+ if (errors.length > 0) {
179
+ throw new ShopifyFailed(`the Partner API refused: ${errors.map((error) => error.extensions?.code ?? error.message).join(', ')}`);
180
+ }
181
+ return body.data.data;
182
+ }
183
+ return {
184
+ exchange: (shop, idToken) => token(shop, {
185
+ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
186
+ subject_token: idToken,
187
+ subject_token_type: 'urn:ietf:params:oauth:token-type:id_token',
188
+ requested_token_type: 'urn:shopify:params:oauth:token-type:offline-access-token',
189
+ expiring: '1'
190
+ }),
191
+ refresh: (shop, refreshToken) => token(shop, { grant_type: 'refresh_token', refresh_token: refreshToken }),
192
+ async shopInfo(shop, accessToken) {
193
+ const answer = await graphql(shop, accessToken, SHOP_INFO_QUERY, {});
194
+ switch (answer.kind) {
195
+ case 'unauthorized':
196
+ return null;
197
+ case 'throttled':
198
+ throw new ShopifyFailed('the Admin API is over the limit of the shop');
199
+ case 'errors':
200
+ throw new ShopifyFailed(`the Admin API refused: ${answer.errors.map((error) => error.code ?? error.message).join(', ')}`);
201
+ case 'data': {
202
+ const info = SHOP_INFO.safeParse(answer.data);
203
+ if (!info.success)
204
+ throw new ShopifyFailed('the shop came in an unknown form');
205
+ return {
206
+ shopId: info.data.shop.id,
207
+ appId: info.data.currentAppInstallation.app.id,
208
+ email: info.data.shop.email,
209
+ development: info.data.shop.plan.partnerDevelopment
210
+ };
211
+ }
212
+ }
213
+ },
214
+ graphql,
215
+ async subscription(appId, shopId) {
216
+ const data = SUBSCRIPTION.safeParse(await partner(SUBSCRIPTION_QUERY, { appId, shopId }));
217
+ if (!data.success)
218
+ throw new ShopifyFailed('the subscription came in an unknown form');
219
+ const active = data.data.activeSubscription;
220
+ if (active === null)
221
+ return { kind: 'none' };
222
+ return {
223
+ kind: 'active',
224
+ handles: active.items.flatMap((item) => (item.handle === null ? [] : [item.handle])),
225
+ trialEndsAt: active.trialEndsAt === null ? null : new Date(active.trialEndsAt)
226
+ };
227
+ },
228
+ async partnerCheck() {
229
+ await partner(PARTNER_CHECK_QUERY, {});
230
+ }
231
+ };
232
+ }