@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,77 @@
|
|
|
1
|
+
/** The offline tokens of a shop, as Shopify gives them: both expire, the refresh token later. */
|
|
2
|
+
export interface ShopTokens {
|
|
3
|
+
accessToken: string;
|
|
4
|
+
accessExpiresAt: Date;
|
|
5
|
+
refreshToken: string;
|
|
6
|
+
refreshExpiresAt: Date;
|
|
7
|
+
/** The scopes granted, as Shopify lists them. */
|
|
8
|
+
scope: string;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Why Shopify gave no tokens: `invalid` for an ID token or refresh token it no longer takes (the shop
|
|
12
|
+
* must open the app again), `failed` for anything else, which a later try may get past.
|
|
13
|
+
*/
|
|
14
|
+
export type TokenRefusal = {
|
|
15
|
+
kind: 'invalid';
|
|
16
|
+
} | {
|
|
17
|
+
kind: 'failed';
|
|
18
|
+
reason: string;
|
|
19
|
+
};
|
|
20
|
+
export type TokenAnswer = {
|
|
21
|
+
kind: 'tokens';
|
|
22
|
+
tokens: ShopTokens;
|
|
23
|
+
} | TokenRefusal;
|
|
24
|
+
/** What the baukasten keeps of a shop when it installs the app. */
|
|
25
|
+
export interface ShopInfo {
|
|
26
|
+
/** gid://shopify/Shop/… */
|
|
27
|
+
shopId: string;
|
|
28
|
+
/** gid://shopify/App/…, this app as Shopify knows it. */
|
|
29
|
+
appId: string;
|
|
30
|
+
/** The address of the shop, where requests about the data of its customers go. */
|
|
31
|
+
email: string;
|
|
32
|
+
/** A development store of a partner, which never pays. */
|
|
33
|
+
development: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** The subscription of a shop as Shopify App Pricing says it. */
|
|
36
|
+
export type Subscription = {
|
|
37
|
+
kind: 'none';
|
|
38
|
+
} | {
|
|
39
|
+
kind: 'active';
|
|
40
|
+
/** The handles of its items: the plan, and events of usage if it has any. */
|
|
41
|
+
handles: string[];
|
|
42
|
+
/** Until when it runs as a trial; null without one. */
|
|
43
|
+
trialEndsAt: Date | null;
|
|
44
|
+
};
|
|
45
|
+
export type GraphqlAnswer = {
|
|
46
|
+
kind: 'data';
|
|
47
|
+
data: unknown;
|
|
48
|
+
} | {
|
|
49
|
+
kind: 'errors';
|
|
50
|
+
errors: {
|
|
51
|
+
message: string;
|
|
52
|
+
code: string | null;
|
|
53
|
+
}[];
|
|
54
|
+
}
|
|
55
|
+
/** The access token no longer works: the shop removed the app, or it expired. */
|
|
56
|
+
| {
|
|
57
|
+
kind: 'unauthorized';
|
|
58
|
+
}
|
|
59
|
+
/** Over the limit of the shop: try again after this many milliseconds. */
|
|
60
|
+
| {
|
|
61
|
+
kind: 'throttled';
|
|
62
|
+
waitMs: number;
|
|
63
|
+
};
|
|
64
|
+
export interface ShopifyApi {
|
|
65
|
+
/** The offline tokens of `shop` for an ID token from the admin (token exchange). */
|
|
66
|
+
exchange(shop: string, idToken: string): Promise<TokenAnswer>;
|
|
67
|
+
/** New offline tokens of `shop` for its refresh token; the old ones end. */
|
|
68
|
+
refresh(shop: string, refreshToken: string): Promise<TokenAnswer>;
|
|
69
|
+
/** What the baukasten keeps of a shop that installed the app; null when the token no longer works. */
|
|
70
|
+
shopInfo(shop: string, accessToken: string): Promise<ShopInfo | null>;
|
|
71
|
+
/** A query of the Admin API in the version of the baukasten. */
|
|
72
|
+
graphql(shop: string, accessToken: string, query: string, variables: Readonly<Record<string, unknown>>): Promise<GraphqlAnswer>;
|
|
73
|
+
/** The subscription of a shop through the Partner API. */
|
|
74
|
+
subscription(appId: string, shopId: string): Promise<Subscription>;
|
|
75
|
+
/** Whether the Partner API takes the token: a query without data of a shop, for /health/deep. */
|
|
76
|
+
partnerCheck(): Promise<void>;
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Queryable } from '../server/ports.js';
|
|
2
|
+
import { type SalesNumbers } from '../server/sales.js';
|
|
3
|
+
import type { ShopifyPlan } from './app.js';
|
|
4
|
+
export declare function shopifySalesNumbers(db: Queryable, plans: readonly ShopifyPlan[], now: Date): Promise<SalesNumbers>;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { SALES_NUMBERS } from '../server/sales.js';
|
|
2
|
+
import { NO_VIA } from '../server/via.js';
|
|
3
|
+
export async function shopifySalesNumbers(db, plans, now) {
|
|
4
|
+
const paid = plans.filter((plan) => plan.paid).map((plan) => plan.handle);
|
|
5
|
+
const [counted] = await db.query(`SELECT count(*)::int AS konten,
|
|
6
|
+
count(*) FILTER (WHERE plan_handles && $1::text[]
|
|
7
|
+
AND (plan_trial_ends_at IS NULL OR plan_trial_ends_at <= $2))::int AS zahlende
|
|
8
|
+
FROM baukasten.shops WHERE uninstalled_at IS NULL AND NOT development`, [paid, now]);
|
|
9
|
+
if (counted === undefined)
|
|
10
|
+
throw new Error('counting the shops gave no row');
|
|
11
|
+
const [konten, zahlende] = SALES_NUMBERS;
|
|
12
|
+
return {
|
|
13
|
+
stand: now.toISOString(),
|
|
14
|
+
je_kennung: counted.konten === 0
|
|
15
|
+
? []
|
|
16
|
+
: [
|
|
17
|
+
{
|
|
18
|
+
kennung: NO_VIA,
|
|
19
|
+
werte: [
|
|
20
|
+
{ name: konten, wert: counted.konten },
|
|
21
|
+
{ name: zahlende, wert: counted.zahlende }
|
|
22
|
+
],
|
|
23
|
+
umsatz: null
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// With managed installation the merchant approves new scopes when the app opens, and only a new
|
|
2
|
+
// token exchange gives tokens with them. Writing a resource includes reading it, as at Shopify.
|
|
3
|
+
function scopes(list) {
|
|
4
|
+
return new Set(list
|
|
5
|
+
.split(',')
|
|
6
|
+
.map((scope) => scope.trim())
|
|
7
|
+
.filter((scope) => scope !== ''));
|
|
8
|
+
}
|
|
9
|
+
/** Whether `granted` (as Shopify lists it) covers every scope of `required`. */
|
|
10
|
+
export function scopesCover(granted, required) {
|
|
11
|
+
const have = scopes(granted);
|
|
12
|
+
return [...scopes(required)].every((scope) => have.has(scope) || (scope.includes('read_') && have.has(scope.replace('read_', 'write_'))));
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The key of the tokens, made only from its hex digits. */
|
|
2
|
+
export interface SealKey {
|
|
3
|
+
readonly bytes: Buffer;
|
|
4
|
+
}
|
|
5
|
+
export declare function sealKey(hex: string): SealKey;
|
|
6
|
+
/**
|
|
7
|
+
* Seals `text` under `key` with a fresh `nonce` of 12 random bytes, bound to `place`, like
|
|
8
|
+
* `example.myshopify.com access`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function seal(key: SealKey, text: string, nonce: Uint8Array, place: string): string;
|
|
11
|
+
/** What `seal` sealed under `key` for `place`; anything else throws. */
|
|
12
|
+
export declare function open(key: SealKey, sealed: string, place: string): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The access and refresh tokens of the shops lie in the database sealed with AES-256-GCM under
|
|
2
|
+
// SHOPIFY_TOKEN_KEY, which the delivery service makes for the app: a dump of the database alone hands
|
|
3
|
+
// no one a token. A sealed token carries its version, its nonce and its tag, and is bound to where it
|
|
4
|
+
// belongs (its shop and column): a changed one, or one moved to another shop, does not open.
|
|
5
|
+
import { createCipheriv, createDecipheriv } from 'node:crypto';
|
|
6
|
+
const VERSION = 'v1';
|
|
7
|
+
const NONCE_BYTES = 12;
|
|
8
|
+
const TAG_BYTES = 16;
|
|
9
|
+
const ALGORITHM = 'aes-256-gcm';
|
|
10
|
+
class Unsealed extends Error {
|
|
11
|
+
name = 'Unsealed';
|
|
12
|
+
}
|
|
13
|
+
export function sealKey(hex) {
|
|
14
|
+
if (!/^[0-9a-f]{64}$/.test(hex))
|
|
15
|
+
throw new Error('the key of the tokens is 64 hex digits');
|
|
16
|
+
return { bytes: Buffer.from(hex, 'hex') };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Seals `text` under `key` with a fresh `nonce` of 12 random bytes, bound to `place`, like
|
|
20
|
+
* `example.myshopify.com access`.
|
|
21
|
+
*/
|
|
22
|
+
export function seal(key, text, nonce, place) {
|
|
23
|
+
if (nonce.length !== NONCE_BYTES)
|
|
24
|
+
throw new Error(`the nonce is ${NONCE_BYTES} bytes`);
|
|
25
|
+
const cipher = createCipheriv(ALGORITHM, key.bytes, nonce, { authTagLength: TAG_BYTES });
|
|
26
|
+
cipher.setAAD(Buffer.from(place, 'utf8'));
|
|
27
|
+
const sealed = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
|
28
|
+
return [
|
|
29
|
+
VERSION,
|
|
30
|
+
Buffer.from(nonce).toString('base64url'),
|
|
31
|
+
cipher.getAuthTag().toString('base64url'),
|
|
32
|
+
sealed.toString('base64url')
|
|
33
|
+
].join('.');
|
|
34
|
+
}
|
|
35
|
+
/** What `seal` sealed under `key` for `place`; anything else throws. */
|
|
36
|
+
export function open(key, sealed, place) {
|
|
37
|
+
const [version, nonce, tag, text, ...rest] = sealed.split('.');
|
|
38
|
+
if (version !== VERSION || nonce === undefined || tag === undefined || text === undefined) {
|
|
39
|
+
throw new Unsealed('a sealed token does not open: not of this form');
|
|
40
|
+
}
|
|
41
|
+
if (rest.length > 0)
|
|
42
|
+
throw new Unsealed('a sealed token does not open: not of this form');
|
|
43
|
+
try {
|
|
44
|
+
// A shorter tag than GCM's whole one would make a forgery easier.
|
|
45
|
+
const decipher = createDecipheriv(ALGORITHM, key.bytes, Buffer.from(nonce, 'base64url'), {
|
|
46
|
+
authTagLength: TAG_BYTES
|
|
47
|
+
});
|
|
48
|
+
decipher.setAAD(Buffer.from(place, 'utf8'));
|
|
49
|
+
decipher.setAuthTag(Buffer.from(tag, 'base64url'));
|
|
50
|
+
return Buffer.concat([
|
|
51
|
+
decipher.update(Buffer.from(text, 'base64url')),
|
|
52
|
+
decipher.final()
|
|
53
|
+
]).toString('utf8');
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw new Unsealed('a sealed token does not open: another key or place, or changed', {
|
|
57
|
+
cause: error
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** The shop of `value`, or null for anything that is not a domain under myshopify.com. */
|
|
2
|
+
export declare function shopDomain(value: string | null): string | null;
|
|
3
|
+
/** The handle of a shop in the admin, admin.shopify.com/store/<handle>: its domain without the suffix. */
|
|
4
|
+
export declare function storeHandle(shop: string): string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The name of a shop, the one way the baukasten takes it: its permanent domain under myshopify.com,
|
|
2
|
+
// which Shopify sends with every ID token and webhook. A domain from a request becomes a header, a
|
|
3
|
+
// URL to call with a token and a key in the database, so anything else is refused.
|
|
4
|
+
const SHOP = /^([a-z0-9][a-z0-9-]*)\.myshopify\.com$/;
|
|
5
|
+
/** The shop of `value`, or null for anything that is not a domain under myshopify.com. */
|
|
6
|
+
export function shopDomain(value) {
|
|
7
|
+
return value !== null && SHOP.test(value) ? value : null;
|
|
8
|
+
}
|
|
9
|
+
/** The handle of a shop in the admin, admin.shopify.com/store/<handle>: its domain without the suffix. */
|
|
10
|
+
export function storeHandle(shop) {
|
|
11
|
+
const handle = SHOP.exec(shop)?.[1];
|
|
12
|
+
if (handle === undefined)
|
|
13
|
+
throw new Error('not the domain of a shop');
|
|
14
|
+
return handle;
|
|
15
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Log } from '../server/log.js';
|
|
2
|
+
import type { Db, Queryable } from '../server/ports.js';
|
|
3
|
+
import type { ShopifyPlan } from './app.js';
|
|
4
|
+
import type { ShopifyApi } from './ports.js';
|
|
5
|
+
import { type SealKey } from './seal.js';
|
|
6
|
+
/** A token is refreshed when it ends within this time, so that no request runs into its end. */
|
|
7
|
+
export declare const REFRESH_BEFORE_MS: number;
|
|
8
|
+
/** The plan of a shop is asked again after this time, and at once after the plan page. */
|
|
9
|
+
export declare const PLAN_FRESH_MS: number;
|
|
10
|
+
export type Plan = {
|
|
11
|
+
kind: 'none';
|
|
12
|
+
} | {
|
|
13
|
+
kind: 'active';
|
|
14
|
+
handle: string;
|
|
15
|
+
/** Until when it runs as a trial; null without one. */
|
|
16
|
+
trialEndsAt: Date | null;
|
|
17
|
+
};
|
|
18
|
+
export type Entry = {
|
|
19
|
+
kind: 'in';
|
|
20
|
+
}
|
|
21
|
+
/** Shopify no longer takes the ID token: App Bridge fetches a new one. */
|
|
22
|
+
| {
|
|
23
|
+
kind: 'invalid';
|
|
24
|
+
}
|
|
25
|
+
/** Shopify did not answer as it should; the merchant tries again later. */
|
|
26
|
+
| {
|
|
27
|
+
kind: 'failed';
|
|
28
|
+
reason: string;
|
|
29
|
+
};
|
|
30
|
+
export interface Shops {
|
|
31
|
+
/**
|
|
32
|
+
* Lets the shop of a valid ID token in: with tokens that work and cover the scopes of the app,
|
|
33
|
+
* refreshed if they end soon, or new ones from the token exchange, which installs the shop when it
|
|
34
|
+
* is new or came back.
|
|
35
|
+
*/
|
|
36
|
+
enter(shop: string, idToken: string): Promise<Entry>;
|
|
37
|
+
/** A working access token of an installed shop; null when only the merchant can get a new one. */
|
|
38
|
+
accessToken(shop: string): Promise<string | null>;
|
|
39
|
+
/**
|
|
40
|
+
* Forgets the tokens of a shop, since Shopify refused `failed`: only if they are still those, and
|
|
41
|
+
* says whether it did; a newer token, refreshed meanwhile, stays.
|
|
42
|
+
*/
|
|
43
|
+
forgetTokens(shop: string, failed: string): Promise<boolean>;
|
|
44
|
+
/** The plan of the shop, remembered for PLAN_FRESH_MS unless `fresh`. */
|
|
45
|
+
plan(shop: string, fresh: boolean): Promise<Plan>;
|
|
46
|
+
/**
|
|
47
|
+
* The end of an installation that Shopify triggered at `triggeredAt`: its tokens are gone, its data
|
|
48
|
+
* stay until shop/redact. False when the shop got its tokens from the token exchange after it, so
|
|
49
|
+
* that it removed the app and added it again before the end was applied; then it stays installed.
|
|
50
|
+
*/
|
|
51
|
+
uninstall(tx: Queryable, shop: string, triggeredAt: Date): Promise<boolean>;
|
|
52
|
+
/** Deletes the shop and, through its references, all the app keeps of it. */
|
|
53
|
+
redact(tx: Queryable, shop: string): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* The address of the shop, for requests about the data of its customers: as Shopify gives it now
|
|
56
|
+
* while the app has a token that works, else the last it gave; null for an unknown shop.
|
|
57
|
+
*/
|
|
58
|
+
email(q: Queryable, shop: string): Promise<string | null>;
|
|
59
|
+
/** A shop that has the app, for the check of the Partner API; null before the first. */
|
|
60
|
+
anyInstalled(): Promise<{
|
|
61
|
+
appId: string;
|
|
62
|
+
shopId: string;
|
|
63
|
+
} | null>;
|
|
64
|
+
}
|
|
65
|
+
export declare function createShops(deps: {
|
|
66
|
+
db: Db;
|
|
67
|
+
api: ShopifyApi;
|
|
68
|
+
key: SealKey;
|
|
69
|
+
plans: readonly ShopifyPlan[];
|
|
70
|
+
/** The scopes the app asks for, from shopify.app.toml. */
|
|
71
|
+
scopes: string;
|
|
72
|
+
log: Log;
|
|
73
|
+
now: () => Date;
|
|
74
|
+
/** 12 random bytes for each sealed token. */
|
|
75
|
+
nonce: () => Uint8Array;
|
|
76
|
+
}): Shops;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { scopesCover } from './scopes.js';
|
|
2
|
+
import { open, seal } from './seal.js';
|
|
3
|
+
/** A token is refreshed when it ends within this time, so that no request runs into its end. */
|
|
4
|
+
export const REFRESH_BEFORE_MS = 5 * 60_000;
|
|
5
|
+
/** The plan of a shop is asked again after this time, and at once after the plan page. */
|
|
6
|
+
export const PLAN_FRESH_MS = 10 * 60_000;
|
|
7
|
+
export function createShops(deps) {
|
|
8
|
+
const { db, api, key, log, now } = deps;
|
|
9
|
+
/** Where a token belongs: a sealed one opens only there. */
|
|
10
|
+
const place = (shop, column) => `${shop} ${column}`;
|
|
11
|
+
/** The refresh of each shop that runs in this process; a second caller waits for it. */
|
|
12
|
+
const refreshing = new Map();
|
|
13
|
+
async function row(q, shop, lock = false) {
|
|
14
|
+
const [found] = await q.query(`SELECT domain, shop_id, app_id, email, scope, access_token, access_expires_at,
|
|
15
|
+
refresh_token, refresh_expires_at, uninstalled_at, plan_handles, plan_trial_ends_at,
|
|
16
|
+
plan_checked_at
|
|
17
|
+
FROM baukasten.shops WHERE domain = $1${lock ? ' FOR UPDATE' : ''}`, [shop]);
|
|
18
|
+
return found ?? null;
|
|
19
|
+
}
|
|
20
|
+
const fresh = (expires) => expires !== null && expires.getTime() - now().getTime() > REFRESH_BEFORE_MS;
|
|
21
|
+
function freshToken(found) {
|
|
22
|
+
const access = found.access_token;
|
|
23
|
+
if (found.uninstalled_at !== null || access === null || !fresh(found.access_expires_at)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return open(key, access, place(found.domain, 'access'));
|
|
27
|
+
}
|
|
28
|
+
async function storeTokens(q, shop, tokens) {
|
|
29
|
+
await q.query(`UPDATE baukasten.shops SET access_token = $2, access_expires_at = $3, refresh_token = $4,
|
|
30
|
+
refresh_expires_at = $5, scope = $6
|
|
31
|
+
WHERE domain = $1`, [
|
|
32
|
+
shop,
|
|
33
|
+
seal(key, tokens.accessToken, deps.nonce(), place(shop, 'access')),
|
|
34
|
+
tokens.accessExpiresAt,
|
|
35
|
+
seal(key, tokens.refreshToken, deps.nonce(), place(shop, 'refresh')),
|
|
36
|
+
tokens.refreshExpiresAt,
|
|
37
|
+
tokens.scope
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
async function clearTokens(q, shop) {
|
|
41
|
+
await q.query(`UPDATE baukasten.shops SET access_token = NULL, access_expires_at = NULL,
|
|
42
|
+
refresh_token = NULL, refresh_expires_at = NULL
|
|
43
|
+
WHERE domain = $1`, [shop]);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Refreshes the token under the lock of the row, looking again first: a refresh of another
|
|
47
|
+
* process may have come before. A refresh that fails for another reason than an invalid token
|
|
48
|
+
* throws, and the tokens stay.
|
|
49
|
+
*/
|
|
50
|
+
function refresh(shop) {
|
|
51
|
+
return db.transaction(async (tx) => {
|
|
52
|
+
const found = await row(tx, shop, true);
|
|
53
|
+
if (found === null || found.uninstalled_at !== null)
|
|
54
|
+
return null;
|
|
55
|
+
const token = freshToken(found);
|
|
56
|
+
if (token !== null)
|
|
57
|
+
return token;
|
|
58
|
+
const { refresh_token: refreshToken, refresh_expires_at: refreshExpires } = found;
|
|
59
|
+
if (refreshToken === null || refreshExpires === null)
|
|
60
|
+
return null;
|
|
61
|
+
if (refreshExpires.getTime() <= now().getTime()) {
|
|
62
|
+
await clearTokens(tx, shop);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const answer = await api.refresh(shop, open(key, refreshToken, place(shop, 'refresh')));
|
|
66
|
+
switch (answer.kind) {
|
|
67
|
+
case 'tokens':
|
|
68
|
+
await storeTokens(tx, shop, answer.tokens);
|
|
69
|
+
return answer.tokens.accessToken;
|
|
70
|
+
case 'invalid':
|
|
71
|
+
log.warn('shopify: the refresh token of a shop no longer works', { shop });
|
|
72
|
+
await clearTokens(tx, shop);
|
|
73
|
+
return null;
|
|
74
|
+
case 'failed':
|
|
75
|
+
throw new Error(`shopify: refreshing the token of ${shop} failed: ${answer.reason}`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** A working access token: the fresh one without a lock, else one refresh at a time. */
|
|
80
|
+
async function accessToken(shop) {
|
|
81
|
+
const found = await row(db, shop);
|
|
82
|
+
if (found === null || found.uninstalled_at !== null)
|
|
83
|
+
return null;
|
|
84
|
+
const token = freshToken(found);
|
|
85
|
+
if (token !== null)
|
|
86
|
+
return token;
|
|
87
|
+
const running = refreshing.get(shop);
|
|
88
|
+
if (running !== undefined)
|
|
89
|
+
return running;
|
|
90
|
+
const started = refresh(shop).finally(() => refreshing.delete(shop));
|
|
91
|
+
refreshing.set(shop, started);
|
|
92
|
+
return started;
|
|
93
|
+
}
|
|
94
|
+
async function remember(q, shop, info) {
|
|
95
|
+
await q.query('UPDATE baukasten.shops SET email = $2, development = $3 WHERE domain = $1', [
|
|
96
|
+
shop,
|
|
97
|
+
info.email,
|
|
98
|
+
info.development
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Asks Shopify what it says of the shop now and takes it; null while the app has no token that
|
|
103
|
+
* works, when the shop keeps what Shopify said last.
|
|
104
|
+
*/
|
|
105
|
+
async function askInfo(q, shop) {
|
|
106
|
+
const token = await accessToken(shop);
|
|
107
|
+
if (token === null)
|
|
108
|
+
return null;
|
|
109
|
+
const info = await api.shopInfo(shop, token);
|
|
110
|
+
if (info !== null)
|
|
111
|
+
await remember(q, shop, info);
|
|
112
|
+
return info;
|
|
113
|
+
}
|
|
114
|
+
async function install(shop, tokens, info) {
|
|
115
|
+
await db.transaction(async (tx) => {
|
|
116
|
+
// Two first requests of a new shop at once install it once: the second waits here and finds
|
|
117
|
+
// the first.
|
|
118
|
+
await tx.query('SELECT pg_advisory_xact_lock(hashtext($1))', [`shopify:${shop}`]);
|
|
119
|
+
const found = await row(tx, shop, true);
|
|
120
|
+
if (found !== null && found.uninstalled_at === null) {
|
|
121
|
+
await remember(tx, shop, info);
|
|
122
|
+
await tx.query('UPDATE baukasten.shops SET exchanged_at = $2 WHERE domain = $1', [
|
|
123
|
+
shop,
|
|
124
|
+
now()
|
|
125
|
+
]);
|
|
126
|
+
await storeTokens(tx, shop, tokens);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
await tx.query(`INSERT INTO baukasten.shops (domain, shop_id, app_id, email, development, scope,
|
|
130
|
+
installed_at, exchanged_at)
|
|
131
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
|
132
|
+
ON CONFLICT (domain) DO UPDATE SET shop_id = EXCLUDED.shop_id,
|
|
133
|
+
app_id = EXCLUDED.app_id, email = EXCLUDED.email, development = EXCLUDED.development,
|
|
134
|
+
installed_at = EXCLUDED.installed_at, exchanged_at = EXCLUDED.exchanged_at,
|
|
135
|
+
uninstalled_at = NULL, plan_handles = '{}', plan_trial_ends_at = NULL,
|
|
136
|
+
plan_checked_at = NULL`, [shop, info.shopId, info.appId, info.email, info.development, tokens.scope, now()]);
|
|
137
|
+
await storeTokens(tx, shop, tokens);
|
|
138
|
+
log.info('shopify: a shop installed the app', {
|
|
139
|
+
shop,
|
|
140
|
+
development: info.development,
|
|
141
|
+
again: found !== null
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
async function exchange(shop, idToken) {
|
|
146
|
+
let tokens;
|
|
147
|
+
let info;
|
|
148
|
+
// Shopify that does not answer, or not as it should, lets the shop not in for now; a failure of
|
|
149
|
+
// the database is the app's own and goes up.
|
|
150
|
+
try {
|
|
151
|
+
const answer = await api.exchange(shop, idToken);
|
|
152
|
+
if (answer.kind !== 'tokens')
|
|
153
|
+
return answer;
|
|
154
|
+
tokens = answer.tokens;
|
|
155
|
+
info = await api.shopInfo(shop, tokens.accessToken);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
log.warn('shopify: the token exchange of a shop failed', { shop, error });
|
|
159
|
+
return { kind: 'failed', reason: error instanceof Error ? error.message : String(error) };
|
|
160
|
+
}
|
|
161
|
+
if (info === null)
|
|
162
|
+
return { kind: 'failed', reason: 'the new token did not work' };
|
|
163
|
+
await install(shop, tokens, info);
|
|
164
|
+
return { kind: 'in' };
|
|
165
|
+
}
|
|
166
|
+
function planOf(handles, trialEndsAt) {
|
|
167
|
+
if (handles.length === 0)
|
|
168
|
+
return { kind: 'none' };
|
|
169
|
+
const known = deps.plans.find((plan) => handles.includes(plan.handle));
|
|
170
|
+
if (known === undefined) {
|
|
171
|
+
log.error('shopify: a subscription without a plan of the app; its plans in baukasten.config.ts differ from the Partner Dashboard', { handles });
|
|
172
|
+
return { kind: 'none' };
|
|
173
|
+
}
|
|
174
|
+
return { kind: 'active', handle: known.handle, trialEndsAt };
|
|
175
|
+
}
|
|
176
|
+
async function ask(found) {
|
|
177
|
+
const subscription = await api.subscription(found.app_id, found.shop_id);
|
|
178
|
+
const handles = subscription.kind === 'active' ? subscription.handles : [];
|
|
179
|
+
const trialEndsAt = subscription.kind === 'active' ? subscription.trialEndsAt : null;
|
|
180
|
+
await db.query(`UPDATE baukasten.shops SET plan_handles = $2, plan_trial_ends_at = $3, plan_checked_at = $4
|
|
181
|
+
WHERE domain = $1`, [found.domain, handles, trialEndsAt, now()]);
|
|
182
|
+
// What Shopify says of the shop comes along, but never keeps the merchant out of the app: a
|
|
183
|
+
// failure keeps what it said last, in the log.
|
|
184
|
+
try {
|
|
185
|
+
await askInfo(db, found.domain);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
log.warn('shopify: what Shopify says of a shop could not be asked again', {
|
|
189
|
+
shop: found.domain,
|
|
190
|
+
error
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return planOf(handles, trialEndsAt);
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
async enter(shop, idToken) {
|
|
197
|
+
const found = await row(db, shop);
|
|
198
|
+
if (found !== null &&
|
|
199
|
+
found.uninstalled_at === null &&
|
|
200
|
+
scopesCover(found.scope, deps.scopes)) {
|
|
201
|
+
try {
|
|
202
|
+
if ((await accessToken(shop)) !== null)
|
|
203
|
+
return { kind: 'in' };
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
log.error('shopify: refreshing a token failed; trying the token exchange', {
|
|
207
|
+
shop,
|
|
208
|
+
error
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return exchange(shop, idToken);
|
|
213
|
+
},
|
|
214
|
+
accessToken,
|
|
215
|
+
forgetTokens: (shop, failed) => db.transaction(async (tx) => {
|
|
216
|
+
const found = await row(tx, shop, true);
|
|
217
|
+
const access = found?.access_token ?? null;
|
|
218
|
+
if (access === null || open(key, access, place(shop, 'access')) !== failed)
|
|
219
|
+
return false;
|
|
220
|
+
await clearTokens(tx, shop);
|
|
221
|
+
return true;
|
|
222
|
+
}),
|
|
223
|
+
async plan(shop, freshly) {
|
|
224
|
+
const found = await row(db, shop);
|
|
225
|
+
if (found === null || found.uninstalled_at !== null)
|
|
226
|
+
return { kind: 'none' };
|
|
227
|
+
const checked = found.plan_checked_at;
|
|
228
|
+
if (!freshly && checked !== null && now().getTime() - checked.getTime() < PLAN_FRESH_MS) {
|
|
229
|
+
return planOf(found.plan_handles, found.plan_trial_ends_at);
|
|
230
|
+
}
|
|
231
|
+
return ask(found);
|
|
232
|
+
},
|
|
233
|
+
async uninstall(tx, shop, triggeredAt) {
|
|
234
|
+
const ended = await tx.query(`UPDATE baukasten.shops SET uninstalled_at = $2, access_token = NULL,
|
|
235
|
+
access_expires_at = NULL, refresh_token = NULL, refresh_expires_at = NULL,
|
|
236
|
+
plan_handles = '{}', plan_trial_ends_at = NULL, plan_checked_at = NULL
|
|
237
|
+
WHERE domain = $1 AND uninstalled_at IS NULL AND exchanged_at <= $3
|
|
238
|
+
RETURNING domain`, [shop, now(), triggeredAt]);
|
|
239
|
+
return ended.length > 0;
|
|
240
|
+
},
|
|
241
|
+
async redact(tx, shop) {
|
|
242
|
+
await tx.query('DELETE FROM baukasten.shops WHERE domain = $1', [shop]);
|
|
243
|
+
},
|
|
244
|
+
async email(q, shop) {
|
|
245
|
+
const found = await row(q, shop);
|
|
246
|
+
if (found === null)
|
|
247
|
+
return null;
|
|
248
|
+
const info = await askInfo(q, shop);
|
|
249
|
+
return info === null ? found.email : info.email;
|
|
250
|
+
},
|
|
251
|
+
async anyInstalled() {
|
|
252
|
+
const [found] = await db.query('SELECT app_id, shop_id FROM baukasten.shops WHERE uninstalled_at IS NULL LIMIT 1');
|
|
253
|
+
return found === undefined ? null : { appId: found.app_id, shopId: found.shop_id };
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const SHOPIFY_SVELTEKIT_OPTIONS: {
|
|
2
|
+
csrf: {
|
|
3
|
+
trustedOrigins: string[];
|
|
4
|
+
};
|
|
5
|
+
csp: {
|
|
6
|
+
mode: "auto";
|
|
7
|
+
directives: {
|
|
8
|
+
'default-src': "self"[];
|
|
9
|
+
'script-src': ("https://cdn.shopify.com/shopifycloud/" | "self")[];
|
|
10
|
+
'style-src': ("https://cdn.shopify.com" | "self" | "unsafe-inline")[];
|
|
11
|
+
'img-src': ("https://cdn.shopify.com" | "data:" | "self")[];
|
|
12
|
+
'font-src': ("https://cdn.shopify.com" | "self")[];
|
|
13
|
+
'connect-src': ("https://cdn.shopify.com" | "self")[];
|
|
14
|
+
'form-action': "self"[];
|
|
15
|
+
'base-uri': "self"[];
|
|
16
|
+
'object-src': "none"[];
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { SHOPIFY_CDN, SHOPIFY_SCRIPTS } from './constants.js';
|
|
2
|
+
export const SHOPIFY_SVELTEKIT_OPTIONS = {
|
|
3
|
+
csrf: { trustedOrigins: ['*'] },
|
|
4
|
+
csp: {
|
|
5
|
+
mode: 'auto',
|
|
6
|
+
directives: {
|
|
7
|
+
'default-src': ['self'],
|
|
8
|
+
'script-src': ['self', SHOPIFY_SCRIPTS],
|
|
9
|
+
'style-src': ['self', 'unsafe-inline', SHOPIFY_CDN],
|
|
10
|
+
'img-src': ['self', 'data:', SHOPIFY_CDN],
|
|
11
|
+
'font-src': ['self', SHOPIFY_CDN],
|
|
12
|
+
'connect-src': ['self', SHOPIFY_CDN],
|
|
13
|
+
'form-action': ['self'],
|
|
14
|
+
'base-uri': ['self'],
|
|
15
|
+
'object-src': ['none']
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Language } from '../product.js';
|
|
2
|
+
export interface ShopifyTexts {
|
|
3
|
+
/** The page of an address of the app opened outside the admin. */
|
|
4
|
+
outside: {
|
|
5
|
+
title: (name: string) => string;
|
|
6
|
+
text: (name: string) => string;
|
|
7
|
+
privacy: string;
|
|
8
|
+
};
|
|
9
|
+
/** The page when Shopify does not answer as it should, or the app is not linked yet. */
|
|
10
|
+
unavailable: {
|
|
11
|
+
title: string;
|
|
12
|
+
text: string;
|
|
13
|
+
};
|
|
14
|
+
dataRequest: {
|
|
15
|
+
subject: (name: string) => string;
|
|
16
|
+
text: (request: {
|
|
17
|
+
name: string;
|
|
18
|
+
shop: string;
|
|
19
|
+
customer: {
|
|
20
|
+
id: string;
|
|
21
|
+
email: string | null;
|
|
22
|
+
phone: string | null;
|
|
23
|
+
};
|
|
24
|
+
data: string | null;
|
|
25
|
+
}) => string;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export declare const SHOPIFY_TEXTS: Record<Language, ShopifyTexts>;
|