@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,99 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // UPSTASH CONSOLE: ACCOUNT > TEAMS, and the invitation's acceptance link (docs/contributing/architecture.md, "Screens").
3
+ // https://upstash.com/docs/common/account/teams: "we will send an invitation email to that address. The user joins the team
4
+ // by clicking the acceptance link in the email ... Until the invitation is accepted, the user appears as a **pending
5
+ // member** and does not have access to the team yet. You can revoke a pending invitation (**Revoke Invite**) or remove an
6
+ // existing member from the same page." No API operation accepts an invitation, and none says a member is pending: both
7
+ // are here. Built from @volter/world-ui's settings layout (Portal) under Upstash's skin; nothing of Upstash's page is
8
+ // copied. Its controls are the ones a life needs: a member's Pending mark, Revoke Invite, and the invitation's Accept.
9
+ //
10
+ // The person on a page is who signed in at /login (session.tsx). Where the documentation stops and the twin decides: the
11
+ // page is at /account/teams and lists every team the person has joined, each with its members; the acceptance link is
12
+ // /invite/<token> and asks the invited person to accept (a person signed in as anyone else is told the invitation is not
13
+ // theirs, 403); a revoked or accepted invitation's link answers 404. The invitation email itself is the World's record,
14
+ // read at GET /_twin/mail?to=<email> → {to, mails: [{at, subject, text}]} (standing in for the person's inbox).
15
+ import { flowPage, Portal, PORTAL_CSS } from '@volter/world-ui';
16
+ import { MAIL, membersOf, removeMember, roleOf, teamOf } from "../semantics/teams.js";
17
+ import { signedIn, toSignIn } from "./session.js";
18
+ const PATH = '/account/teams';
19
+ const SKIN = `
20
+ body { background: #ffffff; color: #1c1917; }
21
+ .portal-side { background: #fafaf9; border-right: 1px solid #e7e5e4; }
22
+ .portal-section h2 { border-color: #e7e5e4; color: #57534e; }
23
+ .portal-notice { background: #ecfdf5; border: 1px solid #a7f3d0; }
24
+ .portal-button { background: #ffffff; border: 1px solid #d6d3d1; border-radius: 6px; padding: 6px 12px; }
25
+ .portal-primary { background: #00c98d; border-color: #00c98d; color: #0c0a09; }
26
+ .portal-danger { border-color: #fecaca; color: #b91c1c; }
27
+ `;
28
+ const ROLE_LABEL = { owner: 'Owner', admin: 'Admin', dev: 'Dev', finance: 'Finance' };
29
+ function teamsPage(ctx, email, notice) {
30
+ const joined = ctx.rowsRaw('TeamMember').filter((m) => m.member_email === email && !m._removed_at && m._pending !== true && teamOf(ctx, String(m.team_id)));
31
+ return flowPage({
32
+ title: 'Teams · Account · Upstash Console',
33
+ css: [PORTAL_CSS, SKIN],
34
+ body: (_jsx(Portal, { merchant: `${email} · Account`, back: { href: '/', label: 'Back to console' }, ...(notice ? { notice } : {}), sections: joined.length === 0 ? [{ heading: 'Teams', items: [], empty: 'You are not a member of any team.' }] : joined.map((j) => {
35
+ const team = String(j.team_id);
36
+ const manager = ['owner', 'admin'].includes(String(j.member_role));
37
+ return {
38
+ heading: String(j.team_name),
39
+ items: membersOf(ctx, team).map((m) => ({
40
+ title: String(m.member_email),
41
+ detail: ROLE_LABEL[String(m.member_role)] ?? String(m.member_role),
42
+ ...(m._pending === true ? { badge: 'Pending', note: 'Invitation sent, not yet accepted' } : typeof m._joined_at === 'string' ? { note: `Joined ${m._joined_at.slice(0, 10)}` } : {}),
43
+ actions: m._pending === true && manager ? [{ label: 'Revoke Invite', action: `${PATH}/revoke`, tone: 'danger', fields: { team_id: team, member_email: String(m.member_email) } }] : [],
44
+ })),
45
+ };
46
+ }) })),
47
+ });
48
+ }
49
+ function invitePage(team, role, token, notice, status = 200) {
50
+ return flowPage({
51
+ title: 'Team invitation · Upstash Console',
52
+ css: [PORTAL_CSS, SKIN],
53
+ status,
54
+ body: (_jsx(Portal, { merchant: "Team invitation", ...(notice ? { notice } : {}), sections: [{ heading: `Join ${team}`, items: [{ title: team, detail: `as ${ROLE_LABEL[role] ?? role}`, actions: notice ? [] : [{ label: 'Accept invitation', action: `/invite/${token}`, tone: 'primary' }] }] }] })),
55
+ });
56
+ }
57
+ async function formOf(request) {
58
+ return Object.fromEntries(new URLSearchParams(await request.text()));
59
+ }
60
+ /** The Teams page, the invitation's link and the World's mail door, or undefined for any other request. */
61
+ export async function teamsPages(request, contextFor) {
62
+ const url = new URL(request.url);
63
+ const path = url.pathname.replace(/\/+$/, '');
64
+ if (path === '/_twin/mail' && request.method === 'GET') {
65
+ const to = (url.searchParams.get('to') ?? '').toLowerCase();
66
+ 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 }));
67
+ return Response.json({ to, mails });
68
+ }
69
+ const invite = /^\/invite\/([0-9a-f]+)$/.exec(path);
70
+ if (path !== PATH && path !== `${PATH}/revoke` && !invite)
71
+ return undefined;
72
+ const ctx = await contextFor(new Request(request.url));
73
+ const person = signedIn(ctx, request);
74
+ if (!person)
75
+ return toSignIn(request);
76
+ if (invite) {
77
+ const member = ctx.rowsRaw('TeamMember').find((m) => m._invite === invite[1] && m._pending === true && !m._removed_at);
78
+ if (!member)
79
+ return new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
80
+ const [team, role] = [String(member.team_name), String(member.member_role)];
81
+ if (member.member_email !== person.email)
82
+ return invitePage(team, role, invite[1], `This invitation is for ${String(member.member_email)}. Log in as that user to accept it.`, 403);
83
+ if (request.method !== 'POST')
84
+ return invitePage(team, role, invite[1]);
85
+ await ctx.write('TeamMember', `${String(member.team_id)}:${person.email}`, { ...ctx.own(member), _pending: false, _joined_at: ctx.occurredAt }, 'team.accept-invite');
86
+ return teamsPage(await contextFor(new Request(request.url)), person.email, `You joined ${team}.`);
87
+ }
88
+ return path === PATH ? teamsPage(ctx, person.email) : revokeInvite(ctx, person.email, request, contextFor);
89
+ }
90
+ /** Revoke Invite: a pending member goes, by the team's Owner or an Admin. */
91
+ async function revokeInvite(ctx, email, request, contextFor) {
92
+ const form = await formOf(request);
93
+ const team = String(form.team_id ?? '');
94
+ const pending = membersOf(ctx, team).find((m) => m.member_email === form.member_email && m._pending === true);
95
+ if (!pending || !['owner', 'admin'].includes(roleOf(ctx, team, email) ?? ''))
96
+ return teamsPage(ctx, email, 'There is no pending invitation you can revoke.');
97
+ await removeMember(ctx, team, String(pending.member_email), 'team.revoke-invite');
98
+ return teamsPage(await contextFor(new Request(request.url)), email, `The invitation to ${String(pending.member_email)} was revoked.`);
99
+ }
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
2
+ import { type Row } from './shared.js';
3
+ export declare const COMPLETE_AFTER_MS: number;
4
+ export declare const DAILY_KEPT_MS = 86400000;
5
+ /** Make a backup of a database as it stands at `ctx.occurredAt`, `pending` until it completes. */
6
+ export declare function makeBackup(ctx: SemanticsContext, db: Row, name: string, daily: boolean): Promise<Row>;
7
+ export declare const backupSemantics: Record<string, Semantics>;
@@ -0,0 +1,75 @@
1
+ import { keyspaceImage, restoreKeyspace } from "../../../src/upstash-store.js";
2
+ import { backingUp, badRequest, body, live, ownFields, uuidOf } from "./shared.js";
3
+ export const COMPLETE_AFTER_MS = 2 * 60_000;
4
+ export const DAILY_KEPT_MS = 86_400_000;
5
+ const scope = (ctx, database) => ({ ...(ctx.root !== undefined ? { root: ctx.root } : {}), occurredAt: ctx.occurredAt, database });
6
+ /** Make a backup of a database as it stands at `ctx.occurredAt`, `pending` until it completes. */
7
+ export async function makeBackup(ctx, db, name, daily) {
8
+ const database = String(db.database_id);
9
+ const image = keyspaceImage(scope(ctx, database));
10
+ const id = uuidOf(`backup:${database}:${ctx.occurredAt}:${name}:${ctx.rowsRaw('Backup', { withDeleted: true }).length + 1}`);
11
+ const fields = {
12
+ database_id: database, customer_id: db.customer_id, name, backup_id: id, creation_time: Math.floor(Date.parse(ctx.occurredAt) / 1000),
13
+ state: 'pending', backup_size: Buffer.byteLength(JSON.stringify(image)), daily_backup: String(daily), hourly_backup: 'false', internal_backup_tag: '',
14
+ _image: image, _owner: db._owner, _created_at: ctx.occurredAt,
15
+ };
16
+ await ctx.write('Backup', id, fields, daily ? 'backup.daily' : 'backup.create');
17
+ return fields;
18
+ }
19
+ /** The live backups of a database, oldest first. */
20
+ const backupsOf = (ctx, database) => ctx.rowsRaw('Backup').filter((k) => k.database_id === database && !k._deleted_at).sort((a, b) => Number(a.creation_time) - Number(b.creation_time));
21
+ /** createBackup {name}: a backup of the database now, answered "OK" (its 200 schema). */
22
+ const createBackup = async (ctx) => {
23
+ const id = String(ctx.call.params.id);
24
+ const db = live(ctx, id);
25
+ if (!db)
26
+ return ctx.notFound('Database', id, 'id');
27
+ const name = typeof body(ctx).name === 'string' ? String(body(ctx).name).trim() : '';
28
+ if (!name)
29
+ return badRequest(ctx, 'name is required');
30
+ const busy = backingUp(ctx, id);
31
+ if (busy)
32
+ return busy;
33
+ await makeBackup(ctx, db, name, false);
34
+ return ctx.reply('OK', 200);
35
+ };
36
+ /** listBackup: the database's backups, as the Backup schema answers each. */
37
+ const listBackup = async (ctx) => {
38
+ const id = String(ctx.call.params.id);
39
+ if (!live(ctx, id))
40
+ return ctx.notFound('Database', id, 'id');
41
+ return ctx.reply(backupsOf(ctx, id).map((k) => ownFields(ctx.own(k))));
42
+ };
43
+ /** deleteBackup: the backup goes, answered "OK". */
44
+ const deleteBackup = async (ctx) => {
45
+ const id = String(ctx.call.params.id);
46
+ if (!live(ctx, id))
47
+ return ctx.notFound('Database', id, 'id');
48
+ const backup = backupsOf(ctx, id).find((k) => k.backup_id === ctx.call.params.backup_id);
49
+ if (!backup)
50
+ return ctx.notFound('Backup', String(ctx.call.params.backup_id), 'backup_id');
51
+ await ctx.write('Backup', String(backup.backup_id), { ...ctx.own(backup), _deleted_at: ctx.occurredAt }, 'backup.delete');
52
+ return ctx.reply('OK', 200);
53
+ };
54
+ /** restoreBackup {backup_id}: the database's keys are replaced by the backup's, answered "OK". */
55
+ const restoreBackup = async (ctx) => {
56
+ const id = String(ctx.call.params.id);
57
+ if (!live(ctx, id))
58
+ return ctx.notFound('Database', id, 'id');
59
+ const wanted = String(body(ctx).backup_id ?? '');
60
+ const backup = ctx.rowsRaw('Backup').find((k) => k.backup_id === wanted && !k._deleted_at && k.state === 'completed' && live(ctx, String(k.database_id)));
61
+ if (!backup)
62
+ return badRequest(ctx, `backup ${wanted} is not a completed backup of a database in your account`);
63
+ await restoreKeyspace(backup._image, scope(ctx, id));
64
+ return ctx.reply('OK', 200);
65
+ };
66
+ /** enableDailyBackup: from now the database is backed up every day (semantics/time.ts), answered "OK". */
67
+ const enableDailyBackup = async (ctx) => {
68
+ const id = String(ctx.call.params.id);
69
+ const db = live(ctx, id);
70
+ if (!db)
71
+ return ctx.notFound('Database', id, 'id');
72
+ await ctx.write('Database', id, { ...ctx.own(db), daily_backup_enabled: true, _daily_since: ctx.occurredAt }, 'database.enable-dailybackup');
73
+ return ctx.reply('OK', 200);
74
+ };
75
+ export const backupSemantics = { createBackup, listBackup, deleteBackup, restoreBackup, enableDailyBackup };
@@ -0,0 +1,90 @@
1
+ // A Redis database's backups (https://upstash.com/docs/redis/features/backup; the spec's create-backup, list-backup,
2
+ // delete-backup, restore-backup and enable-dailybackup). A backup holds the database's keys as they stood when it was
3
+ // made (the pack root's store, `keyspaceImage`), and a restore empties its target and writes them back: "All existing data
4
+ // in the target database will be deleted before the restore operation begins". "You can restore your database from any
5
+ // backup in your account or team": a backup of any database the caller reaches restores into any other it reaches.
6
+ //
7
+ // Where the pages stop and the twin decides: a backup is `pending` for two minutes and then `completed` (semantics/time.ts);
8
+ // a manual backup is kept until it is deleted; a daily backup is made at 00:00 UTC each day after daily backup is enabled,
9
+ // named `daily-backup-<date>`, and kept one day after it completes (the console offers "1 or 3 days", the API's
10
+ // enable-dailybackup takes none, and the production checklist gives "Backup retention up to 3 days with Prod Pack",
11
+ // https://upstash.com/docs/redis/help/production-checklist); a backup's size is the bytes of the keys it holds; an unknown
12
+ // backup, or one not yet completed, answers 400 to a restore and 404 to a delete.
13
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
14
+ import { keyspaceImage, restoreKeyspace, type KeyImage } from '../../../src/upstash-store.ts';
15
+ import { backingUp, badRequest, body, live, ownFields, uuidOf, type Row } from './shared.ts';
16
+
17
+ export const COMPLETE_AFTER_MS = 2 * 60_000;
18
+ export const DAILY_KEPT_MS = 86_400_000;
19
+
20
+ const scope = (ctx: SemanticsContext, database: string) => ({ ...(ctx.root !== undefined ? { root: ctx.root } : {}), occurredAt: ctx.occurredAt, database });
21
+
22
+ /** Make a backup of a database as it stands at `ctx.occurredAt`, `pending` until it completes. */
23
+ export async function makeBackup(ctx: SemanticsContext, db: Row, name: string, daily: boolean): Promise<Row> {
24
+ const database = String(db.database_id);
25
+ const image = keyspaceImage(scope(ctx, database));
26
+ const id = uuidOf(`backup:${database}:${ctx.occurredAt}:${name}:${ctx.rowsRaw('Backup', { withDeleted: true }).length + 1}`);
27
+ const fields: Row = {
28
+ database_id: database, customer_id: db.customer_id, name, backup_id: id, creation_time: Math.floor(Date.parse(ctx.occurredAt) / 1000),
29
+ state: 'pending', backup_size: Buffer.byteLength(JSON.stringify(image)), daily_backup: String(daily), hourly_backup: 'false', internal_backup_tag: '',
30
+ _image: image, _owner: db._owner, _created_at: ctx.occurredAt,
31
+ };
32
+ await ctx.write('Backup', id, fields, daily ? 'backup.daily' : 'backup.create');
33
+ return fields;
34
+ }
35
+
36
+ /** The live backups of a database, oldest first. */
37
+ const backupsOf = (ctx: SemanticsContext, database: string): Row[] =>
38
+ ctx.rowsRaw('Backup').filter((k) => k.database_id === database && !k._deleted_at).sort((a, b) => Number(a.creation_time) - Number(b.creation_time));
39
+
40
+ /** createBackup {name}: a backup of the database now, answered "OK" (its 200 schema). */
41
+ const createBackup: Semantics = async (ctx) => {
42
+ const id = String(ctx.call.params.id);
43
+ const db = live(ctx, id);
44
+ if (!db) return ctx.notFound('Database', id, 'id');
45
+ const name = typeof body(ctx).name === 'string' ? String(body(ctx).name).trim() : '';
46
+ if (!name) return badRequest(ctx, 'name is required');
47
+ const busy = backingUp(ctx, id);
48
+ if (busy) return busy;
49
+ await makeBackup(ctx, db, name, false);
50
+ return ctx.reply('OK', 200);
51
+ };
52
+
53
+ /** listBackup: the database's backups, as the Backup schema answers each. */
54
+ const listBackup: Semantics = async (ctx) => {
55
+ const id = String(ctx.call.params.id);
56
+ if (!live(ctx, id)) return ctx.notFound('Database', id, 'id');
57
+ return ctx.reply(backupsOf(ctx, id).map((k) => ownFields(ctx.own(k))));
58
+ };
59
+
60
+ /** deleteBackup: the backup goes, answered "OK". */
61
+ const deleteBackup: Semantics = async (ctx) => {
62
+ const id = String(ctx.call.params.id);
63
+ if (!live(ctx, id)) return ctx.notFound('Database', id, 'id');
64
+ const backup = backupsOf(ctx, id).find((k) => k.backup_id === ctx.call.params.backup_id);
65
+ if (!backup) return ctx.notFound('Backup', String(ctx.call.params.backup_id), 'backup_id');
66
+ await ctx.write('Backup', String(backup.backup_id), { ...ctx.own(backup), _deleted_at: ctx.occurredAt }, 'backup.delete');
67
+ return ctx.reply('OK', 200);
68
+ };
69
+
70
+ /** restoreBackup {backup_id}: the database's keys are replaced by the backup's, answered "OK". */
71
+ const restoreBackup: Semantics = async (ctx) => {
72
+ const id = String(ctx.call.params.id);
73
+ if (!live(ctx, id)) return ctx.notFound('Database', id, 'id');
74
+ const wanted = String(body(ctx).backup_id ?? '');
75
+ const backup = ctx.rowsRaw('Backup').find((k) => k.backup_id === wanted && !k._deleted_at && k.state === 'completed' && live(ctx, String(k.database_id)));
76
+ if (!backup) return badRequest(ctx, `backup ${wanted} is not a completed backup of a database in your account`);
77
+ await restoreKeyspace(backup._image as KeyImage[], scope(ctx, id));
78
+ return ctx.reply('OK', 200);
79
+ };
80
+
81
+ /** enableDailyBackup: from now the database is backed up every day (semantics/time.ts), answered "OK". */
82
+ const enableDailyBackup: Semantics = async (ctx) => {
83
+ const id = String(ctx.call.params.id);
84
+ const db = live(ctx, id);
85
+ if (!db) return ctx.notFound('Database', id, 'id');
86
+ await ctx.write('Database', id, { ...ctx.own(db), daily_backup_enabled: true, _daily_since: ctx.occurredAt }, 'database.enable-dailybackup');
87
+ return ctx.reply('OK', 200);
88
+ };
89
+
90
+ export const backupSemantics: Record<string, Semantics> = { createBackup, listBackup, deleteBackup, restoreBackup, enableDailyBackup };
@@ -0,0 +1,10 @@
1
+ import type { Semantics, SemanticsContext } from '@volter/world-core';
2
+ import { type Row } from './shared.js';
3
+ /** A database's own fields (never the kernel's), as the Database schema answers it; `credentials=hide` leaves out its
4
+ * password and REST tokens. */
5
+ export declare function view(record: Row, hide: boolean): Row;
6
+ /** A new API key for `email`: its name, permission and expiry, the key shown once and kept as its SHA-256. */
7
+ export declare function makeApiKey(ctx: SemanticsContext, email: string, name: string, permission: 'read_write' | 'read_only', expiresAt: string | null): Promise<{
8
+ key: string;
9
+ }>;
10
+ export declare const apiSemantics: Record<string, Semantics>;
@@ -0,0 +1,191 @@
1
+ // The Developer API lane's handlers, by operationId. Where Upstash's reference stops and the twin decides, the comment
2
+ // beside the rule says so.
3
+ import { createHash } from 'node:crypto';
4
+ import { execRedisRun } from "../../../src/upstash-store.js";
5
+ import { backupSemantics } from "./backups.js";
6
+ import { teamSemantics } from "./teams.js";
7
+ import { backingUp, badRequest, body, emailOf, hex, live, ownFields, uuidOf } from "./shared.js";
8
+ // the CreateDatabaseRequest schema's enums
9
+ const PLATFORMS = ['aws', 'gcp'];
10
+ const REGIONS = ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', 'ca-central-1', 'eu-central-1', 'eu-west-1', 'eu-west-2', 'sa-east-1', 'ap-south-1',
11
+ 'ap-northeast-1', 'ap-southeast-1', 'ap-southeast-2', 'af-south-1', 'us-central1', 'us-east4', 'europe-west1', 'asia-northeast1'];
12
+ const PLANS = ['free', 'payg', 'fixed_250mb', 'fixed_1gb', 'fixed_5gb', 'fixed_10gb', 'fixed_50gb', 'fixed_100gb', 'fixed_500gb'];
13
+ // an endpoint slug in the shape the Database schema's example has ("beloved-stallion-58500"); the words are the twin's
14
+ const ADJECTIVES = ['beloved', 'brave', 'calm', 'clever', 'eager', 'gentle', 'happy', 'lucky', 'merry', 'proud', 'quiet', 'striking'];
15
+ const ANIMALS = ['stallion', 'osprey', 'cat', 'heron', 'otter', 'lynx', 'badger', 'falcon', 'marten', 'wren', 'bison', 'gecko'];
16
+ const MB = 1024 * 1024;
17
+ const GB = 1024 * MB;
18
+ /** The pay-as-you-go and fixed plans' "Unlimited" monthly commands: no page says how the API writes it, so the twin
19
+ * answers the Database schema's example `db_request_limit` (8024278031528737000), a paid database's. */
20
+ const UNLIMITED = 8024278031528737000;
21
+ /** A plan's limits as https://upstash.com/pricing/redis tables them: the plans table ("Max data size", "Monthly Commands",
22
+ * "Max commands per second", "Max request size", "Max record size": Free 256 MB, 500K, 10,000, 10 MB, 100 MB; Pay as You
23
+ * Go 100 GB, Unlimited, 10,000, 10 MB, 100 MB) and "Fixed plans — all tiers" ("Max data", "Max commands/sec", "Max
24
+ * request size", "Max record size" per tier; their Monthly Commands "Unlimited"). */
25
+ const limits = (disk, requests, perSecond, request, entry) => ({ db_disk_threshold: disk, db_request_limit: requests, db_max_commands_per_second: perSecond, db_max_request_size: request, db_max_entry_size: entry });
26
+ const PLAN_LIMITS = {
27
+ free: limits(256 * MB, 500000, 10000, 10 * MB, 100 * MB),
28
+ payg: limits(100 * GB, UNLIMITED, 10000, 10 * MB, 100 * MB),
29
+ fixed_250mb: limits(250 * MB, UNLIMITED, 10000, 10 * MB, 100 * MB),
30
+ fixed_1gb: limits(1 * GB, UNLIMITED, 10000, 10 * MB, 200 * MB),
31
+ fixed_5gb: limits(5 * GB, UNLIMITED, 10000, 20 * MB, 300 * MB),
32
+ fixed_10gb: limits(10 * GB, UNLIMITED, 10000, 30 * MB, 400 * MB),
33
+ fixed_50gb: limits(50 * GB, UNLIMITED, 10000, 50 * MB, 500 * MB),
34
+ fixed_100gb: limits(100 * GB, UNLIMITED, 16000, 75 * MB, 1 * GB),
35
+ fixed_500gb: limits(500 * GB, UNLIMITED, 16000, 100 * MB, 5 * GB),
36
+ };
37
+ const FREE_REGIONS = 'Free usage is limited with max one read region';
38
+ /** The free plan's refusals, each 400 (the twin's reading of the HTTP status codes page's "Your request is invalid"): "Max
39
+ * Databases 1" (https://upstash.com/pricing/redis) and "Free usage is limited with max one read region"
40
+ * (https://upstash.com/docs/redis/features/globaldatabase). */
41
+ function freePlanRefusal(ctx, owner, readRegions) {
42
+ if (readRegions.length > 1)
43
+ return badRequest(ctx, FREE_REGIONS);
44
+ if (ctx.rowsRaw('Database').some((d) => d._owner === owner && ctx.own(d).type === 'free' && !d._deleted_at))
45
+ return badRequest(ctx, 'The free plan allows 1 database');
46
+ return undefined;
47
+ }
48
+ /** A database's id from the World's instant, the name and the count, so a World mints the same id every run. */
49
+ const mintUuid = (ctx, seed) => uuidOf(`database:${ctx.occurredAt}:${seed}:${ctx.rowsRaw('Database', { withDeleted: true }).length + 1}`);
50
+ /** A database's own fields (never the kernel's), as the Database schema answers it; `credentials=hide` leaves out its
51
+ * password and REST tokens. */
52
+ export function view(record, hide) {
53
+ const out = ownFields(record);
54
+ if (hide) {
55
+ delete out.password;
56
+ delete out.rest_token;
57
+ delete out.read_only_rest_token;
58
+ }
59
+ return out;
60
+ }
61
+ /** A database's password and REST tokens, new for every instant they are made at and every reset (`generation`). */
62
+ function credentials(ctx, id, generation = 0) {
63
+ const secret = (what, length) => createHash('sha256').update(`${what}:${id}:${ctx.occurredAt}:${generation}`).digest('base64url').slice(0, length);
64
+ // the Standard and Read Only REST tokens (the spec patch, from Upstash's Terraform provider): the twin's tokens are
65
+ // opaque strings in the shape Upstash's have, an `A` then base64url
66
+ return { password: secret('password', 32), rest_token: `A${secret('rest', 59)}`, read_only_rest_token: `Ag${secret('readonly', 58)}` };
67
+ }
68
+ /** createDatabase. `database_name` and `primary_region` are required (CreateDatabaseRequest, whose `platform` the spec
69
+ * patch makes optional: Upstash's own CLI sends none, spec/patches.json), each enum as the schema lists it; a database
70
+ * made without a platform is on aws (the twin's decision). `region` is `global`, the one value the CLI sends. Where the reference stops and the twin decides: a missing or unlisted field, and a name the
71
+ * account already has, answer 400 naming it; the database is `active` at once (the getDatabase example's state), on the
72
+ * plan asked for or `free` ("If omitted, the database defaults to either the pay-as-you-go or free plan, based on the
73
+ * account type": an account is free unless the World made it pay-as-you-go, screens/session.tsx's door); its limits are its plan's (PLAN_LIMITS, from
74
+ * https://upstash.com/pricing/redis), with the schema's example values for the rest (manifest.ts names them). Its
75
+ * REST endpoint is `https://<endpoint>.upstash.io`, which answers its two REST tokens (the pack root's auth). */
76
+ const createDatabase = async (ctx) => {
77
+ const b = body(ctx);
78
+ const name = typeof b.database_name === 'string' ? b.database_name.trim() : '';
79
+ if (!name)
80
+ return badRequest(ctx, 'database_name is required');
81
+ if (b.platform !== undefined && !PLATFORMS.includes(String(b.platform)))
82
+ return badRequest(ctx, `platform must be one of ${PLATFORMS.join(', ')}`);
83
+ if (typeof b.primary_region !== 'string' || !REGIONS.includes(b.primary_region))
84
+ return badRequest(ctx, `primary_region must be one of ${REGIONS.join(', ')}`);
85
+ const owner = emailOf(ctx);
86
+ const plan = b.plan === undefined ? String(ctx.rowsRaw('_person').find((p) => p.email === owner)?.plan ?? 'free') : String(b.plan);
87
+ if (!PLANS.includes(plan))
88
+ return badRequest(ctx, `plan must be one of ${PLANS.join(', ')}`);
89
+ const readRegions = Array.isArray(b.read_regions) ? b.read_regions.map(String) : [];
90
+ const freeRefusal = plan === 'free' ? freePlanRefusal(ctx, owner, readRegions) : undefined;
91
+ if (freeRefusal)
92
+ return freeRefusal;
93
+ if (ctx.rowsRaw('Database').some((d) => d.database_name === name && d._owner === owner && !d._deleted_at))
94
+ return badRequest(ctx, `database ${name} already exists`);
95
+ const id = mintUuid(ctx, name);
96
+ const h = hex(`endpoint:${id}`);
97
+ const endpoint = `${ADJECTIVES[parseInt(h.slice(0, 2), 16) % ADJECTIVES.length]}-${ANIMALS[parseInt(h.slice(2, 4), 16) % ANIMALS.length]}-${10000 + (parseInt(h.slice(4, 10), 16) % 90000)}`;
98
+ const fields = {
99
+ database_id: id, database_name: name, region: 'global', port: 6379, creation_time: Math.floor(Date.parse(ctx.occurredAt) / 1000),
100
+ state: 'active', ...credentials(ctx, id), endpoint, tls: b.tls !== false, type: plan === 'free' ? 'free' : plan === 'payg' ? 'payg' : 'paid',
101
+ budget: typeof b.budget === 'number' ? b.budget : 0, primary_region: b.primary_region, primary_members: [b.primary_region],
102
+ all_members: [b.primary_region, ...readRegions], read_regions: readRegions, eviction: b.eviction === true, auto_upgrade: false, consistent: false,
103
+ modifying_state: '', db_max_clients: 10000, ...PLAN_LIMITS[plan],
104
+ db_memory_threshold: 268435456, db_resource_size: 'S', db_type: 'pebble',
105
+ // the Database schema's example values (the reference page's rendered answer) for the limits no page states per plan
106
+ // (manifest.ts records them as the twin's choice)
107
+ db_conn_idle_timeout: 21600000000000, db_lua_timeout: 250000000, db_lua_credits_per_min: 10000000000, db_store_max_idle: 900000000000,
108
+ db_max_loads_per_sec: 1000000, replicas: 5,
109
+ securityAddons: { ipWhitelisting: false, vpcPeering: false, privateLink: false, tlsMutualAuth: false, encryptionAtRest: false },
110
+ db_acl_enabled: 'false', db_acl_default_user_status: 'true', db_eviction: b.eviction === true, last_plan_upgrade_time: 0,
111
+ customer_id: owner, daily_backup_enabled: false, prometheus_enabled: 'false', prod_pack_enabled: false,
112
+ _owner: owner,
113
+ };
114
+ await ctx.write('Database', id, fields, 'database.create');
115
+ return ctx.reply(view(fields, false), 200);
116
+ };
117
+ /** getDatabase. `credentials=hide` "remove[s] credentials from the response". A database the account deleted, or never
118
+ * had, answers 404 (the twin's decision; the reference declares no error response). */
119
+ const getDatabase = async (ctx) => {
120
+ const id = String(ctx.call.params.id);
121
+ const record = live(ctx, id);
122
+ if (!record)
123
+ return ctx.notFound('Database', id, 'id');
124
+ return ctx.reply(view(ctx.own(record), new URL(ctx.call.request.url).searchParams.get('credentials') === 'hide'));
125
+ };
126
+ /** deleteDatabase answers "OK" (its 200 schema). A deleted database's data goes with it and its REST tokens answer the
127
+ * REST API's 401 from then on (the pack root's auth): the twin empties its keyspace with FLUSHALL on its own database. */
128
+ const deleteDatabase = async (ctx) => {
129
+ const id = String(ctx.call.params.id);
130
+ const record = live(ctx, id);
131
+ if (!record)
132
+ return ctx.notFound('Database', id, 'id');
133
+ await execRedisRun([['FLUSHALL']], { ...(ctx.root !== undefined ? { root: ctx.root } : {}), occurredAt: ctx.occurredAt, database: id });
134
+ await ctx.write('Database', id, { ...ctx.own(record), _deleted_at: ctx.occurredAt }, 'database.delete');
135
+ return ctx.reply('OK', 200);
136
+ };
137
+ // ── API keys (made on the console's Developer API tab, screens/developer-api.tsx) ─────────────
138
+ /** A new API key for `email`: its name, permission and expiry, the key shown once and kept as its SHA-256. */
139
+ export async function makeApiKey(ctx, email, name, permission, expiresAt) {
140
+ const n = ctx.rowsRaw('_api_key').length + 1;
141
+ const key = hex(`apikey:${email}:${name}:${ctx.occurredAt}:${n}`).slice(0, 36);
142
+ await ctx.record('_api_key', { email, name, permission, expires_at: expiresAt, created_at: ctx.occurredAt, deleted: false, key_sha256: hex(key) }, `apikey:${email}:${name}`);
143
+ return { key };
144
+ }
145
+ /** resetPassword "updates the password of a database" (the spec), and "You can revoke both _Standard_ and _Read Only_
146
+ * tokens by resetting password of your database" (https://upstash.com/docs/redis/features/restapi): the old tokens
147
+ * answer the REST API's 401 from then on (the pack root's auth reads them from this record). Its answer is the
148
+ * `Database` with the new credentials (the twin's reading of its 200 schema; the page does not say). */
149
+ const resetPassword = async (ctx) => {
150
+ const id = String(ctx.call.params.id);
151
+ const record = live(ctx, id);
152
+ if (!record)
153
+ return ctx.notFound('Database', id, 'id');
154
+ const own = ctx.own(record);
155
+ const revoked = [...(Array.isArray(own._revoked_tokens) ? own._revoked_tokens : []), own.rest_token, own.read_only_rest_token];
156
+ const fields = { ...own, ...credentials(ctx, id, revoked.length), _revoked_tokens: revoked };
157
+ await ctx.write('Database', id, fields, 'database.reset-password');
158
+ return ctx.reply(view(fields, false), 200);
159
+ };
160
+ // the read regions update-regions takes (its request schema's enum)
161
+ const READ_REGIONS = ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2', 'ca-central-1', 'eu-central-1', 'eu-west-1', 'eu-west-2', 'sa-east-1', 'ap-south-1', 'ap-northeast-1', 'ap-southeast-1', 'ap-southeast-2'];
162
+ /** updateRegions sets a database's read regions ("You can add or remove regions on a running Redis database",
163
+ * https://upstash.com/docs/redis/features/globaldatabase) and answers "OK". Where the reference stops and the twin
164
+ * decides: a region outside the schema's enum, or no array, answers 400; writes still go to the primary region. */
165
+ const updateRegions = async (ctx) => {
166
+ const id = String(ctx.call.params.id);
167
+ const record = live(ctx, id);
168
+ if (!record)
169
+ return ctx.notFound('Database', id, 'id');
170
+ const asked = body(ctx).read_regions;
171
+ const regions = Array.isArray(asked) ? asked.map(String) : ['none'];
172
+ if (regions.some((r) => !READ_REGIONS.includes(r)))
173
+ return badRequest(ctx, `read_regions must be an array of ${READ_REGIONS.join(', ')}`);
174
+ if (ctx.own(record).type === 'free' && regions.length > 1)
175
+ return badRequest(ctx, FREE_REGIONS);
176
+ const busy = backingUp(ctx, id);
177
+ if (busy)
178
+ return busy;
179
+ const own = ctx.own(record);
180
+ await ctx.write('Database', id, { ...own, read_regions: regions, all_members: [own.primary_region, ...regions] }, 'database.update-regions');
181
+ return ctx.reply('OK', 200);
182
+ };
183
+ export const apiSemantics = {
184
+ createDatabase,
185
+ getDatabase,
186
+ deleteDatabase,
187
+ resetPassword,
188
+ updateRegions,
189
+ ...backupSemantics,
190
+ ...teamSemantics,
191
+ };