@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,141 @@
|
|
|
1
|
+
// What the management lane's handlers share: id minting, the paginated list answer, and the database a path names.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
4
|
+
|
|
5
|
+
type Row = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
/** An id from the instant, a seed and the count, stepped past any id already stored (two creates at one instant). */
|
|
8
|
+
export function mintId(ctx: SemanticsContext, resource: string, prefix: string, seed: string): string {
|
|
9
|
+
const taken = new Set(ctx.rowsRaw(resource, { withDeleted: true }).map((r) => String(r.id)));
|
|
10
|
+
const n = taken.size + 1;
|
|
11
|
+
let id = '';
|
|
12
|
+
for (let k = 0; !id || taken.has(id); k++) id = `${prefix}${createHash('sha256').update(`${ctx.occurredAt}:${seed}:${n}:${k}`).digest('hex').slice(0, 12)}`;
|
|
13
|
+
return id;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A secret the vendor shows once, derived from what the World holds so a replay mints the same one. */
|
|
17
|
+
export function mintSecret(prefix: string, seed: string, length: number): string {
|
|
18
|
+
return `${prefix}${createHash('sha256').update(seed).digest('base64url').slice(0, length)}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The spec's Paginated* answer over `rows`, paged by `page` and `per_page` (default 25). */
|
|
22
|
+
export function page(ctx: SemanticsContext, rows: Row[]): Response {
|
|
23
|
+
const url = new URL(ctx.call.request.url);
|
|
24
|
+
const perPage = Math.max(1, Number(url.searchParams.get('per_page')) || 25);
|
|
25
|
+
const at = Math.max(1, Number(url.searchParams.get('page')) || 1);
|
|
26
|
+
const pages = Math.max(1, Math.ceil(rows.length / perPage));
|
|
27
|
+
const link = (p: number): string => { const u = new URL(url); u.searchParams.set('page', String(p)); return u.toString(); };
|
|
28
|
+
return ctx.reply({
|
|
29
|
+
type: 'list', current_page: at, per_page: perPage,
|
|
30
|
+
next_page: at < pages ? at + 1 : null, next_page_url: at < pages ? link(at + 1) : null,
|
|
31
|
+
prev_page: at > 1 ? at - 1 : null, prev_page_url: at > 1 ? link(at - 1) : null,
|
|
32
|
+
total_count: rows.length, total_pages: pages, data: rows.slice((at - 1) * perPage, at * perPage),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The database a path names. A database this lane created is found by its name (a deleted one answers 404); a name no
|
|
37
|
+
* created database has is the World's own database, the one the psdb data plane serves: an application pointed at the
|
|
38
|
+
* World (Dub's backup cron) addresses it by whatever names its own configuration holds. That second rule is the twin's
|
|
39
|
+
* decision; the vendor answers 404 for a database the organization does not have. */
|
|
40
|
+
export function databaseOf(ctx: SemanticsContext): { refused?: Response; record?: Row; branch?: Row } {
|
|
41
|
+
const name = String(ctx.call.params.database ?? '');
|
|
42
|
+
const record = ctx.rowsRaw('Database').find((d) => d.name === name);
|
|
43
|
+
if (record?._deleted_at) return { refused: ctx.notFound('Database', name, 'database') };
|
|
44
|
+
if (record === undefined) return {};
|
|
45
|
+
// a branch the path names must be one of the database's live branches (semantics/branches.ts)
|
|
46
|
+
const branch = ctx.call.params.branch;
|
|
47
|
+
if (branch === undefined) return { record };
|
|
48
|
+
const found = ctx.rowsRaw('DatabaseBranch').find((b) => b._database === name && b.name === branch && b.deleted_at === null);
|
|
49
|
+
if (!found) return { refused: ctx.notFound('DatabaseBranch', String(branch), 'branch') };
|
|
50
|
+
return { record, branch: found };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The branch a password or backup names, as the spec's `database_branch` reference carries it: the branch record, or
|
|
54
|
+
* for the World's own database (no record) its production branch as the path names it. */
|
|
55
|
+
export function branchRef(ctx: SemanticsContext, branch?: Row): Row {
|
|
56
|
+
return branch ? { id: String(branch.id), name: String(branch.name), production: branch.production === true, mysql_edge_address: 'aws.connect.psdb.cloud', private_edge_connectivity: false, created_at: String(branch.created_at), updated_at: String(branch.updated_at), deleted_at: null } : worldBranchRef(ctx);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The branch of the World's own database (one no lane record names), as the path names it. */
|
|
60
|
+
function worldBranchRef(ctx: SemanticsContext): Row {
|
|
61
|
+
const name = String(ctx.call.params.branch ?? 'main');
|
|
62
|
+
return { id: `branch-${name}`, name, production: true, mysql_edge_address: 'aws.connect.psdb.cloud', private_edge_connectivity: false, created_at: ctx.occurredAt, updated_at: ctx.occurredAt, deleted_at: null };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The avatar an actor answers with: PlanetScale's fallback image, as its service-tokens page's example answer shows a
|
|
66
|
+
* service token's actor with (https://planetscale.com/docs/api/reference/service-tokens: "avatar_url": a gravatar URL whose
|
|
67
|
+
* default is https://app.planetscale.com/gravatar-fallback.png); the World holds no gravatar. */
|
|
68
|
+
export const AVATAR = 'https://app.planetscale.com/gravatar-fallback.png';
|
|
69
|
+
|
|
70
|
+
/** The one region a World's databases live in, as the service-tokens page's example answer gives AWS us-east-1
|
|
71
|
+
* (https://planetscale.com/docs/api/reference/service-tokens, "Creating a PlanetScale branch with service tokens"): its
|
|
72
|
+
* id, provider, public IP addresses, display name, location and slug. Where the page stops: which engines it supports
|
|
73
|
+
* and whether it is the default for new branches, the twin's (MySQL and PostgreSQL, not Neki; the default). */
|
|
74
|
+
export const REGION = (slug = 'us-east'): Row => ({
|
|
75
|
+
id: 'kc0e1ij8juzp', provider: 'AWS', enabled: true,
|
|
76
|
+
public_ip_addresses: ['23.23.187.137', '52.6.141.108', '52.70.2.89', '50.17.188.76', '52.2.251.189', '52.72.234.74', '35.174.68.24', '52.5.253.172', '54.156.81.4', '34.200.24.255', '35.174.79.154', '44.199.177.24'],
|
|
77
|
+
display_name: 'AWS us-east-1', location: 'Northern Virginia', slug, current_default: true, mysql_supported: true, postgresql_supported: true, neki_supported: false,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Each access's description, as the service tokens page's tables give it
|
|
81
|
+
* (https://planetscale.com/docs/api/reference/service-tokens, "Organization access permissions" and "Database access
|
|
82
|
+
* permissions"). */
|
|
83
|
+
const ACCESS_DESCRIPTIONS: Record<string, string> = {
|
|
84
|
+
read_organization: "Get information about an organization",
|
|
85
|
+
read_invoices: "Get invoices for an organization",
|
|
86
|
+
read_audit_logs: "Get audit logs for an organization",
|
|
87
|
+
read_databases: "Get information about an organization's databases",
|
|
88
|
+
create_databases: "Create organization databases",
|
|
89
|
+
delete_databases: "Delete organization databases",
|
|
90
|
+
read_oauth_applications: "Get information about an organization's OAuth applications and their tokens",
|
|
91
|
+
read_oauth_tokens: "Get OAuth tokens for an OAuth application",
|
|
92
|
+
write_oauth_tokens: "Create and refresh OAuth grants and tokens for an OAuth application",
|
|
93
|
+
delete_oauth_tokens: "Delete OAuth tokens for an OAuth application",
|
|
94
|
+
read_service_tokens: "Get information about service tokens",
|
|
95
|
+
write_service_tokens: "Create and update service tokens",
|
|
96
|
+
delete_service_tokens: "Delete service tokens",
|
|
97
|
+
write_teams: "Create and update teams",
|
|
98
|
+
read_metrics_endpoints: "Get information about branch metrics endpoints",
|
|
99
|
+
read_database: "Get information about a database",
|
|
100
|
+
write_database: "Update information about a database",
|
|
101
|
+
delete_database: "Delete a database",
|
|
102
|
+
create_branch: "Create a database branch",
|
|
103
|
+
read_branch: "Read a database branch",
|
|
104
|
+
delete_branch: "Delete a database branch",
|
|
105
|
+
delete_production_branch: "Delete a production database branch",
|
|
106
|
+
connect_branch: "Connect to a database branch",
|
|
107
|
+
connect_production_branch: "Connect to a production database branch",
|
|
108
|
+
delete_branch_password: "Delete a password for a non-production branch",
|
|
109
|
+
delete_production_branch_password: "Delete a password for a production branch",
|
|
110
|
+
create_deploy_request: "Create a deploy request",
|
|
111
|
+
read_deploy_request: "Read a deploy request",
|
|
112
|
+
approve_deploy_request: "Approve a deploy request",
|
|
113
|
+
create_comment: "Create a deploy request comment",
|
|
114
|
+
read_comment: "Read a deploy request comment",
|
|
115
|
+
read_backups: "List backups",
|
|
116
|
+
write_backups: "Create and update backups",
|
|
117
|
+
delete_backups: "Delete development branch backups",
|
|
118
|
+
delete_production_branch_backups: "Delete production branch backups",
|
|
119
|
+
restore_backup: "Restore a development branch backup",
|
|
120
|
+
restore_production_branch_backup: "Restore a production branch backup",
|
|
121
|
+
write_branch_vschema: "Update the VSchema for a database branch",
|
|
122
|
+
write_production_branch_vschema: "Update the VSchema for a production database branch",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** A service token access as the spec's ServiceToken answers it: the resource it gives access to, by name, id and the
|
|
126
|
+
* resource itself. A database is its record; the organization, which the lane keeps no record of, is named by its
|
|
127
|
+
* name, dated from the grant (the twin's decision). */
|
|
128
|
+
export function accessOf(ctx: SemanticsContext, id: string, access: string, type: 'Database' | 'Organization', name: string): Row {
|
|
129
|
+
const db = type === 'Database' ? ctx.rowsRaw('Database').find((d) => d.name === name && !d._deleted_at) : undefined;
|
|
130
|
+
const resource = db
|
|
131
|
+
? { id: String(db.id), name, created_at: String(db.created_at), updated_at: String(db.updated_at), deleted_at: null }
|
|
132
|
+
: { id: name, name, created_at: ctx.occurredAt, updated_at: ctx.occurredAt, deleted_at: null };
|
|
133
|
+
return { id, access, description: ACCESS_DESCRIPTIONS[access] ?? access, resource_name: name, resource_id: resource.id, resource_type: type, resource };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The actor the lane records on what it makes: the service token that asked, or the World's own. */
|
|
137
|
+
export function actorOf(ctx: SemanticsContext): Row {
|
|
138
|
+
const id = /^([^:\s]+):/.exec(ctx.call.request.headers.get('authorization') ?? '')?.[1];
|
|
139
|
+
const token = id === undefined ? undefined : ctx.rowsRaw('ServiceToken').find((t) => t.id === id);
|
|
140
|
+
return token ? { id: String(token.id), display_name: String(token.display_name), avatar_url: AVATAR } : { id: 'service-token', display_name: 'Service token', avatar_url: AVATAR };
|
|
141
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { branchBytes } from "./index.js";
|
|
2
|
+
import { catchUpDeployRequests } from "./deploy-requests.js";
|
|
3
|
+
import { catchUpPolicies, COMPLETE_AFTER, snapshotBranch, START_AFTER } from "./backups.js";
|
|
4
|
+
const READY_AFTER = 60_000;
|
|
5
|
+
const later = (iso, ms) => new Date(Date.parse(iso) + ms).toISOString();
|
|
6
|
+
export async function catchUp(ctx) {
|
|
7
|
+
const now = Date.parse(ctx.occurredAt);
|
|
8
|
+
for (const d of ctx.rowsRaw('Database')) {
|
|
9
|
+
if (d.state !== 'pending' || d._deleted_at)
|
|
10
|
+
continue;
|
|
11
|
+
const due = later(String(d.created_at), READY_AFTER);
|
|
12
|
+
if (Date.parse(due) > now)
|
|
13
|
+
continue;
|
|
14
|
+
const at = await ctx.at(due);
|
|
15
|
+
const refused = at.legal('Database', 'state', 'provision', 'pending', 'ready', String(d.id), 'time');
|
|
16
|
+
if (refused)
|
|
17
|
+
continue;
|
|
18
|
+
await at.write('Database', String(d.id), { ...at.own(d), state: 'ready', ready: true, updated_at: due }, 'database.ready');
|
|
19
|
+
}
|
|
20
|
+
// a branch turns ready as its database does, one minute after it was made (the twin's delay; PlanetScale's CI example
|
|
21
|
+
// waits for it: "pass the `--wait` flag", https://planetscale.com/docs/vitess/integrations/github-actions)
|
|
22
|
+
for (const br of ctx.rowsRaw('DatabaseBranch')) {
|
|
23
|
+
if (br.state !== 'pending' || br.deleted_at !== null)
|
|
24
|
+
continue;
|
|
25
|
+
const due = later(String(br.created_at), READY_AFTER);
|
|
26
|
+
if (Date.parse(due) > now)
|
|
27
|
+
continue;
|
|
28
|
+
const at = await ctx.at(due);
|
|
29
|
+
if (at.legal('DatabaseBranch', 'state', 'provision', 'pending', 'ready', String(br.id), 'time'))
|
|
30
|
+
continue;
|
|
31
|
+
await at.write('DatabaseBranch', String(br.id), { ...at.own(br), state: 'ready', ready: true, schema_ready: true, updated_at: due }, 'branch.ready');
|
|
32
|
+
}
|
|
33
|
+
// a password with a ttl expires when it has passed ("The password will be invalid when TTL has passed", the spec)
|
|
34
|
+
for (const p of ctx.rowsRaw('DatabaseBranchPassword')) {
|
|
35
|
+
if (p.expired === true || p.deleted_at !== null || typeof p.expires_at !== 'string' || Date.parse(p.expires_at) > now)
|
|
36
|
+
continue;
|
|
37
|
+
const at = await ctx.at(p.expires_at);
|
|
38
|
+
await at.write('DatabaseBranchPassword', String(p.id), { ...at.own(p), expired: true }, 'password.expire');
|
|
39
|
+
}
|
|
40
|
+
await catchUpDeployRequests(ctx);
|
|
41
|
+
await catchUpPolicies(ctx);
|
|
42
|
+
for (const k of ctx.rowsRaw('Backup')) {
|
|
43
|
+
if (k.deleted_at !== null && k.deleted_at !== undefined)
|
|
44
|
+
continue;
|
|
45
|
+
let row = k;
|
|
46
|
+
if (row.state === 'pending') {
|
|
47
|
+
const due = later(String(row.created_at), START_AFTER);
|
|
48
|
+
if (Date.parse(due) > now)
|
|
49
|
+
continue;
|
|
50
|
+
const at = await ctx.at(due);
|
|
51
|
+
if (at.legal('Backup', 'state', 'start', 'pending', 'running', String(row.id), 'time'))
|
|
52
|
+
continue;
|
|
53
|
+
await at.write('Backup', String(row.id), { ...at.own(row), state: 'running', started_at: due, updated_at: due }, 'backup.start');
|
|
54
|
+
row = at.rowsRaw('Backup').find((x) => x.id === row.id) ?? row;
|
|
55
|
+
}
|
|
56
|
+
if (row.state === 'running') {
|
|
57
|
+
const due = later(String(row.created_at), COMPLETE_AFTER);
|
|
58
|
+
if (Date.parse(due) > now)
|
|
59
|
+
continue;
|
|
60
|
+
const at = await ctx.at(due);
|
|
61
|
+
if (at.legal('Backup', 'state', 'complete', 'running', 'success', String(row.id), 'time'))
|
|
62
|
+
continue;
|
|
63
|
+
// what it holds: its branch's schema and rows (a restore copies them into a new branch); a backup of `main` is
|
|
64
|
+
// sized by the World's stored rows, as it always has been
|
|
65
|
+
const snapshot = await snapshotBranch(at, row);
|
|
66
|
+
const onMain = String(row.database_branch?.name ?? 'main') === 'main';
|
|
67
|
+
const size = onMain ? branchBytes(at) : snapshot.size;
|
|
68
|
+
const own = at.own(row);
|
|
69
|
+
await at.write('Backup', String(row.id), {
|
|
70
|
+
...own, state: 'success', size, pvc_size: size, uncompressed_size: size, completed_at: due, updated_at: due,
|
|
71
|
+
expires_at: later(due, Number(own._retention_ms ?? 7 * 86_400_000)), _snapshot: snapshot.id,
|
|
72
|
+
}, 'backup.complete');
|
|
73
|
+
row = at.rowsRaw('Backup').find((x) => x.id === row.id) ?? row;
|
|
74
|
+
}
|
|
75
|
+
// a backup is deleted when its retention has passed (the twin's reading of a retention period)
|
|
76
|
+
if (row.state === 'success' && typeof row.expires_at === 'string' && Date.parse(row.expires_at) <= now && (row.deleted_at === null || row.deleted_at === undefined)) {
|
|
77
|
+
const at = await ctx.at(row.expires_at);
|
|
78
|
+
await at.write('Backup', String(row.id), { ...at.own(row), deleted_at: row.expires_at, updated_at: row.expires_at }, 'backup.expire');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// What the World clock does to the management lane's records (manifest.ts's `time` moves), caught up before any request
|
|
2
|
+
// is answered, each written at the instant it fell due: a database turns ready one minute after it was created; a backup
|
|
3
|
+
// starts running 30 seconds after it was asked for and completes two minutes after, with its size, and expires its
|
|
4
|
+
// retention after it completes. The delays are the twin's decisions; PlanetScale documents polling, not durations.
|
|
5
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
6
|
+
import { branchBytes } from './index.ts';
|
|
7
|
+
import { catchUpDeployRequests } from './deploy-requests.ts';
|
|
8
|
+
import { catchUpPolicies, COMPLETE_AFTER, snapshotBranch, START_AFTER } from './backups.ts';
|
|
9
|
+
|
|
10
|
+
const READY_AFTER = 60_000;
|
|
11
|
+
|
|
12
|
+
const later = (iso: string, ms: number): string => new Date(Date.parse(iso) + ms).toISOString();
|
|
13
|
+
|
|
14
|
+
export async function catchUp(ctx: SemanticsContext): Promise<void> {
|
|
15
|
+
const now = Date.parse(ctx.occurredAt);
|
|
16
|
+
for (const d of ctx.rowsRaw('Database')) {
|
|
17
|
+
if (d.state !== 'pending' || d._deleted_at) continue;
|
|
18
|
+
const due = later(String(d.created_at), READY_AFTER);
|
|
19
|
+
if (Date.parse(due) > now) continue;
|
|
20
|
+
const at = await ctx.at(due);
|
|
21
|
+
const refused = at.legal('Database', 'state', 'provision', 'pending', 'ready', String(d.id), 'time');
|
|
22
|
+
if (refused) continue;
|
|
23
|
+
await at.write('Database', String(d.id), { ...at.own(d), state: 'ready', ready: true, updated_at: due }, 'database.ready');
|
|
24
|
+
}
|
|
25
|
+
// a branch turns ready as its database does, one minute after it was made (the twin's delay; PlanetScale's CI example
|
|
26
|
+
// waits for it: "pass the `--wait` flag", https://planetscale.com/docs/vitess/integrations/github-actions)
|
|
27
|
+
for (const br of ctx.rowsRaw('DatabaseBranch')) {
|
|
28
|
+
if (br.state !== 'pending' || br.deleted_at !== null) continue;
|
|
29
|
+
const due = later(String(br.created_at), READY_AFTER);
|
|
30
|
+
if (Date.parse(due) > now) continue;
|
|
31
|
+
const at = await ctx.at(due);
|
|
32
|
+
if (at.legal('DatabaseBranch', 'state', 'provision', 'pending', 'ready', String(br.id), 'time')) continue;
|
|
33
|
+
await at.write('DatabaseBranch', String(br.id), { ...at.own(br), state: 'ready', ready: true, schema_ready: true, updated_at: due }, 'branch.ready');
|
|
34
|
+
}
|
|
35
|
+
// a password with a ttl expires when it has passed ("The password will be invalid when TTL has passed", the spec)
|
|
36
|
+
for (const p of ctx.rowsRaw('DatabaseBranchPassword')) {
|
|
37
|
+
if (p.expired === true || p.deleted_at !== null || typeof p.expires_at !== 'string' || Date.parse(p.expires_at) > now) continue;
|
|
38
|
+
const at = await ctx.at(p.expires_at);
|
|
39
|
+
await at.write('DatabaseBranchPassword', String(p.id), { ...at.own(p), expired: true }, 'password.expire');
|
|
40
|
+
}
|
|
41
|
+
await catchUpDeployRequests(ctx);
|
|
42
|
+
await catchUpPolicies(ctx);
|
|
43
|
+
for (const k of ctx.rowsRaw('Backup')) {
|
|
44
|
+
if (k.deleted_at !== null && k.deleted_at !== undefined) continue;
|
|
45
|
+
let row = k;
|
|
46
|
+
if (row.state === 'pending') {
|
|
47
|
+
const due = later(String(row.created_at), START_AFTER);
|
|
48
|
+
if (Date.parse(due) > now) continue;
|
|
49
|
+
const at = await ctx.at(due);
|
|
50
|
+
if (at.legal('Backup', 'state', 'start', 'pending', 'running', String(row.id), 'time')) continue;
|
|
51
|
+
await at.write('Backup', String(row.id), { ...at.own(row), state: 'running', started_at: due, updated_at: due }, 'backup.start');
|
|
52
|
+
row = at.rowsRaw('Backup').find((x) => x.id === row.id) ?? row;
|
|
53
|
+
}
|
|
54
|
+
if (row.state === 'running') {
|
|
55
|
+
const due = later(String(row.created_at), COMPLETE_AFTER);
|
|
56
|
+
if (Date.parse(due) > now) continue;
|
|
57
|
+
const at = await ctx.at(due);
|
|
58
|
+
if (at.legal('Backup', 'state', 'complete', 'running', 'success', String(row.id), 'time')) continue;
|
|
59
|
+
// what it holds: its branch's schema and rows (a restore copies them into a new branch); a backup of `main` is
|
|
60
|
+
// sized by the World's stored rows, as it always has been
|
|
61
|
+
const snapshot = await snapshotBranch(at, row);
|
|
62
|
+
const onMain = String((row.database_branch as Record<string, unknown> | undefined)?.name ?? 'main') === 'main';
|
|
63
|
+
const size = onMain ? branchBytes(at) : snapshot.size;
|
|
64
|
+
const own = at.own(row);
|
|
65
|
+
await at.write('Backup', String(row.id), {
|
|
66
|
+
...own, state: 'success', size, pvc_size: size, uncompressed_size: size, completed_at: due, updated_at: due,
|
|
67
|
+
expires_at: later(due, Number(own._retention_ms ?? 7 * 86_400_000)), _snapshot: snapshot.id,
|
|
68
|
+
}, 'backup.complete');
|
|
69
|
+
row = at.rowsRaw('Backup').find((x) => x.id === row.id) ?? row;
|
|
70
|
+
}
|
|
71
|
+
// a backup is deleted when its retention has passed (the twin's reading of a retention period)
|
|
72
|
+
if (row.state === 'success' && typeof row.expires_at === 'string' && Date.parse(row.expires_at) <= now && (row.deleted_at === null || row.deleted_at === undefined)) {
|
|
73
|
+
const at = await ctx.at(row.expires_at);
|
|
74
|
+
await at.write('Backup', String(row.id), { ...at.own(row), deleted_at: row.expires_at, updated_at: row.expires_at }, 'backup.expire');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
2
|
+
/** The accesses the calling token holds on `database` (its organization accesses count everywhere), or undefined for a
|
|
3
|
+
* token this lane did not make (the World's own, allowed everything). A handler asks it where one operation needs a
|
|
4
|
+
* different access by what it acts on (a production branch's password, a restore from a production backup). */
|
|
5
|
+
export declare function heldAccesses(ctx: SemanticsContext, database: string): Set<string> | undefined;
|
|
6
|
+
/** PlanetScale's 403 for a token that lacks every one of `needed`, or undefined when it holds one. */
|
|
7
|
+
export declare function lacks(ctx: SemanticsContext, database: string, needed: string[]): Response | undefined;
|
|
8
|
+
/** The gate's answer for a request, or undefined when it may pass. `operation` is the operation it matched, if any. */
|
|
9
|
+
export declare function tokenGate(ctx: SemanticsContext, request: Request, operation: string | undefined): Response | undefined;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The management lane's service-token gate, in front of its dispatch. PlanetScale reads a service token as
|
|
2
|
+
// `Authorization: <SERVICE_TOKEN_ID>:<SERVICE_TOKEN>` (https://planetscale.com/docs/api/reference/service-tokens), and each
|
|
3
|
+
// operation names, in its spec's "Authorization" section, the service token accesses any one of which lets a token call
|
|
4
|
+
// it (`ACCESSES` below, copied from those sections for the operations this lane serves).
|
|
5
|
+
//
|
|
6
|
+
// Where the reference stops and the twin decides: a request without a well-formed token is 401 (the spec lists 401 for
|
|
7
|
+
// every operation), a token this lane made answers 401 when its secret is wrong and 403 when it holds none of the
|
|
8
|
+
// operation's accesses, and a token id no service token of this lane has is the World's own organization token, allowed
|
|
9
|
+
// everything (as the World's own psdb credential is): an application pointed at the World (Dub's backup cron) carries
|
|
10
|
+
// whatever token its own configuration holds.
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
const ACCESSES = {
|
|
13
|
+
create_database: ['create_databases'],
|
|
14
|
+
get_database: ['read_database', 'delete_database', 'write_database', 'read_branch', 'delete_branch', 'create_branch', 'connect_branch', 'connect_production_branch', 'connect_production_read_only_branch', 'read_backups', 'write_backups', 'delete_backups'],
|
|
15
|
+
delete_database: ['delete_database'],
|
|
16
|
+
create_password: ['connect_production_branch', 'connect_production_read_only_branch', 'connect_branch'],
|
|
17
|
+
list_passwords: ['read_branch', 'delete_branch', 'create_branch', 'connect_production_branch', 'connect_production_read_only_branch', 'connect_branch'],
|
|
18
|
+
get_password: ['read_branch', 'delete_branch', 'create_branch', 'connect_production_branch', 'connect_production_read_only_branch', 'connect_branch'],
|
|
19
|
+
delete_password: ['delete_production_branch_password', 'delete_production_read_only_branch_password', 'delete_branch_password'],
|
|
20
|
+
create_backup: ['write_backups'],
|
|
21
|
+
list_backups: ['read_backups'],
|
|
22
|
+
get_backup: ['read_backups'],
|
|
23
|
+
delete_backup: ['delete_backups', 'delete_production_branch_backups'],
|
|
24
|
+
create_branch: ['create_branch', 'restore_production_branch_backup', 'restore_backup'],
|
|
25
|
+
get_branch: ['read_branch', 'delete_branch', 'create_branch', 'connect_production_branch', 'connect_branch'],
|
|
26
|
+
list_branches: ['read_branch', 'delete_branch', 'create_branch', 'connect_production_branch', 'connect_branch'],
|
|
27
|
+
delete_branch: ['delete_branch'],
|
|
28
|
+
// the spec's enable/disable_safe_migrations name no access; the twin asks what changing a database's setup asks
|
|
29
|
+
enable_safe_migrations: ['write_database'],
|
|
30
|
+
disable_safe_migrations: ['write_database'],
|
|
31
|
+
update_database_settings: ['write_database'],
|
|
32
|
+
create_deploy_request: ['create_deploy_request'],
|
|
33
|
+
get_deploy_request: ['read_deploy_request'],
|
|
34
|
+
list_deploy_requests: ['read_deploy_request'],
|
|
35
|
+
get_deployment: ['read_deploy_request'],
|
|
36
|
+
list_deploy_operations: ['read_deploy_request'],
|
|
37
|
+
list_deploy_request_reviews: ['read_deploy_request'],
|
|
38
|
+
review_deploy_request: ['approve_deploy_request', 'review_deploy_request'],
|
|
39
|
+
queue_deploy_request: ['create_deploy_request'],
|
|
40
|
+
close_deploy_request: ['create_deploy_request'],
|
|
41
|
+
complete_revert: ['create_deploy_request'],
|
|
42
|
+
list_audit_logs: ['read_audit_logs'],
|
|
43
|
+
create_backup_policy: ['write_backups'],
|
|
44
|
+
list_backup_policies: ['read_backups'],
|
|
45
|
+
get_backup_policy: ['read_backups'],
|
|
46
|
+
create_service_token: ['write_service_tokens'],
|
|
47
|
+
list_service_tokens: ['read_service_tokens'],
|
|
48
|
+
delete_service_token: ['delete_service_tokens'],
|
|
49
|
+
};
|
|
50
|
+
const refuse = (status, code, message) => Response.json({ code, message }, { status });
|
|
51
|
+
/** The accesses the calling token holds on `database` (its organization accesses count everywhere), or undefined for a
|
|
52
|
+
* token this lane did not make (the World's own, allowed everything). A handler asks it where one operation needs a
|
|
53
|
+
* different access by what it acts on (a production branch's password, a restore from a production backup). */
|
|
54
|
+
export function heldAccesses(ctx, database) {
|
|
55
|
+
const id = /^([^:\s]+):/.exec(ctx.call.request.headers.get('authorization') ?? '')?.[1];
|
|
56
|
+
const token = id === undefined ? undefined : ctx.rowsRaw('ServiceToken').find((t) => t.id === id);
|
|
57
|
+
if (token === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
return new Set((token.service_token_accesses ?? [])
|
|
60
|
+
.filter((a) => a.resource_type === 'Organization' || (a.resource_type === 'Database' && a.resource_name === database)).map((a) => a.access));
|
|
61
|
+
}
|
|
62
|
+
/** PlanetScale's 403 for a token that lacks every one of `needed`, or undefined when it holds one. */
|
|
63
|
+
export function lacks(ctx, database, needed) {
|
|
64
|
+
const held = heldAccesses(ctx, database);
|
|
65
|
+
if (held === undefined || needed.some((a) => held.has(a)))
|
|
66
|
+
return undefined;
|
|
67
|
+
return refuse(403, 'forbidden', `Forbidden: this service token needs one of these accesses: ${needed.join(', ')}`);
|
|
68
|
+
}
|
|
69
|
+
/** The gate's answer for a request, or undefined when it may pass. `operation` is the operation it matched, if any. */
|
|
70
|
+
export function tokenGate(ctx, request, operation) {
|
|
71
|
+
const header = request.headers.get('authorization') ?? '';
|
|
72
|
+
const m = /^([^:\s]+):(\S+)$/.exec(header);
|
|
73
|
+
if (!m)
|
|
74
|
+
return refuse(401, 'unauthorized', 'Unauthorized: send a service token as Authorization: <SERVICE_TOKEN_ID>:<SERVICE_TOKEN>');
|
|
75
|
+
const token = ctx.rowsRaw('ServiceToken').find((t) => t.id === m[1]);
|
|
76
|
+
if (token === undefined)
|
|
77
|
+
return undefined;
|
|
78
|
+
// a token deleted on the settings page or by delete_service_token is refused as any token PlanetScale does not hold
|
|
79
|
+
if (token._deleted_at)
|
|
80
|
+
return refuse(401, 'unauthorized', 'Unauthorized: the service token is not valid');
|
|
81
|
+
// a token whose ttl has passed "will be invalid" (the spec's create_service_token)
|
|
82
|
+
if (typeof token.expires_at === 'string' && Date.parse(token.expires_at) <= Date.parse(ctx.occurredAt))
|
|
83
|
+
return refuse(401, 'unauthorized', 'Unauthorized: the service token is not valid');
|
|
84
|
+
if (token._secret_sha256 !== createHash('sha256').update(m[2]).digest('hex'))
|
|
85
|
+
return refuse(401, 'unauthorized', 'Unauthorized: the service token is not valid');
|
|
86
|
+
const needed = operation === undefined ? undefined : ACCESSES[operation];
|
|
87
|
+
if (needed === undefined)
|
|
88
|
+
return undefined;
|
|
89
|
+
const database = ctx.call.params.database;
|
|
90
|
+
const held = (token.service_token_accesses ?? [])
|
|
91
|
+
.filter((a) => a.resource_type === 'Organization' || (a.resource_type === 'Database' && a.resource_name === database));
|
|
92
|
+
return held.some((a) => needed.includes(a.access)) ? undefined : forbidden(needed);
|
|
93
|
+
}
|
|
94
|
+
/** PlanetScale's 403 for a token that holds none of an operation's accesses. */
|
|
95
|
+
function forbidden(needed) {
|
|
96
|
+
return refuse(403, 'forbidden', `Forbidden: this service token needs one of these accesses: ${needed.join(', ')}`);
|
|
97
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The management lane's service-token gate, in front of its dispatch. PlanetScale reads a service token as
|
|
2
|
+
// `Authorization: <SERVICE_TOKEN_ID>:<SERVICE_TOKEN>` (https://planetscale.com/docs/api/reference/service-tokens), and each
|
|
3
|
+
// operation names, in its spec's "Authorization" section, the service token accesses any one of which lets a token call
|
|
4
|
+
// it (`ACCESSES` below, copied from those sections for the operations this lane serves).
|
|
5
|
+
//
|
|
6
|
+
// Where the reference stops and the twin decides: a request without a well-formed token is 401 (the spec lists 401 for
|
|
7
|
+
// every operation), a token this lane made answers 401 when its secret is wrong and 403 when it holds none of the
|
|
8
|
+
// operation's accesses, and a token id no service token of this lane has is the World's own organization token, allowed
|
|
9
|
+
// everything (as the World's own psdb credential is): an application pointed at the World (Dub's backup cron) carries
|
|
10
|
+
// whatever token its own configuration holds.
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
13
|
+
|
|
14
|
+
const ACCESSES: Record<string, string[]> = {
|
|
15
|
+
create_database: ['create_databases'],
|
|
16
|
+
get_database: ['read_database', 'delete_database', 'write_database', 'read_branch', 'delete_branch', 'create_branch', 'connect_branch', 'connect_production_branch', 'connect_production_read_only_branch', 'read_backups', 'write_backups', 'delete_backups'],
|
|
17
|
+
delete_database: ['delete_database'],
|
|
18
|
+
create_password: ['connect_production_branch', 'connect_production_read_only_branch', 'connect_branch'],
|
|
19
|
+
list_passwords: ['read_branch', 'delete_branch', 'create_branch', 'connect_production_branch', 'connect_production_read_only_branch', 'connect_branch'],
|
|
20
|
+
get_password: ['read_branch', 'delete_branch', 'create_branch', 'connect_production_branch', 'connect_production_read_only_branch', 'connect_branch'],
|
|
21
|
+
delete_password: ['delete_production_branch_password', 'delete_production_read_only_branch_password', 'delete_branch_password'],
|
|
22
|
+
create_backup: ['write_backups'],
|
|
23
|
+
list_backups: ['read_backups'],
|
|
24
|
+
get_backup: ['read_backups'],
|
|
25
|
+
delete_backup: ['delete_backups', 'delete_production_branch_backups'],
|
|
26
|
+
create_branch: ['create_branch', 'restore_production_branch_backup', 'restore_backup'],
|
|
27
|
+
get_branch: ['read_branch', 'delete_branch', 'create_branch', 'connect_production_branch', 'connect_branch'],
|
|
28
|
+
list_branches: ['read_branch', 'delete_branch', 'create_branch', 'connect_production_branch', 'connect_branch'],
|
|
29
|
+
delete_branch: ['delete_branch'],
|
|
30
|
+
// the spec's enable/disable_safe_migrations name no access; the twin asks what changing a database's setup asks
|
|
31
|
+
enable_safe_migrations: ['write_database'],
|
|
32
|
+
disable_safe_migrations: ['write_database'],
|
|
33
|
+
update_database_settings: ['write_database'],
|
|
34
|
+
create_deploy_request: ['create_deploy_request'],
|
|
35
|
+
get_deploy_request: ['read_deploy_request'],
|
|
36
|
+
list_deploy_requests: ['read_deploy_request'],
|
|
37
|
+
get_deployment: ['read_deploy_request'],
|
|
38
|
+
list_deploy_operations: ['read_deploy_request'],
|
|
39
|
+
list_deploy_request_reviews: ['read_deploy_request'],
|
|
40
|
+
review_deploy_request: ['approve_deploy_request', 'review_deploy_request'],
|
|
41
|
+
queue_deploy_request: ['create_deploy_request'],
|
|
42
|
+
close_deploy_request: ['create_deploy_request'],
|
|
43
|
+
complete_revert: ['create_deploy_request'],
|
|
44
|
+
list_audit_logs: ['read_audit_logs'],
|
|
45
|
+
create_backup_policy: ['write_backups'],
|
|
46
|
+
list_backup_policies: ['read_backups'],
|
|
47
|
+
get_backup_policy: ['read_backups'],
|
|
48
|
+
create_service_token: ['write_service_tokens'],
|
|
49
|
+
list_service_tokens: ['read_service_tokens'],
|
|
50
|
+
delete_service_token: ['delete_service_tokens'],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const refuse = (status: number, code: string, message: string): Response => Response.json({ code, message }, { status });
|
|
54
|
+
|
|
55
|
+
/** The accesses the calling token holds on `database` (its organization accesses count everywhere), or undefined for a
|
|
56
|
+
* token this lane did not make (the World's own, allowed everything). A handler asks it where one operation needs a
|
|
57
|
+
* different access by what it acts on (a production branch's password, a restore from a production backup). */
|
|
58
|
+
export function heldAccesses(ctx: SemanticsContext, database: string): Set<string> | undefined {
|
|
59
|
+
const id = /^([^:\s]+):/.exec(ctx.call.request.headers.get('authorization') ?? '')?.[1];
|
|
60
|
+
const token = id === undefined ? undefined : ctx.rowsRaw('ServiceToken').find((t) => t.id === id);
|
|
61
|
+
if (token === undefined) return undefined;
|
|
62
|
+
return new Set(((token.service_token_accesses ?? []) as Array<{ access: string; resource_type: string; resource_name: string }>)
|
|
63
|
+
.filter((a) => a.resource_type === 'Organization' || (a.resource_type === 'Database' && a.resource_name === database)).map((a) => a.access));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** PlanetScale's 403 for a token that lacks every one of `needed`, or undefined when it holds one. */
|
|
67
|
+
export function lacks(ctx: SemanticsContext, database: string, needed: string[]): Response | undefined {
|
|
68
|
+
const held = heldAccesses(ctx, database);
|
|
69
|
+
if (held === undefined || needed.some((a) => held.has(a))) return undefined;
|
|
70
|
+
return refuse(403, 'forbidden', `Forbidden: this service token needs one of these accesses: ${needed.join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The gate's answer for a request, or undefined when it may pass. `operation` is the operation it matched, if any. */
|
|
74
|
+
export function tokenGate(ctx: SemanticsContext, request: Request, operation: string | undefined): Response | undefined {
|
|
75
|
+
const header = request.headers.get('authorization') ?? '';
|
|
76
|
+
const m = /^([^:\s]+):(\S+)$/.exec(header);
|
|
77
|
+
if (!m) return refuse(401, 'unauthorized', 'Unauthorized: send a service token as Authorization: <SERVICE_TOKEN_ID>:<SERVICE_TOKEN>');
|
|
78
|
+
const token = ctx.rowsRaw('ServiceToken').find((t) => t.id === m[1]);
|
|
79
|
+
if (token === undefined) return undefined;
|
|
80
|
+
// a token deleted on the settings page or by delete_service_token is refused as any token PlanetScale does not hold
|
|
81
|
+
if (token._deleted_at) return refuse(401, 'unauthorized', 'Unauthorized: the service token is not valid');
|
|
82
|
+
// a token whose ttl has passed "will be invalid" (the spec's create_service_token)
|
|
83
|
+
if (typeof token.expires_at === 'string' && Date.parse(token.expires_at) <= Date.parse(ctx.occurredAt)) return refuse(401, 'unauthorized', 'Unauthorized: the service token is not valid');
|
|
84
|
+
if (token._secret_sha256 !== createHash('sha256').update(m[2]!).digest('hex')) return refuse(401, 'unauthorized', 'Unauthorized: the service token is not valid');
|
|
85
|
+
const needed = operation === undefined ? undefined : ACCESSES[operation];
|
|
86
|
+
if (needed === undefined) return undefined;
|
|
87
|
+
const database = ctx.call.params.database;
|
|
88
|
+
const held = ((token.service_token_accesses ?? []) as Array<{ access: string; resource_type: string; resource_name: string }>)
|
|
89
|
+
.filter((a) => a.resource_type === 'Organization' || (a.resource_type === 'Database' && a.resource_name === database));
|
|
90
|
+
return held.some((a) => needed.includes(a.access)) ? undefined : forbidden(needed);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** PlanetScale's 403 for a token that holds none of an operation's accesses. */
|
|
94
|
+
function forbidden(needed: string[]): Response {
|
|
95
|
+
return refuse(403, 'forbidden', `Forbidden: this service token needs one of these accesses: ${needed.join(', ')}`);
|
|
96
|
+
}
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
3
|
+
// world-planetscale CLI: serve the KERNEL-BACKED PlanetScale psdb twin, or run conformance.
|
|
4
|
+
// State lives in the @volter/world-core action log (no in-memory side-store). Conformance is dev-only +
|
|
5
|
+
// lazy-imported so the bin runs without @volter/world-tooling (E2).
|
|
6
|
+
import { hasFlag, optionValue } from '@volter/world-core/args';
|
|
7
|
+
import { createPlanetscaleTwinServer } from "./planetscale-server.js";
|
|
8
|
+
import { pack } from "./index.js";
|
|
9
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
10
|
+
const port = Number(optionValue(rest, '--port', String(process.env.PORT ?? '0'))) || undefined;
|
|
11
|
+
const root = optionValue(rest, '--root') || undefined;
|
|
12
|
+
// The credentials this twin will DEMAND. Omit them and any non-empty user:password pair is
|
|
13
|
+
// accepted (a missing or malformed Authorization header is still a vendor-shaped 401) — the right
|
|
14
|
+
// default for a local twin that holds no real secret.
|
|
15
|
+
const username = optionValue(rest, '--username') || undefined;
|
|
16
|
+
const password = optionValue(rest, '--password') || undefined;
|
|
17
|
+
const database = optionValue(rest, '--database') || undefined;
|
|
18
|
+
const branch = optionValue(rest, '--branch') || undefined;
|
|
19
|
+
const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
|
|
20
|
+
if ((cmd === 'serve' || cmd === undefined) && hasFlag(rest, '--mysql')) {
|
|
21
|
+
if (readOnly)
|
|
22
|
+
throw new Error('Set read-only on the upstream HTTP twin; native connections share its policy');
|
|
23
|
+
const { startPlanetscaleMysql } = await import("./planetscale-mysql.js");
|
|
24
|
+
const upstreamEnv = pack.nativeTransport.upstreamEnv;
|
|
25
|
+
const upstream = optionValue(rest, '--upstream') || process.env[upstreamEnv];
|
|
26
|
+
if (!upstream)
|
|
27
|
+
throw new Error(`--mysql requires ${upstreamEnv} or --upstream pointing at the World HTTP twin (declare the HTTP twin service first)`);
|
|
28
|
+
const server = await startPlanetscaleMysql({ upstream, port, username, password, database, ...(hasFlag(rest, '--trace-sql') ? { onQuery: (query) => process.stderr.write(`mysql: ${query}\n`) } : {}) });
|
|
29
|
+
process.stdout.write(`planetscale MySQL frontend at mysql://127.0.0.1:${server.port} (shared HTTP state)\n`);
|
|
30
|
+
let closing = false;
|
|
31
|
+
const close = async () => { if (closing)
|
|
32
|
+
return; closing = true; await server.stop(); process.exit(0); };
|
|
33
|
+
process.once('SIGTERM', () => { void close(); });
|
|
34
|
+
process.once('SIGINT', () => { void close(); });
|
|
35
|
+
await keepProcessAlive();
|
|
36
|
+
}
|
|
37
|
+
else if (cmd === 'serve' || cmd === undefined) {
|
|
38
|
+
const s = await createPlanetscaleTwinServer({
|
|
39
|
+
readOnly,
|
|
40
|
+
...(root ? { root } : {}),
|
|
41
|
+
...(port ? { port } : {}),
|
|
42
|
+
...(username ? { username } : {}),
|
|
43
|
+
...(password ? { password } : {}),
|
|
44
|
+
...(database ? { database } : {}),
|
|
45
|
+
...(branch ? { branch } : {}),
|
|
46
|
+
});
|
|
47
|
+
process.stdout.write(`planetscale twin (psdb.v1alpha1.Database over Connect-JSON: CreateSession, Execute, CloseSession)${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${s.port}\n`
|
|
48
|
+
+ ` point a client at it with PLANETSCALE_DATABASE_URL=http://user:pass@127.0.0.1:${s.port}\n`
|
|
49
|
+
+ ' (the http: scheme is honoured by @planetscale/database itself — every other scheme is forced to https)\n');
|
|
50
|
+
await keepProcessAlive();
|
|
51
|
+
}
|
|
52
|
+
else if (cmd === 'conformance') {
|
|
53
|
+
const { checkPlanetscaleConformance } = await import("./planetscale-conformance.js");
|
|
54
|
+
const report = await checkPlanetscaleConformance();
|
|
55
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
56
|
+
if (!report.ok)
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
process.stdout.write('Usage: world-planetscale serve|conformance [--port N] [--root DIR] [--username U] [--password P] [--database D] [--branch B] [--read-only] [--mysql --upstream URL]\n');
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"generatedBy":"scripts/derive-pack.ts","spec":"proto","format":"proto","version":"psdb.v1alpha1.Database","resources":[{"name":"CloseSessionResponse","schema":"CloseSessionResponse","fields":[{"name":"session","type":"Session","nullable":true},{"name":"error","type":"RPCError","nullable":true}],"stateCandidates":[],"expandable":[]},{"name":"CreateSessionResponse","schema":"CreateSessionResponse","fields":[{"name":"branch","type":"string","nullable":false},{"name":"user","type":"User","nullable":true},{"name":"session","type":"Session","nullable":true}],"stateCandidates":[],"expandable":[]},{"name":"ExecuteResponse","schema":"ExecuteResponse","fields":[{"name":"session","type":"Session","nullable":true},{"name":"result","type":"QueryResult","nullable":true},{"name":"error","type":"RPCError","nullable":true},{"name":"timing","type":"number","nullable":false}],"stateCandidates":[],"expandable":[]},{"name":"PrepareResponse","schema":"PrepareResponse","fields":[{"name":"session","type":"Session","nullable":true},{"name":"fields","type":"array","nullable":false},{"name":"error","type":"RPCError","nullable":true},{"name":"paramsCount","type":"integer","nullable":false}],"stateCandidates":[],"expandable":[]}],"operations":[{"id":"CreateSession","method":"post","path":"/psdb.v1alpha1.Database/CreateSession","pathParams":[],"query":[],"body":[],"bodyEncoding":"json","successStatus":200,"answers":{"resource":"CreateSessionResponse","list":false},"class":"action"},{"id":"Execute","method":"post","path":"/psdb.v1alpha1.Database/Execute","pathParams":[],"query":[],"body":[{"name":"bindVariables","type":"object","required":false},{"name":"prepared","type":"boolean","required":false},{"name":"query","type":"string","required":false},{"name":"session","type":"Session","required":false}],"bodyEncoding":"json","successStatus":200,"answers":{"resource":"ExecuteResponse","list":false},"class":"action"},{"id":"StreamExecute","method":"post","path":"/psdb.v1alpha1.Database/StreamExecute","pathParams":[],"query":[],"body":[{"name":"bindVariables","type":"object","required":false},{"name":"prepared","type":"boolean","required":false},{"name":"query","type":"string","required":false},{"name":"session","type":"Session","required":false}],"bodyEncoding":"json","successStatus":200,"streams":true,"answers":{"resource":"ExecuteResponse","list":false},"class":"action"},{"id":"Prepare","method":"post","path":"/psdb.v1alpha1.Database/Prepare","pathParams":[],"query":[],"body":[{"name":"bindVariables","type":"object","required":false},{"name":"query","type":"string","required":false},{"name":"session","type":"Session","required":false}],"bodyEncoding":"json","successStatus":200,"answers":{"resource":"PrepareResponse","list":false},"class":"action"},{"id":"CloseSession","method":"post","path":"/psdb.v1alpha1.Database/CloseSession","pathParams":[],"query":[],"body":[{"name":"session","type":"Session","required":false}],"bodyEncoding":"json","successStatus":200,"answers":{"resource":"CloseSessionResponse","list":false},"class":"action"}]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"generatedBy":"scripts/derive-pack.ts","resources":{"Session":{"actions":[],"states":{"closed":["false","true"]},"embeds":{}}}}
|