@volter/twin-upstash 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +202 -0
- package/api/src/fetch.ts +54 -0
- package/api/src/generated/surface.gen.json +1 -0
- package/api/src/generated/ui.gen.json +1 -0
- package/api/src/index.ts +19 -0
- package/api/src/key-gate.ts +30 -0
- package/api/src/manifest.ts +103 -0
- package/api/src/screens/developer-api.tsx +106 -0
- package/api/src/screens/qstash.tsx +99 -0
- package/api/src/screens/session.tsx +125 -0
- package/api/src/screens/teams.tsx +114 -0
- package/api/src/semantics/backups.ts +90 -0
- package/api/src/semantics/index.ts +191 -0
- package/api/src/semantics/shared.ts +42 -0
- package/api/src/semantics/teams.ts +108 -0
- package/api/src/semantics/time.ts +40 -0
- package/dist/api/src/fetch.d.ts +15 -0
- package/dist/api/src/fetch.js +44 -0
- package/dist/api/src/fetch.ts +54 -0
- package/dist/api/src/generated/surface.gen.json +1 -0
- package/dist/api/src/generated/ui.gen.json +1 -0
- package/dist/api/src/index.ts +19 -0
- package/dist/api/src/key-gate.d.ts +3 -0
- package/dist/api/src/key-gate.js +30 -0
- package/dist/api/src/key-gate.ts +30 -0
- package/dist/api/src/manifest.d.ts +2 -0
- package/dist/api/src/manifest.js +81 -0
- package/dist/api/src/manifest.ts +103 -0
- package/dist/api/src/screens/developer-api.d.ts +3 -0
- package/dist/api/src/screens/developer-api.js +101 -0
- package/dist/api/src/screens/developer-api.tsx +106 -0
- package/dist/api/src/screens/qstash.d.ts +3 -0
- package/dist/api/src/screens/qstash.js +92 -0
- package/dist/api/src/screens/qstash.tsx +99 -0
- package/dist/api/src/screens/session.d.ts +9 -0
- package/dist/api/src/screens/session.js +118 -0
- package/dist/api/src/screens/session.tsx +125 -0
- package/dist/api/src/screens/teams.d.ts +3 -0
- package/dist/api/src/screens/teams.js +99 -0
- package/dist/api/src/screens/teams.tsx +114 -0
- package/dist/api/src/semantics/backups.d.ts +7 -0
- package/dist/api/src/semantics/backups.js +75 -0
- package/dist/api/src/semantics/backups.ts +90 -0
- package/dist/api/src/semantics/index.d.ts +10 -0
- package/dist/api/src/semantics/index.js +191 -0
- package/dist/api/src/semantics/index.ts +191 -0
- package/dist/api/src/semantics/shared.d.ts +21 -0
- package/dist/api/src/semantics/shared.js +34 -0
- package/dist/api/src/semantics/shared.ts +42 -0
- package/dist/api/src/semantics/teams.d.ts +13 -0
- package/dist/api/src/semantics/teams.js +100 -0
- package/dist/api/src/semantics/teams.ts +108 -0
- package/dist/api/src/semantics/time.d.ts +2 -0
- package/dist/api/src/semantics/time.js +34 -0
- package/dist/api/src/semantics/time.ts +40 -0
- package/dist/qstash/src/doors.d.ts +6 -0
- package/dist/qstash/src/doors.js +33 -0
- package/dist/qstash/src/doors.ts +51 -0
- package/dist/qstash/src/egress.d.ts +7 -0
- package/dist/qstash/src/egress.js +66 -0
- package/dist/qstash/src/egress.ts +58 -0
- package/dist/qstash/src/fetch.d.ts +7 -0
- package/dist/qstash/src/fetch.js +48 -0
- package/dist/qstash/src/fetch.ts +46 -0
- package/dist/qstash/src/generated/surface.gen.json +1 -0
- package/dist/qstash/src/generated/ui.gen.json +1 -0
- package/dist/qstash/src/index.ts +35 -0
- package/dist/qstash/src/manifest.d.ts +10 -0
- package/dist/qstash/src/manifest.js +105 -0
- package/dist/qstash/src/manifest.ts +134 -0
- package/dist/qstash/src/semantics/account.d.ts +29 -0
- package/dist/qstash/src/semantics/account.js +91 -0
- package/dist/qstash/src/semantics/account.ts +98 -0
- package/dist/qstash/src/semantics/delivery.d.ts +17 -0
- package/dist/qstash/src/semantics/delivery.js +274 -0
- package/dist/qstash/src/semantics/delivery.ts +264 -0
- package/dist/qstash/src/semantics/dlq.d.ts +4 -0
- package/dist/qstash/src/semantics/dlq.js +51 -0
- package/dist/qstash/src/semantics/dlq.ts +61 -0
- package/dist/qstash/src/semantics/index.d.ts +2 -0
- package/dist/qstash/src/semantics/index.js +10 -0
- package/dist/qstash/src/semantics/index.ts +13 -0
- package/dist/qstash/src/semantics/keys.d.ts +2 -0
- package/dist/qstash/src/semantics/keys.js +9 -0
- package/dist/qstash/src/semantics/keys.ts +14 -0
- package/dist/qstash/src/semantics/messages.d.ts +74 -0
- package/dist/qstash/src/semantics/messages.js +233 -0
- package/dist/qstash/src/semantics/messages.ts +249 -0
- package/dist/qstash/src/semantics/queues.d.ts +2 -0
- package/dist/qstash/src/semantics/queues.js +60 -0
- package/dist/qstash/src/semantics/queues.ts +66 -0
- package/dist/qstash/src/semantics/schedules.d.ts +19 -0
- package/dist/qstash/src/semantics/schedules.js +125 -0
- package/dist/qstash/src/semantics/schedules.ts +132 -0
- package/dist/qstash/src/semantics/shared.d.ts +45 -0
- package/dist/qstash/src/semantics/shared.js +115 -0
- package/dist/qstash/src/semantics/shared.ts +121 -0
- package/dist/qstash/src/semantics/urlgroups.d.ts +2 -0
- package/dist/qstash/src/semantics/urlgroups.js +58 -0
- package/dist/qstash/src/semantics/urlgroups.ts +69 -0
- package/dist/qstash/src/semantics/workflows.d.ts +44 -0
- package/dist/qstash/src/semantics/workflows.js +379 -0
- package/dist/qstash/src/semantics/workflows.ts +401 -0
- package/dist/qstash/src/signing.d.ts +4 -0
- package/dist/qstash/src/signing.js +16 -0
- package/dist/qstash/src/signing.ts +19 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +35 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.js +124 -0
- package/dist/src/manifest.d.ts +14 -0
- package/dist/src/manifest.js +8 -0
- package/dist/src/upstash-budget.d.ts +85 -0
- package/dist/src/upstash-budget.js +440 -0
- package/dist/src/upstash-capabilities.d.ts +4 -0
- package/dist/src/upstash-capabilities.js +1286 -0
- package/dist/src/upstash-conformance.d.ts +7 -0
- package/dist/src/upstash-conformance.js +119 -0
- package/dist/src/upstash-connector.d.ts +115 -0
- package/dist/src/upstash-connector.js +309 -0
- package/dist/src/upstash-lua.d.ts +140 -0
- package/dist/src/upstash-lua.js +1229 -0
- package/dist/src/upstash-server.d.ts +29 -0
- package/dist/src/upstash-server.js +81 -0
- package/dist/src/upstash-store.d.ts +114 -0
- package/dist/src/upstash-store.js +1663 -0
- package/dist/src/upstash-twin.d.ts +73 -0
- package/dist/src/upstash-twin.js +437 -0
- package/package.json +59 -0
- package/qstash/src/doors.ts +51 -0
- package/qstash/src/egress.ts +58 -0
- package/qstash/src/fetch.ts +46 -0
- package/qstash/src/generated/surface.gen.json +1 -0
- package/qstash/src/generated/ui.gen.json +1 -0
- package/qstash/src/index.ts +35 -0
- package/qstash/src/manifest.ts +134 -0
- package/qstash/src/semantics/account.ts +98 -0
- package/qstash/src/semantics/delivery.ts +264 -0
- package/qstash/src/semantics/dlq.ts +61 -0
- package/qstash/src/semantics/index.ts +13 -0
- package/qstash/src/semantics/keys.ts +14 -0
- package/qstash/src/semantics/messages.ts +249 -0
- package/qstash/src/semantics/queues.ts +66 -0
- package/qstash/src/semantics/schedules.ts +132 -0
- package/qstash/src/semantics/shared.ts +121 -0
- package/qstash/src/semantics/urlgroups.ts +69 -0
- package/qstash/src/semantics/workflows.ts +401 -0
- package/qstash/src/signing.ts +19 -0
- package/src/cli.ts +36 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/index.ts +203 -0
- package/src/manifest.ts +26 -0
- package/src/upstash-budget.ts +486 -0
- package/src/upstash-capabilities.ts +1418 -0
- package/src/upstash-conformance.ts +131 -0
- package/src/upstash-connector.ts +340 -0
- package/src/upstash-lua.ts +1120 -0
- package/src/upstash-server.ts +103 -0
- package/src/upstash-store.ts +1437 -0
- package/src/upstash-twin.ts +465 -0
|
@@ -0,0 +1,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 type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
5
|
+
import { execRedisRun } from '../../../src/upstash-store.ts';
|
|
6
|
+
import { backupSemantics } from './backups.ts';
|
|
7
|
+
import { teamSemantics } from './teams.ts';
|
|
8
|
+
import { backingUp, badRequest, body, emailOf, hex, live, ownFields, uuidOf, type Row } from './shared.ts';
|
|
9
|
+
|
|
10
|
+
// the CreateDatabaseRequest schema's enums
|
|
11
|
+
const PLATFORMS = ['aws', 'gcp'];
|
|
12
|
+
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',
|
|
13
|
+
'ap-northeast-1', 'ap-southeast-1', 'ap-southeast-2', 'af-south-1', 'us-central1', 'us-east4', 'europe-west1', 'asia-northeast1'];
|
|
14
|
+
const PLANS = ['free', 'payg', 'fixed_250mb', 'fixed_1gb', 'fixed_5gb', 'fixed_10gb', 'fixed_50gb', 'fixed_100gb', 'fixed_500gb'];
|
|
15
|
+
|
|
16
|
+
// an endpoint slug in the shape the Database schema's example has ("beloved-stallion-58500"); the words are the twin's
|
|
17
|
+
const ADJECTIVES = ['beloved', 'brave', 'calm', 'clever', 'eager', 'gentle', 'happy', 'lucky', 'merry', 'proud', 'quiet', 'striking'];
|
|
18
|
+
const ANIMALS = ['stallion', 'osprey', 'cat', 'heron', 'otter', 'lynx', 'badger', 'falcon', 'marten', 'wren', 'bison', 'gecko'];
|
|
19
|
+
|
|
20
|
+
const MB = 1024 * 1024;
|
|
21
|
+
const GB = 1024 * MB;
|
|
22
|
+
/** The pay-as-you-go and fixed plans' "Unlimited" monthly commands: no page says how the API writes it, so the twin
|
|
23
|
+
* answers the Database schema's example `db_request_limit` (8024278031528737000), a paid database's. */
|
|
24
|
+
const UNLIMITED = 8024278031528737000;
|
|
25
|
+
/** A plan's limits as https://upstash.com/pricing/redis tables them: the plans table ("Max data size", "Monthly Commands",
|
|
26
|
+
* "Max commands per second", "Max request size", "Max record size": Free 256 MB, 500K, 10,000, 10 MB, 100 MB; Pay as You
|
|
27
|
+
* Go 100 GB, Unlimited, 10,000, 10 MB, 100 MB) and "Fixed plans — all tiers" ("Max data", "Max commands/sec", "Max
|
|
28
|
+
* request size", "Max record size" per tier; their Monthly Commands "Unlimited"). */
|
|
29
|
+
const limits = (disk: number, requests: number, perSecond: number, request: number, entry: number): Row =>
|
|
30
|
+
({ db_disk_threshold: disk, db_request_limit: requests, db_max_commands_per_second: perSecond, db_max_request_size: request, db_max_entry_size: entry });
|
|
31
|
+
const PLAN_LIMITS: Record<string, Row> = {
|
|
32
|
+
free: limits(256 * MB, 500000, 10000, 10 * MB, 100 * MB),
|
|
33
|
+
payg: limits(100 * GB, UNLIMITED, 10000, 10 * MB, 100 * MB),
|
|
34
|
+
fixed_250mb: limits(250 * MB, UNLIMITED, 10000, 10 * MB, 100 * MB),
|
|
35
|
+
fixed_1gb: limits(1 * GB, UNLIMITED, 10000, 10 * MB, 200 * MB),
|
|
36
|
+
fixed_5gb: limits(5 * GB, UNLIMITED, 10000, 20 * MB, 300 * MB),
|
|
37
|
+
fixed_10gb: limits(10 * GB, UNLIMITED, 10000, 30 * MB, 400 * MB),
|
|
38
|
+
fixed_50gb: limits(50 * GB, UNLIMITED, 10000, 50 * MB, 500 * MB),
|
|
39
|
+
fixed_100gb: limits(100 * GB, UNLIMITED, 16000, 75 * MB, 1 * GB),
|
|
40
|
+
fixed_500gb: limits(500 * GB, UNLIMITED, 16000, 100 * MB, 5 * GB),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const FREE_REGIONS = 'Free usage is limited with max one read region';
|
|
44
|
+
|
|
45
|
+
/** The free plan's refusals, each 400 (the twin's reading of the HTTP status codes page's "Your request is invalid"): "Max
|
|
46
|
+
* Databases 1" (https://upstash.com/pricing/redis) and "Free usage is limited with max one read region"
|
|
47
|
+
* (https://upstash.com/docs/redis/features/globaldatabase). */
|
|
48
|
+
function freePlanRefusal(ctx: SemanticsContext, owner: string, readRegions: string[]): Response | undefined {
|
|
49
|
+
if (readRegions.length > 1) return badRequest(ctx, FREE_REGIONS);
|
|
50
|
+
if (ctx.rowsRaw('Database').some((d) => d._owner === owner && ctx.own(d).type === 'free' && !d._deleted_at)) return badRequest(ctx, 'The free plan allows 1 database');
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A database's id from the World's instant, the name and the count, so a World mints the same id every run. */
|
|
55
|
+
const mintUuid = (ctx: SemanticsContext, seed: string): string => uuidOf(`database:${ctx.occurredAt}:${seed}:${ctx.rowsRaw('Database', { withDeleted: true }).length + 1}`);
|
|
56
|
+
|
|
57
|
+
/** A database's own fields (never the kernel's), as the Database schema answers it; `credentials=hide` leaves out its
|
|
58
|
+
* password and REST tokens. */
|
|
59
|
+
export function view(record: Row, hide: boolean): Row {
|
|
60
|
+
const out = ownFields(record);
|
|
61
|
+
if (hide) { delete out.password; delete out.rest_token; delete out.read_only_rest_token; }
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A database's password and REST tokens, new for every instant they are made at and every reset (`generation`). */
|
|
66
|
+
function credentials(ctx: SemanticsContext, id: string, generation = 0): Row {
|
|
67
|
+
const secret = (what: string, length: number): string => createHash('sha256').update(`${what}:${id}:${ctx.occurredAt}:${generation}`).digest('base64url').slice(0, length);
|
|
68
|
+
// the Standard and Read Only REST tokens (the spec patch, from Upstash's Terraform provider): the twin's tokens are
|
|
69
|
+
// opaque strings in the shape Upstash's have, an `A` then base64url
|
|
70
|
+
return { password: secret('password', 32), rest_token: `A${secret('rest', 59)}`, read_only_rest_token: `Ag${secret('readonly', 58)}` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** createDatabase. `database_name` and `primary_region` are required (CreateDatabaseRequest, whose `platform` the spec
|
|
74
|
+
* patch makes optional: Upstash's own CLI sends none, spec/patches.json), each enum as the schema lists it; a database
|
|
75
|
+
* 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
|
|
76
|
+
* account already has, answer 400 naming it; the database is `active` at once (the getDatabase example's state), on the
|
|
77
|
+
* plan asked for or `free` ("If omitted, the database defaults to either the pay-as-you-go or free plan, based on the
|
|
78
|
+
* 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
|
|
79
|
+
* https://upstash.com/pricing/redis), with the schema's example values for the rest (manifest.ts names them). Its
|
|
80
|
+
* REST endpoint is `https://<endpoint>.upstash.io`, which answers its two REST tokens (the pack root's auth). */
|
|
81
|
+
const createDatabase: Semantics = async (ctx) => {
|
|
82
|
+
const b = body(ctx);
|
|
83
|
+
const name = typeof b.database_name === 'string' ? b.database_name.trim() : '';
|
|
84
|
+
if (!name) return badRequest(ctx, 'database_name is required');
|
|
85
|
+
if (b.platform !== undefined && !PLATFORMS.includes(String(b.platform))) return badRequest(ctx, `platform must be one of ${PLATFORMS.join(', ')}`);
|
|
86
|
+
if (typeof b.primary_region !== 'string' || !REGIONS.includes(b.primary_region)) return badRequest(ctx, `primary_region must be one of ${REGIONS.join(', ')}`);
|
|
87
|
+
const owner = emailOf(ctx);
|
|
88
|
+
const plan = b.plan === undefined ? String(ctx.rowsRaw('_person').find((p) => p.email === owner)?.plan ?? 'free') : String(b.plan);
|
|
89
|
+
if (!PLANS.includes(plan)) return badRequest(ctx, `plan must be one of ${PLANS.join(', ')}`);
|
|
90
|
+
const readRegions = Array.isArray(b.read_regions) ? b.read_regions.map(String) : [];
|
|
91
|
+
const freeRefusal = plan === 'free' ? freePlanRefusal(ctx, owner, readRegions) : undefined;
|
|
92
|
+
if (freeRefusal) return freeRefusal;
|
|
93
|
+
if (ctx.rowsRaw('Database').some((d) => d.database_name === name && d._owner === owner && !d._deleted_at)) return badRequest(ctx, `database ${name} already exists`);
|
|
94
|
+
const id = mintUuid(ctx, name);
|
|
95
|
+
const h = hex(`endpoint:${id}`);
|
|
96
|
+
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)}`;
|
|
97
|
+
const fields: Row = {
|
|
98
|
+
database_id: id, database_name: name, region: 'global', port: 6379, creation_time: Math.floor(Date.parse(ctx.occurredAt) / 1000),
|
|
99
|
+
state: 'active', ...credentials(ctx, id), endpoint, tls: b.tls !== false, type: plan === 'free' ? 'free' : plan === 'payg' ? 'payg' : 'paid',
|
|
100
|
+
budget: typeof b.budget === 'number' ? b.budget : 0, primary_region: b.primary_region, primary_members: [b.primary_region],
|
|
101
|
+
all_members: [b.primary_region, ...readRegions], read_regions: readRegions, eviction: b.eviction === true, auto_upgrade: false, consistent: false,
|
|
102
|
+
modifying_state: '', db_max_clients: 10000, ...PLAN_LIMITS[plan],
|
|
103
|
+
db_memory_threshold: 268435456, db_resource_size: 'S', db_type: 'pebble',
|
|
104
|
+
// the Database schema's example values (the reference page's rendered answer) for the limits no page states per plan
|
|
105
|
+
// (manifest.ts records them as the twin's choice)
|
|
106
|
+
db_conn_idle_timeout: 21600000000000, db_lua_timeout: 250000000, db_lua_credits_per_min: 10000000000, db_store_max_idle: 900000000000,
|
|
107
|
+
db_max_loads_per_sec: 1000000, replicas: 5,
|
|
108
|
+
securityAddons: { ipWhitelisting: false, vpcPeering: false, privateLink: false, tlsMutualAuth: false, encryptionAtRest: false },
|
|
109
|
+
db_acl_enabled: 'false', db_acl_default_user_status: 'true', db_eviction: b.eviction === true, last_plan_upgrade_time: 0,
|
|
110
|
+
customer_id: owner, daily_backup_enabled: false, prometheus_enabled: 'false', prod_pack_enabled: false,
|
|
111
|
+
_owner: owner,
|
|
112
|
+
};
|
|
113
|
+
await ctx.write('Database', id, fields, 'database.create');
|
|
114
|
+
return ctx.reply(view(fields, false), 200);
|
|
115
|
+
};
|
|
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: Semantics = async (ctx) => {
|
|
120
|
+
const id = String(ctx.call.params.id);
|
|
121
|
+
const record = live(ctx, id);
|
|
122
|
+
if (!record) return ctx.notFound('Database', id, 'id');
|
|
123
|
+
return ctx.reply(view(ctx.own(record), new URL(ctx.call.request.url).searchParams.get('credentials') === 'hide'));
|
|
124
|
+
};
|
|
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: Semantics = async (ctx) => {
|
|
129
|
+
const id = String(ctx.call.params.id);
|
|
130
|
+
const record = live(ctx, id);
|
|
131
|
+
if (!record) return ctx.notFound('Database', id, 'id');
|
|
132
|
+
await execRedisRun([['FLUSHALL']], { ...(ctx.root !== undefined ? { root: ctx.root } : {}), occurredAt: ctx.occurredAt, database: id });
|
|
133
|
+
await ctx.write('Database', id, { ...ctx.own(record), _deleted_at: ctx.occurredAt }, 'database.delete');
|
|
134
|
+
return ctx.reply('OK', 200);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ── API keys (made on the console's Developer API tab, screens/developer-api.tsx) ─────────────
|
|
138
|
+
|
|
139
|
+
/** A new API key for `email`: its name, permission and expiry, the key shown once and kept as its SHA-256. */
|
|
140
|
+
export async function makeApiKey(ctx: SemanticsContext, email: string, name: string, permission: 'read_write' | 'read_only', expiresAt: string | null): Promise<{ key: string }> {
|
|
141
|
+
const n = ctx.rowsRaw('_api_key').length + 1;
|
|
142
|
+
const key = hex(`apikey:${email}:${name}:${ctx.occurredAt}:${n}`).slice(0, 36);
|
|
143
|
+
await ctx.record('_api_key', { email, name, permission, expires_at: expiresAt, created_at: ctx.occurredAt, deleted: false, key_sha256: hex(key) }, `apikey:${email}:${name}`);
|
|
144
|
+
return { key };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** resetPassword "updates the password of a database" (the spec), and "You can revoke both _Standard_ and _Read Only_
|
|
148
|
+
* tokens by resetting password of your database" (https://upstash.com/docs/redis/features/restapi): the old tokens
|
|
149
|
+
* answer the REST API's 401 from then on (the pack root's auth reads them from this record). Its answer is the
|
|
150
|
+
* `Database` with the new credentials (the twin's reading of its 200 schema; the page does not say). */
|
|
151
|
+
const resetPassword: Semantics = async (ctx) => {
|
|
152
|
+
const id = String(ctx.call.params.id);
|
|
153
|
+
const record = live(ctx, id);
|
|
154
|
+
if (!record) return ctx.notFound('Database', id, 'id');
|
|
155
|
+
const own = ctx.own(record);
|
|
156
|
+
const revoked = [...(Array.isArray(own._revoked_tokens) ? own._revoked_tokens : []), own.rest_token, own.read_only_rest_token];
|
|
157
|
+
const fields = { ...own, ...credentials(ctx, id, revoked.length), _revoked_tokens: revoked };
|
|
158
|
+
await ctx.write('Database', id, fields, 'database.reset-password');
|
|
159
|
+
return ctx.reply(view(fields, false), 200);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// the read regions update-regions takes (its request schema's enum)
|
|
163
|
+
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'];
|
|
164
|
+
|
|
165
|
+
/** updateRegions sets a database's read regions ("You can add or remove regions on a running Redis database",
|
|
166
|
+
* https://upstash.com/docs/redis/features/globaldatabase) and answers "OK". Where the reference stops and the twin
|
|
167
|
+
* decides: a region outside the schema's enum, or no array, answers 400; writes still go to the primary region. */
|
|
168
|
+
const updateRegions: Semantics = async (ctx) => {
|
|
169
|
+
const id = String(ctx.call.params.id);
|
|
170
|
+
const record = live(ctx, id);
|
|
171
|
+
if (!record) return ctx.notFound('Database', id, 'id');
|
|
172
|
+
const asked = body(ctx).read_regions;
|
|
173
|
+
const regions = Array.isArray(asked) ? asked.map(String) : ['none'];
|
|
174
|
+
if (regions.some((r) => !READ_REGIONS.includes(r))) return badRequest(ctx, `read_regions must be an array of ${READ_REGIONS.join(', ')}`);
|
|
175
|
+
if (ctx.own(record).type === 'free' && regions.length > 1) return badRequest(ctx, FREE_REGIONS);
|
|
176
|
+
const busy = backingUp(ctx, id);
|
|
177
|
+
if (busy) return busy;
|
|
178
|
+
const own = ctx.own(record);
|
|
179
|
+
await ctx.write('Database', id, { ...own, read_regions: regions, all_members: [own.primary_region, ...regions] }, 'database.update-regions');
|
|
180
|
+
return ctx.reply('OK', 200);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export const apiSemantics: Record<string, Semantics> = {
|
|
184
|
+
createDatabase,
|
|
185
|
+
getDatabase,
|
|
186
|
+
deleteDatabase,
|
|
187
|
+
resetPassword,
|
|
188
|
+
updateRegions,
|
|
189
|
+
...backupSemantics,
|
|
190
|
+
...teamSemantics,
|
|
191
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
2
|
+
export type Row = Record<string, unknown>;
|
|
3
|
+
export declare const hex: (s: string) => string;
|
|
4
|
+
export declare const badRequest: (ctx: SemanticsContext, message: string) => Response;
|
|
5
|
+
export declare const forbidden: (ctx: SemanticsContext, message: string) => Response;
|
|
6
|
+
export declare const body: (ctx: SemanticsContext) => Row;
|
|
7
|
+
/** A UUID from a seed (the World's instant, a name, a count), so a World mints the same ids every run. */
|
|
8
|
+
export declare function uuidOf(seed: string): string;
|
|
9
|
+
/** The account email the request's Basic credential names (key-gate.ts has admitted only an issued one). */
|
|
10
|
+
export declare function emailOf(ctx: SemanticsContext): string;
|
|
11
|
+
/** A record's own fields (never the lane's `_` bookkeeping), as the vendor's schema answers it. */
|
|
12
|
+
export declare function ownFields(record: Row): Row;
|
|
13
|
+
/** A live database the caller's account reaches: its own, or one moved under a team it owns (moveToTeam makes the
|
|
14
|
+
* team's owner the database's `_owner`). Where Upstash's pages stop and the twin decides: that the owner's key reaches
|
|
15
|
+
* the team's databases is not stated (../../journeys/customer-life.outline.md, "Where the evidence stops"). */
|
|
16
|
+
/** A backup of the database still running: "During a backup operation, certain administrative operations will be
|
|
17
|
+
* temporarily unavailable: Backup operations, database config changes, plan and region setup, transferring database"
|
|
18
|
+
* (https://upstash.com/docs/redis/features/backup). Answered 400: the status is the twin's reading of
|
|
19
|
+
* https://upstash.com/docs/devops/developer-api/http_status_codes ("Your request is invalid"). */
|
|
20
|
+
export declare function backingUp(ctx: SemanticsContext, database: string): Response | undefined;
|
|
21
|
+
export declare const live: (ctx: SemanticsContext, id: string) => Row | undefined;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// What the Developer API lane's handlers share: the caller's account, a database it reaches, a request body, a refusal,
|
|
2
|
+
// and the twin's deterministic ids.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
export const hex = (s) => createHash('sha256').update(s).digest('hex');
|
|
5
|
+
export const badRequest = (ctx, message) => ctx.refuse({ status: 400, code: 'bad_request', message });
|
|
6
|
+
export const forbidden = (ctx, message) => ctx.refuse({ status: 403, code: 'forbidden', message });
|
|
7
|
+
export const body = (ctx) => (ctx.body && typeof ctx.body === 'object' && !Array.isArray(ctx.body) ? ctx.body : {});
|
|
8
|
+
/** A UUID from a seed (the World's instant, a name, a count), so a World mints the same ids every run. */
|
|
9
|
+
export function uuidOf(seed) {
|
|
10
|
+
const h = hex(seed);
|
|
11
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
12
|
+
}
|
|
13
|
+
/** The account email the request's Basic credential names (key-gate.ts has admitted only an issued one). */
|
|
14
|
+
export function emailOf(ctx) {
|
|
15
|
+
const decoded = Buffer.from(/^basic\s+(\S+)$/i.exec(ctx.call.request.headers.get('authorization') ?? '')?.[1] ?? '', 'base64').toString('utf8');
|
|
16
|
+
return decoded.slice(0, decoded.indexOf(':')).toLowerCase();
|
|
17
|
+
}
|
|
18
|
+
/** A record's own fields (never the lane's `_` bookkeeping), as the vendor's schema answers it. */
|
|
19
|
+
export function ownFields(record) {
|
|
20
|
+
return Object.fromEntries(Object.entries(record).filter(([k]) => !k.startsWith('_')));
|
|
21
|
+
}
|
|
22
|
+
/** A live database the caller's account reaches: its own, or one moved under a team it owns (moveToTeam makes the
|
|
23
|
+
* team's owner the database's `_owner`). Where Upstash's pages stop and the twin decides: that the owner's key reaches
|
|
24
|
+
* the team's databases is not stated (../../journeys/customer-life.outline.md, "Where the evidence stops"). */
|
|
25
|
+
/** A backup of the database still running: "During a backup operation, certain administrative operations will be
|
|
26
|
+
* temporarily unavailable: Backup operations, database config changes, plan and region setup, transferring database"
|
|
27
|
+
* (https://upstash.com/docs/redis/features/backup). Answered 400: the status is the twin's reading of
|
|
28
|
+
* https://upstash.com/docs/devops/developer-api/http_status_codes ("Your request is invalid"). */
|
|
29
|
+
export function backingUp(ctx, database) {
|
|
30
|
+
return ctx.rowsRaw('Backup').some((k) => k.database_id === database && k.state === 'pending' && !k._deleted_at)
|
|
31
|
+
? badRequest(ctx, 'A backup of this database is in progress: backup operations, database config changes, plan and region setup and transferring the database are unavailable until it completes')
|
|
32
|
+
: undefined;
|
|
33
|
+
}
|
|
34
|
+
export const live = (ctx, id) => ctx.rowsRaw('Database').find((d) => d.database_id === id && !d._deleted_at && d._owner === emailOf(ctx));
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// What the Developer API lane's handlers share: the caller's account, a database it reaches, a request body, a refusal,
|
|
2
|
+
// and the twin's deterministic ids.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
5
|
+
|
|
6
|
+
export type Row = Record<string, unknown>;
|
|
7
|
+
export const hex = (s: string): string => createHash('sha256').update(s).digest('hex');
|
|
8
|
+
export const badRequest = (ctx: SemanticsContext, message: string): Response => ctx.refuse({ status: 400, code: 'bad_request', message });
|
|
9
|
+
export const forbidden = (ctx: SemanticsContext, message: string): Response => ctx.refuse({ status: 403, code: 'forbidden', message });
|
|
10
|
+
export const body = (ctx: SemanticsContext): Row => (ctx.body && typeof ctx.body === 'object' && !Array.isArray(ctx.body) ? (ctx.body as Row) : {});
|
|
11
|
+
|
|
12
|
+
/** A UUID from a seed (the World's instant, a name, a count), so a World mints the same ids every run. */
|
|
13
|
+
export function uuidOf(seed: string): string {
|
|
14
|
+
const h = hex(seed);
|
|
15
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-8${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The account email the request's Basic credential names (key-gate.ts has admitted only an issued one). */
|
|
19
|
+
export function emailOf(ctx: SemanticsContext): string {
|
|
20
|
+
const decoded = Buffer.from(/^basic\s+(\S+)$/i.exec(ctx.call.request.headers.get('authorization') ?? '')?.[1] ?? '', 'base64').toString('utf8');
|
|
21
|
+
return decoded.slice(0, decoded.indexOf(':')).toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A record's own fields (never the lane's `_` bookkeeping), as the vendor's schema answers it. */
|
|
25
|
+
export function ownFields(record: Row): Row {
|
|
26
|
+
return Object.fromEntries(Object.entries(record).filter(([k]) => !k.startsWith('_')));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A live database the caller's account reaches: its own, or one moved under a team it owns (moveToTeam makes the
|
|
30
|
+
* team's owner the database's `_owner`). Where Upstash's pages stop and the twin decides: that the owner's key reaches
|
|
31
|
+
* the team's databases is not stated (../../journeys/customer-life.outline.md, "Where the evidence stops"). */
|
|
32
|
+
/** A backup of the database still running: "During a backup operation, certain administrative operations will be
|
|
33
|
+
* temporarily unavailable: Backup operations, database config changes, plan and region setup, transferring database"
|
|
34
|
+
* (https://upstash.com/docs/redis/features/backup). Answered 400: the status is the twin's reading of
|
|
35
|
+
* https://upstash.com/docs/devops/developer-api/http_status_codes ("Your request is invalid"). */
|
|
36
|
+
export function backingUp(ctx: SemanticsContext, database: string): Response | undefined {
|
|
37
|
+
return ctx.rowsRaw('Backup').some((k) => k.database_id === database && k.state === 'pending' && !k._deleted_at)
|
|
38
|
+
? badRequest(ctx, 'A backup of this database is in progress: backup operations, database config changes, plan and region setup and transferring the database are unavailable until it completes')
|
|
39
|
+
: undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const live = (ctx: SemanticsContext, id: string): Row | undefined => ctx.rowsRaw('Database').find((d) => d.database_id === id && !d._deleted_at && d._owner === emailOf(ctx));
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
2
|
+
import { type Row } from './shared.js';
|
|
3
|
+
/** The invitation mails the World's mail door shows. */
|
|
4
|
+
export declare const MAIL = "_mail";
|
|
5
|
+
/** A live team by id. */
|
|
6
|
+
export declare const teamOf: (ctx: SemanticsContext, id: string) => Row | undefined;
|
|
7
|
+
/** The team's members, pending ones included, never removed ones. */
|
|
8
|
+
export declare const membersOf: (ctx: SemanticsContext, team: string) => Row[];
|
|
9
|
+
/** The role a person holds in a team they have joined, or undefined. */
|
|
10
|
+
export declare const roleOf: (ctx: SemanticsContext, team: string, email: string) => string | undefined;
|
|
11
|
+
/** Remove a member or revoke a pending invitation (the API's delete-team-member; the Teams page's Revoke Invite). */
|
|
12
|
+
export declare function removeMember(ctx: SemanticsContext, team: string, email: string, operation: string): Promise<string | undefined>;
|
|
13
|
+
export declare const teamSemantics: Record<string, Semantics>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { backingUp, badRequest, body, emailOf, forbidden, hex, live, ownFields, uuidOf } from "./shared.js";
|
|
2
|
+
const ROLES = ['admin', 'dev', 'finance'];
|
|
3
|
+
/** The invitation mails the World's mail door shows. */
|
|
4
|
+
export const MAIL = '_mail';
|
|
5
|
+
/** A live team by id. */
|
|
6
|
+
export const teamOf = (ctx, id) => ctx.rowsRaw('Team').find((t) => t.team_id === id && !t._deleted_at);
|
|
7
|
+
/** The team's members, pending ones included, never removed ones. */
|
|
8
|
+
export const membersOf = (ctx, team) => ctx.rowsRaw('TeamMember').filter((m) => m.team_id === team && !m._removed_at);
|
|
9
|
+
/** The role a person holds in a team they have joined, or undefined. */
|
|
10
|
+
export const roleOf = (ctx, team, email) => membersOf(ctx, team).find((m) => m.member_email === email && m._pending !== true)?.member_role;
|
|
11
|
+
const manages = (ctx, team, email) => ['owner', 'admin'].includes(roleOf(ctx, team, email) ?? '');
|
|
12
|
+
/** createTeam {team_name, copy_cc}: the team, its creator its owner, answered as the Team schema. */
|
|
13
|
+
const createTeam = async (ctx) => {
|
|
14
|
+
const b = body(ctx);
|
|
15
|
+
const name = typeof b.team_name === 'string' ? b.team_name.trim() : '';
|
|
16
|
+
if (!name || typeof b.copy_cc !== 'boolean')
|
|
17
|
+
return badRequest(ctx, 'team_name and copy_cc are required');
|
|
18
|
+
const owner = emailOf(ctx);
|
|
19
|
+
// "A user can create up to 5 teams. You can be part of even more teams but only be the owner of 5 teams."
|
|
20
|
+
if (ctx.rowsRaw('Team').filter((t) => t._owner === owner && !t._deleted_at).length >= 5)
|
|
21
|
+
return badRequest(ctx, 'A user can create up to 5 teams');
|
|
22
|
+
const id = uuidOf(`team:${ctx.occurredAt}:${name}:${ctx.rowsRaw('Team', { withDeleted: true }).length + 1}`);
|
|
23
|
+
const team = { team_id: id, team_name: name, copy_cc: b.copy_cc, _owner: owner };
|
|
24
|
+
await ctx.write('Team', id, team, 'team.create');
|
|
25
|
+
await ctx.write('TeamMember', `${id}:${owner}`, { team_id: id, team_name: name, member_email: owner, member_role: 'owner', copy_cc: b.copy_cc }, 'team.owner');
|
|
26
|
+
return ctx.reply(ownFields(team), 200);
|
|
27
|
+
};
|
|
28
|
+
/** moveToTeam {team_id, database_id}: the database goes under the team, answered "OK". */
|
|
29
|
+
const moveToTeam = async (ctx) => {
|
|
30
|
+
const b = body(ctx);
|
|
31
|
+
const db = live(ctx, String(b.database_id ?? ''));
|
|
32
|
+
if (!db)
|
|
33
|
+
return ctx.notFound('Database', String(b.database_id ?? ''), 'database_id');
|
|
34
|
+
const team = teamOf(ctx, String(b.team_id ?? ''));
|
|
35
|
+
if (!team || roleOf(ctx, String(team.team_id), emailOf(ctx)) === undefined)
|
|
36
|
+
return badRequest(ctx, `team ${String(b.team_id ?? '')} is not a team of yours`);
|
|
37
|
+
// "Finance: This role is limited to managing payment methods and cannot manage databases or users"
|
|
38
|
+
if (roleOf(ctx, String(team.team_id), emailOf(ctx)) === 'finance')
|
|
39
|
+
return forbidden(ctx, 'A Finance member cannot manage databases');
|
|
40
|
+
const busy = backingUp(ctx, String(db.database_id));
|
|
41
|
+
if (busy)
|
|
42
|
+
return busy;
|
|
43
|
+
await ctx.write('Database', String(db.database_id), { ...ctx.own(db), customer_id: team.team_id, _team: team.team_id, _owner: team._owner }, 'database.move-to-team');
|
|
44
|
+
return ctx.reply('OK', 200);
|
|
45
|
+
};
|
|
46
|
+
/** addTeamMember {team_id, member_email, member_role}: a pending member, and the invitation mailed to them. */
|
|
47
|
+
const addTeamMember = async (ctx) => {
|
|
48
|
+
const b = body(ctx);
|
|
49
|
+
const team = teamOf(ctx, String(b.team_id ?? ''));
|
|
50
|
+
const email = String(b.member_email ?? '').trim().toLowerCase();
|
|
51
|
+
if (!team)
|
|
52
|
+
return badRequest(ctx, `team ${String(b.team_id ?? '')} does not exist`);
|
|
53
|
+
if (!manages(ctx, String(team.team_id), emailOf(ctx)))
|
|
54
|
+
return forbidden(ctx, 'Only Admins or the Owner can add/remove users');
|
|
55
|
+
if (!/^[^@\s]+@[^@\s]+$/.test(email) || !ROLES.includes(String(b.member_role)))
|
|
56
|
+
return badRequest(ctx, `member_email and a member_role of ${ROLES.join(', ')} are required`);
|
|
57
|
+
if (membersOf(ctx, String(team.team_id)).some((m) => m.member_email === email))
|
|
58
|
+
return badRequest(ctx, `${email} is already a member of ${String(team.team_name)}`);
|
|
59
|
+
const token = hex(`invite:${String(team.team_id)}:${email}:${ctx.occurredAt}`).slice(0, 40);
|
|
60
|
+
const member = { team_id: team.team_id, team_name: team.team_name, member_email: email, member_role: b.member_role, copy_cc: team.copy_cc, _pending: true, _invite: token };
|
|
61
|
+
await ctx.write('TeamMember', `${String(team.team_id)}:${email}`, member, 'team.add-member');
|
|
62
|
+
await ctx.record(MAIL, {
|
|
63
|
+
to: email, at: ctx.occurredAt, subject: `You are invited to join ${String(team.team_name)} on Upstash`,
|
|
64
|
+
text: `${emailOf(ctx)} invited you to join the team ${String(team.team_name)} as ${String(b.member_role)}. Accept the invitation: https://console.upstash.com/invite/${token}`,
|
|
65
|
+
}, `mail:invite:${token}`);
|
|
66
|
+
return ctx.reply(ownFields(member), 200);
|
|
67
|
+
};
|
|
68
|
+
/** Remove a member or revoke a pending invitation (the API's delete-team-member; the Teams page's Revoke Invite). */
|
|
69
|
+
export async function removeMember(ctx, team, email, operation) {
|
|
70
|
+
const member = membersOf(ctx, team).find((m) => m.member_email === email);
|
|
71
|
+
if (!member || member.member_role === 'owner')
|
|
72
|
+
return `${email} is not a member you can remove`;
|
|
73
|
+
await ctx.write('TeamMember', `${team}:${email}`, { ...ctx.own(member), _removed_at: ctx.occurredAt }, operation);
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
/** deleteTeamMember {team_id, member_email}: the member goes, answered "OK". */
|
|
77
|
+
const deleteTeamMember = async (ctx) => {
|
|
78
|
+
const b = body(ctx);
|
|
79
|
+
const team = teamOf(ctx, String(b.team_id ?? ''));
|
|
80
|
+
if (!team)
|
|
81
|
+
return badRequest(ctx, `team ${String(b.team_id ?? '')} does not exist`);
|
|
82
|
+
if (!manages(ctx, String(team.team_id), emailOf(ctx)))
|
|
83
|
+
return forbidden(ctx, 'Only Admins or the Owner can add/remove users');
|
|
84
|
+
const refused = await removeMember(ctx, String(team.team_id), String(b.member_email ?? '').trim().toLowerCase(), 'team.delete-member');
|
|
85
|
+
return refused ? badRequest(ctx, refused) : ctx.reply('OK', 200);
|
|
86
|
+
};
|
|
87
|
+
/** deleteTeam: the team goes, answered "OK"; only its owner, and only once it has no database. */
|
|
88
|
+
const deleteTeam = async (ctx) => {
|
|
89
|
+
const id = String(ctx.call.params.id);
|
|
90
|
+
const team = teamOf(ctx, id);
|
|
91
|
+
if (!team)
|
|
92
|
+
return ctx.notFound('Team', id, 'id');
|
|
93
|
+
if (team._owner !== emailOf(ctx))
|
|
94
|
+
return forbidden(ctx, 'Only the original creator (owner) can delete a team');
|
|
95
|
+
if (ctx.rowsRaw('Database').some((d) => d._team === id && !d._deleted_at))
|
|
96
|
+
return badRequest(ctx, 'The team should not have any active databases: delete its databases first');
|
|
97
|
+
await ctx.write('Team', id, { ...ctx.own(team), _deleted_at: ctx.occurredAt }, 'team.delete');
|
|
98
|
+
return ctx.reply('OK', 200);
|
|
99
|
+
};
|
|
100
|
+
export const teamSemantics = { createTeam, moveToTeam, addTeamMember, deleteTeamMember, deleteTeam };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Teams (https://upstash.com/docs/common/account/teams; the spec's create-team, move-to-team, add-team-member,
|
|
2
|
+
// delete-team-member and delete-team). "Team management enables collaboration with other users. You can create a team and
|
|
3
|
+
// invite people to join by using their email addresses." A member is added with a role and "we will send an invitation
|
|
4
|
+
// email to that address. The user joins the team by clicking the acceptance link in the email ... Until the invitation is
|
|
5
|
+
// accepted, the user appears as a pending member" (the console's Account > Teams page, ../screens/teams.tsx, where the
|
|
6
|
+
// link also lands). "Only Admins or the Owner can add/remove users." "Only the original creator (owner) can delete a
|
|
7
|
+
// team. Also the team should not have any active databases."
|
|
8
|
+
//
|
|
9
|
+
// Where the pages stop and the twin decides: a team's id is a UUID (the Team schema's example); its creator is its
|
|
10
|
+
// member with the role `owner`; a database moved under a team keeps answering its owner's key (shared.ts, `live`) and
|
|
11
|
+
// its `customer_id` becomes the team's id ("Customer identifier (email or team ID)", the spec's QStashUser); a refused
|
|
12
|
+
// call answers 400 (a request the rules refuse) or 403 (a caller the rules refuse) in the document's Error schema; the
|
|
13
|
+
// invitation email is the World's record, read at `GET console.upstash.com/_twin/mail?to=` (../screens/teams.tsx).
|
|
14
|
+
import type { Semantics, SemanticsContext } from '@volter/world-core';
|
|
15
|
+
import { backingUp, badRequest, body, emailOf, forbidden, hex, live, ownFields, uuidOf, type Row } from './shared.ts';
|
|
16
|
+
|
|
17
|
+
const ROLES = ['admin', 'dev', 'finance'];
|
|
18
|
+
/** The invitation mails the World's mail door shows. */
|
|
19
|
+
export const MAIL = '_mail';
|
|
20
|
+
|
|
21
|
+
/** A live team by id. */
|
|
22
|
+
export const teamOf = (ctx: SemanticsContext, id: string): Row | undefined => ctx.rowsRaw('Team').find((t) => t.team_id === id && !t._deleted_at);
|
|
23
|
+
/** The team's members, pending ones included, never removed ones. */
|
|
24
|
+
export const membersOf = (ctx: SemanticsContext, team: string): Row[] => ctx.rowsRaw('TeamMember').filter((m) => m.team_id === team && !m._removed_at);
|
|
25
|
+
/** The role a person holds in a team they have joined, or undefined. */
|
|
26
|
+
export const roleOf = (ctx: SemanticsContext, team: string, email: string): string | undefined =>
|
|
27
|
+
membersOf(ctx, team).find((m) => m.member_email === email && m._pending !== true)?.member_role as string | undefined;
|
|
28
|
+
const manages = (ctx: SemanticsContext, team: string, email: string): boolean => ['owner', 'admin'].includes(roleOf(ctx, team, email) ?? '');
|
|
29
|
+
|
|
30
|
+
/** createTeam {team_name, copy_cc}: the team, its creator its owner, answered as the Team schema. */
|
|
31
|
+
const createTeam: Semantics = async (ctx) => {
|
|
32
|
+
const b = body(ctx);
|
|
33
|
+
const name = typeof b.team_name === 'string' ? b.team_name.trim() : '';
|
|
34
|
+
if (!name || typeof b.copy_cc !== 'boolean') return badRequest(ctx, 'team_name and copy_cc are required');
|
|
35
|
+
const owner = emailOf(ctx);
|
|
36
|
+
// "A user can create up to 5 teams. You can be part of even more teams but only be the owner of 5 teams."
|
|
37
|
+
if (ctx.rowsRaw('Team').filter((t) => t._owner === owner && !t._deleted_at).length >= 5) return badRequest(ctx, 'A user can create up to 5 teams');
|
|
38
|
+
const id = uuidOf(`team:${ctx.occurredAt}:${name}:${ctx.rowsRaw('Team', { withDeleted: true }).length + 1}`);
|
|
39
|
+
const team = { team_id: id, team_name: name, copy_cc: b.copy_cc, _owner: owner };
|
|
40
|
+
await ctx.write('Team', id, team, 'team.create');
|
|
41
|
+
await ctx.write('TeamMember', `${id}:${owner}`, { team_id: id, team_name: name, member_email: owner, member_role: 'owner', copy_cc: b.copy_cc }, 'team.owner');
|
|
42
|
+
return ctx.reply(ownFields(team), 200);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** moveToTeam {team_id, database_id}: the database goes under the team, answered "OK". */
|
|
46
|
+
const moveToTeam: Semantics = async (ctx) => {
|
|
47
|
+
const b = body(ctx);
|
|
48
|
+
const db = live(ctx, String(b.database_id ?? ''));
|
|
49
|
+
if (!db) return ctx.notFound('Database', String(b.database_id ?? ''), 'database_id');
|
|
50
|
+
const team = teamOf(ctx, String(b.team_id ?? ''));
|
|
51
|
+
if (!team || roleOf(ctx, String(team.team_id), emailOf(ctx)) === undefined) return badRequest(ctx, `team ${String(b.team_id ?? '')} is not a team of yours`);
|
|
52
|
+
// "Finance: This role is limited to managing payment methods and cannot manage databases or users"
|
|
53
|
+
if (roleOf(ctx, String(team.team_id), emailOf(ctx)) === 'finance') return forbidden(ctx, 'A Finance member cannot manage databases');
|
|
54
|
+
const busy = backingUp(ctx, String(db.database_id));
|
|
55
|
+
if (busy) return busy;
|
|
56
|
+
await ctx.write('Database', String(db.database_id), { ...ctx.own(db), customer_id: team.team_id, _team: team.team_id, _owner: team._owner }, 'database.move-to-team');
|
|
57
|
+
return ctx.reply('OK', 200);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** addTeamMember {team_id, member_email, member_role}: a pending member, and the invitation mailed to them. */
|
|
61
|
+
const addTeamMember: Semantics = async (ctx) => {
|
|
62
|
+
const b = body(ctx);
|
|
63
|
+
const team = teamOf(ctx, String(b.team_id ?? ''));
|
|
64
|
+
const email = String(b.member_email ?? '').trim().toLowerCase();
|
|
65
|
+
if (!team) return badRequest(ctx, `team ${String(b.team_id ?? '')} does not exist`);
|
|
66
|
+
if (!manages(ctx, String(team.team_id), emailOf(ctx))) return forbidden(ctx, 'Only Admins or the Owner can add/remove users');
|
|
67
|
+
if (!/^[^@\s]+@[^@\s]+$/.test(email) || !ROLES.includes(String(b.member_role))) return badRequest(ctx, `member_email and a member_role of ${ROLES.join(', ')} are required`);
|
|
68
|
+
if (membersOf(ctx, String(team.team_id)).some((m) => m.member_email === email)) return badRequest(ctx, `${email} is already a member of ${String(team.team_name)}`);
|
|
69
|
+
const token = hex(`invite:${String(team.team_id)}:${email}:${ctx.occurredAt}`).slice(0, 40);
|
|
70
|
+
const member = { team_id: team.team_id, team_name: team.team_name, member_email: email, member_role: b.member_role, copy_cc: team.copy_cc, _pending: true, _invite: token };
|
|
71
|
+
await ctx.write('TeamMember', `${String(team.team_id)}:${email}`, member, 'team.add-member');
|
|
72
|
+
await ctx.record(MAIL, {
|
|
73
|
+
to: email, at: ctx.occurredAt, subject: `You are invited to join ${String(team.team_name)} on Upstash`,
|
|
74
|
+
text: `${emailOf(ctx)} invited you to join the team ${String(team.team_name)} as ${String(b.member_role)}. Accept the invitation: https://console.upstash.com/invite/${token}`,
|
|
75
|
+
}, `mail:invite:${token}`);
|
|
76
|
+
return ctx.reply(ownFields(member), 200);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Remove a member or revoke a pending invitation (the API's delete-team-member; the Teams page's Revoke Invite). */
|
|
80
|
+
export async function removeMember(ctx: SemanticsContext, team: string, email: string, operation: string): Promise<string | undefined> {
|
|
81
|
+
const member = membersOf(ctx, team).find((m) => m.member_email === email);
|
|
82
|
+
if (!member || member.member_role === 'owner') return `${email} is not a member you can remove`;
|
|
83
|
+
await ctx.write('TeamMember', `${team}:${email}`, { ...ctx.own(member), _removed_at: ctx.occurredAt }, operation);
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** deleteTeamMember {team_id, member_email}: the member goes, answered "OK". */
|
|
88
|
+
const deleteTeamMember: Semantics = async (ctx) => {
|
|
89
|
+
const b = body(ctx);
|
|
90
|
+
const team = teamOf(ctx, String(b.team_id ?? ''));
|
|
91
|
+
if (!team) return badRequest(ctx, `team ${String(b.team_id ?? '')} does not exist`);
|
|
92
|
+
if (!manages(ctx, String(team.team_id), emailOf(ctx))) return forbidden(ctx, 'Only Admins or the Owner can add/remove users');
|
|
93
|
+
const refused = await removeMember(ctx, String(team.team_id), String(b.member_email ?? '').trim().toLowerCase(), 'team.delete-member');
|
|
94
|
+
return refused ? badRequest(ctx, refused) : ctx.reply('OK', 200);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** deleteTeam: the team goes, answered "OK"; only its owner, and only once it has no database. */
|
|
98
|
+
const deleteTeam: Semantics = async (ctx) => {
|
|
99
|
+
const id = String(ctx.call.params.id);
|
|
100
|
+
const team = teamOf(ctx, id);
|
|
101
|
+
if (!team) return ctx.notFound('Team', id, 'id');
|
|
102
|
+
if (team._owner !== emailOf(ctx)) return forbidden(ctx, 'Only the original creator (owner) can delete a team');
|
|
103
|
+
if (ctx.rowsRaw('Database').some((d) => d._team === id && !d._deleted_at)) return badRequest(ctx, 'The team should not have any active databases: delete its databases first');
|
|
104
|
+
await ctx.write('Team', id, { ...ctx.own(team), _deleted_at: ctx.occurredAt }, 'team.delete');
|
|
105
|
+
return ctx.reply('OK', 200);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const teamSemantics: Record<string, Semantics> = { createTeam, moveToTeam, addTeamMember, deleteTeamMember, deleteTeam };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { COMPLETE_AFTER_MS, DAILY_KEPT_MS, makeBackup } from "./backups.js";
|
|
2
|
+
const DAY_MS = 86_400_000;
|
|
3
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
4
|
+
/** The first 00:00 UTC strictly after an instant. */
|
|
5
|
+
const nextMidnight = (ms) => (Math.floor(ms / DAY_MS) + 1) * DAY_MS;
|
|
6
|
+
export async function catchUp(ctx) {
|
|
7
|
+
const now = Date.parse(ctx.occurredAt);
|
|
8
|
+
for (const db of ctx.rowsRaw('Database')) {
|
|
9
|
+
if (db._deleted_at || db.daily_backup_enabled !== true)
|
|
10
|
+
continue;
|
|
11
|
+
const last = ctx.rowsRaw('Backup', { withDeleted: true }).filter((k) => k.database_id === db.database_id && k.daily_backup === 'true')
|
|
12
|
+
.reduce((m, k) => Math.max(m, Date.parse(String(k._created_at))), Date.parse(String(db._daily_since)));
|
|
13
|
+
for (let due = nextMidnight(last); due <= now; due += DAY_MS) {
|
|
14
|
+
const at = await ctx.at(iso(due));
|
|
15
|
+
await makeBackup(at, db, `daily-backup-${iso(due).slice(0, 10)}`, true);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
for (const k of ctx.rowsRaw('Backup')) {
|
|
19
|
+
const made = Date.parse(String(k._created_at));
|
|
20
|
+
let row = k;
|
|
21
|
+
if (row.state === 'pending' && made + COMPLETE_AFTER_MS <= now) {
|
|
22
|
+
const at = await ctx.at(iso(made + COMPLETE_AFTER_MS));
|
|
23
|
+
if (at.legal('Backup', 'state', 'complete', 'pending', 'completed', String(k.backup_id), 'time'))
|
|
24
|
+
continue;
|
|
25
|
+
row = { ...at.own(row), state: 'completed' };
|
|
26
|
+
await at.write('Backup', String(k.backup_id), row, 'backup.complete');
|
|
27
|
+
}
|
|
28
|
+
const gone = made + COMPLETE_AFTER_MS + DAILY_KEPT_MS;
|
|
29
|
+
if (row.daily_backup === 'true' && row.state === 'completed' && gone <= now) {
|
|
30
|
+
const at = await ctx.at(iso(gone));
|
|
31
|
+
await at.write('Backup', String(k.backup_id), { ...at.own(row), _deleted_at: iso(gone) }, 'backup.expire');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// What the World clock does to the Developer API lane's records, caught up before any request to the pack is answered
|
|
2
|
+
// (index.ts, and the pack root's REST API through ../../../src/upstash-server.ts, so a backup due before a write holds the
|
|
3
|
+
// keys as they were), each written at the instant it fell due: with daily backup on, "your database will be
|
|
4
|
+
// automatically backed up every day" (https://upstash.com/docs/redis/features/backup); a backup completes, and a daily
|
|
5
|
+
// backup is gone when its retention has passed. The times are the twin's (semantics/backups.ts).
|
|
6
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
7
|
+
import { COMPLETE_AFTER_MS, DAILY_KEPT_MS, makeBackup } from './backups.ts';
|
|
8
|
+
|
|
9
|
+
const DAY_MS = 86_400_000;
|
|
10
|
+
const iso = (ms: number): string => new Date(ms).toISOString();
|
|
11
|
+
/** The first 00:00 UTC strictly after an instant. */
|
|
12
|
+
const nextMidnight = (ms: number): number => (Math.floor(ms / DAY_MS) + 1) * DAY_MS;
|
|
13
|
+
|
|
14
|
+
export async function catchUp(ctx: SemanticsContext): Promise<void> {
|
|
15
|
+
const now = Date.parse(ctx.occurredAt);
|
|
16
|
+
for (const db of ctx.rowsRaw('Database')) {
|
|
17
|
+
if (db._deleted_at || db.daily_backup_enabled !== true) continue;
|
|
18
|
+
const last = ctx.rowsRaw('Backup', { withDeleted: true }).filter((k) => k.database_id === db.database_id && k.daily_backup === 'true')
|
|
19
|
+
.reduce((m, k) => Math.max(m, Date.parse(String(k._created_at))), Date.parse(String(db._daily_since)));
|
|
20
|
+
for (let due = nextMidnight(last); due <= now; due += DAY_MS) {
|
|
21
|
+
const at = await ctx.at(iso(due));
|
|
22
|
+
await makeBackup(at, db, `daily-backup-${iso(due).slice(0, 10)}`, true);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
for (const k of ctx.rowsRaw('Backup')) {
|
|
26
|
+
const made = Date.parse(String(k._created_at));
|
|
27
|
+
let row = k;
|
|
28
|
+
if (row.state === 'pending' && made + COMPLETE_AFTER_MS <= now) {
|
|
29
|
+
const at = await ctx.at(iso(made + COMPLETE_AFTER_MS));
|
|
30
|
+
if (at.legal('Backup', 'state', 'complete', 'pending', 'completed', String(k.backup_id), 'time')) continue;
|
|
31
|
+
row = { ...at.own(row), state: 'completed' };
|
|
32
|
+
await at.write('Backup', String(k.backup_id), row, 'backup.complete');
|
|
33
|
+
}
|
|
34
|
+
const gone = made + COMPLETE_AFTER_MS + DAILY_KEPT_MS;
|
|
35
|
+
if (row.daily_backup === 'true' && row.state === 'completed' && gone <= now) {
|
|
36
|
+
const at = await ctx.at(iso(gone));
|
|
37
|
+
await at.write('Backup', String(k.backup_id), { ...at.own(row), _deleted_at: iso(gone) }, 'backup.expire');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { DerivedOperation, SemanticsContext } from '@volter/world-core';
|
|
2
|
+
import { type CatchUpReport } from './semantics/delivery.js';
|
|
3
|
+
/** The operation a door's context is named by: no spec operation is. */
|
|
4
|
+
export declare const DOOR: DerivedOperation;
|
|
5
|
+
/** A door's answer, or undefined for a request that is not a door's. `report` is what the catch-up in front did. */
|
|
6
|
+
export declare function door(ctx: SemanticsContext, request: Request, report: CatchUpReport): Promise<Response | undefined>;
|