@volter/twin-upstash 0.1.0
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/LICENSE +202 -0
- package/README.md +202 -0
- package/api/src/fetch.ts +54 -0
- package/api/src/generated/surface.gen.json +1 -0
- package/api/src/generated/ui.gen.json +1 -0
- package/api/src/index.ts +19 -0
- package/api/src/key-gate.ts +30 -0
- package/api/src/manifest.ts +103 -0
- package/api/src/screens/developer-api.tsx +106 -0
- package/api/src/screens/qstash.tsx +99 -0
- package/api/src/screens/session.tsx +125 -0
- package/api/src/screens/teams.tsx +114 -0
- package/api/src/semantics/backups.ts +90 -0
- package/api/src/semantics/index.ts +191 -0
- package/api/src/semantics/shared.ts +42 -0
- package/api/src/semantics/teams.ts +108 -0
- package/api/src/semantics/time.ts +40 -0
- package/dist/api/src/fetch.d.ts +15 -0
- package/dist/api/src/fetch.js +44 -0
- package/dist/api/src/fetch.ts +54 -0
- package/dist/api/src/generated/surface.gen.json +1 -0
- package/dist/api/src/generated/ui.gen.json +1 -0
- package/dist/api/src/index.ts +19 -0
- package/dist/api/src/key-gate.d.ts +3 -0
- package/dist/api/src/key-gate.js +30 -0
- package/dist/api/src/key-gate.ts +30 -0
- package/dist/api/src/manifest.d.ts +2 -0
- package/dist/api/src/manifest.js +81 -0
- package/dist/api/src/manifest.ts +103 -0
- package/dist/api/src/screens/developer-api.d.ts +3 -0
- package/dist/api/src/screens/developer-api.js +101 -0
- package/dist/api/src/screens/developer-api.tsx +106 -0
- package/dist/api/src/screens/qstash.d.ts +3 -0
- package/dist/api/src/screens/qstash.js +92 -0
- package/dist/api/src/screens/qstash.tsx +99 -0
- package/dist/api/src/screens/session.d.ts +9 -0
- package/dist/api/src/screens/session.js +118 -0
- package/dist/api/src/screens/session.tsx +125 -0
- package/dist/api/src/screens/teams.d.ts +3 -0
- package/dist/api/src/screens/teams.js +99 -0
- package/dist/api/src/screens/teams.tsx +114 -0
- package/dist/api/src/semantics/backups.d.ts +7 -0
- package/dist/api/src/semantics/backups.js +75 -0
- package/dist/api/src/semantics/backups.ts +90 -0
- package/dist/api/src/semantics/index.d.ts +10 -0
- package/dist/api/src/semantics/index.js +191 -0
- package/dist/api/src/semantics/index.ts +191 -0
- package/dist/api/src/semantics/shared.d.ts +21 -0
- package/dist/api/src/semantics/shared.js +34 -0
- package/dist/api/src/semantics/shared.ts +42 -0
- package/dist/api/src/semantics/teams.d.ts +13 -0
- package/dist/api/src/semantics/teams.js +100 -0
- package/dist/api/src/semantics/teams.ts +108 -0
- package/dist/api/src/semantics/time.d.ts +2 -0
- package/dist/api/src/semantics/time.js +34 -0
- package/dist/api/src/semantics/time.ts +40 -0
- package/dist/qstash/src/doors.d.ts +6 -0
- package/dist/qstash/src/doors.js +33 -0
- package/dist/qstash/src/doors.ts +51 -0
- package/dist/qstash/src/egress.d.ts +7 -0
- package/dist/qstash/src/egress.js +66 -0
- package/dist/qstash/src/egress.ts +58 -0
- package/dist/qstash/src/fetch.d.ts +7 -0
- package/dist/qstash/src/fetch.js +48 -0
- package/dist/qstash/src/fetch.ts +46 -0
- package/dist/qstash/src/generated/surface.gen.json +1 -0
- package/dist/qstash/src/generated/ui.gen.json +1 -0
- package/dist/qstash/src/index.ts +35 -0
- package/dist/qstash/src/manifest.d.ts +10 -0
- package/dist/qstash/src/manifest.js +105 -0
- package/dist/qstash/src/manifest.ts +134 -0
- package/dist/qstash/src/semantics/account.d.ts +29 -0
- package/dist/qstash/src/semantics/account.js +91 -0
- package/dist/qstash/src/semantics/account.ts +98 -0
- package/dist/qstash/src/semantics/delivery.d.ts +17 -0
- package/dist/qstash/src/semantics/delivery.js +274 -0
- package/dist/qstash/src/semantics/delivery.ts +264 -0
- package/dist/qstash/src/semantics/dlq.d.ts +4 -0
- package/dist/qstash/src/semantics/dlq.js +51 -0
- package/dist/qstash/src/semantics/dlq.ts +61 -0
- package/dist/qstash/src/semantics/index.d.ts +2 -0
- package/dist/qstash/src/semantics/index.js +10 -0
- package/dist/qstash/src/semantics/index.ts +13 -0
- package/dist/qstash/src/semantics/keys.d.ts +2 -0
- package/dist/qstash/src/semantics/keys.js +9 -0
- package/dist/qstash/src/semantics/keys.ts +14 -0
- package/dist/qstash/src/semantics/messages.d.ts +74 -0
- package/dist/qstash/src/semantics/messages.js +233 -0
- package/dist/qstash/src/semantics/messages.ts +249 -0
- package/dist/qstash/src/semantics/queues.d.ts +2 -0
- package/dist/qstash/src/semantics/queues.js +60 -0
- package/dist/qstash/src/semantics/queues.ts +66 -0
- package/dist/qstash/src/semantics/schedules.d.ts +19 -0
- package/dist/qstash/src/semantics/schedules.js +125 -0
- package/dist/qstash/src/semantics/schedules.ts +132 -0
- package/dist/qstash/src/semantics/shared.d.ts +45 -0
- package/dist/qstash/src/semantics/shared.js +115 -0
- package/dist/qstash/src/semantics/shared.ts +121 -0
- package/dist/qstash/src/semantics/urlgroups.d.ts +2 -0
- package/dist/qstash/src/semantics/urlgroups.js +58 -0
- package/dist/qstash/src/semantics/urlgroups.ts +69 -0
- package/dist/qstash/src/semantics/workflows.d.ts +44 -0
- package/dist/qstash/src/semantics/workflows.js +379 -0
- package/dist/qstash/src/semantics/workflows.ts +401 -0
- package/dist/qstash/src/signing.d.ts +4 -0
- package/dist/qstash/src/signing.js +16 -0
- package/dist/qstash/src/signing.ts +19 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +35 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.js +124 -0
- package/dist/src/manifest.d.ts +14 -0
- package/dist/src/manifest.js +8 -0
- package/dist/src/upstash-budget.d.ts +85 -0
- package/dist/src/upstash-budget.js +440 -0
- package/dist/src/upstash-capabilities.d.ts +4 -0
- package/dist/src/upstash-capabilities.js +1286 -0
- package/dist/src/upstash-conformance.d.ts +7 -0
- package/dist/src/upstash-conformance.js +119 -0
- package/dist/src/upstash-connector.d.ts +115 -0
- package/dist/src/upstash-connector.js +309 -0
- package/dist/src/upstash-lua.d.ts +140 -0
- package/dist/src/upstash-lua.js +1229 -0
- package/dist/src/upstash-server.d.ts +29 -0
- package/dist/src/upstash-server.js +81 -0
- package/dist/src/upstash-store.d.ts +114 -0
- package/dist/src/upstash-store.js +1663 -0
- package/dist/src/upstash-twin.d.ts +73 -0
- package/dist/src/upstash-twin.js +437 -0
- package/package.json +59 -0
- package/qstash/src/doors.ts +51 -0
- package/qstash/src/egress.ts +58 -0
- package/qstash/src/fetch.ts +46 -0
- package/qstash/src/generated/surface.gen.json +1 -0
- package/qstash/src/generated/ui.gen.json +1 -0
- package/qstash/src/index.ts +35 -0
- package/qstash/src/manifest.ts +134 -0
- package/qstash/src/semantics/account.ts +98 -0
- package/qstash/src/semantics/delivery.ts +264 -0
- package/qstash/src/semantics/dlq.ts +61 -0
- package/qstash/src/semantics/index.ts +13 -0
- package/qstash/src/semantics/keys.ts +14 -0
- package/qstash/src/semantics/messages.ts +249 -0
- package/qstash/src/semantics/queues.ts +66 -0
- package/qstash/src/semantics/schedules.ts +132 -0
- package/qstash/src/semantics/shared.ts +121 -0
- package/qstash/src/semantics/urlgroups.ts +69 -0
- package/qstash/src/semantics/workflows.ts +401 -0
- package/qstash/src/signing.ts +19 -0
- package/src/cli.ts +36 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/index.ts +203 -0
- package/src/manifest.ts +26 -0
- package/src/upstash-budget.ts +486 -0
- package/src/upstash-capabilities.ts +1418 -0
- package/src/upstash-conformance.ts +131 -0
- package/src/upstash-connector.ts +340 -0
- package/src/upstash-lua.ts +1120 -0
- package/src/upstash-server.ts +103 -0
- package/src/upstash-store.ts +1437 -0
- package/src/upstash-twin.ts +465 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// UPSTASH CONSOLE: ACCOUNT > DEVELOPER API — a flow (docs/contributing/architecture.md, "Screens"): the page where a person
|
|
2
|
+
// makes the API key the Developer API reads. No API operation makes a key, so the first can only come from here.
|
|
3
|
+
// https://upstash.com/docs/devops/developer-api/introduction: "Log in to the console, then open Account and click the
|
|
4
|
+
// Developer API tab. Click the Create API Key button. Enter a name for your key (you can not use the same name for
|
|
5
|
+
// multiple keys), choose the permissions (Read/Write or Read Only) and an expiration, then click Submit. You need to
|
|
6
|
+
// download or copy/save your API key. Upstash does not remember or keep your API for security reasons." and "By default
|
|
7
|
+
// one user can create up to 37 API keys." Built from @volter/world-ui's settings layout (Portal) under Upstash's skin;
|
|
8
|
+
// nothing of Upstash's page is copied. Its controls are the ones the pack's lives need: Create API Key with its Name,
|
|
9
|
+
// Permissions and Expiration, and Submit; and each key's Delete ("You can delete the API keys in the `Account > Developer
|
|
10
|
+
// API` tab").
|
|
11
|
+
//
|
|
12
|
+
// The person on the page is who signed in at /login (screens/session.tsx): a visitor nobody signed in as is sent there
|
|
13
|
+
// first. Where the documentation stops and the twin decides: the tab is at /account/api; the expirations offered are
|
|
14
|
+
// 30 days, 90 days and 1 year (the page says a key has one, and names none); a name the person already used, and a 38th key, answer the page again with a
|
|
15
|
+
// notice (422); the new key is shown once in the page's notice.
|
|
16
|
+
import { flowPage, Portal, PORTAL_CSS } from '@volter/world-ui';
|
|
17
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
18
|
+
import { makeApiKey } from '../semantics/index.ts';
|
|
19
|
+
import { signedIn, toSignIn } from './session.tsx';
|
|
20
|
+
|
|
21
|
+
const PATH = '/account/api';
|
|
22
|
+
const EXPIRATIONS: Record<string, number> = { '30d': 30, '90d': 90, '1y': 365 };
|
|
23
|
+
const MAX_KEYS = 37;
|
|
24
|
+
|
|
25
|
+
// Upstash's skin over the settings layout: its stone greys and green primary button
|
|
26
|
+
const SKIN = `
|
|
27
|
+
body { background: #ffffff; color: #1c1917; }
|
|
28
|
+
.portal-side { background: #fafaf9; border-right: 1px solid #e7e5e4; }
|
|
29
|
+
.portal-section h2 { border-color: #e7e5e4; color: #57534e; }
|
|
30
|
+
.portal-notice { background: #ecfdf5; border: 1px solid #a7f3d0; font-family: ui-monospace, monospace; word-break: break-all; }
|
|
31
|
+
.portal-button { background: #ffffff; border: 1px solid #d6d3d1; border-radius: 6px; padding: 6px 12px; }
|
|
32
|
+
.portal-primary { background: #00c98d; border-color: #00c98d; color: #0c0a09; }
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
function page(ctx: SemanticsContext, email: string, notice?: string, status = 200): Response {
|
|
36
|
+
const keys = ctx.rowsRaw('_api_key').filter((k) => k.email === email && k.deleted !== true);
|
|
37
|
+
return flowPage({
|
|
38
|
+
title: 'Developer API · Account · Upstash Console',
|
|
39
|
+
css: [PORTAL_CSS, SKIN],
|
|
40
|
+
status,
|
|
41
|
+
body: (
|
|
42
|
+
<Portal
|
|
43
|
+
merchant={`${email} · Account`}
|
|
44
|
+
back={{ href: '/', label: 'Back to console' }}
|
|
45
|
+
{...(notice ? { notice } : {})}
|
|
46
|
+
sections={[{
|
|
47
|
+
heading: 'Developer API',
|
|
48
|
+
empty: 'No API keys yet.',
|
|
49
|
+
items: keys.map((k) => ({
|
|
50
|
+
title: String(k.name),
|
|
51
|
+
detail: k.permission === 'read_only' ? 'Read Only' : 'Read/Write',
|
|
52
|
+
note: k.expires_at ? `Expires ${String(k.expires_at).slice(0, 10)}` : 'Never expires',
|
|
53
|
+
actions: [{ label: 'Delete', action: `${PATH}/delete`, tone: 'danger' as const, fields: { name: String(k.name) } }],
|
|
54
|
+
})),
|
|
55
|
+
}]}
|
|
56
|
+
forms={[{
|
|
57
|
+
heading: 'Create API Key',
|
|
58
|
+
action: PATH,
|
|
59
|
+
fields: [
|
|
60
|
+
{ id: 'name', label: 'Name' },
|
|
61
|
+
{ id: 'permission', label: 'Permissions', options: [{ value: 'read_write', label: 'Read/Write' }, { value: 'read_only', label: 'Read Only' }] },
|
|
62
|
+
{ id: 'expiration', label: 'Expiration', options: [{ value: '30d', label: '30 days' }, { value: '90d', label: '90 days' }, { value: '1y', label: '1 year' }] },
|
|
63
|
+
],
|
|
64
|
+
submit: { label: 'Submit' },
|
|
65
|
+
}]}
|
|
66
|
+
/>
|
|
67
|
+
),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Delete: the key stops working at once (key-gate.ts admits no deleted key); "When an API key is exposed ... or not
|
|
72
|
+
* being used anymore; you should delete it. You can delete the API keys in the `Account > Developer API` tab." */
|
|
73
|
+
async function deleteKey(ctx: SemanticsContext, email: string, name: string): Promise<Response> {
|
|
74
|
+
const key = ctx.rowsRaw('_api_key').find((k) => k.email === email && k.name === name && k.deleted !== true);
|
|
75
|
+
if (!key) return page(ctx, email, `You have no API key named ${name}.`, 422);
|
|
76
|
+
await ctx.record('_api_key', { deleted: true, deleted_at: ctx.occurredAt }, `apikey:${email}:${name}`);
|
|
77
|
+
return page(ctx, email, `API key ${name} deleted.`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function formOf(request: Request): Promise<Record<string, string>> {
|
|
81
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The Developer API tab, or undefined for any other request. `contextFor` gives the lane's semantics context. */
|
|
85
|
+
export async function developerApiPage(request: Request, contextFor: (request: Request) => Promise<SemanticsContext>): Promise<Response | undefined> {
|
|
86
|
+
const url = new URL(request.url);
|
|
87
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
88
|
+
if (path !== PATH && path !== `${PATH}/delete`) return undefined;
|
|
89
|
+
const person = signedIn(await contextFor(new Request(request.url)), request);
|
|
90
|
+
if (!person) return toSignIn(request);
|
|
91
|
+
if (request.method === 'GET' && path === PATH) return page(await contextFor(new Request(request.url)), person.email);
|
|
92
|
+
if (request.method !== 'POST') return undefined;
|
|
93
|
+
const form = await formOf(request);
|
|
94
|
+
const ctx = await contextFor(new Request(request.url));
|
|
95
|
+
if (path !== PATH) return deleteKey(ctx, person.email, (form.name ?? '').trim());
|
|
96
|
+
const name = (form.name ?? '').trim();
|
|
97
|
+
const held = ctx.rowsRaw('_api_key').filter((k) => k.email === person.email && k.deleted !== true);
|
|
98
|
+
if (!name) return page(ctx, person.email, 'An API key needs a name.', 422);
|
|
99
|
+
if (held.some((k) => k.name === name)) return page(ctx, person.email, `You already have an API key named ${name}.`, 422);
|
|
100
|
+
if (held.length >= MAX_KEYS) return page(ctx, person.email, `You can create up to ${MAX_KEYS} API keys.`, 422);
|
|
101
|
+
const permission = form.permission === 'read_only' ? 'read_only' : 'read_write';
|
|
102
|
+
const days = form.expiration !== undefined && form.expiration in EXPIRATIONS ? EXPIRATIONS[form.expiration]! : EXPIRATIONS['1y']!;
|
|
103
|
+
const expiresAt = new Date(Date.parse(ctx.occurredAt) + days * 86_400_000).toISOString();
|
|
104
|
+
const { key } = await makeApiKey(ctx, person.email, name, permission, expiresAt);
|
|
105
|
+
return page(await contextFor(new Request(request.url)), person.email, `API key ${name} created: ${key} — copy it now: Upstash does not keep it.`);
|
|
106
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
2
|
+
/** The QStash page, or undefined for any other request. `contextFor` gives the lane's semantics context. */
|
|
3
|
+
export declare function qstashPage(request: Request, contextFor: (request: Request) => Promise<SemanticsContext>): Promise<Response | undefined>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// UPSTASH CONSOLE: QSTASH — a flow (docs/contributing/architecture.md, "Screens"): the page where a person gets their QStash
|
|
3
|
+
// token and signing keys, which no API call gives before the first token exists. "Go to the QStash tab in the Upstash
|
|
4
|
+
// Console, pick the region you want to use (see Select a Region), and copy the QSTASH_TOKEN from the Quickstart section."
|
|
5
|
+
// (https://upstash.com/docs/qstash/overall/getstarted); "To reset your token, simply click on the "Reset token" button at
|
|
6
|
+
// the bottom in the QStash UI and confirm the dialog." (https://upstash.com/docs/qstash/howto/reset-token). The token and
|
|
7
|
+
// keys are the account's (../../../qstash/src/semantics/account.ts). Built from @volter/world-ui's settings layout (Portal)
|
|
8
|
+
// under Upstash's skin; nothing of Upstash's page is copied. Its controls are the ones the pack's lives need: a region's
|
|
9
|
+
// Select, Reset token, and Roll keys ("Rolling your keys can be done by going to the QStash UI and clicking on the "Roll
|
|
10
|
+
// keys" button", https://upstash.com/docs/qstash/howto/roll-signing-keys; the roll is the QStash lane's `rollKeys`).
|
|
11
|
+
//
|
|
12
|
+
// The person on the page is who signed in at /login (session.tsx). Where the documentation stops and the twin decides: the
|
|
13
|
+
// page is at /qstash, a region is picked with `?region=` (us-east-1 or eu-central-1, both documents' `servers`;
|
|
14
|
+
// eu-central-1 when none is picked, their default), the Quickstart lists `QSTASH_URL`, `QSTASH_TOKEN`,
|
|
15
|
+
// `QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY` as the getting-started page's screenshot does, and the reset's
|
|
16
|
+
// confirmation is the button's form (`confirm=yes`), after which the page shows the new token with the reset page's words.
|
|
17
|
+
import { flowPage, Portal, PORTAL_CSS } from '@volter/world-ui';
|
|
18
|
+
import { accountOf, resetToken, rollKeys } from "../../../qstash/src/semantics/account.js";
|
|
19
|
+
import { signedIn, toSignIn } from "./session.js";
|
|
20
|
+
const PATH = '/qstash';
|
|
21
|
+
const REGIONS = ['us-east-1', 'eu-central-1'];
|
|
22
|
+
const SKIN = `
|
|
23
|
+
body { background: #ffffff; color: #1c1917; }
|
|
24
|
+
.portal-side { background: #fafaf9; border-right: 1px solid #e7e5e4; }
|
|
25
|
+
.portal-section h2 { border-color: #e7e5e4; color: #57534e; }
|
|
26
|
+
.portal-detail { font-family: ui-monospace, monospace; word-break: break-all; }
|
|
27
|
+
.portal-notice { background: #ecfdf5; border: 1px solid #a7f3d0; }
|
|
28
|
+
.portal-button { background: #ffffff; border: 1px solid #d6d3d1; border-radius: 6px; padding: 6px 12px; }
|
|
29
|
+
.portal-danger { border-color: #fecaca; color: #b91c1c; }
|
|
30
|
+
`;
|
|
31
|
+
function page(ctx, email, region, notice) {
|
|
32
|
+
const a = accountOf(ctx, email);
|
|
33
|
+
return flowPage({
|
|
34
|
+
title: 'QStash · Upstash Console',
|
|
35
|
+
css: [PORTAL_CSS, SKIN],
|
|
36
|
+
body: (_jsx(Portal, { merchant: `${email} · QStash`, back: { href: '/', label: 'Back to console' }, ...(notice ? { notice } : {}), sections: [{
|
|
37
|
+
heading: 'Region',
|
|
38
|
+
items: REGIONS.map((r) => ({ title: r, ...(r === region ? { badge: 'Selected' } : {}), actions: r === region ? [] : [{ label: 'Select', action: PATH, method: 'get', fields: { region: r } }] })),
|
|
39
|
+
}, {
|
|
40
|
+
heading: 'Quickstart',
|
|
41
|
+
items: [
|
|
42
|
+
{ title: 'QSTASH_URL', detail: `QSTASH_URL="https://qstash-${region}.upstash.io"` },
|
|
43
|
+
{ title: 'QSTASH_TOKEN', detail: `QSTASH_TOKEN="${a.token}"` },
|
|
44
|
+
{ title: 'QSTASH_CURRENT_SIGNING_KEY', detail: `QSTASH_CURRENT_SIGNING_KEY="${a.current}"` },
|
|
45
|
+
{ title: 'QSTASH_NEXT_SIGNING_KEY', detail: `QSTASH_NEXT_SIGNING_KEY="${a.next}"` },
|
|
46
|
+
],
|
|
47
|
+
}, {
|
|
48
|
+
heading: 'Token',
|
|
49
|
+
items: [{
|
|
50
|
+
title: 'Reset token',
|
|
51
|
+
note: 'Resetting your token will invalidate your current token and all future requests with the old token will be rejected.',
|
|
52
|
+
actions: [{ label: 'Reset token', action: `${PATH}/reset-token`, tone: 'danger', fields: { region, confirm: 'yes' } }],
|
|
53
|
+
}],
|
|
54
|
+
}, {
|
|
55
|
+
heading: 'Signing keys',
|
|
56
|
+
items: [{
|
|
57
|
+
title: 'Roll keys',
|
|
58
|
+
note: 'The current key is replaced with the next key and a new next key is generated. Rolling your keys twice without updating your applications will cause your apps to reject all requests.',
|
|
59
|
+
actions: [{ label: 'Roll keys', action: `${PATH}/roll-keys`, tone: 'danger', fields: { region } }],
|
|
60
|
+
}],
|
|
61
|
+
}] })),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
async function formOf(request) {
|
|
65
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
66
|
+
}
|
|
67
|
+
/** The QStash page, or undefined for any other request. `contextFor` gives the lane's semantics context. */
|
|
68
|
+
export async function qstashPage(request, contextFor) {
|
|
69
|
+
const url = new URL(request.url);
|
|
70
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
71
|
+
if (path !== PATH && path !== `${PATH}/reset-token` && path !== `${PATH}/roll-keys`)
|
|
72
|
+
return undefined;
|
|
73
|
+
const ctx = await contextFor(new Request(request.url));
|
|
74
|
+
const person = signedIn(ctx, request);
|
|
75
|
+
if (!person)
|
|
76
|
+
return toSignIn(request);
|
|
77
|
+
const pick = (r) => (r && REGIONS.includes(r) ? r : 'eu-central-1');
|
|
78
|
+
if (path === PATH && request.method === 'GET')
|
|
79
|
+
return page(ctx, person.email, pick(url.searchParams.get('region')));
|
|
80
|
+
if (request.method !== 'POST' || path === PATH)
|
|
81
|
+
return undefined;
|
|
82
|
+
const form = await formOf(request);
|
|
83
|
+
const region = pick(form.region);
|
|
84
|
+
if (path === `${PATH}/roll-keys`) {
|
|
85
|
+
await rollKeys(ctx, person.email);
|
|
86
|
+
return page(await contextFor(new Request(request.url)), person.email, region, 'Your signing keys were rolled: update QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY in your applications.');
|
|
87
|
+
}
|
|
88
|
+
if (form.confirm !== 'yes')
|
|
89
|
+
return page(ctx, person.email, region, 'Confirm the dialog to reset your token.');
|
|
90
|
+
await resetToken(ctx, person.email);
|
|
91
|
+
return page(await contextFor(new Request(request.url)), person.email, region, 'Your token was reset. Afterwards you should immediately update your token in all your applications.');
|
|
92
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// UPSTASH CONSOLE: QSTASH — a flow (docs/contributing/architecture.md, "Screens"): the page where a person gets their QStash
|
|
2
|
+
// token and signing keys, which no API call gives before the first token exists. "Go to the QStash tab in the Upstash
|
|
3
|
+
// Console, pick the region you want to use (see Select a Region), and copy the QSTASH_TOKEN from the Quickstart section."
|
|
4
|
+
// (https://upstash.com/docs/qstash/overall/getstarted); "To reset your token, simply click on the "Reset token" button at
|
|
5
|
+
// the bottom in the QStash UI and confirm the dialog." (https://upstash.com/docs/qstash/howto/reset-token). The token and
|
|
6
|
+
// keys are the account's (../../../qstash/src/semantics/account.ts). Built from @volter/world-ui's settings layout (Portal)
|
|
7
|
+
// under Upstash's skin; nothing of Upstash's page is copied. Its controls are the ones the pack's lives need: a region's
|
|
8
|
+
// Select, Reset token, and Roll keys ("Rolling your keys can be done by going to the QStash UI and clicking on the "Roll
|
|
9
|
+
// keys" button", https://upstash.com/docs/qstash/howto/roll-signing-keys; the roll is the QStash lane's `rollKeys`).
|
|
10
|
+
//
|
|
11
|
+
// The person on the page is who signed in at /login (session.tsx). Where the documentation stops and the twin decides: the
|
|
12
|
+
// page is at /qstash, a region is picked with `?region=` (us-east-1 or eu-central-1, both documents' `servers`;
|
|
13
|
+
// eu-central-1 when none is picked, their default), the Quickstart lists `QSTASH_URL`, `QSTASH_TOKEN`,
|
|
14
|
+
// `QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY` as the getting-started page's screenshot does, and the reset's
|
|
15
|
+
// confirmation is the button's form (`confirm=yes`), after which the page shows the new token with the reset page's words.
|
|
16
|
+
import { flowPage, Portal, PORTAL_CSS } from '@volter/world-ui';
|
|
17
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
18
|
+
import { accountOf, resetToken, rollKeys } from '../../../qstash/src/semantics/account.ts';
|
|
19
|
+
import { signedIn, toSignIn } from './session.tsx';
|
|
20
|
+
|
|
21
|
+
const PATH = '/qstash';
|
|
22
|
+
const REGIONS = ['us-east-1', 'eu-central-1'];
|
|
23
|
+
|
|
24
|
+
const SKIN = `
|
|
25
|
+
body { background: #ffffff; color: #1c1917; }
|
|
26
|
+
.portal-side { background: #fafaf9; border-right: 1px solid #e7e5e4; }
|
|
27
|
+
.portal-section h2 { border-color: #e7e5e4; color: #57534e; }
|
|
28
|
+
.portal-detail { font-family: ui-monospace, monospace; word-break: break-all; }
|
|
29
|
+
.portal-notice { background: #ecfdf5; border: 1px solid #a7f3d0; }
|
|
30
|
+
.portal-button { background: #ffffff; border: 1px solid #d6d3d1; border-radius: 6px; padding: 6px 12px; }
|
|
31
|
+
.portal-danger { border-color: #fecaca; color: #b91c1c; }
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
function page(ctx: SemanticsContext, email: string, region: string, notice?: string): Response {
|
|
35
|
+
const a = accountOf(ctx, email);
|
|
36
|
+
return flowPage({
|
|
37
|
+
title: 'QStash · Upstash Console',
|
|
38
|
+
css: [PORTAL_CSS, SKIN],
|
|
39
|
+
body: (
|
|
40
|
+
<Portal
|
|
41
|
+
merchant={`${email} · QStash`}
|
|
42
|
+
back={{ href: '/', label: 'Back to console' }}
|
|
43
|
+
{...(notice ? { notice } : {})}
|
|
44
|
+
sections={[{
|
|
45
|
+
heading: 'Region',
|
|
46
|
+
items: REGIONS.map((r) => ({ title: r, ...(r === region ? { badge: 'Selected' } : {}), actions: r === region ? [] : [{ label: 'Select', action: PATH, method: 'get' as const, fields: { region: r } }] })),
|
|
47
|
+
}, {
|
|
48
|
+
heading: 'Quickstart',
|
|
49
|
+
items: [
|
|
50
|
+
{ title: 'QSTASH_URL', detail: `QSTASH_URL="https://qstash-${region}.upstash.io"` },
|
|
51
|
+
{ title: 'QSTASH_TOKEN', detail: `QSTASH_TOKEN="${a.token}"` },
|
|
52
|
+
{ title: 'QSTASH_CURRENT_SIGNING_KEY', detail: `QSTASH_CURRENT_SIGNING_KEY="${a.current}"` },
|
|
53
|
+
{ title: 'QSTASH_NEXT_SIGNING_KEY', detail: `QSTASH_NEXT_SIGNING_KEY="${a.next}"` },
|
|
54
|
+
],
|
|
55
|
+
}, {
|
|
56
|
+
heading: 'Token',
|
|
57
|
+
items: [{
|
|
58
|
+
title: 'Reset token',
|
|
59
|
+
note: 'Resetting your token will invalidate your current token and all future requests with the old token will be rejected.',
|
|
60
|
+
actions: [{ label: 'Reset token', action: `${PATH}/reset-token`, tone: 'danger' as const, fields: { region, confirm: 'yes' } }],
|
|
61
|
+
}],
|
|
62
|
+
}, {
|
|
63
|
+
heading: 'Signing keys',
|
|
64
|
+
items: [{
|
|
65
|
+
title: 'Roll keys',
|
|
66
|
+
note: 'The current key is replaced with the next key and a new next key is generated. Rolling your keys twice without updating your applications will cause your apps to reject all requests.',
|
|
67
|
+
actions: [{ label: 'Roll keys', action: `${PATH}/roll-keys`, tone: 'danger' as const, fields: { region } }],
|
|
68
|
+
}],
|
|
69
|
+
}]}
|
|
70
|
+
/>
|
|
71
|
+
),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function formOf(request: Request): Promise<Record<string, string>> {
|
|
76
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The QStash page, or undefined for any other request. `contextFor` gives the lane's semantics context. */
|
|
80
|
+
export async function qstashPage(request: Request, contextFor: (request: Request) => Promise<SemanticsContext>): Promise<Response | undefined> {
|
|
81
|
+
const url = new URL(request.url);
|
|
82
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
83
|
+
if (path !== PATH && path !== `${PATH}/reset-token` && path !== `${PATH}/roll-keys`) return undefined;
|
|
84
|
+
const ctx = await contextFor(new Request(request.url));
|
|
85
|
+
const person = signedIn(ctx, request);
|
|
86
|
+
if (!person) return toSignIn(request);
|
|
87
|
+
const pick = (r: string | null | undefined): string => (r && REGIONS.includes(r) ? r : 'eu-central-1');
|
|
88
|
+
if (path === PATH && request.method === 'GET') return page(ctx, person.email, pick(url.searchParams.get('region')));
|
|
89
|
+
if (request.method !== 'POST' || path === PATH) return undefined;
|
|
90
|
+
const form = await formOf(request);
|
|
91
|
+
const region = pick(form.region);
|
|
92
|
+
if (path === `${PATH}/roll-keys`) {
|
|
93
|
+
await rollKeys(ctx, person.email);
|
|
94
|
+
return page(await contextFor(new Request(request.url)), person.email, region, 'Your signing keys were rolled: update QSTASH_CURRENT_SIGNING_KEY and QSTASH_NEXT_SIGNING_KEY in your applications.');
|
|
95
|
+
}
|
|
96
|
+
if (form.confirm !== 'yes') return page(ctx, person.email, region, 'Confirm the dialog to reset your token.');
|
|
97
|
+
await resetToken(ctx, person.email);
|
|
98
|
+
return page(await contextFor(new Request(request.url)), person.email, region, 'Your token was reset. Afterwards you should immediately update your token in all your applications.');
|
|
99
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
2
|
+
/** The person the request's session cookie names, or undefined when nobody is signed in. */
|
|
3
|
+
export declare function signedIn(ctx: SemanticsContext, request: Request): {
|
|
4
|
+
email: string;
|
|
5
|
+
} | undefined;
|
|
6
|
+
/** Where a page asked for by nobody signed in sends its visitor: the sign-in, returning here after. */
|
|
7
|
+
export declare function toSignIn(request: Request): Response;
|
|
8
|
+
/** The sign-in's paths and the World's password door, or undefined for any other request. */
|
|
9
|
+
export declare function sessionFlow(request: Request, ctx: SemanticsContext): Promise<Response | undefined>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// UPSTASH CONSOLE SIGN-IN — the door before every console.upstash.com page (docs/contributing/architecture.md, "Who is on a
|
|
3
|
+
// screen"), following planetscale's (packages/twin/planetscale/api/src/screens/session.tsx): console.upstash.com/login shows
|
|
4
|
+
// the form, which posts back to /login; the right password for the account signs its person in, and a session cookie
|
|
5
|
+
// names them to every page after. A page asked for by nobody signed in sends its visitor to the sign-in with `return_to`,
|
|
6
|
+
// and the sign-in returns them there. Built from @volter/world-ui's sign-in piece under Upstash's skin; nothing of
|
|
7
|
+
// Upstash's page is copied. "Log in to the console" (https://upstash.com/docs/devops/developer-api/introduction).
|
|
8
|
+
//
|
|
9
|
+
// Where Upstash's documentation stops and the twin decides: a person's account is their email, and their password is what
|
|
10
|
+
// the World gave them (`POST /_twin/users/:email/password` {password}, standing in for Upstash's sign-up, as planetscale's
|
|
11
|
+
// door of the same path does); the sign-in is at /login and its cookie is named `upstash_console_session` (Upstash
|
|
12
|
+
// documents neither); a refused sign-in shows the form again with "Incorrect email or password." (answered 200); a
|
|
13
|
+
// session does not end, since no life signs out yet.
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { cookieOf, flowPage, SignIn, SIGN_IN_CSS } from '@volter/world-ui';
|
|
16
|
+
import { makeApiKey } from "../semantics/index.js";
|
|
17
|
+
const COOKIE = 'upstash_console_session';
|
|
18
|
+
const hex = (s) => createHash('sha256').update(s).digest('hex');
|
|
19
|
+
const passwordHash = (email, password) => hex(`password:${email.toLowerCase()}:${password}`);
|
|
20
|
+
// Upstash's skin over the sign-in: its dark ink and green primary button
|
|
21
|
+
const SKIN = `
|
|
22
|
+
body { background: #f5f5f4; color: #1c1917; }
|
|
23
|
+
.sign-in-mark { background: #00c98d; }
|
|
24
|
+
.sign-in-error { background: #fef2f2; border-color: #fecaca; color: #1c1917; }
|
|
25
|
+
.sign-in-box { background: #ffffff; border-color: #e7e5e4; }
|
|
26
|
+
.sign-in-hint { color: #57534e; }
|
|
27
|
+
.sign-in-submit { background: #00c98d; border-color: #00c98d; color: #0c0a09; }
|
|
28
|
+
`;
|
|
29
|
+
/** A return_to the console follows: a path on console.upstash.com, never another site. */
|
|
30
|
+
const returnTo = (raw) => (raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/');
|
|
31
|
+
function page(back, email, error) {
|
|
32
|
+
return flowPage({
|
|
33
|
+
title: 'Log in · Upstash Console',
|
|
34
|
+
css: [SIGN_IN_CSS, SKIN],
|
|
35
|
+
body: (_jsx(SignIn, { heading: "Log in to Upstash", action: "/login", fields: { return_to: back }, account: { name: 'email', label: 'Email', ...(email ? { value: email } : {}) }, password: { name: 'password', label: 'Password' }, submit: "Sign in", ...(error ? { error } : {}) })),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/** The person the request's session cookie names, or undefined when nobody is signed in. */
|
|
39
|
+
export function signedIn(ctx, request) {
|
|
40
|
+
const token = cookieOf(request, COOKIE);
|
|
41
|
+
if (!token)
|
|
42
|
+
return undefined;
|
|
43
|
+
const session = ctx.rowsRaw('_web_session').find((s) => s.token === token);
|
|
44
|
+
if (!session)
|
|
45
|
+
return undefined;
|
|
46
|
+
const person = ctx.rowsRaw('_person').find((p) => p.email === session.email);
|
|
47
|
+
return person ? { email: String(person.email) } : undefined;
|
|
48
|
+
}
|
|
49
|
+
/** Where a page asked for by nobody signed in sends its visitor: the sign-in, returning here after. */
|
|
50
|
+
export function toSignIn(request) {
|
|
51
|
+
const url = new URL(request.url);
|
|
52
|
+
return new Response(null, { status: 302, headers: { location: `/login?return_to=${encodeURIComponent(url.pathname + url.search)}` } });
|
|
53
|
+
}
|
|
54
|
+
async function formOf(request) {
|
|
55
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
56
|
+
}
|
|
57
|
+
/** The sign-in's paths and the World's password door, or undefined for any other request. */
|
|
58
|
+
export async function sessionFlow(request, ctx) {
|
|
59
|
+
const url = new URL(request.url);
|
|
60
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
61
|
+
if (path === '/login' && request.method === 'GET')
|
|
62
|
+
return page(returnTo(url.searchParams.get('return_to')));
|
|
63
|
+
if (path === '/login' && request.method === 'POST') {
|
|
64
|
+
const f = await formOf(request);
|
|
65
|
+
const email = (f.email ?? '').trim().toLowerCase();
|
|
66
|
+
const back = returnTo(f.return_to);
|
|
67
|
+
const person = ctx.rowsRaw('_person').find((p) => p.email === email);
|
|
68
|
+
if (!email || !person || person.hash !== passwordHash(email, f.password ?? ''))
|
|
69
|
+
return page(back, email, 'Incorrect email or password.');
|
|
70
|
+
const n = ctx.rowsRaw('_web_session').length + 1;
|
|
71
|
+
const token = hex(`session:${email}:${ctx.occurredAt}:${n}`).slice(0, 48);
|
|
72
|
+
await ctx.record('_web_session', { token, email, created_at: ctx.occurredAt }, `websession:${token}`);
|
|
73
|
+
const headers = new Headers({ location: back });
|
|
74
|
+
headers.append('set-cookie', `${COOKIE}=${token}; path=/; HttpOnly; Secure; SameSite=Lax`);
|
|
75
|
+
return new Response(null, { status: 302, headers });
|
|
76
|
+
}
|
|
77
|
+
// POST /_twin/users/:email/password {password, plan?}: the password the World gives a person, with which they sign in at
|
|
78
|
+
// /login. Upstash's sign-up is not modelled; the World keeps the password's hash, never the password.
|
|
79
|
+
const door = /^\/_twin\/users\/([^/]+)\/password$/.exec(path);
|
|
80
|
+
if (door && request.method === 'POST') {
|
|
81
|
+
const email = decodeURIComponent(door[1]).toLowerCase();
|
|
82
|
+
let body = {};
|
|
83
|
+
try {
|
|
84
|
+
body = JSON.parse(await request.text());
|
|
85
|
+
}
|
|
86
|
+
catch { /* refused below */ }
|
|
87
|
+
const password = typeof body.password === 'string' ? body.password : '';
|
|
88
|
+
if (!/^[^@\s]+@[^@\s]+$/.test(email) || password.length < 8)
|
|
89
|
+
return badPassword();
|
|
90
|
+
// `plan: "payg"` makes the account pay-as-you-go (a card on file), so a database made without a plan is on it
|
|
91
|
+
await ctx.record('_person', { email, hash: passwordHash(email, password), set_at: ctx.occurredAt, plan: body.plan === 'payg' ? 'payg' : 'free' }, `person:${email}`);
|
|
92
|
+
return new Response(null, { status: 204 });
|
|
93
|
+
}
|
|
94
|
+
// POST /_twin/users/:email/api-key {name, permission?}: an API key the World issues for an account of its own (a World
|
|
95
|
+
// whose application calls the Developer API), made as the console's Developer API tab makes one, Read/Write unless
|
|
96
|
+
// `read_only` is asked, never expiring; answered 201 {email, key}. The account needs no console password.
|
|
97
|
+
const keyDoor = /^\/_twin\/users\/([^/]+)\/api-key$/.exec(path);
|
|
98
|
+
return keyDoor && request.method === 'POST' ? apiKeyDoor(ctx, decodeURIComponent(keyDoor[1]).toLowerCase(), request) : undefined;
|
|
99
|
+
}
|
|
100
|
+
/** The password door's refusal: no email, or a password shorter than 8 characters. */
|
|
101
|
+
function badPassword() {
|
|
102
|
+
return Response.json({ code: 'bad_request', message: 'an email and a password of at least 8 characters are required' }, { status: 400 });
|
|
103
|
+
}
|
|
104
|
+
/** POST /_twin/users/:email/api-key's key, made for the World's own account. */
|
|
105
|
+
async function apiKeyDoor(ctx, email, request) {
|
|
106
|
+
let body = {};
|
|
107
|
+
try {
|
|
108
|
+
body = JSON.parse(await request.text());
|
|
109
|
+
}
|
|
110
|
+
catch { /* refused below */ }
|
|
111
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
112
|
+
if (!/^[^@\s]+@[^@\s]+$/.test(email) || !name)
|
|
113
|
+
return Response.json({ code: 'bad_request', message: 'an email and a key name are required' }, { status: 400 });
|
|
114
|
+
if (ctx.rowsRaw('_api_key').some((k) => k.email === email && k.name === name && k.deleted !== true))
|
|
115
|
+
return Response.json({ code: 'bad_request', message: `${email} already has an API key named ${name}` }, { status: 400 });
|
|
116
|
+
const { key } = await makeApiKey(ctx, email, name, body.permission === 'read_only' ? 'read_only' : 'read_write', null);
|
|
117
|
+
return Response.json({ email, key }, { status: 201 });
|
|
118
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// UPSTASH CONSOLE SIGN-IN — the door before every console.upstash.com page (docs/contributing/architecture.md, "Who is on a
|
|
2
|
+
// screen"), following planetscale's (packages/twin/planetscale/api/src/screens/session.tsx): console.upstash.com/login shows
|
|
3
|
+
// the form, which posts back to /login; the right password for the account signs its person in, and a session cookie
|
|
4
|
+
// names them to every page after. A page asked for by nobody signed in sends its visitor to the sign-in with `return_to`,
|
|
5
|
+
// and the sign-in returns them there. Built from @volter/world-ui's sign-in piece under Upstash's skin; nothing of
|
|
6
|
+
// Upstash's page is copied. "Log in to the console" (https://upstash.com/docs/devops/developer-api/introduction).
|
|
7
|
+
//
|
|
8
|
+
// Where Upstash's documentation stops and the twin decides: a person's account is their email, and their password is what
|
|
9
|
+
// the World gave them (`POST /_twin/users/:email/password` {password}, standing in for Upstash's sign-up, as planetscale's
|
|
10
|
+
// door of the same path does); the sign-in is at /login and its cookie is named `upstash_console_session` (Upstash
|
|
11
|
+
// documents neither); a refused sign-in shows the form again with "Incorrect email or password." (answered 200); a
|
|
12
|
+
// session does not end, since no life signs out yet.
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { cookieOf, flowPage, SignIn, SIGN_IN_CSS } from '@volter/world-ui';
|
|
15
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
16
|
+
import { makeApiKey } from '../semantics/index.ts';
|
|
17
|
+
|
|
18
|
+
const COOKIE = 'upstash_console_session';
|
|
19
|
+
const hex = (s: string): string => createHash('sha256').update(s).digest('hex');
|
|
20
|
+
const passwordHash = (email: string, password: string): string => hex(`password:${email.toLowerCase()}:${password}`);
|
|
21
|
+
|
|
22
|
+
// Upstash's skin over the sign-in: its dark ink and green primary button
|
|
23
|
+
const SKIN = `
|
|
24
|
+
body { background: #f5f5f4; color: #1c1917; }
|
|
25
|
+
.sign-in-mark { background: #00c98d; }
|
|
26
|
+
.sign-in-error { background: #fef2f2; border-color: #fecaca; color: #1c1917; }
|
|
27
|
+
.sign-in-box { background: #ffffff; border-color: #e7e5e4; }
|
|
28
|
+
.sign-in-hint { color: #57534e; }
|
|
29
|
+
.sign-in-submit { background: #00c98d; border-color: #00c98d; color: #0c0a09; }
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
/** A return_to the console follows: a path on console.upstash.com, never another site. */
|
|
33
|
+
const returnTo = (raw: string | null | undefined): string => (raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/');
|
|
34
|
+
|
|
35
|
+
function page(back: string, email?: string, error?: string): Response {
|
|
36
|
+
return flowPage({
|
|
37
|
+
title: 'Log in · Upstash Console',
|
|
38
|
+
css: [SIGN_IN_CSS, SKIN],
|
|
39
|
+
body: (
|
|
40
|
+
<SignIn
|
|
41
|
+
heading="Log in to Upstash"
|
|
42
|
+
action="/login"
|
|
43
|
+
fields={{ return_to: back }}
|
|
44
|
+
account={{ name: 'email', label: 'Email', ...(email ? { value: email } : {}) }}
|
|
45
|
+
password={{ name: 'password', label: 'Password' }}
|
|
46
|
+
submit="Sign in"
|
|
47
|
+
{...(error ? { error } : {})}
|
|
48
|
+
/>
|
|
49
|
+
),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The person the request's session cookie names, or undefined when nobody is signed in. */
|
|
54
|
+
export function signedIn(ctx: SemanticsContext, request: Request): { email: string } | undefined {
|
|
55
|
+
const token = cookieOf(request, COOKIE);
|
|
56
|
+
if (!token) return undefined;
|
|
57
|
+
const session = ctx.rowsRaw('_web_session').find((s) => s.token === token);
|
|
58
|
+
if (!session) return undefined;
|
|
59
|
+
const person = ctx.rowsRaw('_person').find((p) => p.email === session.email);
|
|
60
|
+
return person ? { email: String(person.email) } : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Where a page asked for by nobody signed in sends its visitor: the sign-in, returning here after. */
|
|
64
|
+
export function toSignIn(request: Request): Response {
|
|
65
|
+
const url = new URL(request.url);
|
|
66
|
+
return new Response(null, { status: 302, headers: { location: `/login?return_to=${encodeURIComponent(url.pathname + url.search)}` } });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function formOf(request: Request): Promise<Record<string, string>> {
|
|
70
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The sign-in's paths and the World's password door, or undefined for any other request. */
|
|
74
|
+
export async function sessionFlow(request: Request, ctx: SemanticsContext): Promise<Response | undefined> {
|
|
75
|
+
const url = new URL(request.url);
|
|
76
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
77
|
+
if (path === '/login' && request.method === 'GET') return page(returnTo(url.searchParams.get('return_to')));
|
|
78
|
+
if (path === '/login' && request.method === 'POST') {
|
|
79
|
+
const f = await formOf(request);
|
|
80
|
+
const email = (f.email ?? '').trim().toLowerCase();
|
|
81
|
+
const back = returnTo(f.return_to);
|
|
82
|
+
const person = ctx.rowsRaw('_person').find((p) => p.email === email);
|
|
83
|
+
if (!email || !person || person.hash !== passwordHash(email, f.password ?? '')) return page(back, email, 'Incorrect email or password.');
|
|
84
|
+
const n = ctx.rowsRaw('_web_session').length + 1;
|
|
85
|
+
const token = hex(`session:${email}:${ctx.occurredAt}:${n}`).slice(0, 48);
|
|
86
|
+
await ctx.record('_web_session', { token, email, created_at: ctx.occurredAt }, `websession:${token}`);
|
|
87
|
+
const headers = new Headers({ location: back });
|
|
88
|
+
headers.append('set-cookie', `${COOKIE}=${token}; path=/; HttpOnly; Secure; SameSite=Lax`);
|
|
89
|
+
return new Response(null, { status: 302, headers });
|
|
90
|
+
}
|
|
91
|
+
// POST /_twin/users/:email/password {password, plan?}: the password the World gives a person, with which they sign in at
|
|
92
|
+
// /login. Upstash's sign-up is not modelled; the World keeps the password's hash, never the password.
|
|
93
|
+
const door = /^\/_twin\/users\/([^/]+)\/password$/.exec(path);
|
|
94
|
+
if (door && request.method === 'POST') {
|
|
95
|
+
const email = decodeURIComponent(door[1]!).toLowerCase();
|
|
96
|
+
let body: Record<string, unknown> = {};
|
|
97
|
+
try { body = JSON.parse(await request.text()) as Record<string, unknown>; } catch { /* refused below */ }
|
|
98
|
+
const password = typeof body.password === 'string' ? body.password : '';
|
|
99
|
+
if (!/^[^@\s]+@[^@\s]+$/.test(email) || password.length < 8) return badPassword();
|
|
100
|
+
// `plan: "payg"` makes the account pay-as-you-go (a card on file), so a database made without a plan is on it
|
|
101
|
+
await ctx.record('_person', { email, hash: passwordHash(email, password), set_at: ctx.occurredAt, plan: body.plan === 'payg' ? 'payg' : 'free' }, `person:${email}`);
|
|
102
|
+
return new Response(null, { status: 204 });
|
|
103
|
+
}
|
|
104
|
+
// POST /_twin/users/:email/api-key {name, permission?}: an API key the World issues for an account of its own (a World
|
|
105
|
+
// whose application calls the Developer API), made as the console's Developer API tab makes one, Read/Write unless
|
|
106
|
+
// `read_only` is asked, never expiring; answered 201 {email, key}. The account needs no console password.
|
|
107
|
+
const keyDoor = /^\/_twin\/users\/([^/]+)\/api-key$/.exec(path);
|
|
108
|
+
return keyDoor && request.method === 'POST' ? apiKeyDoor(ctx, decodeURIComponent(keyDoor[1]!).toLowerCase(), request) : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The password door's refusal: no email, or a password shorter than 8 characters. */
|
|
112
|
+
function badPassword(): Response {
|
|
113
|
+
return Response.json({ code: 'bad_request', message: 'an email and a password of at least 8 characters are required' }, { status: 400 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** POST /_twin/users/:email/api-key's key, made for the World's own account. */
|
|
117
|
+
async function apiKeyDoor(ctx: SemanticsContext, email: string, request: Request): Promise<Response> {
|
|
118
|
+
let body: Record<string, unknown> = {};
|
|
119
|
+
try { body = JSON.parse(await request.text()) as Record<string, unknown>; } catch { /* refused below */ }
|
|
120
|
+
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
121
|
+
if (!/^[^@\s]+@[^@\s]+$/.test(email) || !name) return Response.json({ code: 'bad_request', message: 'an email and a key name are required' }, { status: 400 });
|
|
122
|
+
if (ctx.rowsRaw('_api_key').some((k) => k.email === email && k.name === name && k.deleted !== true)) return Response.json({ code: 'bad_request', message: `${email} already has an API key named ${name}` }, { status: 400 });
|
|
123
|
+
const { key } = await makeApiKey(ctx, email, name, body.permission === 'read_only' ? 'read_only' : 'read_write', null);
|
|
124
|
+
return Response.json({ email, key }, { status: 201 });
|
|
125
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
2
|
+
/** The Teams page, the invitation's link and the World's mail door, or undefined for any other request. */
|
|
3
|
+
export declare function teamsPages(request: Request, contextFor: (request: Request) => Promise<SemanticsContext>): Promise<Response | undefined>;
|