@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 @@
|
|
|
1
|
+
{"generatedBy":"scripts/derive-pack.ts","resources":{"Database":{"retrieve":"/v1/organizations/{organization}/databases/{database}","create":{"path":"/v1/organizations/{organization}/databases","encoding":"json","body":[{"name":"cluster_size","type":"string","required":true},{"name":"kind","type":"string","required":false},{"name":"major_version","type":"string","required":false},{"name":"name","type":"string","required":true},{"name":"region","type":"string","required":false},{"name":"replicas","type":"integer","required":false},{"name":"storage","type":"object","required":false}]},"actions":[],"states":{"state":["pending","ready"]},"embeds":{}},"Backup":{"retrieve":"/v1/organizations/{organization}/databases/{database}/branches/{branch}/backups/{id}","create":{"path":"/v1/organizations/{organization}/databases/{database}/branches/{branch}/backups","encoding":"json","body":[{"name":"emergency","type":"boolean","required":false},{"name":"name","type":"string","required":false},{"name":"retention_unit","type":"string","required":false},{"name":"retention_value","type":"integer","required":false}]},"actions":[],"states":{"state":["pending","running","success"]},"embeds":{}},"DatabaseBranchPassword":{"retrieve":"/v1/organizations/{organization}/databases/{database}/branches/{branch}/passwords/{id}","actions":[],"states":{},"embeds":{}},"ServiceToken":{"create":{"path":"/v1/organizations/{organization}/service-tokens","encoding":"json","body":[{"name":"name","type":"string","required":false},{"name":"ttl","type":"integer","required":false}]},"actions":[],"states":{},"embeds":{}}}}
|
package/api/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The management API lane of the planetscale pack (`api.planetscale.com/v1`), as a plain fetch: the whole pack's fetch
|
|
2
|
+
// (the psdb data plane, the management API and app.planetscale.com's pages), whose owners are the operations this lane
|
|
3
|
+
// serves. Its life walks through all of them: a customer of the management API makes its tokens on the settings page,
|
|
4
|
+
// approves deploy requests on their page, and runs its migrations and queries through psdb on the branches the API makes
|
|
5
|
+
// (journeys/customer-life.outline.md).
|
|
6
|
+
import type { DerivedFetch } from '@volter/world-core';
|
|
7
|
+
import { createPlanetscaleTwinFetch } from '../../src/planetscale-server.ts';
|
|
8
|
+
import { createPlanetscaleApiLaneFetch } from './fetch.ts';
|
|
9
|
+
|
|
10
|
+
export { manifest } from './manifest.ts';
|
|
11
|
+
export { createPlanetscaleApiLaneFetch } from './fetch.ts';
|
|
12
|
+
// how a journey reads a psdb answer: the pack's own view (../../src/index.ts)
|
|
13
|
+
export { answerViews } from '../../src/index.ts';
|
|
14
|
+
|
|
15
|
+
export function createPlanetscaleApiTwinFetch(options: { root?: string; readOnly?: boolean } = {}): DerivedFetch {
|
|
16
|
+
const lane = createPlanetscaleApiLaneFetch(options);
|
|
17
|
+
const pack = createPlanetscaleTwinFetch({ ...(options.root !== undefined ? { root: options.root } : {}), ...(options.readOnly !== undefined ? { readOnly: options.readOnly } : {}) });
|
|
18
|
+
return Object.assign((request: Request) => pack(request), { owners: () => lane.owners() });
|
|
19
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// PlanetScale's management API manifest (the pack's `api` lane; docs/contributing/architecture.md, "Other wires: lanes"):
|
|
2
|
+
// the vendor facts its published Swagger document (../spec/openapi.json.gz) does not carry, and the resources whose state
|
|
3
|
+
// the lane moves. The surface is generated (./generated/surface.gen.json, by `bun scripts/derive-pack.ts planetscale/api`).
|
|
4
|
+
//
|
|
5
|
+
// The lane serves the slice an application and the pack's customer lives reach: databases (create, read, delete), their
|
|
6
|
+
// branches (create from a parent or a backup, read, list, delete; semantics/branches.ts), a branch's passwords (create,
|
|
7
|
+
// list, read, delete), a branch's backups (create, list, read, delete) and service tokens (create, list). Every other
|
|
8
|
+
// operation of the spec is the gap. The psdb data plane (the pack root) reads the passwords
|
|
9
|
+
// this lane issues, so both write the one kernel service `planetscale`; the lane's records are control-plane records the
|
|
10
|
+
// pack never pushes to a real branch, so each is stored under a `_` type, beside psdb's `_session`.
|
|
11
|
+
//
|
|
12
|
+
// NO `auth`: PlanetScale reads a service token as `Authorization: <SERVICE_TOKEN_ID>:<SERVICE_TOKEN>`, with no scheme
|
|
13
|
+
// (planetscale.com/docs/api/reference, "Service tokens"), which the kernel's scheme-prefixed gate cannot state. The lane's
|
|
14
|
+
// token gate (token-gate.ts) sits in front of the dispatch instead, and checks each operation's service token
|
|
15
|
+
// accesses as the spec's own "Authorization" section for it lists them.
|
|
16
|
+
import type { DerivedManifest, StateField } from '@volter/world-core';
|
|
17
|
+
|
|
18
|
+
/** A database's `state`. A new database is `pending` and not `ready`; PlanetScale provisions it, and a caller polls:
|
|
19
|
+
* "After creation, poll `get_database` until `ready` is `true` before creating branches or backups"
|
|
20
|
+
* (https://planetscale.com/docs/api/reference/create_database). The twin's database turns ready one minute after it was
|
|
21
|
+
* created, on the World clock (semantics/time.ts): how long provisioning takes is the twin's decision, the reference
|
|
22
|
+
* gives none. Not made by the twin: importing, import_ready, sleep_in_progress, sleeping, awakening. */
|
|
23
|
+
const databaseState: StateField = {
|
|
24
|
+
initial: 'pending',
|
|
25
|
+
transitions: [
|
|
26
|
+
{ actor: 'time', from: ['pending'], to: 'ready', source: 'spec:/definitions/Database/properties/ready "If the database is ready to be used"' },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A backup's `state`. A new backup is `pending`; it starts `running` and ends `success`, and a caller polls: "Use
|
|
31
|
+
* `get_backup` to poll the current state of a backup" (https://planetscale.com/docs/api/reference/create_backup). The
|
|
32
|
+
* twin's backup starts 30 seconds after it was asked for and completes two minutes after (semantics/time.ts), its size
|
|
33
|
+
* the branch's stored rows at completion: the timing is the twin's decision, the reference gives none. Not made by the
|
|
34
|
+
* twin: failed, canceled, ignored. */
|
|
35
|
+
const backupState: StateField = {
|
|
36
|
+
initial: 'pending',
|
|
37
|
+
transitions: [
|
|
38
|
+
{ actor: 'time', from: ['pending'], to: 'running', source: 'spec:/definitions/Backup/properties/state "The current state of the backup"' },
|
|
39
|
+
{ actor: 'time', from: ['running'], to: 'success', source: 'spec:/definitions/Backup/properties/state "The current state of the backup"' },
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** A branch's `state`. A new branch is `pending` and not `ready`; PlanetScale's CI example waits for it before using it
|
|
44
|
+
* (https://planetscale.com/docs/vitess/integrations/github-actions: "we create it and pass the `--wait` flag"). The twin's
|
|
45
|
+
* branch turns ready one minute after it was made, on the World clock (semantics/time.ts): the delay is the twin's
|
|
46
|
+
* decision. Not made by the twin: sleep_in_progress, sleeping, awakening. */
|
|
47
|
+
const branchState: StateField = {
|
|
48
|
+
initial: 'pending',
|
|
49
|
+
transitions: [
|
|
50
|
+
{ actor: 'time', from: ['pending'], to: 'ready', source: 'spec:/definitions/DatabaseBranch/properties/state "The current state of the branch"' },
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const DR_PAGE = 'https://planetscale.com/docs/vitess/schema-changes/deploy-requests';
|
|
55
|
+
|
|
56
|
+
/** A deploy request's `state`: open until it is closed, by `close_deploy_request`, by a revert ("The deploy request will
|
|
57
|
+
* be closed", the deploy-requests page), or when its deployment completes (the twin's reading: the request stays open
|
|
58
|
+
* through its revert window). */
|
|
59
|
+
const deployRequestState: StateField = {
|
|
60
|
+
initial: 'open',
|
|
61
|
+
transitions: [
|
|
62
|
+
{ operation: 'close_deploy_request', from: ['open'], to: 'closed', source: 'spec:/paths/~1organizations~1{organization}~1databases~1{database}~1deploy-requests~1{number}/patch "The deploy request will be updated to this state"' },
|
|
63
|
+
{ operation: 'complete_revert', from: ['open'], to: 'closed', source: `${DR_PAGE} "The deploy request will be closed, but the branch will remain"` },
|
|
64
|
+
{ actor: 'time', from: ['open'], to: 'closed', source: `${DR_PAGE} "After the 30 minute period is up, the deployment becomes permanent"` },
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** A deploy request's `deployment_state` (semantics/deploy-requests.ts): checked, queued, deployed into its revert window,
|
|
69
|
+
* then permanent or reverted. The durations are the twin's. Not made by the twin: submitting, pending_cutover,
|
|
70
|
+
* in_progress_vschema, in_progress_cancel, in_progress_cutover, complete_cancel, complete_error, in_progress_revert,
|
|
71
|
+
* in_progress_revert_vschema, complete_revert_error, cancelled, error. */
|
|
72
|
+
const deploymentState: StateField = {
|
|
73
|
+
initial: 'pending',
|
|
74
|
+
transitions: [
|
|
75
|
+
{ actor: 'time', from: ['pending'], to: 'ready', source: `${DR_PAGE} "PlanetScale will check if the request is deployable"` },
|
|
76
|
+
{ actor: 'time', from: ['pending'], to: 'no_changes', source: 'spec:/definitions/DatabaseDeployRequest/properties/deployment_state "The deployment state of the deploy request"' },
|
|
77
|
+
{ operation: 'queue_deploy_request', from: ['ready'], to: 'queued', source: `${DR_PAGE} "The deployment will begin immediately, or join the serial deploy queue if other deployments are already pending"` },
|
|
78
|
+
{ actor: 'time', from: ['queued'], to: 'in_progress', source: `${DR_PAGE} "only one deploy runs at a time, and later requests wait until earlier ones finish"` },
|
|
79
|
+
{ actor: 'time', from: ['in_progress'], to: 'complete_pending_revert', source: `${DR_PAGE} "After you deploy, you have 30 minutes to \"undo\" it"` },
|
|
80
|
+
{ actor: 'time', from: ['complete_pending_revert'], to: 'complete', source: `${DR_PAGE} "After the 30 minute period is up, the deployment becomes permanent"` },
|
|
81
|
+
{ actor: 'time', from: ['in_progress'], to: 'complete', source: `${DR_PAGE} "these schema changes must be auto-applied and cannot be reverted"` },
|
|
82
|
+
{ operation: 'complete_revert', from: ['complete_pending_revert'], to: 'complete_revert', source: `${DR_PAGE} "You can revert a deployment for up to 30 minutes after the deploying"` },
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const manifest: DerivedManifest = {
|
|
87
|
+
vendor: 'planetscale',
|
|
88
|
+
service: 'planetscale',
|
|
89
|
+
body: { json: 'always' },
|
|
90
|
+
// PlanetScale's ids are opaque strings; the handlers mint each from the instant, the name and the count
|
|
91
|
+
// (semantics/shared.ts, `mintId`), prefixed per resource, so a World mints the same ids every run. The core mints none.
|
|
92
|
+
ids: { template: '{prefix}{n}' },
|
|
93
|
+
time: 'iso',
|
|
94
|
+
// the management API's error body (the pack's backups have answered it since they were served)
|
|
95
|
+
error: { code: '{code}', message: '{message}' },
|
|
96
|
+
readOnly: { status: 403, code: 'forbidden', message: 'The twin is read-only' },
|
|
97
|
+
malformedBody: { status: 422, code: 'unprocessable_entity', message: 'The request body is not valid JSON' },
|
|
98
|
+
notFound: { status: 404, code: 'not_found', message: 'Not Found' },
|
|
99
|
+
// the spec's Paginated* envelope; the lists are handlers (semantics/shared.ts, `page`)
|
|
100
|
+
list: {
|
|
101
|
+
style: 'envelope',
|
|
102
|
+
envelope: { type: 'list', data: '{data}' },
|
|
103
|
+
limit: { param: 'per_page', default: 25, max: 100 },
|
|
104
|
+
page: { param: 'page', link: false },
|
|
105
|
+
},
|
|
106
|
+
deleted: {},
|
|
107
|
+
// operations of the declared resources the lane does not serve: the gap
|
|
108
|
+
unmodeled: ['update_branch', 'update_backup_policy', 'delete_backup_policy', 'update_backup', 'update_password', 'get_service_token', 'get_oauth_token', 'delete_oauth_token'],
|
|
109
|
+
// the page where a service token is made and granted its accesses (no API operation grants an access)
|
|
110
|
+
screens: [{
|
|
111
|
+
id: 'sign-in', kind: 'flow', host: 'app.planetscale.com', path: '/sign-in',
|
|
112
|
+
demand: 'every app.planetscale.com page a person reaches: each asks who is signed in', status: 'done',
|
|
113
|
+
controls: ['Email', 'Password', 'Sign in'], source: 'https://planetscale.com/docs/api/reference/service-tokens',
|
|
114
|
+
}, {
|
|
115
|
+
id: 'service-tokens', kind: 'flow', host: 'app.planetscale.com', path: '/{organization}/settings/service-tokens',
|
|
116
|
+
demand: 'the first service token of an organization, and the accesses of every token, are made here',
|
|
117
|
+
status: 'done', controls: ['New service token', 'Name', 'Create service token', 'Service token', 'Database', 'Access', 'Save permissions', 'Delete service token'],
|
|
118
|
+
source: 'https://planetscale.com/docs/api/reference/service-tokens',
|
|
119
|
+
}, {
|
|
120
|
+
id: 'deploy-request', kind: 'flow', host: 'app.planetscale.com', path: '/{organization}/{database}/deploy-requests/{number}',
|
|
121
|
+
demand: 'a deploy request is approved by an administrator other than its creator here', status: 'done',
|
|
122
|
+
controls: ['Summary', 'Schema changes', 'Approve changes'],
|
|
123
|
+
source: 'https://planetscale.com/docs/vitess/schema-changes/deploy-requests',
|
|
124
|
+
}],
|
|
125
|
+
resources: {
|
|
126
|
+
Database: { storedAs: '_database', idPrefix: 'db', notFound: 'Not Found', notState: ['kind'], state: { state: databaseState } },
|
|
127
|
+
DatabaseBranch: { storedAs: '_branch', idPrefix: 'br', notFound: 'Not Found', notState: ['kind'], state: { state: branchState } },
|
|
128
|
+
DatabaseDeployRequest: { storedAs: '_deploy_request', idPrefix: 'dr', notFound: 'Not Found', state: { state: deployRequestState, deployment_state: deploymentState } },
|
|
129
|
+
BackupPolicy: { storedAs: '_backup_policy', idPrefix: 'bp', notFound: 'Not Found' },
|
|
130
|
+
Backup: { storedAs: '_backup', idPrefix: 'bk', notFound: 'Not Found', state: { state: backupState } },
|
|
131
|
+
// a password's role is set when it is made: "once a password is created, its role assignment cannot be changed"
|
|
132
|
+
// (planetscale.com/docs/vitess/connecting/password-roles)
|
|
133
|
+
DatabaseBranchPassword: { storedAs: '_password', idPrefix: 'pw', notFound: 'Not Found', notState: ['role'] },
|
|
134
|
+
ServiceToken: { storedAs: '_service_token', idPrefix: 'tk', notFound: 'Not Found' },
|
|
135
|
+
},
|
|
136
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// PLANETSCALE'S DEPLOY REQUEST PAGE — a flow (docs/contributing/architecture.md, "Screens"): the page at
|
|
2
|
+
// app.planetscale.com/{organization}/{database}/deploy-requests/{number}, where a teammate reads a deploy request's schema
|
|
3
|
+
// changes and approves it. https://planetscale.com/docs/vitess/schema-changes/deploy-requests, "Review a deploy request":
|
|
4
|
+
// "Under "Summary", you'll see if the request is deployable. To review the schema changes, click the "Schema changes"
|
|
5
|
+
// tab"; "If you have required deploy requests to be approved before deployment, other users in your Organization will
|
|
6
|
+
// see the option to "Approve changes" or "Leave a comment" on the "Schema changes" tab." Built from @volter/world-ui's
|
|
7
|
+
// settings layout (Portal) under PlanetScale's skin; nothing of PlanetScale's page is copied. Its controls are the ones
|
|
8
|
+
// the lane's life needs: the Summary, the Schema changes, and "Approve changes".
|
|
9
|
+
//
|
|
10
|
+
// The person on the page is who signed in at /sign-in (screens/session.tsx); only a member of the organization reaches
|
|
11
|
+
// it (another answers PlanetScale's 404). Where the documentation stops and the twin decides: approving takes an
|
|
12
|
+
// administrator (the setting "Require administrator approval for deploy requests" names an administrator's approval),
|
|
13
|
+
// the page posts the approval to `…/deploy-requests/{number}/review` and comes back to the page, and the approval is the
|
|
14
|
+
// same review the API's review_deploy_request records (semantics/deploy-requests.ts), with the person as reviewer.
|
|
15
|
+
import { flowPage, Portal, PORTAL_CSS } from '@volter/world-ui';
|
|
16
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
17
|
+
import { deploymentOf, recordReview, reviewsOf } from '../semantics/deploy-requests.ts';
|
|
18
|
+
import { createdById, personActorOf } from '../semantics/audit.ts';
|
|
19
|
+
import { signedIn, toSignIn } from './session.tsx';
|
|
20
|
+
|
|
21
|
+
type Row = Record<string, unknown>;
|
|
22
|
+
const PAGE = /^\/([^/]+)\/([^/]+)\/deploy-requests\/(\d+)(?:\/(review))?$/;
|
|
23
|
+
const SKIN = `
|
|
24
|
+
body { background: #ffffff; color: #111111; }
|
|
25
|
+
.portal-side { background: #fafafa; border-right: 1px solid #e5e5e5; }
|
|
26
|
+
.portal-section h2 { border-color: #e5e5e5; color: #525252; }
|
|
27
|
+
.portal-notice { background: #f0f7ff; border: 1px solid #b6d4fe; }
|
|
28
|
+
.portal-button { background: #ffffff; border: 1px solid #d4d4d4; border-radius: 6px; padding: 6px 12px; }
|
|
29
|
+
.portal-primary { background: #1d4ed8; border-color: #1d4ed8; color: #ffffff; }
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
/** The spec's actor object for a person on a page. */
|
|
33
|
+
export const personActor = (email: string): Row => ({ id: createdById(email), display_name: email, avatar_url: 'https://app.planetscale.com/gravatar-fallback.png' });
|
|
34
|
+
|
|
35
|
+
function page(ctx: SemanticsContext, org: string, database: string, dr: Row, canApprove: boolean, notice?: string, status = 200): Response {
|
|
36
|
+
const deployment = deploymentOf(dr);
|
|
37
|
+
const ops = deployment.deploy_operations as Row[];
|
|
38
|
+
const reviews = reviewsOf(ctx, dr);
|
|
39
|
+
const action = `/${org}/${database}/deploy-requests/${String(dr.number)}/review`;
|
|
40
|
+
const approvable = canApprove && dr.state === 'open' && dr.approved !== true;
|
|
41
|
+
return flowPage({
|
|
42
|
+
title: `Deploy request #${String(dr.number)} · ${database} · PlanetScale`,
|
|
43
|
+
css: [PORTAL_CSS, SKIN],
|
|
44
|
+
status,
|
|
45
|
+
body: (
|
|
46
|
+
<Portal
|
|
47
|
+
merchant={`${org} / ${database}`}
|
|
48
|
+
back={{ href: `/${org}/${database}`, label: 'Back to database' }}
|
|
49
|
+
{...(notice ? { notice } : {})}
|
|
50
|
+
sections={[
|
|
51
|
+
{
|
|
52
|
+
heading: 'Summary',
|
|
53
|
+
items: [{
|
|
54
|
+
title: `#${String(dr.number)}: ${String(dr.branch)} → ${String(dr.into_branch)}`,
|
|
55
|
+
detail: deployment.deployable === true ? 'Deployable' : 'Not deployable',
|
|
56
|
+
note: `${String(dr.state)} · ${String(dr.deployment_state)}${dr.approved === true ? ' · Approved' : ''}${dr.notes ? ` · ${String(dr.notes)}` : ''}`,
|
|
57
|
+
...(approvable ? { actions: [{ label: 'Approve changes', action, fields: { state: 'approved' }, tone: 'primary' as const, method: 'post' as const }] } : {}),
|
|
58
|
+
}],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
heading: 'Schema changes',
|
|
62
|
+
empty: 'No schema changes.',
|
|
63
|
+
items: ops.map((o) => ({ title: String(o.table_name), detail: String(o.ddl_statement), ...(o.can_drop_data === true ? { badge: 'Drops data' } : {}) })),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
heading: 'Reviews',
|
|
67
|
+
empty: 'No reviews yet.',
|
|
68
|
+
items: reviews.map((r) => ({ title: String((r.actor as Row).display_name), detail: String(r.state), note: String(r.created_at) })),
|
|
69
|
+
},
|
|
70
|
+
]}
|
|
71
|
+
/>
|
|
72
|
+
),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The page again with the refusal an approval drew (a creator approving their own request, or a token's maker its
|
|
77
|
+
* request), in the page's notice. */
|
|
78
|
+
async function approvalRefused(out: Response, again: (text: string, status: number) => Response): Promise<Response> {
|
|
79
|
+
return again(((await out.json()) as { message?: string }).message ?? 'The approval was refused.', out.status);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function formOf(request: Request): Promise<Record<string, string>> {
|
|
83
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The deploy request page, or undefined for any other request. `contextFor` gives the lane's semantics context. */
|
|
87
|
+
export async function deployRequestPage(request: Request, contextFor: (request: Request) => Promise<SemanticsContext>): Promise<Response | undefined> {
|
|
88
|
+
const url = new URL(request.url);
|
|
89
|
+
const m = PAGE.exec(url.pathname.replace(/\/+$/, ''));
|
|
90
|
+
if (!m) return undefined;
|
|
91
|
+
const [, org, database, number, review] = m.map((x) => (x === undefined ? x : decodeURIComponent(x))) as [string, string, string, string, string | undefined];
|
|
92
|
+
const ctx = await contextFor(new Request(request.url));
|
|
93
|
+
const person = signedIn(ctx, request);
|
|
94
|
+
if (!person) return toSignIn(request);
|
|
95
|
+
const notFound = new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
96
|
+
if (!person.organizations.includes(org)) return notFound;
|
|
97
|
+
const db = ctx.rowsRaw('Database').find((d) => d.name === database && !d._deleted_at);
|
|
98
|
+
const find = (c: SemanticsContext): Row | undefined => c.rowsRaw('DatabaseDeployRequest').find((d) => d._database === database && String(d.number) === number);
|
|
99
|
+
const dr = db ? find(ctx) : undefined;
|
|
100
|
+
if (!db || !dr) return notFound;
|
|
101
|
+
const admin = person.role === 'admin';
|
|
102
|
+
if (!review && request.method === 'GET') return page(ctx, org, database, dr, admin);
|
|
103
|
+
if (!review || request.method !== 'POST') return undefined;
|
|
104
|
+
const form = await formOf(request);
|
|
105
|
+
if (form.state !== 'approved') return page(ctx, org, database, dr, admin, 'Choose Approve changes.', 422);
|
|
106
|
+
if (!admin) return page(ctx, org, database, dr, admin, 'Only an organization administrator can approve a deploy request.', 403);
|
|
107
|
+
const out = await recordReview(ctx, db, dr, `person:${person.email}`, personActor(person.email), 'approved', '', personActorOf(person.email));
|
|
108
|
+
const after = await contextFor(new Request(request.url));
|
|
109
|
+
if (out instanceof Response) return approvalRefused(out, (text, status) => page(after, org, database, find(after)!, admin, text, status));
|
|
110
|
+
return page(after, org, database, find(after)!, admin, `You approved deploy request #${number}.`);
|
|
111
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// PLANETSCALE'S SERVICE TOKENS PAGE — a flow (docs/contributing/architecture.md, "Screens"): the organization's settings
|
|
2
|
+
// page at app.planetscale.com/{organization}/settings/service-tokens, where a token is made and granted its accesses. No
|
|
3
|
+
// operation of the management API grants an access, and the first token of an organization can only come from here.
|
|
4
|
+
// https://planetscale.com/docs/api/reference/service-tokens: "Navigate to Settings > Service tokens > New service token.
|
|
5
|
+
// Provide a descriptive name and click Create service token", after which the Service Token ID and the Service Token are
|
|
6
|
+
// shown to be stored; organization and database accesses are checked on the token's settings and saved with "Save
|
|
7
|
+
// permissions". Built from @volter/world-ui's settings layout (Portal) under PlanetScale's skin; nothing of PlanetScale's
|
|
8
|
+
// page is copied. Its controls are the ones the pack's lives need: "New service token" with its Name and "Create service
|
|
9
|
+
// token"; the permissions form with its Service token, Database and Access, and "Save permissions"; and each token's
|
|
10
|
+
// "Delete service token" ("You can delete a service token at any time from the service token detail page. Simply click
|
|
11
|
+
// the "Delete service token" button", https://planetscale.com/docs/cli/service-tokens).
|
|
12
|
+
//
|
|
13
|
+
// The person on the page is who signed in at /sign-in (screens/session.tsx): a visitor nobody signed in as is sent there
|
|
14
|
+
// first, and only a person of the organization reaches its settings (another answers PlanetScale's 404).
|
|
15
|
+
//
|
|
16
|
+
// Where the documentation stops and the twin decides: a database access names a database this
|
|
17
|
+
// lane created, and "Organization" grants the access across the organization; one access is saved per submission; the
|
|
18
|
+
// accesses offered are the ones the spec's operations this lane serves name. Each form posts and comes back to the page,
|
|
19
|
+
// and the new token is shown once in the page's notice.
|
|
20
|
+
import { flowPage, Portal, PORTAL_CSS } from '@volter/world-ui';
|
|
21
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
22
|
+
import { makeServiceToken, removeServiceToken } from '../semantics/index.ts';
|
|
23
|
+
import { accessOf } from '../semantics/shared.ts';
|
|
24
|
+
import { audit, personActorOf } from '../semantics/audit.ts';
|
|
25
|
+
import { signedIn, toSignIn } from './session.tsx';
|
|
26
|
+
|
|
27
|
+
type Row = Record<string, unknown>;
|
|
28
|
+
const PAGE = /^\/([^/]+)\/settings\/service-tokens(?:\/(accesses|delete))?$/;
|
|
29
|
+
|
|
30
|
+
// PlanetScale's skin over the settings layout: its near-black ink, grey rules and its blue primary button
|
|
31
|
+
const SKIN = `
|
|
32
|
+
body { background: #ffffff; color: #111111; }
|
|
33
|
+
.portal-side { background: #fafafa; border-right: 1px solid #e5e5e5; }
|
|
34
|
+
.portal-section h2 { border-color: #e5e5e5; color: #525252; }
|
|
35
|
+
.portal-notice { background: #f0f7ff; border: 1px solid #b6d4fe; font-family: ui-monospace, monospace; word-break: break-all; }
|
|
36
|
+
.portal-button { background: #ffffff; border: 1px solid #d4d4d4; border-radius: 6px; padding: 6px 12px; }
|
|
37
|
+
.portal-primary { background: #1d4ed8; border-color: #1d4ed8; color: #ffffff; }
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
/** The accesses the page offers: those the operations of this lane name (token-gate.ts). */
|
|
41
|
+
const ACCESS_OPTIONS = [
|
|
42
|
+
// organization accesses
|
|
43
|
+
'create_databases', 'read_audit_logs', 'read_service_tokens', 'write_service_tokens', 'delete_service_tokens',
|
|
44
|
+
// database accesses
|
|
45
|
+
'read_database', 'write_database', 'delete_database', 'create_branch', 'read_branch', 'delete_branch', 'delete_production_branch',
|
|
46
|
+
'connect_branch', 'connect_production_branch', 'delete_branch_password', 'delete_production_branch_password',
|
|
47
|
+
'create_deploy_request', 'read_deploy_request', 'approve_deploy_request',
|
|
48
|
+
'read_backups', 'write_backups', 'delete_backups', 'restore_backup', 'restore_production_branch_backup',
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
type Access = { id: string; access: string; description: string; resource_name: string; resource_type: string };
|
|
52
|
+
|
|
53
|
+
function page(ctx: SemanticsContext, org: string, notice?: string, status = 200): Response {
|
|
54
|
+
const tokens = ctx.rowsRaw('ServiceToken').filter((t) => !t._deleted_at);
|
|
55
|
+
const databases = ctx.rowsRaw('Database').filter((d) => !d._deleted_at).map((d) => String(d.name));
|
|
56
|
+
const action = `/${org}/settings/service-tokens`;
|
|
57
|
+
return flowPage({
|
|
58
|
+
title: `Service tokens · ${org} · PlanetScale`,
|
|
59
|
+
css: [PORTAL_CSS, SKIN],
|
|
60
|
+
status,
|
|
61
|
+
body: (
|
|
62
|
+
<Portal
|
|
63
|
+
merchant={`${org} · Settings`}
|
|
64
|
+
back={{ href: `/${org}`, label: 'Back to organization' }}
|
|
65
|
+
{...(notice ? { notice } : {})}
|
|
66
|
+
sections={[{
|
|
67
|
+
heading: 'Service tokens',
|
|
68
|
+
empty: 'No service tokens yet.',
|
|
69
|
+
items: tokens.map((t) => ({
|
|
70
|
+
title: String(t.display_name),
|
|
71
|
+
detail: `ID ${String(t.id)}`,
|
|
72
|
+
note: ((t.service_token_accesses ?? []) as Access[]).map((a) => `${a.access} (${a.resource_type === 'Organization' ? 'organization' : a.resource_name})`).join(', ') || 'No accesses',
|
|
73
|
+
actions: [{ label: 'Delete service token', action: `${action}/delete`, fields: { token: String(t.id) }, tone: 'danger' as const, method: 'post' as const }],
|
|
74
|
+
})),
|
|
75
|
+
}]}
|
|
76
|
+
forms={[
|
|
77
|
+
{ heading: 'New service token', action, fields: [{ id: 'name', label: 'Name' }], submit: { label: 'Create service token' } },
|
|
78
|
+
...(tokens.length ? [{
|
|
79
|
+
heading: 'Permissions',
|
|
80
|
+
action: `${action}/accesses`,
|
|
81
|
+
fields: [
|
|
82
|
+
{ id: 'token', label: 'Service token', options: tokens.map((t) => ({ value: String(t.id), label: String(t.display_name) })) },
|
|
83
|
+
{ id: 'database', label: 'Database', options: [{ value: '', label: 'Organization' }, ...databases.map((d) => ({ value: d, label: d }))] },
|
|
84
|
+
{ id: 'access', label: 'Access', options: ACCESS_OPTIONS.map((a) => ({ value: a, label: a })) },
|
|
85
|
+
],
|
|
86
|
+
submit: { label: 'Save permissions' },
|
|
87
|
+
}] : []),
|
|
88
|
+
]}
|
|
89
|
+
/>
|
|
90
|
+
),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function formOf(request: Request): Promise<Record<string, string>> {
|
|
95
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The service tokens page, or undefined for any other request. `contextFor` gives the lane's semantics context. */
|
|
99
|
+
export async function serviceTokensPage(request: Request, contextFor: (request: Request) => Promise<SemanticsContext>): Promise<Response | undefined> {
|
|
100
|
+
const url = new URL(request.url);
|
|
101
|
+
const m = PAGE.exec(url.pathname.replace(/\/+$/, ''));
|
|
102
|
+
if (!m) return undefined;
|
|
103
|
+
const org = decodeURIComponent(m[1]!);
|
|
104
|
+
const accesses = m[2] === 'accesses';
|
|
105
|
+
const deleting = m[2] === 'delete';
|
|
106
|
+
const person = signedIn(await contextFor(new Request(request.url)), request);
|
|
107
|
+
if (!person) return toSignIn(request);
|
|
108
|
+
if (!person.organizations.includes(org)) return new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
|
|
109
|
+
if (request.method === 'GET' && !accesses && !deleting) return page(await contextFor(new Request(request.url)), org);
|
|
110
|
+
if (request.method !== 'POST') return undefined;
|
|
111
|
+
const form = await formOf(request);
|
|
112
|
+
const ctx = await contextFor(new Request(request.url));
|
|
113
|
+
if (deleting) {
|
|
114
|
+
const doomed = ctx.rowsRaw('ServiceToken').find((t) => t.id === form.token && !t._deleted_at);
|
|
115
|
+
if (!doomed) return page(ctx, org, 'Choose a service token.', 422);
|
|
116
|
+
await removeServiceToken(ctx, doomed, org, personActorOf(person.email));
|
|
117
|
+
return page(await contextFor(new Request(request.url)), org, `Service token ${String(doomed.display_name)} deleted.`);
|
|
118
|
+
}
|
|
119
|
+
if (!accesses) {
|
|
120
|
+
const name = (form.name ?? '').trim();
|
|
121
|
+
if (!name) return page(ctx, org, 'A service token needs a name.', 422);
|
|
122
|
+
const { record, token } = await makeServiceToken(ctx, name, [], person.email, org);
|
|
123
|
+
// PlanetScale shows the new token's ID and value once; the notice is where a person copies them from
|
|
124
|
+
return page(await contextFor(new Request(request.url)), org, `Service token ${name} created. ID: ${String(record.id)} Token: ${token} — copy the token now: it will not be shown again.`);
|
|
125
|
+
}
|
|
126
|
+
const token = ctx.rowsRaw('ServiceToken').find((t) => t.id === form.token && !t._deleted_at);
|
|
127
|
+
if (!token) return page(ctx, org, 'Choose a service token.', 422);
|
|
128
|
+
if (!ACCESS_OPTIONS.includes(form.access ?? '')) return page(ctx, org, 'Choose an access.', 422);
|
|
129
|
+
const database = form.database ?? '';
|
|
130
|
+
const held = (token.service_token_accesses ?? []) as Access[];
|
|
131
|
+
const resource = database ? { resource_type: 'Database', resource_name: database } : { resource_type: 'Organization', resource_name: org };
|
|
132
|
+
if (!held.some((a) => a.access === form.access && a.resource_type === resource.resource_type && a.resource_name === resource.resource_name)) {
|
|
133
|
+
const granted = accessOf(ctx, `${String(token.id)}-${held.length + 1}`, form.access!, resource.resource_type as 'Database' | 'Organization', resource.resource_name) as Access;
|
|
134
|
+
await ctx.write('ServiceToken', String(token.id), { ...ctx.own(token as Row), service_token_accesses: [...held, granted], updated_at: ctx.occurredAt }, 'service_token.grant');
|
|
135
|
+
// saving a token's database accesses is audited as "service_token … updated_bulk_database_access"
|
|
136
|
+
// (https://planetscale.com/docs/security/audit-log, "Audited organization events"); the table names no event for an
|
|
137
|
+
// organization access
|
|
138
|
+
if (database) await audit(ctx, org, 'service_token', 'updated_bulk_database_access', { type: 'ServiceToken', id: String(token.id), name: String(token.display_name) }, { actor: personActorOf(person.email), metadata: { database, access: form.access } });
|
|
139
|
+
}
|
|
140
|
+
return page(await contextFor(new Request(request.url)), org, `${form.access} added to ${String(token.display_name)} on ${database || 'the organization'}.`);
|
|
141
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// PLANETSCALE'S SIGN-IN — the door before every app.planetscale.com page (docs/contributing/architecture.md, "Who is on a
|
|
2
|
+
// screen"), following github's (packages/twin/github/src/screens/session.tsx): app.planetscale.com/sign-in shows the form,
|
|
3
|
+
// which posts back to /sign-in; the right password for the account signs its person in, and a session cookie names them
|
|
4
|
+
// to every page after. A page asked for by nobody signed in sends its visitor to the sign-in with `return_to`, and the
|
|
5
|
+
// sign-in returns them there. Built from @volter/world-ui's sign-in piece under PlanetScale's skin; nothing of
|
|
6
|
+
// PlanetScale's page is copied.
|
|
7
|
+
//
|
|
8
|
+
// Where PlanetScale's documentation stops and the twin decides: a person's account is their email; their password and the
|
|
9
|
+
// organization they own are what the World gave them (`POST /_twin/users/:email/password` {password, organization},
|
|
10
|
+
// standing in for PlanetScale's sign-up, as github's door of the same path does); the session cookie is named
|
|
11
|
+
// `_planetscale_session` (PlanetScale does not document its cookie); a refused sign-in shows the form again with the
|
|
12
|
+
// message "Incorrect email or password." (answered 200), naming neither half; a session does not end, since no life signs
|
|
13
|
+
// out yet.
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { cookieOf, flowPage, SignIn, SIGN_IN_CSS } from '@volter/world-ui';
|
|
16
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
17
|
+
import { audit, createdById, personActorOf } from '../semantics/audit.ts';
|
|
18
|
+
|
|
19
|
+
const COOKIE = '_planetscale_session';
|
|
20
|
+
const hex = (s: string): string => createHash('sha256').update(s).digest('hex');
|
|
21
|
+
const passwordHash = (email: string, password: string): string => hex(`password:${email.toLowerCase()}:${password}`);
|
|
22
|
+
|
|
23
|
+
// PlanetScale's skin over the sign-in: its near-black ink and dark primary button
|
|
24
|
+
const SKIN = `
|
|
25
|
+
body { background: #fafafa; color: #111111; }
|
|
26
|
+
.sign-in-mark { background: #111111; }
|
|
27
|
+
.sign-in-error { background: #fef2f2; border-color: #fecaca; color: #111111; }
|
|
28
|
+
.sign-in-box { background: #ffffff; border-color: #e5e5e5; }
|
|
29
|
+
.sign-in-hint { color: #525252; }
|
|
30
|
+
.sign-in-submit { background: #111111; border-color: #111111; color: #ffffff; }
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
/** A return_to PlanetScale follows: a path on app.planetscale.com, never another site. */
|
|
34
|
+
const returnTo = (raw: string | null | undefined): string => (raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/');
|
|
35
|
+
|
|
36
|
+
function page(back: string, email?: string, error?: string): Response {
|
|
37
|
+
return flowPage({
|
|
38
|
+
title: 'Sign in · PlanetScale',
|
|
39
|
+
css: [SIGN_IN_CSS, SKIN],
|
|
40
|
+
body: (
|
|
41
|
+
<SignIn
|
|
42
|
+
heading="Sign in to PlanetScale"
|
|
43
|
+
action="/sign-in"
|
|
44
|
+
fields={{ return_to: back }}
|
|
45
|
+
account={{ name: 'email', label: 'Email', ...(email ? { value: email } : {}) }}
|
|
46
|
+
password={{ name: 'password', label: 'Password' }}
|
|
47
|
+
submit="Sign in"
|
|
48
|
+
{...(error ? { error } : {})}
|
|
49
|
+
/>
|
|
50
|
+
),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The World's door refusing an account it cannot make. */
|
|
55
|
+
function malformedAccount(): Response {
|
|
56
|
+
return Response.json({ code: 'unprocessable_entity', message: 'an email, a password of at least 8 characters and an organization are required' }, { status: 422 });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The person the request's session cookie names, or undefined when nobody is signed in. */
|
|
60
|
+
export function signedIn(ctx: SemanticsContext, request: Request): { email: string; organizations: string[]; role: string } | undefined {
|
|
61
|
+
const token = cookieOf(request, COOKIE);
|
|
62
|
+
if (!token) return undefined;
|
|
63
|
+
const session = ctx.rowsRaw('_web_session').find((s) => s.token === token);
|
|
64
|
+
if (!session) return undefined;
|
|
65
|
+
const person = ctx.rowsRaw('_person').find((p) => p.email === session.email);
|
|
66
|
+
return person ? { email: String(person.email), organizations: (person.organizations ?? []) as string[], role: typeof person.role === 'string' ? person.role : 'admin' } : undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Where a page asked for by nobody signed in sends its visitor: the sign-in, returning here after. */
|
|
70
|
+
export function toSignIn(request: Request): Response {
|
|
71
|
+
const url = new URL(request.url);
|
|
72
|
+
return new Response(null, { status: 302, headers: { location: `/sign-in?return_to=${encodeURIComponent(url.pathname + url.search)}` } });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function formOf(request: Request): Promise<Record<string, string>> {
|
|
76
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The sign-in's paths and the World's password door, or undefined for any other request. */
|
|
80
|
+
export async function sessionFlow(request: Request, ctx: SemanticsContext): Promise<Response | undefined> {
|
|
81
|
+
const url = new URL(request.url);
|
|
82
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
83
|
+
if (path === '/sign-in' && request.method === 'GET') return page(returnTo(url.searchParams.get('return_to')));
|
|
84
|
+
if (path === '/sign-in' && request.method === 'POST') {
|
|
85
|
+
const f = await formOf(request);
|
|
86
|
+
const email = (f.email ?? '').trim().toLowerCase();
|
|
87
|
+
const back = returnTo(f.return_to);
|
|
88
|
+
const person = ctx.rowsRaw('_person').find((p) => p.email === email);
|
|
89
|
+
if (!email || !person || person.hash !== passwordHash(email, f.password ?? '')) return page(back, email, 'Incorrect email or password.');
|
|
90
|
+
const n = ctx.rowsRaw('_web_session').length + 1;
|
|
91
|
+
const token = hex(`session:${email}:${ctx.occurredAt}:${n}`).slice(0, 48);
|
|
92
|
+
await ctx.record('_web_session', { token, email, created_at: ctx.occurredAt }, `websession:${token}`);
|
|
93
|
+
// "user … signed_in" (https://planetscale.com/docs/security/audit-log, "Audited organization events")
|
|
94
|
+
for (const org of (person.organizations ?? []) as string[]) await audit(ctx, org, 'user', 'signed_in', { type: 'User', id: createdById(email), name: email }, { actor: personActorOf(email) });
|
|
95
|
+
const headers = new Headers({ location: back });
|
|
96
|
+
headers.append('set-cookie', `${COOKIE}=${token}; path=/; HttpOnly; Secure; SameSite=Lax`);
|
|
97
|
+
return new Response(null, { status: 302, headers });
|
|
98
|
+
}
|
|
99
|
+
// POST /_twin/users/:email/password {password, organization, role?}: the password the World gives a person, the
|
|
100
|
+
// organization they belong to, and their role in it (`admin`, the default, as the person who made it is; or `member`),
|
|
101
|
+
// with which they sign in at /sign-in. PlanetScale's sign-up and its invitations are not modelled (the management API's
|
|
102
|
+
// spec has no invitation operation); the World keeps the password's hash, never the password.
|
|
103
|
+
const door = /^\/_twin\/users\/([^/]+)\/password$/.exec(path);
|
|
104
|
+
if (door && request.method === 'POST') {
|
|
105
|
+
const email = decodeURIComponent(door[1]!).toLowerCase();
|
|
106
|
+
let body: Record<string, unknown> = {};
|
|
107
|
+
try { body = JSON.parse(await request.text()) as Record<string, unknown>; } catch { /* refused below */ }
|
|
108
|
+
const password = typeof body.password === 'string' ? body.password : '';
|
|
109
|
+
const organization = typeof body.organization === 'string' ? body.organization : '';
|
|
110
|
+
const role = body.role === undefined ? 'admin' : String(body.role);
|
|
111
|
+
if (role !== 'admin' && role !== 'member') return Response.json({ code: 'unprocessable_entity', message: 'role must be admin or member' }, { status: 422 });
|
|
112
|
+
if (!/^[^@\s]+@[^@\s]+$/.test(email) || password.length < 8 || !organization) return malformedAccount();
|
|
113
|
+
await ctx.record('_person', { email, hash: passwordHash(email, password), organizations: [organization], ...(body.role === undefined ? {} : { role }), set_at: ctx.occurredAt }, `person:${email}`);
|
|
114
|
+
return new Response(null, { status: 204 });
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The organization's audit log. "The organization audit log grants Organization Administrators access to review actions
|
|
2
|
+
// performed by individual members of the organization. In addition, each audit log includes events detailing who
|
|
3
|
+
// performed the action and when it happened"; its table "Audited organization events" names each event and its actions
|
|
4
|
+
// (https://planetscale.com/docs/security/audit-log). The lane records one event for each act of its operations and pages
|
|
5
|
+
// that the table names: database created/deleted; database_branch created/deleted/enabled_safe_migrations/
|
|
6
|
+
// disabled_safe_migrations; database_branch_password created/deleted; database_deploy_request created/closed;
|
|
7
|
+
// deploy_request_review approved; backup_policy created; service_token created/deleted; user signed_in.
|
|
8
|
+
//
|
|
9
|
+
// Where the pages stop and the twin decides: an event's `audit_action` is `<event>.<action>` and its `action` the action;
|
|
10
|
+
// the auditable is the organization (the database, for what happens inside one); the actor is the service token that
|
|
11
|
+
// asked, the person on the page, or PlanetScale for what the World clock does; `location` and `remote_ip` are null (a
|
|
12
|
+
// World has no network). The log keeps events for 15 days, the audit-log page's retention for the Base plan (the plans
|
|
13
|
+
// page's table says 6 months; the twin follows the more specific page). list_audit_logs answers newest first, `limit`
|
|
14
|
+
// events (at most 100, 25 by default): `starting_after` an event's id gives the events older than it, and `ending_before`
|
|
15
|
+
// an event's id the `limit` events just newer than it (the next page toward the present); an id orders events even once
|
|
16
|
+
// its own event has aged out of the log.
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import type { SemanticsContext } from '@volter/world-core';
|
|
19
|
+
|
|
20
|
+
type Row = Record<string, unknown>;
|
|
21
|
+
const RETENTION_MS = 15 * 86_400_000;
|
|
22
|
+
|
|
23
|
+
export type AuditActor = { type: 'ServiceToken' | 'User' | 'PlanetScale'; id: string; name: string };
|
|
24
|
+
export type AuditObject = { type: string; id: string; name: string };
|
|
25
|
+
|
|
26
|
+
/** The actor a call acts as: the service token its Authorization names, or the World's own token. */
|
|
27
|
+
export function callActor(ctx: SemanticsContext): AuditActor {
|
|
28
|
+
const id = /^([^:\s]+):/.exec(ctx.call.request.headers.get('authorization') ?? '')?.[1];
|
|
29
|
+
const token = id === undefined ? undefined : ctx.rowsRaw('ServiceToken').find((t) => t.id === id);
|
|
30
|
+
return token ? { type: 'ServiceToken', id: String(token.id), name: String(token.display_name) } : { type: 'ServiceToken', id: id ?? 'service-token', name: 'Service token' };
|
|
31
|
+
}
|
|
32
|
+
export const SYSTEM: AuditActor = { type: 'PlanetScale', id: 'planetscale', name: 'PlanetScale' };
|
|
33
|
+
export const personOf = (email: string, id: string): AuditActor => ({ type: 'User', id, name: email });
|
|
34
|
+
/** A person's id, as a page names them in an actor (the twin's: a hash of their email). */
|
|
35
|
+
export const createdById = (email: string): string => `u${createHash('sha256').update(email).digest('hex').slice(0, 12)}`;
|
|
36
|
+
/** The actor for a person on a page. */
|
|
37
|
+
export const personActorOf = (email: string): AuditActor => personOf(email, createdById(email));
|
|
38
|
+
|
|
39
|
+
/** Record one event of the organization `org`. */
|
|
40
|
+
export async function audit(ctx: SemanticsContext, org: string, event: string, action: string, target: AuditObject, o: { actor?: AuditActor; auditable?: AuditObject; metadata?: Row } = {}): Promise<void> {
|
|
41
|
+
const n = ctx.tree().filter((r) => r.type === '_audit_event').length + 1;
|
|
42
|
+
const id = `ale${String(n).padStart(8, '0')}`;
|
|
43
|
+
const actor = o.actor ?? callActor(ctx);
|
|
44
|
+
const auditable = o.auditable ?? { type: 'Organization', id: org, name: org };
|
|
45
|
+
await ctx.record('_audit_event', {
|
|
46
|
+
id, actor_id: actor.id, actor_type: actor.type, auditable_id: auditable.id, auditable_type: auditable.type,
|
|
47
|
+
target_id: target.id, target_type: target.type, location: null, target_display_name: target.name,
|
|
48
|
+
audit_action: `${event}.${action}`, action, actor_display_name: actor.name, auditable_display_name: auditable.name,
|
|
49
|
+
remote_ip: null, created_at: ctx.occurredAt, updated_at: ctx.occurredAt, metadata: o.metadata ?? null, _organization: org, _n: n,
|
|
50
|
+
}, id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The organization a path names. */
|
|
54
|
+
export const orgOf = (ctx: SemanticsContext): string => String(ctx.call.params.organization ?? '');
|
|
55
|
+
|
|
56
|
+
/** list_audit_logs. */
|
|
57
|
+
export async function listAuditLogs(ctx: SemanticsContext): Promise<Response> {
|
|
58
|
+
const org = orgOf(ctx);
|
|
59
|
+
const q = new URL(ctx.call.request.url).searchParams;
|
|
60
|
+
const limitRaw = q.get('limit');
|
|
61
|
+
const limit = limitRaw === null ? 25 : Number(limitRaw);
|
|
62
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) return ctx.refuse({ status: 422, code: 'unprocessable_entity', message: 'limit must be between 1 and 100' });
|
|
63
|
+
const since = Date.parse(ctx.occurredAt) - RETENTION_MS;
|
|
64
|
+
const events: Row[] = ctx.tree().filter((r) => r.type === '_audit_event').map((r): Row => ({ ...ctx.own(r), id: String(r.id) }))
|
|
65
|
+
.filter((e) => e._organization === org && Date.parse(String(e.created_at)) >= since)
|
|
66
|
+
.sort((a, b) => Number(b._n) - Number(a._n));
|
|
67
|
+
// a cursor is an event's id, which counts up, so it orders every event, kept or aged out of the log
|
|
68
|
+
const nOf = (id: string | null): number | undefined => (id && /^ale\d+$/.test(id) ? Number(id.slice(3)) : undefined);
|
|
69
|
+
const after = nOf(q.get('starting_after'));
|
|
70
|
+
const before = nOf(q.get('ending_before'));
|
|
71
|
+
if ((q.get('starting_after') && after === undefined) || (q.get('ending_before') && before === undefined)) return ctx.refuse({ status: 422, code: 'unprocessable_entity', message: 'starting_after and ending_before take an audit log id' });
|
|
72
|
+
let from = after === undefined ? 0 : events.findIndex((e) => Number(e._n) < after);
|
|
73
|
+
if (from < 0) from = events.length;
|
|
74
|
+
let to = events.length;
|
|
75
|
+
if (before !== undefined) { to = events.findIndex((e) => Number(e._n) <= before); if (to < 0) to = events.length; from = Math.max(from, to - limit); }
|
|
76
|
+
const slice = events.slice(from, Math.min(to, from + limit));
|
|
77
|
+
const data = slice.map(({ _organization: _o, _n: _k, ...e }) => e);
|
|
78
|
+
return ctx.reply({
|
|
79
|
+
type: 'list', has_next: from + slice.length < events.length, has_prev: from > 0,
|
|
80
|
+
cursor_start: data[0]?.id ?? null, cursor_end: data.at(-1)?.id ?? null, data,
|
|
81
|
+
});
|
|
82
|
+
}
|