@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,113 @@
|
|
|
1
|
+
// One advisory lock, so that two starts never apply a file twice. Both layers are checked before
|
|
2
|
+
// anything changes; each file runs in its own transaction together with its row in the list of its
|
|
3
|
+
// layer.
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import pg from 'pg';
|
|
8
|
+
import { migrationFiles, plan } from '../migrations.js';
|
|
9
|
+
import { SHOPIFY_MIGRATIONS } from '../../shopify/migrations.js';
|
|
10
|
+
import { BAUKASTEN_MIGRATIONS } from '../baukasten-migrations.js';
|
|
11
|
+
/**
|
|
12
|
+
* The list of the applied migrations of each layer: the migration command lays that of the baukasten,
|
|
13
|
+
* the first file of the baukasten that of the product.
|
|
14
|
+
*/
|
|
15
|
+
const LEDGER = {
|
|
16
|
+
baukasten: 'baukasten.migrations',
|
|
17
|
+
product: 'baukasten.product_migrations'
|
|
18
|
+
};
|
|
19
|
+
/** Any number, the same in every product; it only has to differ from other locks of the database. */
|
|
20
|
+
const LOCK = 7_270_011_001;
|
|
21
|
+
const LAYER = {
|
|
22
|
+
webapp: BAUKASTEN_MIGRATIONS,
|
|
23
|
+
shopify: SHOPIFY_MIGRATIONS
|
|
24
|
+
};
|
|
25
|
+
export function migrationSources(root, art) {
|
|
26
|
+
return { baukasten: LAYER[art], product: path.join(root, 'migrations') };
|
|
27
|
+
}
|
|
28
|
+
async function productFiles(dir) {
|
|
29
|
+
if (!existsSync(dir))
|
|
30
|
+
return [];
|
|
31
|
+
const names = await readdir(dir);
|
|
32
|
+
const entries = await Promise.all(names.map(async (name) => ({ name, sql: await readFile(path.join(dir, name), 'utf8') })));
|
|
33
|
+
return migrationFiles('product', entries);
|
|
34
|
+
}
|
|
35
|
+
async function applied(db, layer) {
|
|
36
|
+
const list = await db.query('SELECT 1 AS found WHERE to_regclass($1) IS NOT NULL', [
|
|
37
|
+
LEDGER[layer]
|
|
38
|
+
]);
|
|
39
|
+
if (list.length === 0)
|
|
40
|
+
return [];
|
|
41
|
+
return db.query(`SELECT name, checksum FROM ${LEDGER[layer]} ORDER BY name`);
|
|
42
|
+
}
|
|
43
|
+
async function plans(db, sources) {
|
|
44
|
+
const files = {
|
|
45
|
+
baukasten: migrationFiles('baukasten', sources.baukasten),
|
|
46
|
+
product: await productFiles(sources.product)
|
|
47
|
+
};
|
|
48
|
+
const layers = ['baukasten', 'product'];
|
|
49
|
+
return Promise.all(layers.map(async (layer) => ({ layer, ...plan(layer, files[layer], await applied(db, layer)) })));
|
|
50
|
+
}
|
|
51
|
+
/** What the database still needs of this version and what forbids migrating it, for /health/deep. */
|
|
52
|
+
export async function migrationStatus(db, sources) {
|
|
53
|
+
const both = await plans(db, sources);
|
|
54
|
+
return {
|
|
55
|
+
pending: both.flatMap(({ layer, pending }) => pending.map((file) => `${layer}/${file.name}`)),
|
|
56
|
+
problems: both.flatMap(({ problems }) => problems)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
class MigrationError extends Error {
|
|
60
|
+
name = 'MigrationError';
|
|
61
|
+
}
|
|
62
|
+
export async function migrate(databaseUrl, sources) {
|
|
63
|
+
const client = new pg.Client({ connectionString: databaseUrl });
|
|
64
|
+
await client.connect();
|
|
65
|
+
const db = {
|
|
66
|
+
query: async (sql, params) => (await client.query(sql, params)).rows
|
|
67
|
+
};
|
|
68
|
+
try {
|
|
69
|
+
await client.query('SELECT pg_advisory_lock($1)', [LOCK]);
|
|
70
|
+
await client.query(`CREATE SCHEMA IF NOT EXISTS baukasten;
|
|
71
|
+
CREATE TABLE IF NOT EXISTS ${LEDGER.baukasten} (
|
|
72
|
+
name text PRIMARY KEY,
|
|
73
|
+
checksum text NOT NULL,
|
|
74
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
75
|
+
)`);
|
|
76
|
+
const both = await plans(db, sources);
|
|
77
|
+
const problems = both.flatMap((layer) => layer.problems);
|
|
78
|
+
if (problems.length > 0)
|
|
79
|
+
throw new MigrationError(problems.join('; '));
|
|
80
|
+
const result = {
|
|
81
|
+
applied: { baukasten: [], product: [] },
|
|
82
|
+
ahead: { baukasten: [], product: [] }
|
|
83
|
+
};
|
|
84
|
+
for (const { layer, pending, ahead } of both) {
|
|
85
|
+
result.ahead[layer] = ahead;
|
|
86
|
+
for (const file of pending) {
|
|
87
|
+
await apply(client, layer, file);
|
|
88
|
+
result.applied[layer].push(file.name);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
// Ending the session releases the lock as well.
|
|
95
|
+
await client.end();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function apply(client, layer, file) {
|
|
99
|
+
await client.query('BEGIN');
|
|
100
|
+
try {
|
|
101
|
+
await client.query(file.sql);
|
|
102
|
+
await client.query(`INSERT INTO ${LEDGER[layer]} (name, checksum) VALUES ($1, $2)`, [
|
|
103
|
+
file.name,
|
|
104
|
+
file.checksum
|
|
105
|
+
]);
|
|
106
|
+
await client.query('COMMIT');
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
await client.query('ROLLBACK');
|
|
110
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
111
|
+
throw new MigrationError(`${layer} migration ${file.name} failed: ${reason}`, { cause: error });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// scrypt takes 32 MiB; Node runs it on the libuv pool of four threads, which bounds the memory of
|
|
2
|
+
// parallel logins. A hash carries its parameters, so that stronger ones can come for new hashes.
|
|
3
|
+
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
const N = 32_768;
|
|
5
|
+
const R = 8;
|
|
6
|
+
const P = 1;
|
|
7
|
+
const KEY_LENGTH = 32;
|
|
8
|
+
const MAX_MEMORY = 64 * 1024 * 1024;
|
|
9
|
+
function derive(password, salt, n, r, p) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
scrypt(password, salt, KEY_LENGTH, { N: n, r, p, maxmem: MAX_MEMORY }, (error, key) => {
|
|
12
|
+
if (error === null)
|
|
13
|
+
resolve(key);
|
|
14
|
+
else
|
|
15
|
+
reject(error);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
const STORED = /^scrypt\$(\d+)\$(\d+)\$(\d+)\$([A-Za-z0-9+/=]+)\$([A-Za-z0-9+/=]+)$/;
|
|
20
|
+
export const secrets = {
|
|
21
|
+
token: () => randomBytes(32).toString('base64url'),
|
|
22
|
+
async hashPassword(password) {
|
|
23
|
+
const salt = randomBytes(16);
|
|
24
|
+
const key = await derive(password, salt, N, R, P);
|
|
25
|
+
return ['scrypt', N, R, P, salt.toString('base64'), key.toString('base64')].join('$');
|
|
26
|
+
},
|
|
27
|
+
async verifyPassword(password, stored) {
|
|
28
|
+
const match = STORED.exec(stored);
|
|
29
|
+
if (match === null)
|
|
30
|
+
return false;
|
|
31
|
+
const [, n, r, p, salt, key] = match;
|
|
32
|
+
if (n === undefined || r === undefined || p === undefined)
|
|
33
|
+
return false;
|
|
34
|
+
if (salt === undefined || key === undefined)
|
|
35
|
+
return false;
|
|
36
|
+
const expected = Buffer.from(key, 'base64');
|
|
37
|
+
const actual = await derive(password, Buffer.from(salt, 'base64'), Number(n), Number(r), Number(p));
|
|
38
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Answers of the API come in the version the SDK pins; the payload of a webhook comes in the version
|
|
2
|
+
// of the endpoint, which the delivery service set up, so it is read with Zod, field by field, as far
|
|
3
|
+
// as the baukasten needs it.
|
|
4
|
+
import Stripe from 'stripe';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { StripeFailed } from '../billing.js';
|
|
7
|
+
/** A request to Stripe ends after this long: a slow answer to a webhook makes Stripe send it again. */
|
|
8
|
+
const TIMEOUT_MS = 8_000;
|
|
9
|
+
/** A key of Stripe in a message, without its secret part. */
|
|
10
|
+
function redacted(text) {
|
|
11
|
+
return text.replace(/\b(sk|rk|whsec)_[A-Za-z0-9_*]+/g, '$1_…');
|
|
12
|
+
}
|
|
13
|
+
async function call(what, request) {
|
|
14
|
+
try {
|
|
15
|
+
return await request();
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof Stripe.errors.StripeError) {
|
|
19
|
+
const kind = error.code ?? error.rawType ?? error.type;
|
|
20
|
+
throw new StripeFailed(kind, redacted(`stripe: ${what}: ${error.message}`), {
|
|
21
|
+
cause: error
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const isMissing = (error) => error instanceof StripeFailed && error.kind === 'resource_missing';
|
|
28
|
+
const idOf = (value) => (typeof value === 'string' ? value : value.id);
|
|
29
|
+
function priceOf(price) {
|
|
30
|
+
const base = {
|
|
31
|
+
id: price.id,
|
|
32
|
+
product: idOf(price.product),
|
|
33
|
+
amount: price.unit_amount,
|
|
34
|
+
currency: price.currency
|
|
35
|
+
};
|
|
36
|
+
return price.recurring === null
|
|
37
|
+
? { ...base, type: 'one_time' }
|
|
38
|
+
: {
|
|
39
|
+
...base,
|
|
40
|
+
type: 'recurring',
|
|
41
|
+
interval: price.recurring.interval,
|
|
42
|
+
intervalCount: price.recurring.interval_count
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const STATUS = z.enum([
|
|
46
|
+
'incomplete',
|
|
47
|
+
'incomplete_expired',
|
|
48
|
+
'trialing',
|
|
49
|
+
'active',
|
|
50
|
+
'past_due',
|
|
51
|
+
'canceled',
|
|
52
|
+
'unpaid',
|
|
53
|
+
'paused'
|
|
54
|
+
]);
|
|
55
|
+
function statusOf(status) {
|
|
56
|
+
const known = STATUS.safeParse(status);
|
|
57
|
+
if (!known.success) {
|
|
58
|
+
throw new StripeFailed('unexpected_status', `stripe: a subscription with the status ${status}`);
|
|
59
|
+
}
|
|
60
|
+
return known.data;
|
|
61
|
+
}
|
|
62
|
+
const SESSION = z.object({
|
|
63
|
+
id: z.string(),
|
|
64
|
+
mode: z.string(),
|
|
65
|
+
customer: z.string().nullable(),
|
|
66
|
+
subscription: z.string().nullable().optional(),
|
|
67
|
+
payment_status: z.string(),
|
|
68
|
+
amount_total: z.int().nullable(),
|
|
69
|
+
currency: z.string().nullable(),
|
|
70
|
+
metadata: z.record(z.string(), z.string()).nullable().optional()
|
|
71
|
+
});
|
|
72
|
+
const SUBSCRIPTION = z.object({ id: z.string(), customer: z.string() });
|
|
73
|
+
const INVOICE = z.object({
|
|
74
|
+
id: z.string(),
|
|
75
|
+
customer: z.string(),
|
|
76
|
+
amount_paid: z.int(),
|
|
77
|
+
currency: z.string(),
|
|
78
|
+
status_transitions: z.object({ paid_at: z.int().nullable() })
|
|
79
|
+
});
|
|
80
|
+
const EVENT = z.object({
|
|
81
|
+
id: z.string().min(1),
|
|
82
|
+
type: z.string().min(1),
|
|
83
|
+
created: z.int(),
|
|
84
|
+
data: z.object({ object: z.unknown() })
|
|
85
|
+
});
|
|
86
|
+
const at = (seconds) => new Date(seconds * 1_000);
|
|
87
|
+
function read(schema, value, type) {
|
|
88
|
+
const parsed = schema.safeParse(value);
|
|
89
|
+
if (!parsed.success) {
|
|
90
|
+
throw new StripeFailed('unexpected_payload', `stripe: the payload of ${type} does not fit: ${parsed.error.message}`);
|
|
91
|
+
}
|
|
92
|
+
return parsed.data;
|
|
93
|
+
}
|
|
94
|
+
function sessionOf(raw) {
|
|
95
|
+
const { id, customer } = raw;
|
|
96
|
+
if (raw.mode === 'subscription' && customer !== null && typeof raw.subscription === 'string') {
|
|
97
|
+
return { mode: 'subscription', id, customerId: customer, subscriptionId: raw.subscription };
|
|
98
|
+
}
|
|
99
|
+
const product = raw.metadata?.product;
|
|
100
|
+
const lookupKey = raw.metadata?.lookup_key;
|
|
101
|
+
if (raw.mode === 'payment' &&
|
|
102
|
+
customer !== null &&
|
|
103
|
+
raw.amount_total !== null &&
|
|
104
|
+
raw.currency !== null &&
|
|
105
|
+
product !== undefined &&
|
|
106
|
+
lookupKey !== undefined) {
|
|
107
|
+
return {
|
|
108
|
+
mode: 'payment',
|
|
109
|
+
id,
|
|
110
|
+
customerId: customer,
|
|
111
|
+
paid: raw.payment_status === 'paid',
|
|
112
|
+
amount: raw.amount_total,
|
|
113
|
+
currency: raw.currency,
|
|
114
|
+
product,
|
|
115
|
+
lookupKey
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return { mode: 'other', id };
|
|
119
|
+
}
|
|
120
|
+
function eventOf(payload) {
|
|
121
|
+
const event = read(EVENT, payload, 'an event');
|
|
122
|
+
const base = { id: event.id, type: event.type, created: at(event.created) };
|
|
123
|
+
const object = event.data.object;
|
|
124
|
+
switch (event.type) {
|
|
125
|
+
case 'checkout.session.completed':
|
|
126
|
+
case 'checkout.session.async_payment_succeeded':
|
|
127
|
+
return { ...base, kind: 'checkout', session: sessionOf(read(SESSION, object, event.type)) };
|
|
128
|
+
case 'customer.subscription.created':
|
|
129
|
+
case 'customer.subscription.updated':
|
|
130
|
+
case 'customer.subscription.deleted': {
|
|
131
|
+
const subscription = read(SUBSCRIPTION, object, event.type);
|
|
132
|
+
return {
|
|
133
|
+
...base,
|
|
134
|
+
kind: 'subscription',
|
|
135
|
+
subscriptionId: subscription.id,
|
|
136
|
+
customerId: subscription.customer
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
case 'invoice.paid': {
|
|
140
|
+
const invoice = read(INVOICE, object, event.type);
|
|
141
|
+
return {
|
|
142
|
+
...base,
|
|
143
|
+
kind: 'invoice',
|
|
144
|
+
invoice: {
|
|
145
|
+
id: invoice.id,
|
|
146
|
+
customerId: invoice.customer,
|
|
147
|
+
amount: invoice.amount_paid,
|
|
148
|
+
currency: invoice.currency,
|
|
149
|
+
// A paid invoice has its time of payment; the event is as late.
|
|
150
|
+
paidAt: at(invoice.status_transitions.paid_at ?? event.created)
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
default:
|
|
155
|
+
return { ...base, kind: 'other' };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export function createStripe(config) {
|
|
159
|
+
const double = config.api.kind === 'double' ? new URL(config.api.url) : null;
|
|
160
|
+
const stripe = new Stripe(config.secretKey, {
|
|
161
|
+
timeout: TIMEOUT_MS,
|
|
162
|
+
maxNetworkRetries: 1,
|
|
163
|
+
...(double === null ? {} : { host: double.hostname, port: double.port, protocol: 'http' })
|
|
164
|
+
});
|
|
165
|
+
return {
|
|
166
|
+
async product(id) {
|
|
167
|
+
try {
|
|
168
|
+
const found = await call('reading a product', () => stripe.products.retrieve(id));
|
|
169
|
+
return { id: found.id, name: found.name, active: found.active };
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (isMissing(error))
|
|
173
|
+
return null;
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
async createProduct(product) {
|
|
178
|
+
await call('creating a product', () => stripe.products.create(product));
|
|
179
|
+
},
|
|
180
|
+
async updateProduct(product) {
|
|
181
|
+
await call('updating a product', () => stripe.products.update(product.id, { name: product.name, active: true }));
|
|
182
|
+
},
|
|
183
|
+
async activePrice(lookupKey) {
|
|
184
|
+
const { data } = await call('reading a price', () => stripe.prices.list({ lookup_keys: [lookupKey], active: true, limit: 1 }));
|
|
185
|
+
const [found] = data;
|
|
186
|
+
return found === undefined ? null : priceOf(found);
|
|
187
|
+
},
|
|
188
|
+
async createPrice(price) {
|
|
189
|
+
const created = await call('creating a price', () => stripe.prices.create({
|
|
190
|
+
product: price.product,
|
|
191
|
+
currency: price.currency,
|
|
192
|
+
unit_amount: price.amount,
|
|
193
|
+
lookup_key: price.lookupKey,
|
|
194
|
+
transfer_lookup_key: true,
|
|
195
|
+
...(price.type === 'recurring' ? { recurring: { interval: price.interval } } : {})
|
|
196
|
+
}));
|
|
197
|
+
return created.id;
|
|
198
|
+
},
|
|
199
|
+
async defaultPortal() {
|
|
200
|
+
const { data } = await call('reading the customer portal', () => stripe.billingPortal.configurations.list({
|
|
201
|
+
is_default: true,
|
|
202
|
+
limit: 1,
|
|
203
|
+
// The products of the subscription updates come only on request.
|
|
204
|
+
expand: ['data.features.subscription_update.products']
|
|
205
|
+
}));
|
|
206
|
+
const [found] = data;
|
|
207
|
+
return found === undefined ? null : { id: found.id, configuration: found };
|
|
208
|
+
},
|
|
209
|
+
async updatePortal(id, configuration) {
|
|
210
|
+
await call('updating the customer portal', () => stripe.billingPortal.configurations.update(id, configuration));
|
|
211
|
+
},
|
|
212
|
+
async webhookEndpoints() {
|
|
213
|
+
const { data } = await call('reading the webhook endpoints', () => stripe.webhookEndpoints.list({ limit: 100 }));
|
|
214
|
+
return data.map((endpoint) => ({
|
|
215
|
+
id: endpoint.id,
|
|
216
|
+
url: endpoint.url,
|
|
217
|
+
enabled: endpoint.status === 'enabled'
|
|
218
|
+
}));
|
|
219
|
+
},
|
|
220
|
+
async createCustomer(customer) {
|
|
221
|
+
const created = await call('creating a customer', () => stripe.customers.create({
|
|
222
|
+
email: customer.email,
|
|
223
|
+
metadata: { account_id: customer.accountId }
|
|
224
|
+
}));
|
|
225
|
+
return created.id;
|
|
226
|
+
},
|
|
227
|
+
async updateCustomerEmail(customerId, email) {
|
|
228
|
+
await call('updating a customer', () => stripe.customers.update(customerId, { email }));
|
|
229
|
+
},
|
|
230
|
+
async subscriptionsOf(customerId) {
|
|
231
|
+
const { data } = await call('reading subscriptions', () => stripe.subscriptions.list({ customer: customerId, status: 'all', limit: 100 }));
|
|
232
|
+
return data.map((subscription) => ({
|
|
233
|
+
id: subscription.id,
|
|
234
|
+
status: statusOf(subscription.status)
|
|
235
|
+
}));
|
|
236
|
+
},
|
|
237
|
+
async subscription(id) {
|
|
238
|
+
const found = await call('reading a subscription', () => stripe.subscriptions.retrieve(id));
|
|
239
|
+
// The baukasten opens subscriptions of one price; the first item is that price.
|
|
240
|
+
const [item] = found.items.data;
|
|
241
|
+
if (item === undefined || item.quantity === undefined) {
|
|
242
|
+
throw new StripeFailed('unexpected_subscription', `stripe: the subscription ${id} has no item with a quantity`);
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
id: found.id,
|
|
246
|
+
customerId: idOf(found.customer),
|
|
247
|
+
status: statusOf(found.status),
|
|
248
|
+
product: idOf(item.price.product),
|
|
249
|
+
price: { id: item.price.id, lookupKey: item.price.lookup_key },
|
|
250
|
+
quantity: item.quantity,
|
|
251
|
+
latestInvoice: found.latest_invoice === null ? null : idOf(found.latest_invoice)
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
async cancelSubscription(id) {
|
|
255
|
+
try {
|
|
256
|
+
await call('ending a subscription', () => stripe.subscriptions.cancel(id));
|
|
257
|
+
return 'canceled';
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
if (isMissing(error))
|
|
261
|
+
return 'missing';
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
async createCheckout(request) {
|
|
266
|
+
const item = request.mode === 'subscription'
|
|
267
|
+
? {
|
|
268
|
+
price: request.priceId,
|
|
269
|
+
quantity: request.quantity,
|
|
270
|
+
...(request.adjustable.kind === 'up to'
|
|
271
|
+
? {
|
|
272
|
+
adjustable_quantity: {
|
|
273
|
+
enabled: true,
|
|
274
|
+
minimum: 1,
|
|
275
|
+
maximum: request.adjustable.max
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
: {})
|
|
279
|
+
}
|
|
280
|
+
: { price: request.priceId, quantity: 1 };
|
|
281
|
+
const session = await call('opening Checkout', () => stripe.checkout.sessions.create({
|
|
282
|
+
mode: request.mode,
|
|
283
|
+
customer: request.customerId,
|
|
284
|
+
client_reference_id: request.accountId,
|
|
285
|
+
line_items: [item],
|
|
286
|
+
// Whoever pays accepts the terms again, for the business; Stripe keeps the consent.
|
|
287
|
+
consent_collection: { terms_of_service: 'required' },
|
|
288
|
+
custom_text: { terms_of_service_acceptance: { message: request.termsMessage } },
|
|
289
|
+
billing_address_collection: 'required',
|
|
290
|
+
// A business may give its tax id; Stripe keeps it, name and address at the customer.
|
|
291
|
+
tax_id_collection: { enabled: true },
|
|
292
|
+
customer_update: { name: 'auto', address: 'auto' },
|
|
293
|
+
// Paid in the currency of the catalog, which alone counts as revenue.
|
|
294
|
+
adaptive_pricing: { enabled: false },
|
|
295
|
+
expires_at: Math.floor(request.expiresAt.getTime() / 1_000),
|
|
296
|
+
success_url: request.successUrl,
|
|
297
|
+
cancel_url: request.cancelUrl,
|
|
298
|
+
metadata: request.mode === 'payment'
|
|
299
|
+
? {
|
|
300
|
+
account_id: request.accountId,
|
|
301
|
+
product: request.product,
|
|
302
|
+
lookup_key: request.lookupKey
|
|
303
|
+
}
|
|
304
|
+
: { account_id: request.accountId }
|
|
305
|
+
}));
|
|
306
|
+
if (session.url === null)
|
|
307
|
+
throw new StripeFailed('no_url', 'stripe: Checkout gave no address');
|
|
308
|
+
return session.url;
|
|
309
|
+
},
|
|
310
|
+
async createPortalSession(session) {
|
|
311
|
+
const created = await call('opening the customer portal', () => stripe.billingPortal.sessions.create({
|
|
312
|
+
customer: session.customerId,
|
|
313
|
+
configuration: session.configurationId,
|
|
314
|
+
return_url: session.returnUrl
|
|
315
|
+
}));
|
|
316
|
+
return created.url;
|
|
317
|
+
},
|
|
318
|
+
event(payload, signature) {
|
|
319
|
+
let verified;
|
|
320
|
+
try {
|
|
321
|
+
// Without the header there is no signature to fit.
|
|
322
|
+
verified = stripe.webhooks.constructEvent(payload, signature ?? '', config.webhookSecret);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (error instanceof Stripe.errors.StripeSignatureVerificationError) {
|
|
326
|
+
return { kind: 'invalid' };
|
|
327
|
+
}
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
return { kind: 'event', event: eventOf(verified) };
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import type { Log } from './log.js';
|
|
3
|
+
import type { Messages } from './messages.js';
|
|
4
|
+
export declare class ApiError extends Error {
|
|
5
|
+
name: string;
|
|
6
|
+
readonly status: number;
|
|
7
|
+
readonly code: string;
|
|
8
|
+
readonly details: unknown;
|
|
9
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
10
|
+
constructor(status: number, code: string, message: string, options?: {
|
|
11
|
+
details?: unknown;
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Runs a handler of the API: an ApiError becomes its answer, anything else a logged 500. */
|
|
16
|
+
export declare function respond(log: Log, messages: Messages, handler: () => Promise<Response>): Promise<Response>;
|
|
17
|
+
/** The JSON body of a request, parsed with `schema`: 400 when it is no JSON, 422 when it does not fit. */
|
|
18
|
+
export declare function readBody<S extends z.ZodType>(request: Request, schema: S, messages: Messages): Promise<z.output<S>>;
|
|
19
|
+
/** The key in `Authorization: Bearer <prefix>_<secret>`, or null. */
|
|
20
|
+
export declare function bearerKey(headers: Headers, prefix: string): string | null;
|
|
21
|
+
/**
|
|
22
|
+
* SvelteKit answers some errors under /api/ itself, in text, HTML or its own JSON (an unknown path,
|
|
23
|
+
* a wrong method, a failed endpoint); the API promises its shape for every error.
|
|
24
|
+
*/
|
|
25
|
+
export declare function asApiError(response: Response, messages: Messages): Promise<Response>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
name = 'ApiError';
|
|
3
|
+
status;
|
|
4
|
+
code;
|
|
5
|
+
details;
|
|
6
|
+
headers;
|
|
7
|
+
constructor(status, code, message, options = {}) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.details = options.details;
|
|
12
|
+
this.headers = options.headers ?? {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function errorResponse(error) {
|
|
16
|
+
const body = { code: error.code, message: error.message };
|
|
17
|
+
if (error.details !== undefined)
|
|
18
|
+
body.details = error.details;
|
|
19
|
+
return Response.json({ error: body }, { status: error.status, headers: error.headers });
|
|
20
|
+
}
|
|
21
|
+
/** Runs a handler of the API: an ApiError becomes its answer, anything else a logged 500. */
|
|
22
|
+
export async function respond(log, messages, handler) {
|
|
23
|
+
try {
|
|
24
|
+
return await handler();
|
|
25
|
+
}
|
|
26
|
+
catch (caught) {
|
|
27
|
+
if (caught instanceof ApiError)
|
|
28
|
+
return errorResponse(caught);
|
|
29
|
+
log.error('api: unexpected error', { error: caught });
|
|
30
|
+
return errorResponse(new ApiError(500, 'internal', messages.api.internal));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** The JSON body of a request, parsed with `schema`: 400 when it is no JSON, 422 when it does not fit. */
|
|
34
|
+
export async function readBody(request, schema, messages) {
|
|
35
|
+
let body;
|
|
36
|
+
try {
|
|
37
|
+
body = await request.json();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new ApiError(400, 'invalid_json', messages.api.invalidJson);
|
|
41
|
+
}
|
|
42
|
+
const result = schema.safeParse(body);
|
|
43
|
+
if (!result.success) {
|
|
44
|
+
throw new ApiError(422, 'invalid_request', messages.api.invalidRequest, {
|
|
45
|
+
details: result.error.issues.map((issue) => ({
|
|
46
|
+
path: issue.path.join('.'),
|
|
47
|
+
message: issue.message
|
|
48
|
+
}))
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return result.data;
|
|
52
|
+
}
|
|
53
|
+
/** The key in `Authorization: Bearer <prefix>_<secret>`, or null. */
|
|
54
|
+
export function bearerKey(headers, prefix) {
|
|
55
|
+
const match = new RegExp(`^Bearer (${prefix}_[A-Za-z0-9_-]{43})$`).exec(headers.get('authorization') ?? '');
|
|
56
|
+
return match?.[1] ?? null;
|
|
57
|
+
}
|
|
58
|
+
const CODES = {
|
|
59
|
+
403: (m) => ['forbidden', m.api.forbidden],
|
|
60
|
+
404: (m) => ['not_found', m.api.notFound],
|
|
61
|
+
405: (m) => ['method_not_allowed', m.api.methodNotAllowed],
|
|
62
|
+
413: (m) => ['payload_too_large', m.api.payloadTooLarge]
|
|
63
|
+
};
|
|
64
|
+
const isShaped = (body) => {
|
|
65
|
+
if (typeof body !== 'object' || body === null || !('error' in body))
|
|
66
|
+
return false;
|
|
67
|
+
const error = body.error;
|
|
68
|
+
return (typeof error === 'object' &&
|
|
69
|
+
error !== null &&
|
|
70
|
+
'code' in error &&
|
|
71
|
+
typeof error.code === 'string' &&
|
|
72
|
+
'message' in error &&
|
|
73
|
+
typeof error.message === 'string');
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* SvelteKit answers some errors under /api/ itself, in text, HTML or its own JSON (an unknown path,
|
|
77
|
+
* a wrong method, a failed endpoint); the API promises its shape for every error.
|
|
78
|
+
*/
|
|
79
|
+
export async function asApiError(response, messages) {
|
|
80
|
+
if (response.status < 400)
|
|
81
|
+
return response;
|
|
82
|
+
if (response.headers.get('content-type')?.startsWith('application/json') === true) {
|
|
83
|
+
const body = await response
|
|
84
|
+
.clone()
|
|
85
|
+
.json()
|
|
86
|
+
.catch(() => null);
|
|
87
|
+
if (isShaped(body))
|
|
88
|
+
return response;
|
|
89
|
+
}
|
|
90
|
+
const [code, message] = CODES[response.status]?.(messages) ??
|
|
91
|
+
(response.status >= 500
|
|
92
|
+
? ['internal', messages.api.internal]
|
|
93
|
+
: ['failed', messages.api.failed]);
|
|
94
|
+
const headers = new Headers(response.headers);
|
|
95
|
+
headers.delete('content-type');
|
|
96
|
+
headers.delete('content-length');
|
|
97
|
+
return Response.json({ error: { code, message } }, { status: response.status, headers });
|
|
98
|
+
}
|