@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.
Files changed (162) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +202 -0
  3. package/api/src/fetch.ts +54 -0
  4. package/api/src/generated/surface.gen.json +1 -0
  5. package/api/src/generated/ui.gen.json +1 -0
  6. package/api/src/index.ts +19 -0
  7. package/api/src/key-gate.ts +30 -0
  8. package/api/src/manifest.ts +103 -0
  9. package/api/src/screens/developer-api.tsx +106 -0
  10. package/api/src/screens/qstash.tsx +99 -0
  11. package/api/src/screens/session.tsx +125 -0
  12. package/api/src/screens/teams.tsx +114 -0
  13. package/api/src/semantics/backups.ts +90 -0
  14. package/api/src/semantics/index.ts +191 -0
  15. package/api/src/semantics/shared.ts +42 -0
  16. package/api/src/semantics/teams.ts +108 -0
  17. package/api/src/semantics/time.ts +40 -0
  18. package/dist/api/src/fetch.d.ts +15 -0
  19. package/dist/api/src/fetch.js +44 -0
  20. package/dist/api/src/fetch.ts +54 -0
  21. package/dist/api/src/generated/surface.gen.json +1 -0
  22. package/dist/api/src/generated/ui.gen.json +1 -0
  23. package/dist/api/src/index.ts +19 -0
  24. package/dist/api/src/key-gate.d.ts +3 -0
  25. package/dist/api/src/key-gate.js +30 -0
  26. package/dist/api/src/key-gate.ts +30 -0
  27. package/dist/api/src/manifest.d.ts +2 -0
  28. package/dist/api/src/manifest.js +81 -0
  29. package/dist/api/src/manifest.ts +103 -0
  30. package/dist/api/src/screens/developer-api.d.ts +3 -0
  31. package/dist/api/src/screens/developer-api.js +101 -0
  32. package/dist/api/src/screens/developer-api.tsx +106 -0
  33. package/dist/api/src/screens/qstash.d.ts +3 -0
  34. package/dist/api/src/screens/qstash.js +92 -0
  35. package/dist/api/src/screens/qstash.tsx +99 -0
  36. package/dist/api/src/screens/session.d.ts +9 -0
  37. package/dist/api/src/screens/session.js +118 -0
  38. package/dist/api/src/screens/session.tsx +125 -0
  39. package/dist/api/src/screens/teams.d.ts +3 -0
  40. package/dist/api/src/screens/teams.js +99 -0
  41. package/dist/api/src/screens/teams.tsx +114 -0
  42. package/dist/api/src/semantics/backups.d.ts +7 -0
  43. package/dist/api/src/semantics/backups.js +75 -0
  44. package/dist/api/src/semantics/backups.ts +90 -0
  45. package/dist/api/src/semantics/index.d.ts +10 -0
  46. package/dist/api/src/semantics/index.js +191 -0
  47. package/dist/api/src/semantics/index.ts +191 -0
  48. package/dist/api/src/semantics/shared.d.ts +21 -0
  49. package/dist/api/src/semantics/shared.js +34 -0
  50. package/dist/api/src/semantics/shared.ts +42 -0
  51. package/dist/api/src/semantics/teams.d.ts +13 -0
  52. package/dist/api/src/semantics/teams.js +100 -0
  53. package/dist/api/src/semantics/teams.ts +108 -0
  54. package/dist/api/src/semantics/time.d.ts +2 -0
  55. package/dist/api/src/semantics/time.js +34 -0
  56. package/dist/api/src/semantics/time.ts +40 -0
  57. package/dist/qstash/src/doors.d.ts +6 -0
  58. package/dist/qstash/src/doors.js +33 -0
  59. package/dist/qstash/src/doors.ts +51 -0
  60. package/dist/qstash/src/egress.d.ts +7 -0
  61. package/dist/qstash/src/egress.js +66 -0
  62. package/dist/qstash/src/egress.ts +58 -0
  63. package/dist/qstash/src/fetch.d.ts +7 -0
  64. package/dist/qstash/src/fetch.js +48 -0
  65. package/dist/qstash/src/fetch.ts +46 -0
  66. package/dist/qstash/src/generated/surface.gen.json +1 -0
  67. package/dist/qstash/src/generated/ui.gen.json +1 -0
  68. package/dist/qstash/src/index.ts +35 -0
  69. package/dist/qstash/src/manifest.d.ts +10 -0
  70. package/dist/qstash/src/manifest.js +105 -0
  71. package/dist/qstash/src/manifest.ts +134 -0
  72. package/dist/qstash/src/semantics/account.d.ts +29 -0
  73. package/dist/qstash/src/semantics/account.js +91 -0
  74. package/dist/qstash/src/semantics/account.ts +98 -0
  75. package/dist/qstash/src/semantics/delivery.d.ts +17 -0
  76. package/dist/qstash/src/semantics/delivery.js +274 -0
  77. package/dist/qstash/src/semantics/delivery.ts +264 -0
  78. package/dist/qstash/src/semantics/dlq.d.ts +4 -0
  79. package/dist/qstash/src/semantics/dlq.js +51 -0
  80. package/dist/qstash/src/semantics/dlq.ts +61 -0
  81. package/dist/qstash/src/semantics/index.d.ts +2 -0
  82. package/dist/qstash/src/semantics/index.js +10 -0
  83. package/dist/qstash/src/semantics/index.ts +13 -0
  84. package/dist/qstash/src/semantics/keys.d.ts +2 -0
  85. package/dist/qstash/src/semantics/keys.js +9 -0
  86. package/dist/qstash/src/semantics/keys.ts +14 -0
  87. package/dist/qstash/src/semantics/messages.d.ts +74 -0
  88. package/dist/qstash/src/semantics/messages.js +233 -0
  89. package/dist/qstash/src/semantics/messages.ts +249 -0
  90. package/dist/qstash/src/semantics/queues.d.ts +2 -0
  91. package/dist/qstash/src/semantics/queues.js +60 -0
  92. package/dist/qstash/src/semantics/queues.ts +66 -0
  93. package/dist/qstash/src/semantics/schedules.d.ts +19 -0
  94. package/dist/qstash/src/semantics/schedules.js +125 -0
  95. package/dist/qstash/src/semantics/schedules.ts +132 -0
  96. package/dist/qstash/src/semantics/shared.d.ts +45 -0
  97. package/dist/qstash/src/semantics/shared.js +115 -0
  98. package/dist/qstash/src/semantics/shared.ts +121 -0
  99. package/dist/qstash/src/semantics/urlgroups.d.ts +2 -0
  100. package/dist/qstash/src/semantics/urlgroups.js +58 -0
  101. package/dist/qstash/src/semantics/urlgroups.ts +69 -0
  102. package/dist/qstash/src/semantics/workflows.d.ts +44 -0
  103. package/dist/qstash/src/semantics/workflows.js +379 -0
  104. package/dist/qstash/src/semantics/workflows.ts +401 -0
  105. package/dist/qstash/src/signing.d.ts +4 -0
  106. package/dist/qstash/src/signing.js +16 -0
  107. package/dist/qstash/src/signing.ts +19 -0
  108. package/dist/src/cli.d.ts +2 -0
  109. package/dist/src/cli.js +35 -0
  110. package/dist/src/generated/surface.gen.json +1 -0
  111. package/dist/src/index.d.ts +18 -0
  112. package/dist/src/index.js +124 -0
  113. package/dist/src/manifest.d.ts +14 -0
  114. package/dist/src/manifest.js +8 -0
  115. package/dist/src/upstash-budget.d.ts +85 -0
  116. package/dist/src/upstash-budget.js +440 -0
  117. package/dist/src/upstash-capabilities.d.ts +4 -0
  118. package/dist/src/upstash-capabilities.js +1286 -0
  119. package/dist/src/upstash-conformance.d.ts +7 -0
  120. package/dist/src/upstash-conformance.js +119 -0
  121. package/dist/src/upstash-connector.d.ts +115 -0
  122. package/dist/src/upstash-connector.js +309 -0
  123. package/dist/src/upstash-lua.d.ts +140 -0
  124. package/dist/src/upstash-lua.js +1229 -0
  125. package/dist/src/upstash-server.d.ts +29 -0
  126. package/dist/src/upstash-server.js +81 -0
  127. package/dist/src/upstash-store.d.ts +114 -0
  128. package/dist/src/upstash-store.js +1663 -0
  129. package/dist/src/upstash-twin.d.ts +73 -0
  130. package/dist/src/upstash-twin.js +437 -0
  131. package/package.json +59 -0
  132. package/qstash/src/doors.ts +51 -0
  133. package/qstash/src/egress.ts +58 -0
  134. package/qstash/src/fetch.ts +46 -0
  135. package/qstash/src/generated/surface.gen.json +1 -0
  136. package/qstash/src/generated/ui.gen.json +1 -0
  137. package/qstash/src/index.ts +35 -0
  138. package/qstash/src/manifest.ts +134 -0
  139. package/qstash/src/semantics/account.ts +98 -0
  140. package/qstash/src/semantics/delivery.ts +264 -0
  141. package/qstash/src/semantics/dlq.ts +61 -0
  142. package/qstash/src/semantics/index.ts +13 -0
  143. package/qstash/src/semantics/keys.ts +14 -0
  144. package/qstash/src/semantics/messages.ts +249 -0
  145. package/qstash/src/semantics/queues.ts +66 -0
  146. package/qstash/src/semantics/schedules.ts +132 -0
  147. package/qstash/src/semantics/shared.ts +121 -0
  148. package/qstash/src/semantics/urlgroups.ts +69 -0
  149. package/qstash/src/semantics/workflows.ts +401 -0
  150. package/qstash/src/signing.ts +19 -0
  151. package/src/cli.ts +36 -0
  152. package/src/generated/surface.gen.json +1 -0
  153. package/src/index.ts +203 -0
  154. package/src/manifest.ts +26 -0
  155. package/src/upstash-budget.ts +486 -0
  156. package/src/upstash-capabilities.ts +1418 -0
  157. package/src/upstash-conformance.ts +131 -0
  158. package/src/upstash-connector.ts +340 -0
  159. package/src/upstash-lua.ts +1120 -0
  160. package/src/upstash-server.ts +103 -0
  161. package/src/upstash-store.ts +1437 -0
  162. package/src/upstash-twin.ts +465 -0
@@ -0,0 +1,103 @@
1
+ // Upstash's Developer API manifest (the upstash pack's `api` lane; docs/contributing/architecture.md, "Other wires:
2
+ // lanes"): the vendor facts its published OpenAPI document (../spec/openapi.yaml) does not carry, and the resource whose
3
+ // state the lane moves. The surface is generated (./generated/surface.gen.json, by `bun scripts/derive-pack.ts
4
+ // upstash/api`), at `https://api.upstash.com/v2`.
5
+ //
6
+ // The lane serves the slice the customers' lives reach: a Redis database's create, read and delete, its read regions and
7
+ // password, its backups (semantics/backups.ts, with the daily backup on the World clock, semantics/time.ts), and teams
8
+ // (semantics/teams.ts). Every other operation of the document (renaming, plans, stats, audit logs, listing teams and
9
+ // members, Vector, Search, QStash) is the gap. The REST data plane (the pack
10
+ // root) reads the tokens this lane issues, so both write the one kernel service `upstash`; a database is a
11
+ // control-plane record, stored under the `_database` type, beside the root's `key` and `script` subjects.
12
+ //
13
+ // NO `auth`: the Developer API reads HTTP Basic with the account's email and an API key made in the console
14
+ // (https://upstash.com/docs/devops/developer-api/authentication), and a key's owner and its permission (Read/Write or
15
+ // Read Only) are the lane's records, which the kernel's gate cannot read. The lane's key gate (key-gate.ts) sits in front
16
+ // of the dispatch instead.
17
+ //
18
+ // THE ERROR BODY is the twin's decision: the document declares no error response for any operation. The twin answers in
19
+ // the document's own `Error` schema ({code, message}), which no operation references.
20
+ import type { DerivedManifest, StateField } from '@volter/world-core';
21
+
22
+ /** A backup's `state` (the Backup schema: pending, completed, failed). A backup is `pending` when it is made and
23
+ * `completed` two minutes later, on the World clock (semantics/time.ts): the duration is the twin's decision; Upstash's
24
+ * backup page says only that "Backup process will start and will appear in the backups table"
25
+ * (https://upstash.com/docs/redis/features/backup). Not made by the twin: failed. */
26
+ const backupState: StateField = {
27
+ initial: 'pending',
28
+ transitions: [
29
+ { actor: 'time', from: ['pending'], to: 'completed', source: 'spec:/components/schemas/Backup/properties/state "State of the backup"' },
30
+ ],
31
+ };
32
+
33
+ export const manifest: DerivedManifest = {
34
+ vendor: 'upstash',
35
+ service: 'upstash',
36
+ body: { json: 'always' },
37
+ // database ids are UUIDs (the Database schema's example, 96ad0856-03b1-4ee7-9666-e81abd0349e1); the handler mints one
38
+ // per database from the World's instant and name (semantics/index.ts), so a World mints the same ids every run
39
+ ids: { template: '{uuid}' },
40
+ // `creation_time`: "Creation time of the database as Unix time"
41
+ time: 'unix',
42
+ error: { code: '{code}', message: '{message}' },
43
+ readOnly: { status: 403, code: 'forbidden', message: 'The twin is read-only' },
44
+ malformedBody: { status: 400, code: 'bad_request', message: 'The request body is not valid JSON' },
45
+ notFound: { status: 404, code: 'not_found', message: 'database not found' },
46
+ // listDatabases answers a bare array, which the envelope cannot state; it is the gap. Required by the type.
47
+ list: { style: 'envelope', envelope: { data: '{data}' }, limit: { param: 'limit', default: 100, max: 100 } },
48
+ // deleteDatabase answers the string "OK" (its 200 schema: type string, example OK)
49
+ deleted: 'OK',
50
+ // the operations of a declared resource the lane does not serve: the gap
51
+ unmodeled: [
52
+ 'listDatabases', 'renameDatabase', 'enableTls', 'enableEviction', 'disableEviction', 'enableAutoUpgrade',
53
+ 'disableAutoUpgrade', 'changePlan', 'updateBudget', 'getDatabaseStats', 'listTeams', 'getTeamMembers',
54
+ ],
55
+ screens: [{
56
+ id: 'sign-in', kind: 'flow', host: 'console.upstash.com', path: '/login',
57
+ demand: 'every console page a person reaches: each asks who is signed in', status: 'done',
58
+ controls: ['Email', 'Password', 'Sign in'], source: 'https://upstash.com/docs/devops/developer-api/introduction',
59
+ }, {
60
+ id: 'developer-api', kind: 'flow', host: 'console.upstash.com', path: '/account/api',
61
+ demand: 'the Developer API needs an API key, and a key is made only here',
62
+ status: 'done', controls: ['Create API Key', 'Name', 'Permissions', 'Expiration', 'Submit', 'Delete'],
63
+ source: 'https://upstash.com/docs/devops/developer-api/introduction',
64
+ }, {
65
+ id: 'qstash', kind: 'flow', host: 'console.upstash.com', path: '/qstash',
66
+ demand: "QStash's token and signing keys are shown, and the token reset, only here (the qstash lane's life)",
67
+ status: 'done', controls: ['Select', 'Reset token', 'Roll keys'], source: 'https://upstash.com/docs/qstash/howto/reset-token',
68
+ }, {
69
+ id: 'teams', kind: 'workspace', host: 'console.upstash.com', path: '/account/teams',
70
+ demand: 'a pending member is shown, and a pending invitation revoked, only here', status: 'done',
71
+ controls: ['Pending', 'Revoke Invite'], source: 'https://upstash.com/docs/common/account/teams',
72
+ }, {
73
+ id: 'team-invite', kind: 'flow', host: 'console.upstash.com', path: '/invite/{token}',
74
+ demand: 'a person joins a team by the acceptance link in the invitation email; no API operation accepts one',
75
+ status: 'done', controls: ['Accept invitation'], source: 'https://upstash.com/docs/common/account/teams',
76
+ }],
77
+ resources: {
78
+ Database: {
79
+ storedAs: '_database',
80
+ idPrefix: '',
81
+ // Not state the twin moves. `state` (active, suspended, passive): a database is active from its create (the
82
+ // getDatabase example's "state": "active"); suspension is Upstash's act on an account, which the twin does not
83
+ // model. `type` is the plan and `primary_region`, `db_type`, `db_resource_size` are set when it is made;
84
+ // `db_acl_enabled`, `db_acl_default_user_status` and `prometheus_enabled` are settings no served operation changes.
85
+ // Fields no page states a value for, answered with the Database schema's example values as the reference page
86
+ // renders them (https://upstash.com/docs/devops/developer-api/redis/get_database), the twin's choice: `replicas`
87
+ // (5, whatever the regions; no page says how Upstash counts them), `securityAddons` (all false), and
88
+ // `db_conn_idle_timeout`, `db_lua_timeout`, `db_lua_credits_per_min`, `db_store_max_idle`, `db_max_loads_per_sec`.
89
+ // Also the twin's, no page stating them: `db_memory_threshold` (256 MB on every plan), `db_max_clients` (10000), and
90
+ // how "Unlimited" monthly commands are written, `db_request_limit` 8024278031528737000 on pay-as-you-go and fixed
91
+ // plans (the schema's example value, a paid database's). Only the data size and the limits the pricing table gives
92
+ // (monthly commands, commands per second, request and record size) follow https://upstash.com/pricing/redis, per
93
+ // plan (semantics/index.ts, PLAN_LIMITS).
94
+ notState: ['state', 'type', 'primary_region', 'db_type', 'db_resource_size', 'db_acl_enabled', 'db_acl_default_user_status', 'prometheus_enabled'],
95
+ },
96
+ // `daily_backup` is whether the daily schedule made it, and `hourly_backup` is never set: both fixed when it is made
97
+ Backup: { storedAs: '_backup', idPrefix: '', notState: ['daily_backup', 'hourly_backup'], state: { state: backupState } },
98
+ Team: { storedAs: '_team', idPrefix: '' },
99
+ // a member's role is fixed: "If you want to change a user's role, you will need to delete and re-add them"
100
+ // (https://upstash.com/docs/common/account/teams); whether they are pending is the lane's bookkeeping (`_pending`)
101
+ TeamMember: { storedAs: '_team_member', idPrefix: '', notState: ['member_role'] },
102
+ },
103
+ };
@@ -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,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,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,114 @@
1
+ // UPSTASH CONSOLE: ACCOUNT > TEAMS, and the invitation's acceptance link (docs/contributing/architecture.md, "Screens").
2
+ // https://upstash.com/docs/common/account/teams: "we will send an invitation email to that address. The user joins the team
3
+ // by clicking the acceptance link in the email ... Until the invitation is accepted, the user appears as a **pending
4
+ // member** and does not have access to the team yet. You can revoke a pending invitation (**Revoke Invite**) or remove an
5
+ // existing member from the same page." No API operation accepts an invitation, and none says a member is pending: both
6
+ // are here. Built from @volter/world-ui's settings layout (Portal) under Upstash's skin; nothing of Upstash's page is
7
+ // copied. Its controls are the ones a life needs: a member's Pending mark, Revoke Invite, and the invitation's Accept.
8
+ //
9
+ // The person on a page is who signed in at /login (session.tsx). Where the documentation stops and the twin decides: the
10
+ // page is at /account/teams and lists every team the person has joined, each with its members; the acceptance link is
11
+ // /invite/<token> and asks the invited person to accept (a person signed in as anyone else is told the invitation is not
12
+ // theirs, 403); a revoked or accepted invitation's link answers 404. The invitation email itself is the World's record,
13
+ // read at GET /_twin/mail?to=<email> → {to, mails: [{at, subject, text}]} (standing in for the person's inbox).
14
+ import { flowPage, Portal, PORTAL_CSS } from '@volter/world-ui';
15
+ import type { SemanticsContext } from '@volter/world-core';
16
+ import { MAIL, membersOf, removeMember, roleOf, teamOf } from '../semantics/teams.ts';
17
+ import { signedIn, toSignIn } from './session.tsx';
18
+
19
+ const PATH = '/account/teams';
20
+
21
+ const SKIN = `
22
+ body { background: #ffffff; color: #1c1917; }
23
+ .portal-side { background: #fafaf9; border-right: 1px solid #e7e5e4; }
24
+ .portal-section h2 { border-color: #e7e5e4; color: #57534e; }
25
+ .portal-notice { background: #ecfdf5; border: 1px solid #a7f3d0; }
26
+ .portal-button { background: #ffffff; border: 1px solid #d6d3d1; border-radius: 6px; padding: 6px 12px; }
27
+ .portal-primary { background: #00c98d; border-color: #00c98d; color: #0c0a09; }
28
+ .portal-danger { border-color: #fecaca; color: #b91c1c; }
29
+ `;
30
+
31
+ const ROLE_LABEL: Record<string, string> = { owner: 'Owner', admin: 'Admin', dev: 'Dev', finance: 'Finance' };
32
+
33
+ function teamsPage(ctx: SemanticsContext, email: string, notice?: string): Response {
34
+ const joined = ctx.rowsRaw('TeamMember').filter((m) => m.member_email === email && !m._removed_at && m._pending !== true && teamOf(ctx, String(m.team_id)));
35
+ return flowPage({
36
+ title: 'Teams · Account · Upstash Console',
37
+ css: [PORTAL_CSS, SKIN],
38
+ body: (
39
+ <Portal
40
+ merchant={`${email} · Account`}
41
+ back={{ href: '/', label: 'Back to console' }}
42
+ {...(notice ? { notice } : {})}
43
+ sections={joined.length === 0 ? [{ heading: 'Teams', items: [], empty: 'You are not a member of any team.' }] : joined.map((j) => {
44
+ const team = String(j.team_id);
45
+ const manager = ['owner', 'admin'].includes(String(j.member_role));
46
+ return {
47
+ heading: String(j.team_name),
48
+ items: membersOf(ctx, team).map((m) => ({
49
+ title: String(m.member_email),
50
+ detail: ROLE_LABEL[String(m.member_role)] ?? String(m.member_role),
51
+ ...(m._pending === true ? { badge: 'Pending', note: 'Invitation sent, not yet accepted' } : typeof m._joined_at === 'string' ? { note: `Joined ${m._joined_at.slice(0, 10)}` } : {}),
52
+ actions: m._pending === true && manager ? [{ label: 'Revoke Invite', action: `${PATH}/revoke`, tone: 'danger' as const, fields: { team_id: team, member_email: String(m.member_email) } }] : [],
53
+ })),
54
+ };
55
+ })}
56
+ />
57
+ ),
58
+ });
59
+ }
60
+
61
+ function invitePage(team: string, role: string, token: string, notice?: string, status = 200): Response {
62
+ return flowPage({
63
+ title: 'Team invitation · Upstash Console',
64
+ css: [PORTAL_CSS, SKIN],
65
+ status,
66
+ body: (
67
+ <Portal
68
+ merchant="Team invitation"
69
+ {...(notice ? { notice } : {})}
70
+ sections={[{ heading: `Join ${team}`, items: [{ title: team, detail: `as ${ROLE_LABEL[role] ?? role}`, actions: notice ? [] : [{ label: 'Accept invitation', action: `/invite/${token}`, tone: 'primary' as const }] }] }]}
71
+ />
72
+ ),
73
+ });
74
+ }
75
+
76
+ async function formOf(request: Request): Promise<Record<string, string>> {
77
+ return Object.fromEntries(new URLSearchParams(await request.text()));
78
+ }
79
+
80
+ /** The Teams page, the invitation's link and the World's mail door, or undefined for any other request. */
81
+ export async function teamsPages(request: Request, contextFor: (request: Request) => Promise<SemanticsContext>): Promise<Response | undefined> {
82
+ const url = new URL(request.url);
83
+ const path = url.pathname.replace(/\/+$/, '');
84
+ if (path === '/_twin/mail' && request.method === 'GET') {
85
+ const to = (url.searchParams.get('to') ?? '').toLowerCase();
86
+ const mails = (await contextFor(new Request(request.url))).rowsRaw(MAIL).filter((m) => m.to === to).map((m) => ({ at: m.at, subject: m.subject, text: m.text }));
87
+ return Response.json({ to, mails });
88
+ }
89
+ const invite = /^\/invite\/([0-9a-f]+)$/.exec(path);
90
+ if (path !== PATH && path !== `${PATH}/revoke` && !invite) return undefined;
91
+ const ctx = await contextFor(new Request(request.url));
92
+ const person = signedIn(ctx, request);
93
+ if (!person) return toSignIn(request);
94
+ if (invite) {
95
+ const member = ctx.rowsRaw('TeamMember').find((m) => m._invite === invite[1] && m._pending === true && !m._removed_at);
96
+ if (!member) return new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
97
+ const [team, role] = [String(member.team_name), String(member.member_role)];
98
+ if (member.member_email !== person.email) return invitePage(team, role, invite[1]!, `This invitation is for ${String(member.member_email)}. Log in as that user to accept it.`, 403);
99
+ if (request.method !== 'POST') return invitePage(team, role, invite[1]!);
100
+ await ctx.write('TeamMember', `${String(member.team_id)}:${person.email}`, { ...ctx.own(member), _pending: false, _joined_at: ctx.occurredAt }, 'team.accept-invite');
101
+ return teamsPage(await contextFor(new Request(request.url)), person.email, `You joined ${team}.`);
102
+ }
103
+ return path === PATH ? teamsPage(ctx, person.email) : revokeInvite(ctx, person.email, request, contextFor);
104
+ }
105
+
106
+ /** Revoke Invite: a pending member goes, by the team's Owner or an Admin. */
107
+ async function revokeInvite(ctx: SemanticsContext, email: string, request: Request, contextFor: (request: Request) => Promise<SemanticsContext>): Promise<Response> {
108
+ const form = await formOf(request);
109
+ const team = String(form.team_id ?? '');
110
+ const pending = membersOf(ctx, team).find((m) => m.member_email === form.member_email && m._pending === true);
111
+ if (!pending || !['owner', 'admin'].includes(roleOf(ctx, team, email) ?? '')) return teamsPage(ctx, email, 'There is no pending invitation you can revoke.');
112
+ await removeMember(ctx, team, String(pending.member_email), 'team.revoke-invite');
113
+ return teamsPage(await contextFor(new Request(request.url)), email, `The invitation to ${String(pending.member_email)} was revoked.`);
114
+ }