@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.
- 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,128 @@
|
|
|
1
|
+
import type { StripeMode } from './config.js';
|
|
2
|
+
import type { Db, Queryable, Secrets } from './ports.js';
|
|
3
|
+
export declare const SESSION_DAYS = 30;
|
|
4
|
+
export declare const LINK_SECONDS: {
|
|
5
|
+
readonly verify: number;
|
|
6
|
+
readonly reset: 3600;
|
|
7
|
+
readonly change_email: number;
|
|
8
|
+
};
|
|
9
|
+
export type LinkPurpose = keyof typeof LINK_SECONDS;
|
|
10
|
+
/** An account as the product sees it, through the locals of a request or the API. */
|
|
11
|
+
export interface Account {
|
|
12
|
+
id: string;
|
|
13
|
+
email: string;
|
|
14
|
+
emailVerified: boolean;
|
|
15
|
+
hasPassword: boolean;
|
|
16
|
+
/** The terms accepted at sign-up, named by their day, and when. */
|
|
17
|
+
termsVersion: string;
|
|
18
|
+
termsAcceptedAt: Date;
|
|
19
|
+
/** The referral tag of the link the account came by; `ohne` without one. */
|
|
20
|
+
via: string;
|
|
21
|
+
createdAt: Date;
|
|
22
|
+
}
|
|
23
|
+
export interface SignUp {
|
|
24
|
+
email: string;
|
|
25
|
+
termsVersion: string;
|
|
26
|
+
via: string;
|
|
27
|
+
/** The mode of Stripe the product runs in now. */
|
|
28
|
+
stripeMode: StripeMode;
|
|
29
|
+
}
|
|
30
|
+
export interface ApiKey {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
/** The start of the key, to tell keys apart. */
|
|
34
|
+
prefix: string;
|
|
35
|
+
createdAt: Date;
|
|
36
|
+
}
|
|
37
|
+
export interface LinkUse {
|
|
38
|
+
accountId: string;
|
|
39
|
+
hasPassword: boolean;
|
|
40
|
+
newEmail: string | null;
|
|
41
|
+
}
|
|
42
|
+
export type EmailChange = {
|
|
43
|
+
kind: 'changed';
|
|
44
|
+
accountId: string;
|
|
45
|
+
oldEmail: string;
|
|
46
|
+
newEmail: string;
|
|
47
|
+
} | {
|
|
48
|
+
kind: 'invalid';
|
|
49
|
+
} | {
|
|
50
|
+
kind: 'taken';
|
|
51
|
+
};
|
|
52
|
+
/** What the product deletes of an account, in the transaction of the deletion. */
|
|
53
|
+
export type DeleteHook = (tx: Queryable, accountId: string) => Promise<void>;
|
|
54
|
+
export declare class EmailTaken extends Error {
|
|
55
|
+
name: string;
|
|
56
|
+
constructor();
|
|
57
|
+
}
|
|
58
|
+
export declare function normalizeEmail(email: string): string;
|
|
59
|
+
/** How the database keeps a token, a link or a key. */
|
|
60
|
+
export declare function sha256(secret: string): Buffer;
|
|
61
|
+
export type Accounts = ReturnType<typeof createAccounts>;
|
|
62
|
+
export declare function createAccounts(db: Db, secrets: Secrets): {
|
|
63
|
+
/** Signing up accepts the current terms: the account keeps their name and the time. */
|
|
64
|
+
create: (input: SignUp & {
|
|
65
|
+
password: string | null;
|
|
66
|
+
}) => Promise<Account>;
|
|
67
|
+
/** The sign-up of the API: account and first key together, or neither. */
|
|
68
|
+
createWithKey: (input: SignUp & {
|
|
69
|
+
keyName: string;
|
|
70
|
+
keyPrefix: string;
|
|
71
|
+
}) => Promise<{
|
|
72
|
+
account: Account;
|
|
73
|
+
key: string;
|
|
74
|
+
}>;
|
|
75
|
+
findByEmail(email: string): Promise<Account | null>;
|
|
76
|
+
/** The account of an address and password; one scrypt, whatever the answer. */
|
|
77
|
+
authenticate(email: string, password: string): Promise<Account | null>;
|
|
78
|
+
/** Whether `current` is the password (an account without one needs none), then sets `next`. */
|
|
79
|
+
changePassword(accountId: string, current: string | null, next: string): Promise<boolean>;
|
|
80
|
+
createSession(accountId: string): Promise<string>;
|
|
81
|
+
forSession(token: string): Promise<Account | null>;
|
|
82
|
+
endSession(token: string): Promise<void>;
|
|
83
|
+
endOtherSessions(accountId: string, keep: string): Promise<void>;
|
|
84
|
+
issueLink: (accountId: string, purpose: "verify" | "reset") => Promise<string>;
|
|
85
|
+
/** A link that makes `newEmail` the address of the account, sent to that address. */
|
|
86
|
+
issueEmailChange: (accountId: string, newEmail: string) => Promise<string>;
|
|
87
|
+
/** Looks at a link without using it, for the page that asks to confirm. */
|
|
88
|
+
peekLink(token: string, purpose: LinkPurpose): Promise<LinkUse | null>;
|
|
89
|
+
/**
|
|
90
|
+
* Confirms the address with its link, once. An account without a password (from the API) may
|
|
91
|
+
* set one here; an account that has one keeps it, whoever holds the link. The other open links
|
|
92
|
+
* of this kind go.
|
|
93
|
+
*/
|
|
94
|
+
confirmEmail: (token: string, password: string | null) => Promise<LinkUse | null>;
|
|
95
|
+
/**
|
|
96
|
+
* Sets a new password with its link, once: the link proves the address, every session ends
|
|
97
|
+
* and the other reset links go. When it confirms the address for the first time, the keys go
|
|
98
|
+
* too: whoever signed up over the API with an address not theirs got a key at once, and the
|
|
99
|
+
* owner of the address now takes the account. The id of the account, or null for a link that
|
|
100
|
+
* is not usable.
|
|
101
|
+
*/
|
|
102
|
+
resetPassword(token: string, password: string): Promise<string | null>;
|
|
103
|
+
/**
|
|
104
|
+
* Makes the confirmed address the address of the account and voids every other open link of
|
|
105
|
+
* the account, so that links sent to the old address stop working. An address another account
|
|
106
|
+
* took in between leaves the link unused.
|
|
107
|
+
*/
|
|
108
|
+
confirmEmailChange(token: string): Promise<EmailChange>;
|
|
109
|
+
createApiKey: (accountId: string, name: string, prefix: string) => Promise<{
|
|
110
|
+
key: string;
|
|
111
|
+
apiKey: ApiKey;
|
|
112
|
+
}>;
|
|
113
|
+
listApiKeys(accountId: string): Promise<ApiKey[]>;
|
|
114
|
+
/** False when the account has no such key. */
|
|
115
|
+
revokeApiKey(accountId: string, keyId: string): Promise<boolean>;
|
|
116
|
+
forApiKey(key: string): Promise<{
|
|
117
|
+
account: Account;
|
|
118
|
+
keyId: string;
|
|
119
|
+
} | null>;
|
|
120
|
+
/**
|
|
121
|
+
* Deletes what is personal in one transaction: first the data of the product through its hook,
|
|
122
|
+
* then sessions, links and keys; the row stays without address and password, for the numbers
|
|
123
|
+
* and the books. A failing hook changes nothing.
|
|
124
|
+
*/
|
|
125
|
+
delete: (accountId: string, hook: DeleteHook) => Promise<void>;
|
|
126
|
+
/** Removes expired sessions and links; it runs on login, never on a timer. */
|
|
127
|
+
removeExpired(): Promise<void>;
|
|
128
|
+
};
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// A secret leaves this module once, when it is made; the database keeps its SHA-256, a password its
|
|
2
|
+
// scrypt. Expiry is the clock of the database.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
export const SESSION_DAYS = 30;
|
|
5
|
+
const HOUR_S = 3_600;
|
|
6
|
+
export const LINK_SECONDS = {
|
|
7
|
+
verify: 48 * HOUR_S,
|
|
8
|
+
reset: HOUR_S,
|
|
9
|
+
change_email: 24 * HOUR_S
|
|
10
|
+
};
|
|
11
|
+
export class EmailTaken extends Error {
|
|
12
|
+
name = 'EmailTaken';
|
|
13
|
+
constructor() {
|
|
14
|
+
super('an account with this email address exists');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const COLUMNS = `a.id, a.email, a.email_verified_at, a.password_hash, a.terms_version,
|
|
18
|
+
a.terms_accepted_at, a.via, a.created_at`;
|
|
19
|
+
/** Accounts that are not deleted; a deleted one keeps its row without an address. */
|
|
20
|
+
const LIVE = 'a.deleted_at IS NULL';
|
|
21
|
+
function toAccount(row) {
|
|
22
|
+
return {
|
|
23
|
+
id: row.id,
|
|
24
|
+
email: row.email,
|
|
25
|
+
emailVerified: row.email_verified_at !== null,
|
|
26
|
+
hasPassword: row.password_hash !== null,
|
|
27
|
+
termsVersion: row.terms_version,
|
|
28
|
+
termsAcceptedAt: row.terms_accepted_at,
|
|
29
|
+
via: row.via,
|
|
30
|
+
createdAt: row.created_at
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const toKey = (row) => ({
|
|
34
|
+
id: row.id,
|
|
35
|
+
name: row.name,
|
|
36
|
+
prefix: row.prefix,
|
|
37
|
+
createdAt: row.created_at
|
|
38
|
+
});
|
|
39
|
+
const toUse = (row) => ({
|
|
40
|
+
accountId: row.account_id,
|
|
41
|
+
hasPassword: row.has_password,
|
|
42
|
+
newEmail: row.new_email
|
|
43
|
+
});
|
|
44
|
+
export function normalizeEmail(email) {
|
|
45
|
+
return email.trim().toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
/** How the database keeps a token, a link or a key. */
|
|
48
|
+
export function sha256(secret) {
|
|
49
|
+
return createHash('sha256').update(secret).digest();
|
|
50
|
+
}
|
|
51
|
+
function first(rows, what) {
|
|
52
|
+
const [row] = rows;
|
|
53
|
+
if (row === undefined)
|
|
54
|
+
throw new Error(`${what} gave no row`);
|
|
55
|
+
return row;
|
|
56
|
+
}
|
|
57
|
+
const isUniqueViolation = (error) => error instanceof Error && Reflect.get(error, 'code') === '23505';
|
|
58
|
+
const USABLE = `t.token_hash = $1 AND t.purpose = $2 AND t.used_at IS NULL AND t.expires_at > now()
|
|
59
|
+
AND a.id = t.account_id AND ${LIVE}`;
|
|
60
|
+
export function createAccounts(db, secrets) {
|
|
61
|
+
/**
|
|
62
|
+
* A login with an address that has no account, or an account without a password, checks the
|
|
63
|
+
* password against this hash: every login costs one scrypt and takes the same time.
|
|
64
|
+
*/
|
|
65
|
+
let absent;
|
|
66
|
+
async function insertAccount(tx, input) {
|
|
67
|
+
const hash = input.password === null ? null : await secrets.hashPassword(input.password);
|
|
68
|
+
try {
|
|
69
|
+
const rows = await tx.query(`INSERT INTO baukasten.accounts AS a
|
|
70
|
+
(email, password_hash, terms_version, terms_accepted_at, via, stripe_mode)
|
|
71
|
+
VALUES ($1, $2, $3, now(), $4, $5) RETURNING ${COLUMNS}`, [normalizeEmail(input.email), hash, input.termsVersion, input.via, input.stripeMode]);
|
|
72
|
+
return toAccount(first(rows, 'creating an account'));
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (isUniqueViolation(error))
|
|
76
|
+
throw new EmailTaken();
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function insertKey(tx, input) {
|
|
81
|
+
const key = `${input.prefix}_${secrets.token()}`;
|
|
82
|
+
const rows = await tx.query(`INSERT INTO baukasten.api_keys (account_id, name, key_hash, prefix) VALUES ($1, $2, $3, $4)
|
|
83
|
+
RETURNING id, name, prefix, created_at`,
|
|
84
|
+
// The prefix of the product, its underscore and six characters of the secret.
|
|
85
|
+
[input.accountId, input.name, sha256(key), key.slice(0, input.prefix.length + 7)]);
|
|
86
|
+
return { key, apiKey: toKey(first(rows, 'creating a key')) };
|
|
87
|
+
}
|
|
88
|
+
async function consume(tx, token, purpose) {
|
|
89
|
+
const [row] = await tx.query(`UPDATE baukasten.links t SET used_at = now() FROM baukasten.accounts a WHERE ${USABLE}
|
|
90
|
+
RETURNING t.account_id, a.password_hash IS NOT NULL AS has_password, t.new_email`, [sha256(token), purpose]);
|
|
91
|
+
return row === undefined ? null : toUse(row);
|
|
92
|
+
}
|
|
93
|
+
async function insertLink(accountId, purpose, newEmail) {
|
|
94
|
+
const token = secrets.token();
|
|
95
|
+
await db.query(`INSERT INTO baukasten.links (token_hash, account_id, purpose, new_email, expires_at)
|
|
96
|
+
VALUES ($1, $2, $3, $4, now() + make_interval(secs => $5))`, [sha256(token), accountId, purpose, newEmail, LINK_SECONDS[purpose]]);
|
|
97
|
+
return token;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
/** Signing up accepts the current terms: the account keeps their name and the time. */
|
|
101
|
+
create: (input) => insertAccount(db, input),
|
|
102
|
+
/** The sign-up of the API: account and first key together, or neither. */
|
|
103
|
+
createWithKey: (input) => db.transaction(async (tx) => {
|
|
104
|
+
const account = await insertAccount(tx, { ...input, password: null });
|
|
105
|
+
const { key } = await insertKey(tx, {
|
|
106
|
+
accountId: account.id,
|
|
107
|
+
name: input.keyName,
|
|
108
|
+
prefix: input.keyPrefix
|
|
109
|
+
});
|
|
110
|
+
return { account, key };
|
|
111
|
+
}),
|
|
112
|
+
async findByEmail(email) {
|
|
113
|
+
const [row] = await db.query(`SELECT ${COLUMNS} FROM baukasten.accounts a WHERE a.email = $1 AND ${LIVE}`, [normalizeEmail(email)]);
|
|
114
|
+
return row === undefined ? null : toAccount(row);
|
|
115
|
+
},
|
|
116
|
+
/** The account of an address and password; one scrypt, whatever the answer. */
|
|
117
|
+
async authenticate(email, password) {
|
|
118
|
+
const [row] = await db.query(`SELECT ${COLUMNS} FROM baukasten.accounts a WHERE a.email = $1 AND ${LIVE}`, [normalizeEmail(email)]);
|
|
119
|
+
absent ??= secrets.hashPassword(secrets.token());
|
|
120
|
+
const stored = row?.password_hash ?? (await absent);
|
|
121
|
+
const fits = await secrets.verifyPassword(password, stored);
|
|
122
|
+
return row !== undefined && row.password_hash !== null && fits ? toAccount(row) : null;
|
|
123
|
+
},
|
|
124
|
+
/** Whether `current` is the password (an account without one needs none), then sets `next`. */
|
|
125
|
+
async changePassword(accountId, current, next) {
|
|
126
|
+
const [row] = await db.query(`SELECT a.password_hash FROM baukasten.accounts a WHERE a.id = $1 AND ${LIVE}`, [accountId]);
|
|
127
|
+
if (row === undefined)
|
|
128
|
+
return false;
|
|
129
|
+
if (row.password_hash !== null) {
|
|
130
|
+
if (current === null || !(await secrets.verifyPassword(current, row.password_hash))) {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const hash = await secrets.hashPassword(next);
|
|
135
|
+
await db.transaction(async (tx) => {
|
|
136
|
+
await tx.query('UPDATE baukasten.accounts SET password_hash = $2 WHERE id = $1', [
|
|
137
|
+
accountId,
|
|
138
|
+
hash
|
|
139
|
+
]);
|
|
140
|
+
// A reset link asked for before would set a password again behind the new one.
|
|
141
|
+
await tx.query("DELETE FROM baukasten.links WHERE account_id = $1 AND purpose = 'reset' AND used_at IS NULL", [accountId]);
|
|
142
|
+
});
|
|
143
|
+
return true;
|
|
144
|
+
},
|
|
145
|
+
async createSession(accountId) {
|
|
146
|
+
const token = secrets.token();
|
|
147
|
+
await db.query(`INSERT INTO baukasten.sessions (token_hash, account_id, expires_at)
|
|
148
|
+
VALUES ($1, $2, now() + make_interval(days => $3))`, [sha256(token), accountId, SESSION_DAYS]);
|
|
149
|
+
return token;
|
|
150
|
+
},
|
|
151
|
+
async forSession(token) {
|
|
152
|
+
const [row] = await db.query(`SELECT ${COLUMNS} FROM baukasten.sessions s JOIN baukasten.accounts a ON a.id = s.account_id
|
|
153
|
+
WHERE s.token_hash = $1 AND s.expires_at > now() AND ${LIVE}`, [sha256(token)]);
|
|
154
|
+
return row === undefined ? null : toAccount(row);
|
|
155
|
+
},
|
|
156
|
+
async endSession(token) {
|
|
157
|
+
await db.query('DELETE FROM baukasten.sessions WHERE token_hash = $1', [sha256(token)]);
|
|
158
|
+
},
|
|
159
|
+
async endOtherSessions(accountId, keep) {
|
|
160
|
+
await db.query('DELETE FROM baukasten.sessions WHERE account_id = $1 AND token_hash <> $2', [
|
|
161
|
+
accountId,
|
|
162
|
+
sha256(keep)
|
|
163
|
+
]);
|
|
164
|
+
},
|
|
165
|
+
issueLink: (accountId, purpose) => insertLink(accountId, purpose, null),
|
|
166
|
+
/** A link that makes `newEmail` the address of the account, sent to that address. */
|
|
167
|
+
issueEmailChange: (accountId, newEmail) => insertLink(accountId, 'change_email', normalizeEmail(newEmail)),
|
|
168
|
+
/** Looks at a link without using it, for the page that asks to confirm. */
|
|
169
|
+
async peekLink(token, purpose) {
|
|
170
|
+
const [row] = await db.query(`SELECT t.account_id, a.password_hash IS NOT NULL AS has_password, t.new_email
|
|
171
|
+
FROM baukasten.links t, baukasten.accounts a WHERE ${USABLE}`, [sha256(token), purpose]);
|
|
172
|
+
return row === undefined ? null : toUse(row);
|
|
173
|
+
},
|
|
174
|
+
/**
|
|
175
|
+
* Confirms the address with its link, once. An account without a password (from the API) may
|
|
176
|
+
* set one here; an account that has one keeps it, whoever holds the link. The other open links
|
|
177
|
+
* of this kind go.
|
|
178
|
+
*/
|
|
179
|
+
confirmEmail: (token, password) => db.transaction(async (tx) => {
|
|
180
|
+
const use = await consume(tx, token, 'verify');
|
|
181
|
+
if (use === null)
|
|
182
|
+
return null;
|
|
183
|
+
const hash = !use.hasPassword && password !== null ? await secrets.hashPassword(password) : null;
|
|
184
|
+
await tx.query(`UPDATE baukasten.accounts SET email_verified_at = coalesce(email_verified_at, now()),
|
|
185
|
+
password_hash = coalesce(password_hash, $2) WHERE id = $1`, [use.accountId, hash]);
|
|
186
|
+
await tx.query("DELETE FROM baukasten.links WHERE account_id = $1 AND purpose = 'verify' AND used_at IS NULL", [use.accountId]);
|
|
187
|
+
return use;
|
|
188
|
+
}),
|
|
189
|
+
/**
|
|
190
|
+
* Sets a new password with its link, once: the link proves the address, every session ends
|
|
191
|
+
* and the other reset links go. When it confirms the address for the first time, the keys go
|
|
192
|
+
* too: whoever signed up over the API with an address not theirs got a key at once, and the
|
|
193
|
+
* owner of the address now takes the account. The id of the account, or null for a link that
|
|
194
|
+
* is not usable.
|
|
195
|
+
*/
|
|
196
|
+
async resetPassword(token, password) {
|
|
197
|
+
const hash = await secrets.hashPassword(password);
|
|
198
|
+
return db.transaction(async (tx) => {
|
|
199
|
+
const use = await consume(tx, token, 'reset');
|
|
200
|
+
if (use === null)
|
|
201
|
+
return null;
|
|
202
|
+
await tx.query(`UPDATE baukasten.api_keys SET revoked_at = now()
|
|
203
|
+
WHERE account_id = $1 AND revoked_at IS NULL AND EXISTS (
|
|
204
|
+
SELECT 1 FROM baukasten.accounts WHERE id = $1 AND email_verified_at IS NULL)`, [use.accountId]);
|
|
205
|
+
await tx.query(`UPDATE baukasten.accounts SET password_hash = $2,
|
|
206
|
+
email_verified_at = coalesce(email_verified_at, now()) WHERE id = $1`, [use.accountId, hash]);
|
|
207
|
+
await tx.query('DELETE FROM baukasten.sessions WHERE account_id = $1', [use.accountId]);
|
|
208
|
+
await tx.query("DELETE FROM baukasten.links WHERE account_id = $1 AND purpose = 'reset' AND used_at IS NULL", [use.accountId]);
|
|
209
|
+
return use.accountId;
|
|
210
|
+
});
|
|
211
|
+
},
|
|
212
|
+
/**
|
|
213
|
+
* Makes the confirmed address the address of the account and voids every other open link of
|
|
214
|
+
* the account, so that links sent to the old address stop working. An address another account
|
|
215
|
+
* took in between leaves the link unused.
|
|
216
|
+
*/
|
|
217
|
+
async confirmEmailChange(token) {
|
|
218
|
+
try {
|
|
219
|
+
return await db.transaction(async (tx) => {
|
|
220
|
+
const use = await consume(tx, token, 'change_email');
|
|
221
|
+
if (use === null || use.newEmail === null)
|
|
222
|
+
return { kind: 'invalid' };
|
|
223
|
+
const { email } = first(await tx.query('SELECT email FROM baukasten.accounts WHERE id = $1', [use.accountId]), 'the account of a link');
|
|
224
|
+
await tx.query('UPDATE baukasten.accounts SET email = $2, email_verified_at = now() WHERE id = $1', [use.accountId, use.newEmail]);
|
|
225
|
+
await tx.query('DELETE FROM baukasten.links WHERE account_id = $1 AND used_at IS NULL', [
|
|
226
|
+
use.accountId
|
|
227
|
+
]);
|
|
228
|
+
return {
|
|
229
|
+
kind: 'changed',
|
|
230
|
+
accountId: use.accountId,
|
|
231
|
+
oldEmail: email,
|
|
232
|
+
newEmail: use.newEmail
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (isUniqueViolation(error))
|
|
238
|
+
return { kind: 'taken' };
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
createApiKey: (accountId, name, prefix) => insertKey(db, { accountId, name, prefix }),
|
|
243
|
+
async listApiKeys(accountId) {
|
|
244
|
+
const rows = await db.query(`SELECT id, name, prefix, created_at FROM baukasten.api_keys
|
|
245
|
+
WHERE account_id = $1 AND revoked_at IS NULL ORDER BY created_at, id`, [accountId]);
|
|
246
|
+
return rows.map(toKey);
|
|
247
|
+
},
|
|
248
|
+
/** False when the account has no such key. */
|
|
249
|
+
async revokeApiKey(accountId, keyId) {
|
|
250
|
+
const rows = await db.query(`UPDATE baukasten.api_keys SET revoked_at = now()
|
|
251
|
+
WHERE id = $1 AND account_id = $2 AND revoked_at IS NULL RETURNING id`, [keyId, accountId]);
|
|
252
|
+
return rows.length > 0;
|
|
253
|
+
},
|
|
254
|
+
async forApiKey(key) {
|
|
255
|
+
const [row] = await db.query(`SELECT ${COLUMNS}, k.id AS key_id FROM baukasten.api_keys k
|
|
256
|
+
JOIN baukasten.accounts a ON a.id = k.account_id
|
|
257
|
+
WHERE k.key_hash = $1 AND k.revoked_at IS NULL AND ${LIVE}`, [sha256(key)]);
|
|
258
|
+
return row === undefined ? null : { account: toAccount(row), keyId: row.key_id };
|
|
259
|
+
},
|
|
260
|
+
/**
|
|
261
|
+
* Deletes what is personal in one transaction: first the data of the product through its hook,
|
|
262
|
+
* then sessions, links and keys; the row stays without address and password, for the numbers
|
|
263
|
+
* and the books. A failing hook changes nothing.
|
|
264
|
+
*/
|
|
265
|
+
delete: (accountId, hook) => db.transaction(async (tx) => {
|
|
266
|
+
// The webhook of Stripe locks the account as well: one that comes now waits, and then
|
|
267
|
+
// finds it deleted, instead of writing data of the product to it.
|
|
268
|
+
await tx.query('SELECT 1 FROM baukasten.accounts WHERE id = $1 FOR UPDATE', [accountId]);
|
|
269
|
+
await hook(tx, accountId);
|
|
270
|
+
for (const table of ['sessions', 'links', 'api_keys']) {
|
|
271
|
+
await tx.query(`DELETE FROM baukasten.${table} WHERE account_id = $1`, [accountId]);
|
|
272
|
+
}
|
|
273
|
+
await tx.query(`UPDATE baukasten.accounts SET email = NULL, password_hash = NULL,
|
|
274
|
+
email_verified_at = NULL, deleted_at = now() WHERE id = $1 AND deleted_at IS NULL`, [accountId]);
|
|
275
|
+
}),
|
|
276
|
+
/** Removes expired sessions and links; it runs on login, never on a timer. */
|
|
277
|
+
async removeExpired() {
|
|
278
|
+
await db.query('DELETE FROM baukasten.sessions WHERE expires_at <= now()');
|
|
279
|
+
await db.query('DELETE FROM baukasten.links WHERE expires_at <= now()');
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
/** An idle connection closes soon, so that Neon can suspend after the work is done. */
|
|
3
|
+
const IDLE_TIMEOUT_MS = 10_000;
|
|
4
|
+
export function createDb(databaseUrl, log, options = {}) {
|
|
5
|
+
const pool = new pg.Pool({
|
|
6
|
+
connectionString: databaseUrl,
|
|
7
|
+
max: 5,
|
|
8
|
+
idleTimeoutMillis: options.idleTimeoutMillis ?? IDLE_TIMEOUT_MS,
|
|
9
|
+
connectionTimeoutMillis: 5_000
|
|
10
|
+
});
|
|
11
|
+
let opened = 0;
|
|
12
|
+
pool.on('connect', () => {
|
|
13
|
+
opened += 1;
|
|
14
|
+
});
|
|
15
|
+
// An idle connection that breaks (Neon suspended it, a restart) must not end the process; the
|
|
16
|
+
// next query opens a new one.
|
|
17
|
+
pool.on('error', (error) => log.warn('database: an idle connection failed', { error }));
|
|
18
|
+
return {
|
|
19
|
+
async query(sql, params) {
|
|
20
|
+
return (await pool.query(sql, params)).rows;
|
|
21
|
+
},
|
|
22
|
+
withTimeout: (ms) => ({
|
|
23
|
+
async query(sql, params) {
|
|
24
|
+
// pg reads query_timeout per query, its types know it only for the pool; a query that
|
|
25
|
+
// runs out fails, and the pool drops its connection.
|
|
26
|
+
const config = {
|
|
27
|
+
text: sql,
|
|
28
|
+
values: params,
|
|
29
|
+
query_timeout: ms
|
|
30
|
+
};
|
|
31
|
+
return (await pool.query(config)).rows;
|
|
32
|
+
}
|
|
33
|
+
}),
|
|
34
|
+
async transaction(fn) {
|
|
35
|
+
const client = await pool.connect();
|
|
36
|
+
let broken;
|
|
37
|
+
try {
|
|
38
|
+
await client.query('BEGIN');
|
|
39
|
+
const result = await fn({
|
|
40
|
+
query: async (sql, params) => (await client.query(sql, params)).rows
|
|
41
|
+
});
|
|
42
|
+
await client.query('COMMIT');
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
try {
|
|
47
|
+
await client.query('ROLLBACK');
|
|
48
|
+
}
|
|
49
|
+
catch (rollback) {
|
|
50
|
+
// The connection is broken: it goes away instead of back to the pool.
|
|
51
|
+
broken = rollback instanceof Error ? rollback : new Error(String(rollback));
|
|
52
|
+
log.warn('database: a rollback failed, the connection is dropped', { error: broken });
|
|
53
|
+
}
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
client.release(broken);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
connectionsOpened: () => opened,
|
|
61
|
+
close: () => pool.end()
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createLog } from '../log.js';
|
|
2
|
+
export const log = createLog({
|
|
3
|
+
level: process.env.LOG_LEVEL,
|
|
4
|
+
write: (line) => process.stdout.write(line),
|
|
5
|
+
now: () => new Date()
|
|
6
|
+
});
|
|
7
|
+
/** Ends the process once every line written before is out, even through a pipe that writes later. */
|
|
8
|
+
export function exitAfterLog(code) {
|
|
9
|
+
return new Promise(() => {
|
|
10
|
+
process.stdout.write('', () => process.exit(code));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Plain SMTP without a login: the relay of the fleet lies inside the network of the products and
|
|
2
|
+
// sends on over TLS. Only a mail in the local log, without SMTP, puts an address into the log.
|
|
3
|
+
import nodemailer from 'nodemailer';
|
|
4
|
+
import { MailFailed } from '../mail.js';
|
|
5
|
+
/** What SMTP calls a failure; nodemailer adds it to its errors. */
|
|
6
|
+
function failure(error) {
|
|
7
|
+
const field = (name) => typeof error === 'object' && error !== null ? Reflect.get(error, name) : undefined;
|
|
8
|
+
const code = field('code');
|
|
9
|
+
const responseCode = field('responseCode');
|
|
10
|
+
const command = field('command');
|
|
11
|
+
return new MailFailed({
|
|
12
|
+
code: typeof code === 'string' ? code : 'error',
|
|
13
|
+
...(typeof responseCode === 'number' ? { responseCode } : {}),
|
|
14
|
+
...(typeof command === 'string' ? { command } : {})
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function createMailer(config, name, log) {
|
|
18
|
+
switch (config.kind) {
|
|
19
|
+
case 'log':
|
|
20
|
+
return {
|
|
21
|
+
send(mail) {
|
|
22
|
+
log.info('mail not sent: SMTP is not configured', { ...mail });
|
|
23
|
+
return Promise.resolve();
|
|
24
|
+
},
|
|
25
|
+
check: () => Promise.resolve()
|
|
26
|
+
};
|
|
27
|
+
case 'smtp': {
|
|
28
|
+
const transport = nodemailer.createTransport({
|
|
29
|
+
host: config.host,
|
|
30
|
+
port: config.port,
|
|
31
|
+
connectionTimeout: 10_000,
|
|
32
|
+
greetingTimeout: 10_000,
|
|
33
|
+
socketTimeout: 20_000
|
|
34
|
+
});
|
|
35
|
+
const from = { name, address: config.from };
|
|
36
|
+
return {
|
|
37
|
+
async send(mail) {
|
|
38
|
+
try {
|
|
39
|
+
await transport.sendMail({ from, to: mail.to, subject: mail.subject, text: mail.text });
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw failure(error);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
async check() {
|
|
46
|
+
try {
|
|
47
|
+
await transport.verify();
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw failure(error);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Layer, type MigrationStatus } from '../migrations.js';
|
|
2
|
+
import type { Queryable } from '../ports.js';
|
|
3
|
+
export interface MigrationSources {
|
|
4
|
+
baukasten: readonly {
|
|
5
|
+
name: string;
|
|
6
|
+
sql: string;
|
|
7
|
+
}[];
|
|
8
|
+
/** The folder of the product's migrations; a product without one has none. */
|
|
9
|
+
product: string;
|
|
10
|
+
}
|
|
11
|
+
export type ByLayer = Record<Layer, string[]>;
|
|
12
|
+
/** What a run applied, and what the database had ahead of this version. */
|
|
13
|
+
export interface MigrateResult {
|
|
14
|
+
applied: ByLayer;
|
|
15
|
+
ahead: ByLayer;
|
|
16
|
+
}
|
|
17
|
+
export type Art = 'webapp' | 'shopify';
|
|
18
|
+
export declare function migrationSources(root: string, art: Art): MigrationSources;
|
|
19
|
+
/** What the database still needs of this version and what forbids migrating it, for /health/deep. */
|
|
20
|
+
export declare function migrationStatus(db: Queryable, sources: MigrationSources): Promise<MigrationStatus>;
|
|
21
|
+
export declare function migrate(databaseUrl: string, sources: MigrationSources): Promise<MigrateResult>;
|