@volter/twin-planetscale 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 +473 -0
- package/api/src/fetch.ts +50 -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/manifest.ts +136 -0
- package/api/src/screens/deploy-request.tsx +111 -0
- package/api/src/screens/service-tokens.tsx +141 -0
- package/api/src/screens/session.tsx +117 -0
- package/api/src/semantics/audit.ts +82 -0
- package/api/src/semantics/backups.ts +258 -0
- package/api/src/semantics/branches.ts +201 -0
- package/api/src/semantics/deploy-requests.ts +493 -0
- package/api/src/semantics/index.ts +371 -0
- package/api/src/semantics/shared.ts +141 -0
- package/api/src/semantics/time.ts +77 -0
- package/api/src/token-gate.ts +96 -0
- package/dist/api/src/fetch.d.ts +8 -0
- package/dist/api/src/fetch.js +51 -0
- package/dist/api/src/fetch.ts +50 -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/manifest.d.ts +2 -0
- package/dist/api/src/manifest.js +113 -0
- package/dist/api/src/manifest.ts +136 -0
- package/dist/api/src/screens/deploy-request.d.ts +7 -0
- package/dist/api/src/screens/deploy-request.js +106 -0
- package/dist/api/src/screens/deploy-request.tsx +111 -0
- package/dist/api/src/screens/service-tokens.d.ts +3 -0
- package/dist/api/src/screens/service-tokens.js +134 -0
- package/dist/api/src/screens/service-tokens.tsx +141 -0
- package/dist/api/src/screens/session.d.ts +11 -0
- package/dist/api/src/screens/session.js +108 -0
- package/dist/api/src/screens/session.tsx +117 -0
- package/dist/api/src/semantics/audit.d.ts +31 -0
- package/dist/api/src/semantics/audit.js +80 -0
- package/dist/api/src/semantics/audit.ts +82 -0
- package/dist/api/src/semantics/backups.d.ts +37 -0
- package/dist/api/src/semantics/backups.js +264 -0
- package/dist/api/src/semantics/backups.ts +258 -0
- package/dist/api/src/semantics/branches.d.ts +53 -0
- package/dist/api/src/semantics/branches.js +197 -0
- package/dist/api/src/semantics/branches.ts +201 -0
- package/dist/api/src/semantics/deploy-requests.d.ts +47 -0
- package/dist/api/src/semantics/deploy-requests.js +491 -0
- package/dist/api/src/semantics/deploy-requests.ts +493 -0
- package/dist/api/src/semantics/index.d.ts +20 -0
- package/dist/api/src/semantics/index.js +381 -0
- package/dist/api/src/semantics/index.ts +371 -0
- package/dist/api/src/semantics/shared.d.ts +36 -0
- package/dist/api/src/semantics/shared.js +132 -0
- package/dist/api/src/semantics/shared.ts +141 -0
- package/dist/api/src/semantics/time.d.ts +2 -0
- package/dist/api/src/semantics/time.js +81 -0
- package/dist/api/src/semantics/time.ts +77 -0
- package/dist/api/src/token-gate.d.ts +9 -0
- package/dist/api/src/token-gate.js +97 -0
- package/dist/api/src/token-gate.ts +96 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +61 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +26 -0
- package/dist/src/index.js +156 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +41 -0
- package/dist/src/planetscale-budget.d.ts +78 -0
- package/dist/src/planetscale-budget.js +305 -0
- package/dist/src/planetscale-capabilities.d.ts +10 -0
- package/dist/src/planetscale-capabilities.js +3977 -0
- package/dist/src/planetscale-collation-weights.gen.d.ts +4 -0
- package/dist/src/planetscale-collation-weights.gen.js +12 -0
- package/dist/src/planetscale-collation.d.ts +70 -0
- package/dist/src/planetscale-collation.js +391 -0
- package/dist/src/planetscale-conformance.d.ts +8 -0
- package/dist/src/planetscale-conformance.js +213 -0
- package/dist/src/planetscale-connector.d.ts +150 -0
- package/dist/src/planetscale-connector.js +532 -0
- package/dist/src/planetscale-deploy.d.ts +26 -0
- package/dist/src/planetscale-deploy.js +235 -0
- package/dist/src/planetscale-information-schema.d.ts +32 -0
- package/dist/src/planetscale-information-schema.js +299 -0
- package/dist/src/planetscale-mysql.d.ts +33 -0
- package/dist/src/planetscale-mysql.js +547 -0
- package/dist/src/planetscale-roles.d.ts +11 -0
- package/dist/src/planetscale-roles.js +60 -0
- package/dist/src/planetscale-row.d.ts +12 -0
- package/dist/src/planetscale-row.js +39 -0
- package/dist/src/planetscale-server.d.ts +42 -0
- package/dist/src/planetscale-server.js +137 -0
- package/dist/src/planetscale-sql.d.ts +701 -0
- package/dist/src/planetscale-sql.js +7167 -0
- package/dist/src/planetscale-store.d.ts +126 -0
- package/dist/src/planetscale-store.js +827 -0
- package/dist/src/planetscale-twin.d.ts +48 -0
- package/dist/src/planetscale-twin.js +290 -0
- package/dist/src/planetscale-values.d.ts +139 -0
- package/dist/src/planetscale-values.js +719 -0
- package/dist/src/planetscale-wire.d.ts +110 -0
- package/dist/src/planetscale-wire.js +188 -0
- package/dist/src/semantics/psdb.d.ts +18 -0
- package/dist/src/semantics/psdb.js +30 -0
- package/package.json +58 -0
- package/src/cli.ts +58 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +267 -0
- package/src/manifest.ts +60 -0
- package/src/planetscale-budget.ts +347 -0
- package/src/planetscale-capabilities.ts +3862 -0
- package/src/planetscale-collation-weights.gen.ts +13 -0
- package/src/planetscale-collation.ts +378 -0
- package/src/planetscale-conformance.ts +237 -0
- package/src/planetscale-connector.ts +571 -0
- package/src/planetscale-deploy.ts +197 -0
- package/src/planetscale-information-schema.ts +322 -0
- package/src/planetscale-mysql.ts +339 -0
- package/src/planetscale-roles.ts +71 -0
- package/src/planetscale-row.ts +43 -0
- package/src/planetscale-server.ts +162 -0
- package/src/planetscale-sql.ts +5957 -0
- package/src/planetscale-store.ts +869 -0
- package/src/planetscale-twin.ts +338 -0
- package/src/planetscale-values.ts +572 -0
- package/src/planetscale-wire.ts +274 -0
- package/src/semantics/psdb.ts +57 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// The management lane's handlers, by operationId. Where PlanetScale's reference stops and the twin decides, the comment
|
|
2
|
+
// beside the rule says so.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { dropAllTables } from "../../../src/planetscale-store.js";
|
|
5
|
+
import { lacks } from "../token-gate.js";
|
|
6
|
+
import { branchSemantics, branchesOf, makeBranch, removeBranch } from "./branches.js";
|
|
7
|
+
import { deployRequestSemantics } from "./deploy-requests.js";
|
|
8
|
+
import { backupPolicySemantics, makeDefaultPolicy } from "./backups.js";
|
|
9
|
+
import { audit, createdById, listAuditLogs, orgOf, personOf } from "./audit.js";
|
|
10
|
+
import { accessOf, actorOf, AVATAR, branchRef, databaseOf, mintId, mintSecret, page, REGION } from "./shared.js";
|
|
11
|
+
const unprocessable = (ctx, message) => ctx.refuse({ status: 422, code: 'unprocessable_entity', message });
|
|
12
|
+
const body = (ctx) => (ctx.body && typeof ctx.body === 'object' && !Array.isArray(ctx.body) ? ctx.body : {});
|
|
13
|
+
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
|
|
14
|
+
/** Which database a password or backup belongs to: a created database's name, or '' for the World's own. */
|
|
15
|
+
const databaseKey = (record) => (record ? String(record.name) : '');
|
|
16
|
+
const newestFirst = (rows) => [...rows].sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
|
|
17
|
+
// ── databases ───────────────────────────────────────────────────────────────────────────────
|
|
18
|
+
/** create_database. `name` and `cluster_size` are required (the spec's body schema). Where the reference stops: a name
|
|
19
|
+
* the organization already has, and a missing required field, answer 422 naming the field. The database is ready at once
|
|
20
|
+
* (manifest.ts, its state machine), with one production branch, `main`. */
|
|
21
|
+
const createDatabase = async (ctx) => {
|
|
22
|
+
const b = body(ctx);
|
|
23
|
+
const name = typeof b.name === 'string' ? b.name.trim() : '';
|
|
24
|
+
if (!name)
|
|
25
|
+
return unprocessable(ctx, 'name is required');
|
|
26
|
+
if (typeof b.cluster_size !== 'string' || !b.cluster_size)
|
|
27
|
+
return unprocessable(ctx, 'cluster_size is required');
|
|
28
|
+
if (ctx.rowsRaw('Database').some((d) => d.name === name && !d._deleted_at))
|
|
29
|
+
return unprocessable(ctx, 'Name has already been taken');
|
|
30
|
+
const org = String(ctx.call.params.organization);
|
|
31
|
+
const id = mintId(ctx, 'Database', 'db', name);
|
|
32
|
+
const at = ctx.occurredAt;
|
|
33
|
+
const record = await ctx.write('Database', id, {
|
|
34
|
+
id, name, kind: typeof b.kind === 'string' ? b.kind : 'mysql', state: 'pending', ready: false, plan: 'scaler_pro',
|
|
35
|
+
region: REGION(typeof b.region === 'string' && b.region ? b.region : 'us-east'),
|
|
36
|
+
default_branch: 'main', branches_count: 1, production_branches_count: 1, development_branches_count: 0, issues_count: 0,
|
|
37
|
+
sharded: false, created_at: at, updated_at: at, schema_last_updated_at: null,
|
|
38
|
+
// the rest of what the spec requires of a Database, as a new database has it: one unsharded production branch with
|
|
39
|
+
// no tables, read-only regions or schema recommendations, and every optional setting off (where the reference
|
|
40
|
+
// stops: it gives no defaults for these settings, and the twin answers each off)
|
|
41
|
+
default_branch_shard_count: 1, default_branch_read_only_regions_count: 0, default_branch_table_count: 0, open_schema_recommendations_count: 0,
|
|
42
|
+
at_backup_restore_branches_limit: false, at_development_branch_usage_limit: false, multiple_admins_required_for_deletion: false,
|
|
43
|
+
require_approval_for_deploy: false, deletion_protected: false, resizing: false, resize_queued: false, config_changing: false,
|
|
44
|
+
config_change_queued: false, allow_data_branching: false, foreign_keys_enabled: false, restrict_branch_region: false,
|
|
45
|
+
prefer_instant: false, insights_raw_queries: false, insights_enabled: false, production_branch_web_console: false,
|
|
46
|
+
html_url: `https://app.planetscale.com/${org}/${name}`, url: `https://api.planetscale.com/v1/organizations/${org}/databases/${name}`,
|
|
47
|
+
branches_url: `https://api.planetscale.com/v1/organizations/${org}/databases/${name}/branches`,
|
|
48
|
+
_cluster_size: b.cluster_size, _organization: org,
|
|
49
|
+
}, 'database.create');
|
|
50
|
+
await audit(ctx, org, 'database', 'created', { type: 'Database', id, name });
|
|
51
|
+
// "a single production branch is created called `main` and acts as the default branch"
|
|
52
|
+
// (https://planetscale.com/docs/vitess/schema-changes/branching); it turns ready with its database
|
|
53
|
+
await makeBranch(ctx, record, { name: 'main', parent: null, production: true, parentSchema: [] });
|
|
54
|
+
// "Our Base plan includes automated backups every 12 hours" (https://planetscale.com/docs/vitess/backups)
|
|
55
|
+
await makeDefaultPolicy(ctx, name);
|
|
56
|
+
await grantCreatorAccess(ctx, name);
|
|
57
|
+
return ctx.reply(record, 201);
|
|
58
|
+
};
|
|
59
|
+
/** Every database access permission, as the service tokens reference lists them
|
|
60
|
+
* (https://planetscale.com/docs/api/reference/service-tokens, "Database access permissions"). */
|
|
61
|
+
const DATABASE_ACCESSES = ['read_database', 'write_database', 'delete_database', 'create_branch', 'read_branch', 'delete_branch',
|
|
62
|
+
'delete_production_branch', 'connect_branch', 'connect_production_branch', 'delete_branch_password', 'delete_production_branch_password',
|
|
63
|
+
'create_deploy_request', 'read_deploy_request', 'approve_deploy_request', 'create_comment', 'read_comment', 'read_backups', 'write_backups',
|
|
64
|
+
'delete_backups', 'delete_production_branch_backups', 'restore_backup', 'restore_production_branch_backup', 'write_branch_vschema',
|
|
65
|
+
'write_production_branch_vschema'];
|
|
66
|
+
/** "When a service token creates a database, it is automatically granted database access permissions for that database"
|
|
67
|
+
* (https://planetscale.com/docs/api/service-tokens, "Creating databases with service tokens"): the token that asked gets
|
|
68
|
+
* every database access on the new database. A token no lane record names (the World's own) holds everything already. */
|
|
69
|
+
async function grantCreatorAccess(ctx, database) {
|
|
70
|
+
const id = /^([^:\s]+):/.exec(ctx.call.request.headers.get('authorization') ?? '')?.[1];
|
|
71
|
+
const token = id === undefined ? undefined : ctx.rowsRaw('ServiceToken').find((t) => t.id === id);
|
|
72
|
+
if (!token)
|
|
73
|
+
return;
|
|
74
|
+
const held = (token.service_token_accesses ?? []);
|
|
75
|
+
const granted = DATABASE_ACCESSES.filter((access) => !held.some((a) => a.access === access && a.resource_type === 'Database' && a.resource_name === database))
|
|
76
|
+
.map((access, i) => accessOf(ctx, `${String(token.id)}-${held.length + i + 1}`, access, 'Database', database));
|
|
77
|
+
await ctx.write('ServiceToken', String(token.id), { ...ctx.own(token), service_token_accesses: [...held, ...granted], updated_at: ctx.occurredAt }, 'service_token.grant');
|
|
78
|
+
}
|
|
79
|
+
const getDatabase = async (ctx) => {
|
|
80
|
+
const name = String(ctx.call.params.database);
|
|
81
|
+
const record = ctx.rowsRaw('Database').find((d) => d.name === name && !d._deleted_at);
|
|
82
|
+
return record ? ctx.reply(ctx.get('Database', String(record.id))) : ctx.notFound('Database', name, 'database');
|
|
83
|
+
};
|
|
84
|
+
/** delete_database. Deleting a database "will: Delete the entire database and all of its branches / Remove all data and
|
|
85
|
+
* backups permanently / Disconnect any applications currently connected to the database"
|
|
86
|
+
* (https://planetscale.com/docs/vitess/delete-a-database). So its passwords and backups are deleted with it, its tables
|
|
87
|
+
* and rows are dropped, and psdb refuses its passwords from then on (the pack root's auth). A name no created database
|
|
88
|
+
* has is not found: the World's own database is not deleted through the API (the twin's decision). */
|
|
89
|
+
const deleteDatabase = async (ctx) => {
|
|
90
|
+
const name = String(ctx.call.params.database);
|
|
91
|
+
const record = ctx.rowsRaw('Database').find((d) => d.name === name && !d._deleted_at);
|
|
92
|
+
if (!record)
|
|
93
|
+
return ctx.notFound('Database', name, 'database');
|
|
94
|
+
// "Whether deletion protection is enabled for the database" (the spec's update_database_settings): a protected database
|
|
95
|
+
// is not deleted. Where the reference stops: the refusal's status and words, the twin's 422 naming the rule
|
|
96
|
+
if (record.deletion_protected === true)
|
|
97
|
+
return unprocessable(ctx, 'Deletion protection is enabled for this database; turn it off in its settings first');
|
|
98
|
+
const at = ctx.occurredAt;
|
|
99
|
+
for (const p of ctx.rowsRaw('DatabaseBranchPassword'))
|
|
100
|
+
if (p._database === name && p.deleted_at === null)
|
|
101
|
+
await ctx.write('DatabaseBranchPassword', String(p.id), { ...ctx.own(p), deleted_at: at }, 'password.delete');
|
|
102
|
+
for (const k of ctx.rowsRaw('Backup'))
|
|
103
|
+
if (k._database === name && k.deleted_at === null)
|
|
104
|
+
await ctx.write('Backup', String(k.id), { ...ctx.own(k), deleted_at: at, updated_at: at }, 'backup.delete');
|
|
105
|
+
for (const b of branchesOf(ctx, name))
|
|
106
|
+
if (b.name !== 'main')
|
|
107
|
+
await removeBranch(ctx, name, b);
|
|
108
|
+
await dropAllTables({ ...(ctx.root !== undefined ? { root: ctx.root } : {}), occurredAt: at });
|
|
109
|
+
for (const b of branchesOf(ctx, name))
|
|
110
|
+
await ctx.write('DatabaseBranch', String(b.id), { ...ctx.own(b), deleted_at: at, updated_at: at }, 'branch.delete');
|
|
111
|
+
for (const p of ctx.rowsRaw('BackupPolicy'))
|
|
112
|
+
if (p._database === name && !p._deleted_at)
|
|
113
|
+
await ctx.write('BackupPolicy', String(p.id), { ...ctx.own(p), updated_at: at, _deleted_at: at }, 'backup_policy.delete');
|
|
114
|
+
await ctx.write('Database', String(record.id), { ...ctx.own(record), updated_at: at, _deleted_at: at }, 'database.delete');
|
|
115
|
+
await audit(ctx, orgOf(ctx), 'database', 'deleted', { type: 'Database', id: String(record.id), name });
|
|
116
|
+
return new Response(null, { status: 204 });
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* update_database_settings: the body's settings, each stored as given (the spec's body; `new_name` renames). The life's
|
|
120
|
+
* use is `require_approval_for_deploy`: "Whether or not deploy requests must be approved by a database administrator
|
|
121
|
+
* other than the request creator" (the spec), which the deploy requests read (semantics/deploy-requests.ts). A field of
|
|
122
|
+
* the body the spec does not have is refused at the edge; the rest are stored with no further effect (the twin's reading).
|
|
123
|
+
*/
|
|
124
|
+
const SETTINGS = {
|
|
125
|
+
new_name: 'string', automatic_migrations: 'boolean', migration_framework: 'string', migration_table_name: 'string',
|
|
126
|
+
require_approval_for_deploy: 'boolean', restrict_branch_region: 'boolean', allow_data_branching: 'boolean',
|
|
127
|
+
allow_foreign_key_constraints: 'boolean', insights_raw_queries: 'boolean', deletion_protected: 'boolean', production_branch_web_console: 'boolean', default_branch: 'string',
|
|
128
|
+
};
|
|
129
|
+
const updateDatabaseSettings = async (ctx) => {
|
|
130
|
+
const name = String(ctx.call.params.database);
|
|
131
|
+
const record = ctx.rowsRaw('Database').find((d) => d.name === name && !d._deleted_at);
|
|
132
|
+
if (!record)
|
|
133
|
+
return ctx.notFound('Database', name, 'database');
|
|
134
|
+
const b = body(ctx);
|
|
135
|
+
const changes = {};
|
|
136
|
+
for (const [k, v] of Object.entries(b)) {
|
|
137
|
+
const type = SETTINGS[k];
|
|
138
|
+
if (type === undefined)
|
|
139
|
+
return unprocessable(ctx, `${k} is not a database setting`);
|
|
140
|
+
if (typeof v !== type)
|
|
141
|
+
return unprocessable(ctx, `${k} must be a ${type}`);
|
|
142
|
+
if (k === 'new_name')
|
|
143
|
+
continue;
|
|
144
|
+
// the default branch must be one of the database's branches (the branching page's "Default branches")
|
|
145
|
+
if (k === 'default_branch' && !ctx.rowsRaw('DatabaseBranch').some((x) => x._database === name && x.name === v && x.deleted_at === null))
|
|
146
|
+
return unprocessable(ctx, `default_branch ${String(v)} is not a branch of ${name}`);
|
|
147
|
+
changes[k === 'allow_foreign_key_constraints' ? 'foreign_keys_enabled' : k] = v;
|
|
148
|
+
}
|
|
149
|
+
if (typeof b.new_name === 'string' && b.new_name !== name)
|
|
150
|
+
return unprocessable(ctx, 'Renaming a database is not modelled by the twin');
|
|
151
|
+
await ctx.write('Database', String(record.id), { ...ctx.own(record), ...changes, updated_at: ctx.occurredAt }, 'database.update_settings');
|
|
152
|
+
return ctx.reply(ctx.get('Database', String(record.id)));
|
|
153
|
+
};
|
|
154
|
+
// ── passwords ───────────────────────────────────────────────────────────────────────────────
|
|
155
|
+
const ROLES = ['reader', 'writer', 'admin', 'readwriter'];
|
|
156
|
+
/** create_password. The role is one of the four the spec names. The reference's request body does not mark `role`
|
|
157
|
+
* required and gives it no default (https://planetscale.com/docs/api/reference/create_password: "role enum<string> The
|
|
158
|
+
* database role of the password (i.e. admin)"; only the response marks it required, and the API states no default).
|
|
159
|
+
* When none is sent the twin takes the CLI flag's documented default, inferred for the API: `reader` for a replica
|
|
160
|
+
* password, otherwise `admin` (https://planetscale.com/docs/cli/password, --role: "Defaults to `reader` for `--replica`
|
|
161
|
+
* and `--read-only-region`, otherwise `admin`"). The answer carries `plain_text`, which "is shown only once and cannot be
|
|
162
|
+
* retrieved later" (https://planetscale.com/docs/api/reference/create_password): the twin keeps only its SHA-256, which
|
|
163
|
+
* psdb checks a Basic credential against. */
|
|
164
|
+
const createPassword = async (ctx) => {
|
|
165
|
+
const db = databaseOf(ctx);
|
|
166
|
+
if (db.refused)
|
|
167
|
+
return db.refused;
|
|
168
|
+
const b = body(ctx);
|
|
169
|
+
// the CLI flag's documented default, inferred for the API, which states none (https://planetscale.com/docs/cli/password,
|
|
170
|
+
// --role: "Defaults to `reader` for `--replica` and `--read-only-region`, otherwise `admin`")
|
|
171
|
+
const role = b.role === undefined ? (b.replica === true ? 'reader' : 'admin') : String(b.role);
|
|
172
|
+
if (!ROLES.includes(role))
|
|
173
|
+
return unprocessable(ctx, `role must be one of ${ROLES.join(', ')}`);
|
|
174
|
+
const name = typeof b.name === 'string' && b.name ? b.name : undefined;
|
|
175
|
+
// a production branch's password needs `connect_production_branch`, any other branch's `connect_branch` ("Connect to a
|
|
176
|
+
// database branch" / "Connect to a production database branch", https://planetscale.com/docs/api/reference/service-tokens)
|
|
177
|
+
if (db.branch) {
|
|
178
|
+
const denied = lacks(ctx, String(db.record.name), db.branch.production === true ? ['connect_production_branch', 'connect_production_read_only_branch'] : ['connect_branch']);
|
|
179
|
+
if (denied)
|
|
180
|
+
return denied;
|
|
181
|
+
}
|
|
182
|
+
if (b.ttl !== undefined && (!Number.isInteger(b.ttl) || Number(b.ttl) < 0))
|
|
183
|
+
return unprocessable(ctx, 'ttl must be a whole number of seconds');
|
|
184
|
+
const ttl = typeof b.ttl === 'number' && b.ttl > 0 ? b.ttl : null;
|
|
185
|
+
const id = mintId(ctx, 'DatabaseBranchPassword', 'pw', name ?? role);
|
|
186
|
+
const username = mintSecret('', `username:${ctx.occurredAt}:${id}`, 12).toLowerCase().replace(/[^a-z0-9]/g, 'x');
|
|
187
|
+
const plainText = mintSecret('pscale_pw_', `password:${ctx.occurredAt}:${id}`, 43);
|
|
188
|
+
const at = ctx.occurredAt;
|
|
189
|
+
const record = await ctx.write('DatabaseBranchPassword', id, {
|
|
190
|
+
id, name: name ?? `${role}-${id.slice(2, 8)}`, role, username, plain_text: null,
|
|
191
|
+
access_host_url: 'aws.connect.psdb.cloud', access_host_regional_url: 'aws.connect.psdb.cloud', access_host_regional_urls: [],
|
|
192
|
+
actor: actorOf(ctx), database_branch: branchRef(ctx, db.branch), region: REGION(),
|
|
193
|
+
// "Time to live (in seconds) for the password. The password will be invalid when TTL has passed" (the spec)
|
|
194
|
+
created_at: at, deleted_at: null, expires_at: ttl === null ? null : new Date(Date.parse(at) + ttl * 1000).toISOString(), expired: false, last_used_at: null, replica: b.replica === true,
|
|
195
|
+
direct_vtgate_addresses: [], read_only_region: false,
|
|
196
|
+
renewable: false, ttl_seconds: ttl, cidrs: Array.isArray(b.cidrs) ? b.cidrs : null, direct_vtgate: b.direct_vtgate === true,
|
|
197
|
+
_database: databaseKey(db.record), _secret_sha256: sha256(plainText),
|
|
198
|
+
}, 'password.create');
|
|
199
|
+
if (db.record)
|
|
200
|
+
await audit(ctx, orgOf(ctx), 'database_branch_password', 'created', { type: 'DatabaseBranchPassword', id, name: String(record.name) }, { auditable: { type: 'Database', id: String(db.record.id), name: String(db.record.name) } });
|
|
201
|
+
return ctx.reply({ ...record, plain_text: plainText }, 201);
|
|
202
|
+
};
|
|
203
|
+
const passwordsOf = (ctx, record) => ctx.rowsRaw('DatabaseBranchPassword').filter((p) => p._database === databaseKey(record) && p.database_branch?.name === ctx.call.params.branch);
|
|
204
|
+
const listPasswords = async (ctx) => {
|
|
205
|
+
const db = databaseOf(ctx);
|
|
206
|
+
if (db.refused)
|
|
207
|
+
return db.refused;
|
|
208
|
+
const q = new URL(ctx.call.request.url).searchParams.get('q');
|
|
209
|
+
const rows = passwordsOf(ctx, db.record).filter((p) => p.deleted_at === null && (!q || String(p.name).includes(q)));
|
|
210
|
+
return page(ctx, newestFirst(rows).map((p) => ctx.get('DatabaseBranchPassword', String(p.id))));
|
|
211
|
+
};
|
|
212
|
+
const getPassword = async (ctx) => {
|
|
213
|
+
const db = databaseOf(ctx);
|
|
214
|
+
if (db.refused)
|
|
215
|
+
return db.refused;
|
|
216
|
+
const p = passwordsOf(ctx, db.record).find((x) => x.id === ctx.id && x.deleted_at === null);
|
|
217
|
+
return p ? ctx.reply(ctx.get('DatabaseBranchPassword', String(p.id))) : ctx.notFound('DatabaseBranchPassword', String(ctx.id), 'id');
|
|
218
|
+
};
|
|
219
|
+
/** delete_password: the password stops authenticating at once (psdb reads `deleted_at`). */
|
|
220
|
+
const deletePassword = async (ctx) => {
|
|
221
|
+
const db = databaseOf(ctx);
|
|
222
|
+
if (db.refused)
|
|
223
|
+
return db.refused;
|
|
224
|
+
const p = passwordsOf(ctx, db.record).find((x) => x.id === ctx.id && x.deleted_at === null);
|
|
225
|
+
if (!p)
|
|
226
|
+
return ctx.notFound('DatabaseBranchPassword', String(ctx.id), 'id');
|
|
227
|
+
await ctx.write('DatabaseBranchPassword', String(p.id), { ...ctx.own(p), deleted_at: ctx.occurredAt }, 'password.delete');
|
|
228
|
+
if (db.record)
|
|
229
|
+
await audit(ctx, orgOf(ctx), 'database_branch_password', 'deleted', { type: 'DatabaseBranchPassword', id: String(p.id), name: String(p.name) }, { auditable: { type: 'Database', id: String(db.record.id), name: String(db.record.name) } });
|
|
230
|
+
return new Response(null, { status: 204 });
|
|
231
|
+
};
|
|
232
|
+
// ── backups ─────────────────────────────────────────────────────────────────────────────────
|
|
233
|
+
// From PlanetScale's API reference: create takes `name`, `retention_unit` (hour, day, week, month, year), `retention_value`
|
|
234
|
+
// ("Value between `1` and `1000`") and `emergency` ("Emergency backups are only supported for PostgreSQL databases"), and
|
|
235
|
+
// answers 201 with the backup, `pending` (its example), then polled with get_backup; the list takes `all` ("Whether to include all backups, including deleted ones"), `state`,
|
|
236
|
+
// `policy`, `from`, `to`, `running_at` and `production`, paged by `page` and `per_page`. Where the reference stops and the
|
|
237
|
+
// twin decides: a backup runs and completes on the World clock (manifest.ts, semantics/time.ts), its size the bytes of the
|
|
238
|
+
// branch's stored rows when it completes, its storage cost 0, expiring its retention (default 7 days) after it completes
|
|
239
|
+
// (the backups pages do not say from when retention counts), on no backup policy; the list is newest first; a deleted backup is
|
|
240
|
+
// still read by its id (with its deleted_at) and listed only with `all`. Dub's backup cron
|
|
241
|
+
// (apps/web/app/(ee)/api/cron/trigger-backup) creates one four times a day.
|
|
242
|
+
const UNITS = { hour: 3_600_000, day: 86_400_000, week: 604_800_000, month: 2_592_000_000, year: 31_536_000_000 };
|
|
243
|
+
/** The bytes of the branch's stored rows: what a backup of it holds. */
|
|
244
|
+
export function branchBytes(ctx) {
|
|
245
|
+
return ctx.tree().filter((r) => r.type === 'row' && r.deleted !== true).map((r) => ctx.own(r))
|
|
246
|
+
.filter((f) => f.gone !== true && f.deleted !== true).reduce((n, f) => n + Buffer.byteLength(JSON.stringify(f.cells ?? {})), 0);
|
|
247
|
+
}
|
|
248
|
+
const backupsOf = (ctx, record) => ctx.rowsRaw('Backup').filter((k) => (k._database ?? '') === databaseKey(record));
|
|
249
|
+
const createBackup = async (ctx) => {
|
|
250
|
+
const db = databaseOf(ctx);
|
|
251
|
+
if (db.refused)
|
|
252
|
+
return db.refused;
|
|
253
|
+
const b = body(ctx);
|
|
254
|
+
const unit = b.retention_unit === undefined ? 'day' : String(b.retention_unit);
|
|
255
|
+
const value = b.retention_value === undefined ? 7 : Number(b.retention_value);
|
|
256
|
+
if (!(unit in UNITS))
|
|
257
|
+
return unprocessable(ctx, 'retention_unit must be one of hour, day, week, month, year');
|
|
258
|
+
if (!Number.isInteger(value) || value < 1 || value > 1000)
|
|
259
|
+
return unprocessable(ctx, 'retention_value must be between 1 and 1000');
|
|
260
|
+
if (b.emergency === true)
|
|
261
|
+
return unprocessable(ctx, 'Emergency backups are only supported for PostgreSQL databases');
|
|
262
|
+
const at = ctx.occurredAt;
|
|
263
|
+
const n = backupsOf(ctx, db.record).length + 1;
|
|
264
|
+
const id = mintId(ctx, 'Backup', 'bk', String(b.name ?? ''));
|
|
265
|
+
const record = await ctx.write('Backup', id, {
|
|
266
|
+
id, name: typeof b.name === 'string' && b.name ? b.name : `backup-${n}`, state: 'pending', size: 0, estimated_storage_cost: 0,
|
|
267
|
+
created_at: at, updated_at: at, started_at: null, completed_at: null, expires_at: null, deleted_at: null,
|
|
268
|
+
pvc_size: 0, uncompressed_size: 0, protected: false, required: false, restored_branches: [],
|
|
269
|
+
actor: actorOf(ctx), backup_policy: null, schema_snapshot: null, database_branch: branchRef(ctx, db.branch),
|
|
270
|
+
_database: databaseKey(db.record), _retention_ms: value * UNITS[unit],
|
|
271
|
+
}, 'backup.create');
|
|
272
|
+
return ctx.reply(record, 201);
|
|
273
|
+
};
|
|
274
|
+
const listBackups = async (ctx) => {
|
|
275
|
+
const db = databaseOf(ctx);
|
|
276
|
+
if (db.refused)
|
|
277
|
+
return db.refused;
|
|
278
|
+
const q = new URL(ctx.call.request.url).searchParams;
|
|
279
|
+
const state = q.get('state');
|
|
280
|
+
const all = q.get('all') === 'true';
|
|
281
|
+
const time = (v) => (typeof v === 'string' ? Date.parse(v) : NaN);
|
|
282
|
+
const from = q.has('from') ? time(q.get('from')) : undefined;
|
|
283
|
+
const to = q.has('to') ? time(q.get('to')) : undefined;
|
|
284
|
+
const [runFrom, runTo] = q.has('running_at') ? (q.get('running_at') ?? '').split('..').map(time) : [undefined, undefined];
|
|
285
|
+
if ([from, to, runFrom].some((v) => v !== undefined && Number.isNaN(v)) || (runTo !== undefined && Number.isNaN(runTo)))
|
|
286
|
+
return unprocessable(ctx, 'from, to and running_at take ISO-8601 times (running_at as <from>..<to>)');
|
|
287
|
+
const rows = backupsOf(ctx, db.record).filter((k) => {
|
|
288
|
+
const started = time(k.started_at);
|
|
289
|
+
const completed = time(k.completed_at);
|
|
290
|
+
return (all || k.deleted_at === null) && (!state || k.state === state)
|
|
291
|
+
&& (!q.has('policy') || k.backup_policy?.id === q.get('policy'))
|
|
292
|
+
&& (!q.has('production') || q.get('production') === 'true')
|
|
293
|
+
&& (from === undefined || started > from) && (to === undefined || started < to)
|
|
294
|
+
&& (runFrom === undefined || (started <= (runTo ?? runFrom) && completed >= runFrom));
|
|
295
|
+
});
|
|
296
|
+
// newest first, by when each was made
|
|
297
|
+
const newest = rows.map((k, i) => ({ k, i })).sort((x, y) => String(y.k.created_at).localeCompare(String(x.k.created_at)) || y.i - x.i).map((x) => x.k);
|
|
298
|
+
return page(ctx, newest.map((k) => ctx.get('Backup', String(k.id))));
|
|
299
|
+
};
|
|
300
|
+
const getBackup = async (ctx) => {
|
|
301
|
+
const db = databaseOf(ctx);
|
|
302
|
+
if (db.refused)
|
|
303
|
+
return db.refused;
|
|
304
|
+
const k = backupsOf(ctx, db.record).find((x) => x.id === ctx.id);
|
|
305
|
+
return k ? ctx.reply(ctx.get('Backup', String(k.id))) : ctx.notFound('Backup', String(ctx.id), 'id');
|
|
306
|
+
};
|
|
307
|
+
const deleteBackup = async (ctx) => {
|
|
308
|
+
const db = databaseOf(ctx);
|
|
309
|
+
if (db.refused)
|
|
310
|
+
return db.refused;
|
|
311
|
+
const k = backupsOf(ctx, db.record).find((x) => x.id === ctx.id && x.deleted_at === null);
|
|
312
|
+
if (!k)
|
|
313
|
+
return ctx.notFound('Backup', String(ctx.id), 'id');
|
|
314
|
+
await ctx.write('Backup', String(k.id), { ...ctx.own(k), deleted_at: ctx.occurredAt, updated_at: ctx.occurredAt }, 'backup.delete');
|
|
315
|
+
return new Response(null, { status: 204 });
|
|
316
|
+
};
|
|
317
|
+
// ── service tokens ──────────────────────────────────────────────────────────────────────────
|
|
318
|
+
/** A service token as the API answers it, its secret left out ("The plaintext token. Available only after create."). */
|
|
319
|
+
export async function makeServiceToken(ctx, name, accesses, createdBy, organization = orgOf(ctx), ttl) {
|
|
320
|
+
const id = mintId(ctx, 'ServiceToken', '', name);
|
|
321
|
+
const token = mintSecret('pscale_tkn_', `token:${ctx.occurredAt}:${id}`, 43);
|
|
322
|
+
const at = ctx.occurredAt;
|
|
323
|
+
const record = await ctx.write('ServiceToken', id, {
|
|
324
|
+
id, name, display_name: name, token: null, plain_text_refresh_token: null, avatar_url: AVATAR,
|
|
325
|
+
// "Time to live (in seconds) for the service token. The token will be invalid when TTL has passed" (the spec)
|
|
326
|
+
created_at: at, updated_at: at, expires_at: ttl ? new Date(Date.parse(at) + ttl * 1000).toISOString() : null, last_used_at: null,
|
|
327
|
+
actor_id: null, actor_display_name: null, actor_type: null, oauth_accesses_by_resource: null,
|
|
328
|
+
service_token_accesses: accesses.map((a, i) => accessOf(ctx, `${id}-${i + 1}`, a.access, a.resource_type === 'Database' ? 'Database' : 'Organization', a.resource_name)),
|
|
329
|
+
// who made it on the settings page: a person cannot approve a deploy request a token they made created
|
|
330
|
+
// (https://planetscale.com/docs/api/reference/service-tokens, "Service tokens and deploy requests approvals")
|
|
331
|
+
_secret_sha256: sha256(token), ...(createdBy ? { _created_by: createdBy } : {}),
|
|
332
|
+
}, 'service_token.create');
|
|
333
|
+
await audit(ctx, organization, 'service_token', 'created', { type: 'ServiceToken', id, name }, createdBy ? { actor: personOf(createdBy, createdById(createdBy)) } : {});
|
|
334
|
+
return { record, token };
|
|
335
|
+
}
|
|
336
|
+
/** create_service_token: a token with no accesses; they are granted on the organization's settings page (manifest.ts,
|
|
337
|
+
* `screens`), since no operation of the API grants one. */
|
|
338
|
+
const createServiceToken = async (ctx) => {
|
|
339
|
+
const b = body(ctx);
|
|
340
|
+
const name = typeof b.name === 'string' && b.name ? b.name : 'service-token';
|
|
341
|
+
if (b.ttl !== undefined && (!Number.isInteger(b.ttl) || Number(b.ttl) < 1))
|
|
342
|
+
return unprocessable(ctx, 'ttl must be a whole number of seconds');
|
|
343
|
+
const { record, token } = await makeServiceToken(ctx, name, [], undefined, orgOf(ctx), typeof b.ttl === 'number' ? b.ttl : undefined);
|
|
344
|
+
return ctx.reply({ ...record, token }, 200);
|
|
345
|
+
};
|
|
346
|
+
const listServiceTokens = async (ctx) => page(ctx, ctx.rowsRaw('ServiceToken').filter((t) => !t._deleted_at).map((t) => ctx.get('ServiceToken', String(t.id))));
|
|
347
|
+
/** A service token deleted: its ID and token stop authenticating at once (token-gate.ts refuses a deleted token as any
|
|
348
|
+
* token PlanetScale does not hold). "You can delete a service token at any time from the service token detail page"
|
|
349
|
+
* (https://planetscale.com/docs/cli/service-tokens); the API's delete_service_token does the same. */
|
|
350
|
+
export async function removeServiceToken(ctx, token, organization, actor) {
|
|
351
|
+
await ctx.write('ServiceToken', String(token.id), { ...ctx.own(token), updated_at: ctx.occurredAt, _deleted_at: ctx.occurredAt }, 'service_token.delete');
|
|
352
|
+
await audit(ctx, organization, 'service_token', 'deleted', { type: 'ServiceToken', id: String(token.id), name: String(token.display_name) }, actor ? { actor } : {});
|
|
353
|
+
}
|
|
354
|
+
const deleteServiceToken = async (ctx) => {
|
|
355
|
+
const token = ctx.rowsRaw('ServiceToken').find((t) => t.id === ctx.call.params.id && !t._deleted_at);
|
|
356
|
+
if (!token)
|
|
357
|
+
return ctx.notFound('ServiceToken', String(ctx.call.params.id), 'id');
|
|
358
|
+
await removeServiceToken(ctx, token, orgOf(ctx));
|
|
359
|
+
return new Response(null, { status: 204 });
|
|
360
|
+
};
|
|
361
|
+
export const apiSemantics = {
|
|
362
|
+
...branchSemantics,
|
|
363
|
+
...deployRequestSemantics,
|
|
364
|
+
...backupPolicySemantics,
|
|
365
|
+
list_audit_logs: listAuditLogs,
|
|
366
|
+
create_database: createDatabase,
|
|
367
|
+
get_database: getDatabase,
|
|
368
|
+
update_database_settings: updateDatabaseSettings,
|
|
369
|
+
delete_database: deleteDatabase,
|
|
370
|
+
create_password: createPassword,
|
|
371
|
+
list_passwords: listPasswords,
|
|
372
|
+
get_password: getPassword,
|
|
373
|
+
delete_password: deletePassword,
|
|
374
|
+
create_backup: createBackup,
|
|
375
|
+
list_backups: listBackups,
|
|
376
|
+
get_backup: getBackup,
|
|
377
|
+
delete_backup: deleteBackup,
|
|
378
|
+
create_service_token: createServiceToken,
|
|
379
|
+
list_service_tokens: listServiceTokens,
|
|
380
|
+
delete_service_token: deleteServiceToken,
|
|
381
|
+
};
|