@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,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,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,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,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,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,15 @@
|
|
|
1
|
+
import { type DerivedOwner } from '@volter/world-core';
|
|
2
|
+
export declare const API_HOST = "api.upstash.com";
|
|
3
|
+
export declare const CONSOLE_HOST = "console.upstash.com";
|
|
4
|
+
/** The lane's fetch: a request, and the vendor host it was sent to (api.upstash.com or console.upstash.com). */
|
|
5
|
+
export type UpstashRedisApiFetch = ((request: Request, host: string) => Promise<Response>) & {
|
|
6
|
+
owners(): Record<string, DerivedOwner>;
|
|
7
|
+
/** What the World clock has made due (a daily backup, a backup completing), caught up for a request another wire of
|
|
8
|
+
* the pack answers: the REST API's write lands after the night's backup, never inside it. */
|
|
9
|
+
catchUp(request: Request): Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export declare function createUpstashApiLaneFetch(options?: {
|
|
12
|
+
root?: string;
|
|
13
|
+
readOnly?: boolean;
|
|
14
|
+
clock?: () => string;
|
|
15
|
+
}): UpstashRedisApiFetch;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The Developer API lane of the upstash pack (`api.upstash.com/v2`), as a plain fetch: the pack's router
|
|
2
|
+
// (../../src/upstash-server.ts) sends every request for api.upstash.com here, and every one for
|
|
3
|
+
// console.upstash.com (its sign-in, the Account > Developer API tab, Account > Teams and a team invitation's link, and the
|
|
4
|
+
// QStash page, screens/). What the World clock has made due is caught up first (semantics/time.ts). The generated surface is Upstash's own
|
|
5
|
+
// OpenAPI document; the key gate sits in front of the dispatch (key-gate.ts), then the kernel's cross-cutting checks
|
|
6
|
+
// (read-only, a malformed body), then the handlers (semantics/). An operation this lane does not serve is the gap,
|
|
7
|
+
// answered 404 in the document's Error schema (the twin's decision: the document declares no error response).
|
|
8
|
+
import { bindSemantics, coreFor, createDerivedFetch, crossCutting, semanticsContext } from '@volter/world-core';
|
|
9
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
10
|
+
import { manifest } from "./manifest.js";
|
|
11
|
+
import { apiSemantics } from "./semantics/index.js";
|
|
12
|
+
import { keyGate } from "./key-gate.js";
|
|
13
|
+
import { developerApiPage } from "./screens/developer-api.js";
|
|
14
|
+
import { sessionFlow } from "./screens/session.js";
|
|
15
|
+
import { qstashPage } from "./screens/qstash.js";
|
|
16
|
+
import { teamsPages } from "./screens/teams.js";
|
|
17
|
+
import { catchUp } from "./semantics/time.js";
|
|
18
|
+
const SCREEN = { id: 'DeveloperApiPage', method: 'POST', path: '/account/api', class: 'action' };
|
|
19
|
+
const GATE = { id: 'TwinKeyGate', method: 'GET', path: '/v2/redis/databases', class: 'retrieve' };
|
|
20
|
+
export const API_HOST = 'api.upstash.com';
|
|
21
|
+
export const CONSOLE_HOST = 'console.upstash.com';
|
|
22
|
+
export function createUpstashApiLaneFetch(options = {}) {
|
|
23
|
+
const scope = { ...(options.root !== undefined ? { root: options.root } : {}), ...(options.clock ? { clock: options.clock } : {}) };
|
|
24
|
+
const derived = createDerivedFetch({
|
|
25
|
+
surface: surface,
|
|
26
|
+
handlers: bindSemantics(manifest, apiSemantics, scope),
|
|
27
|
+
core: coreFor(manifest, scope),
|
|
28
|
+
around: crossCutting(manifest, { readOnly: options.readOnly ?? false, ...scope }),
|
|
29
|
+
gap: () => Response.json({ code: 'not_found', message: 'Not Found' }, { status: 404 }),
|
|
30
|
+
});
|
|
31
|
+
const contextFor = (request) => semanticsContext(manifest, request, SCREEN, scope);
|
|
32
|
+
const due = async (request) => { if (!options.readOnly)
|
|
33
|
+
await catchUp(await contextFor(new Request(request.url))); };
|
|
34
|
+
return Object.assign(async (request, host) => {
|
|
35
|
+
await due(request);
|
|
36
|
+
if (host === CONSOLE_HOST) {
|
|
37
|
+
const screen = (await sessionFlow(request.clone(), await contextFor(new Request(request.url)))) ?? (await qstashPage(request.clone(), contextFor))
|
|
38
|
+
?? (await teamsPages(request.clone(), contextFor)) ?? (await developerApiPage(request, contextFor));
|
|
39
|
+
return screen ?? new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
40
|
+
}
|
|
41
|
+
const ctx = await semanticsContext(manifest, new Request(request.url, { headers: request.headers }), GATE, scope);
|
|
42
|
+
return keyGate(ctx, request) ?? derived(request);
|
|
43
|
+
}, { owners: () => derived.owners(), catchUp: due });
|
|
44
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The Developer API lane of the upstash pack (`api.upstash.com/v2`), as a plain fetch: the pack's router
|
|
2
|
+
// (../../src/upstash-server.ts) sends every request for api.upstash.com here, and every one for
|
|
3
|
+
// console.upstash.com (its sign-in, the Account > Developer API tab, Account > Teams and a team invitation's link, and the
|
|
4
|
+
// QStash page, screens/). What the World clock has made due is caught up first (semantics/time.ts). The generated surface is Upstash's own
|
|
5
|
+
// OpenAPI document; the key gate sits in front of the dispatch (key-gate.ts), then the kernel's cross-cutting checks
|
|
6
|
+
// (read-only, a malformed body), then the handlers (semantics/). An operation this lane does not serve is the gap,
|
|
7
|
+
// answered 404 in the document's Error schema (the twin's decision: the document declares no error response).
|
|
8
|
+
import { bindSemantics, coreFor, createDerivedFetch, crossCutting, semanticsContext, type DerivedOperation, type DerivedOwner, type DerivedSurface } from '@volter/world-core';
|
|
9
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
10
|
+
import { manifest } from './manifest.ts';
|
|
11
|
+
import { apiSemantics } from './semantics/index.ts';
|
|
12
|
+
import { keyGate } from './key-gate.ts';
|
|
13
|
+
import { developerApiPage } from './screens/developer-api.tsx';
|
|
14
|
+
import { sessionFlow } from './screens/session.tsx';
|
|
15
|
+
import { qstashPage } from './screens/qstash.tsx';
|
|
16
|
+
import { teamsPages } from './screens/teams.tsx';
|
|
17
|
+
import { catchUp } from './semantics/time.ts';
|
|
18
|
+
|
|
19
|
+
const SCREEN: DerivedOperation = { id: 'DeveloperApiPage', method: 'POST', path: '/account/api', class: 'action' };
|
|
20
|
+
const GATE: DerivedOperation = { id: 'TwinKeyGate', method: 'GET', path: '/v2/redis/databases', class: 'retrieve' };
|
|
21
|
+
|
|
22
|
+
export const API_HOST = 'api.upstash.com';
|
|
23
|
+
export const CONSOLE_HOST = 'console.upstash.com';
|
|
24
|
+
|
|
25
|
+
/** The lane's fetch: a request, and the vendor host it was sent to (api.upstash.com or console.upstash.com). */
|
|
26
|
+
export type UpstashRedisApiFetch = ((request: Request, host: string) => Promise<Response>) & {
|
|
27
|
+
owners(): Record<string, DerivedOwner>;
|
|
28
|
+
/** What the World clock has made due (a daily backup, a backup completing), caught up for a request another wire of
|
|
29
|
+
* the pack answers: the REST API's write lands after the night's backup, never inside it. */
|
|
30
|
+
catchUp(request: Request): Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function createUpstashApiLaneFetch(options: { root?: string; readOnly?: boolean; clock?: () => string } = {}): UpstashRedisApiFetch {
|
|
34
|
+
const scope = { ...(options.root !== undefined ? { root: options.root } : {}), ...(options.clock ? { clock: options.clock } : {}) };
|
|
35
|
+
const derived = createDerivedFetch({
|
|
36
|
+
surface: surface as DerivedSurface,
|
|
37
|
+
handlers: bindSemantics(manifest, apiSemantics, scope),
|
|
38
|
+
core: coreFor(manifest, scope),
|
|
39
|
+
around: crossCutting(manifest, { readOnly: options.readOnly ?? false, ...scope }),
|
|
40
|
+
gap: () => Response.json({ code: 'not_found', message: 'Not Found' }, { status: 404 }),
|
|
41
|
+
});
|
|
42
|
+
const contextFor = (request: Request) => semanticsContext(manifest, request, SCREEN, scope);
|
|
43
|
+
const due = async (request: Request): Promise<void> => { if (!options.readOnly) await catchUp(await contextFor(new Request(request.url))); };
|
|
44
|
+
return Object.assign(async (request: Request, host: string): Promise<Response> => {
|
|
45
|
+
await due(request);
|
|
46
|
+
if (host === CONSOLE_HOST) {
|
|
47
|
+
const screen = (await sessionFlow(request.clone(), await contextFor(new Request(request.url)))) ?? (await qstashPage(request.clone(), contextFor))
|
|
48
|
+
?? (await teamsPages(request.clone(), contextFor)) ?? (await developerApiPage(request, contextFor));
|
|
49
|
+
return screen ?? new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
50
|
+
}
|
|
51
|
+
const ctx = await semanticsContext(manifest, new Request(request.url, { headers: request.headers }), GATE, scope);
|
|
52
|
+
return keyGate(ctx, request) ?? derived(request);
|
|
53
|
+
}, { owners: () => derived.owners(), catchUp: due });
|
|
54
|
+
}
|