@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.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +473 -0
  3. package/api/src/fetch.ts +50 -0
  4. package/api/src/generated/surface.gen.json +1 -0
  5. package/api/src/generated/ui.gen.json +1 -0
  6. package/api/src/index.ts +19 -0
  7. package/api/src/manifest.ts +136 -0
  8. package/api/src/screens/deploy-request.tsx +111 -0
  9. package/api/src/screens/service-tokens.tsx +141 -0
  10. package/api/src/screens/session.tsx +117 -0
  11. package/api/src/semantics/audit.ts +82 -0
  12. package/api/src/semantics/backups.ts +258 -0
  13. package/api/src/semantics/branches.ts +201 -0
  14. package/api/src/semantics/deploy-requests.ts +493 -0
  15. package/api/src/semantics/index.ts +371 -0
  16. package/api/src/semantics/shared.ts +141 -0
  17. package/api/src/semantics/time.ts +77 -0
  18. package/api/src/token-gate.ts +96 -0
  19. package/dist/api/src/fetch.d.ts +8 -0
  20. package/dist/api/src/fetch.js +51 -0
  21. package/dist/api/src/fetch.ts +50 -0
  22. package/dist/api/src/generated/surface.gen.json +1 -0
  23. package/dist/api/src/generated/ui.gen.json +1 -0
  24. package/dist/api/src/index.ts +19 -0
  25. package/dist/api/src/manifest.d.ts +2 -0
  26. package/dist/api/src/manifest.js +113 -0
  27. package/dist/api/src/manifest.ts +136 -0
  28. package/dist/api/src/screens/deploy-request.d.ts +7 -0
  29. package/dist/api/src/screens/deploy-request.js +106 -0
  30. package/dist/api/src/screens/deploy-request.tsx +111 -0
  31. package/dist/api/src/screens/service-tokens.d.ts +3 -0
  32. package/dist/api/src/screens/service-tokens.js +134 -0
  33. package/dist/api/src/screens/service-tokens.tsx +141 -0
  34. package/dist/api/src/screens/session.d.ts +11 -0
  35. package/dist/api/src/screens/session.js +108 -0
  36. package/dist/api/src/screens/session.tsx +117 -0
  37. package/dist/api/src/semantics/audit.d.ts +31 -0
  38. package/dist/api/src/semantics/audit.js +80 -0
  39. package/dist/api/src/semantics/audit.ts +82 -0
  40. package/dist/api/src/semantics/backups.d.ts +37 -0
  41. package/dist/api/src/semantics/backups.js +264 -0
  42. package/dist/api/src/semantics/backups.ts +258 -0
  43. package/dist/api/src/semantics/branches.d.ts +53 -0
  44. package/dist/api/src/semantics/branches.js +197 -0
  45. package/dist/api/src/semantics/branches.ts +201 -0
  46. package/dist/api/src/semantics/deploy-requests.d.ts +47 -0
  47. package/dist/api/src/semantics/deploy-requests.js +491 -0
  48. package/dist/api/src/semantics/deploy-requests.ts +493 -0
  49. package/dist/api/src/semantics/index.d.ts +20 -0
  50. package/dist/api/src/semantics/index.js +381 -0
  51. package/dist/api/src/semantics/index.ts +371 -0
  52. package/dist/api/src/semantics/shared.d.ts +36 -0
  53. package/dist/api/src/semantics/shared.js +132 -0
  54. package/dist/api/src/semantics/shared.ts +141 -0
  55. package/dist/api/src/semantics/time.d.ts +2 -0
  56. package/dist/api/src/semantics/time.js +81 -0
  57. package/dist/api/src/semantics/time.ts +77 -0
  58. package/dist/api/src/token-gate.d.ts +9 -0
  59. package/dist/api/src/token-gate.js +97 -0
  60. package/dist/api/src/token-gate.ts +96 -0
  61. package/dist/src/cli.d.ts +2 -0
  62. package/dist/src/cli.js +61 -0
  63. package/dist/src/generated/surface.gen.json +1 -0
  64. package/dist/src/generated/ui.gen.json +1 -0
  65. package/dist/src/index.d.ts +26 -0
  66. package/dist/src/index.js +156 -0
  67. package/dist/src/manifest.d.ts +2 -0
  68. package/dist/src/manifest.js +41 -0
  69. package/dist/src/planetscale-budget.d.ts +78 -0
  70. package/dist/src/planetscale-budget.js +305 -0
  71. package/dist/src/planetscale-capabilities.d.ts +10 -0
  72. package/dist/src/planetscale-capabilities.js +3977 -0
  73. package/dist/src/planetscale-collation-weights.gen.d.ts +4 -0
  74. package/dist/src/planetscale-collation-weights.gen.js +12 -0
  75. package/dist/src/planetscale-collation.d.ts +70 -0
  76. package/dist/src/planetscale-collation.js +391 -0
  77. package/dist/src/planetscale-conformance.d.ts +8 -0
  78. package/dist/src/planetscale-conformance.js +213 -0
  79. package/dist/src/planetscale-connector.d.ts +150 -0
  80. package/dist/src/planetscale-connector.js +532 -0
  81. package/dist/src/planetscale-deploy.d.ts +26 -0
  82. package/dist/src/planetscale-deploy.js +235 -0
  83. package/dist/src/planetscale-information-schema.d.ts +32 -0
  84. package/dist/src/planetscale-information-schema.js +299 -0
  85. package/dist/src/planetscale-mysql.d.ts +33 -0
  86. package/dist/src/planetscale-mysql.js +547 -0
  87. package/dist/src/planetscale-roles.d.ts +11 -0
  88. package/dist/src/planetscale-roles.js +60 -0
  89. package/dist/src/planetscale-row.d.ts +12 -0
  90. package/dist/src/planetscale-row.js +39 -0
  91. package/dist/src/planetscale-server.d.ts +42 -0
  92. package/dist/src/planetscale-server.js +137 -0
  93. package/dist/src/planetscale-sql.d.ts +701 -0
  94. package/dist/src/planetscale-sql.js +7167 -0
  95. package/dist/src/planetscale-store.d.ts +126 -0
  96. package/dist/src/planetscale-store.js +827 -0
  97. package/dist/src/planetscale-twin.d.ts +48 -0
  98. package/dist/src/planetscale-twin.js +290 -0
  99. package/dist/src/planetscale-values.d.ts +139 -0
  100. package/dist/src/planetscale-values.js +719 -0
  101. package/dist/src/planetscale-wire.d.ts +110 -0
  102. package/dist/src/planetscale-wire.js +188 -0
  103. package/dist/src/semantics/psdb.d.ts +18 -0
  104. package/dist/src/semantics/psdb.js +30 -0
  105. package/package.json +58 -0
  106. package/src/cli.ts +58 -0
  107. package/src/generated/surface.gen.json +1 -0
  108. package/src/generated/ui.gen.json +1 -0
  109. package/src/index.ts +267 -0
  110. package/src/manifest.ts +60 -0
  111. package/src/planetscale-budget.ts +347 -0
  112. package/src/planetscale-capabilities.ts +3862 -0
  113. package/src/planetscale-collation-weights.gen.ts +13 -0
  114. package/src/planetscale-collation.ts +378 -0
  115. package/src/planetscale-conformance.ts +237 -0
  116. package/src/planetscale-connector.ts +571 -0
  117. package/src/planetscale-deploy.ts +197 -0
  118. package/src/planetscale-information-schema.ts +322 -0
  119. package/src/planetscale-mysql.ts +339 -0
  120. package/src/planetscale-roles.ts +71 -0
  121. package/src/planetscale-row.ts +43 -0
  122. package/src/planetscale-server.ts +162 -0
  123. package/src/planetscale-sql.ts +5957 -0
  124. package/src/planetscale-store.ts +869 -0
  125. package/src/planetscale-twin.ts +338 -0
  126. package/src/planetscale-values.ts +572 -0
  127. package/src/planetscale-wire.ts +274 -0
  128. package/src/semantics/psdb.ts +57 -0
@@ -0,0 +1,134 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // PLANETSCALE'S SERVICE TOKENS PAGE — a flow (docs/contributing/architecture.md, "Screens"): the organization's settings
3
+ // page at app.planetscale.com/{organization}/settings/service-tokens, where a token is made and granted its accesses. No
4
+ // operation of the management API grants an access, and the first token of an organization can only come from here.
5
+ // https://planetscale.com/docs/api/reference/service-tokens: "Navigate to Settings > Service tokens > New service token.
6
+ // Provide a descriptive name and click Create service token", after which the Service Token ID and the Service Token are
7
+ // shown to be stored; organization and database accesses are checked on the token's settings and saved with "Save
8
+ // permissions". Built from @volter/world-ui's settings layout (Portal) under PlanetScale's skin; nothing of PlanetScale's
9
+ // page is copied. Its controls are the ones the pack's lives need: "New service token" with its Name and "Create service
10
+ // token"; the permissions form with its Service token, Database and Access, and "Save permissions"; and each token's
11
+ // "Delete service token" ("You can delete a service token at any time from the service token detail page. Simply click
12
+ // the "Delete service token" button", https://planetscale.com/docs/cli/service-tokens).
13
+ //
14
+ // The person on the page is who signed in at /sign-in (screens/session.tsx): a visitor nobody signed in as is sent there
15
+ // first, and only a person of the organization reaches its settings (another answers PlanetScale's 404).
16
+ //
17
+ // Where the documentation stops and the twin decides: a database access names a database this
18
+ // lane created, and "Organization" grants the access across the organization; one access is saved per submission; the
19
+ // accesses offered are the ones the spec's operations this lane serves name. Each form posts and comes back to the page,
20
+ // and the new token is shown once in the page's notice.
21
+ import { flowPage, Portal, PORTAL_CSS } from '@volter/world-ui';
22
+ import { makeServiceToken, removeServiceToken } from "../semantics/index.js";
23
+ import { accessOf } from "../semantics/shared.js";
24
+ import { audit, personActorOf } from "../semantics/audit.js";
25
+ import { signedIn, toSignIn } from "./session.js";
26
+ const PAGE = /^\/([^/]+)\/settings\/service-tokens(?:\/(accesses|delete))?$/;
27
+ // PlanetScale's skin over the settings layout: its near-black ink, grey rules and its blue primary button
28
+ const SKIN = `
29
+ body { background: #ffffff; color: #111111; }
30
+ .portal-side { background: #fafafa; border-right: 1px solid #e5e5e5; }
31
+ .portal-section h2 { border-color: #e5e5e5; color: #525252; }
32
+ .portal-notice { background: #f0f7ff; border: 1px solid #b6d4fe; font-family: ui-monospace, monospace; word-break: break-all; }
33
+ .portal-button { background: #ffffff; border: 1px solid #d4d4d4; border-radius: 6px; padding: 6px 12px; }
34
+ .portal-primary { background: #1d4ed8; border-color: #1d4ed8; color: #ffffff; }
35
+ `;
36
+ /** The accesses the page offers: those the operations of this lane name (token-gate.ts). */
37
+ const ACCESS_OPTIONS = [
38
+ // organization accesses
39
+ 'create_databases', 'read_audit_logs', 'read_service_tokens', 'write_service_tokens', 'delete_service_tokens',
40
+ // database accesses
41
+ 'read_database', 'write_database', 'delete_database', 'create_branch', 'read_branch', 'delete_branch', 'delete_production_branch',
42
+ 'connect_branch', 'connect_production_branch', 'delete_branch_password', 'delete_production_branch_password',
43
+ 'create_deploy_request', 'read_deploy_request', 'approve_deploy_request',
44
+ 'read_backups', 'write_backups', 'delete_backups', 'restore_backup', 'restore_production_branch_backup',
45
+ ];
46
+ function page(ctx, org, notice, status = 200) {
47
+ const tokens = ctx.rowsRaw('ServiceToken').filter((t) => !t._deleted_at);
48
+ const databases = ctx.rowsRaw('Database').filter((d) => !d._deleted_at).map((d) => String(d.name));
49
+ const action = `/${org}/settings/service-tokens`;
50
+ return flowPage({
51
+ title: `Service tokens · ${org} · PlanetScale`,
52
+ css: [PORTAL_CSS, SKIN],
53
+ status,
54
+ body: (_jsx(Portal, { merchant: `${org} · Settings`, back: { href: `/${org}`, label: 'Back to organization' }, ...(notice ? { notice } : {}), sections: [{
55
+ heading: 'Service tokens',
56
+ empty: 'No service tokens yet.',
57
+ items: tokens.map((t) => ({
58
+ title: String(t.display_name),
59
+ detail: `ID ${String(t.id)}`,
60
+ note: (t.service_token_accesses ?? []).map((a) => `${a.access} (${a.resource_type === 'Organization' ? 'organization' : a.resource_name})`).join(', ') || 'No accesses',
61
+ actions: [{ label: 'Delete service token', action: `${action}/delete`, fields: { token: String(t.id) }, tone: 'danger', method: 'post' }],
62
+ })),
63
+ }], forms: [
64
+ { heading: 'New service token', action, fields: [{ id: 'name', label: 'Name' }], submit: { label: 'Create service token' } },
65
+ ...(tokens.length ? [{
66
+ heading: 'Permissions',
67
+ action: `${action}/accesses`,
68
+ fields: [
69
+ { id: 'token', label: 'Service token', options: tokens.map((t) => ({ value: String(t.id), label: String(t.display_name) })) },
70
+ { id: 'database', label: 'Database', options: [{ value: '', label: 'Organization' }, ...databases.map((d) => ({ value: d, label: d }))] },
71
+ { id: 'access', label: 'Access', options: ACCESS_OPTIONS.map((a) => ({ value: a, label: a })) },
72
+ ],
73
+ submit: { label: 'Save permissions' },
74
+ }] : []),
75
+ ] })),
76
+ });
77
+ }
78
+ async function formOf(request) {
79
+ return Object.fromEntries(new URLSearchParams(await request.text()));
80
+ }
81
+ /** The service tokens page, or undefined for any other request. `contextFor` gives the lane's semantics context. */
82
+ export async function serviceTokensPage(request, contextFor) {
83
+ const url = new URL(request.url);
84
+ const m = PAGE.exec(url.pathname.replace(/\/+$/, ''));
85
+ if (!m)
86
+ return undefined;
87
+ const org = decodeURIComponent(m[1]);
88
+ const accesses = m[2] === 'accesses';
89
+ const deleting = m[2] === 'delete';
90
+ const person = signedIn(await contextFor(new Request(request.url)), request);
91
+ if (!person)
92
+ return toSignIn(request);
93
+ if (!person.organizations.includes(org))
94
+ return new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
95
+ if (request.method === 'GET' && !accesses && !deleting)
96
+ return page(await contextFor(new Request(request.url)), org);
97
+ if (request.method !== 'POST')
98
+ return undefined;
99
+ const form = await formOf(request);
100
+ const ctx = await contextFor(new Request(request.url));
101
+ if (deleting) {
102
+ const doomed = ctx.rowsRaw('ServiceToken').find((t) => t.id === form.token && !t._deleted_at);
103
+ if (!doomed)
104
+ return page(ctx, org, 'Choose a service token.', 422);
105
+ await removeServiceToken(ctx, doomed, org, personActorOf(person.email));
106
+ return page(await contextFor(new Request(request.url)), org, `Service token ${String(doomed.display_name)} deleted.`);
107
+ }
108
+ if (!accesses) {
109
+ const name = (form.name ?? '').trim();
110
+ if (!name)
111
+ return page(ctx, org, 'A service token needs a name.', 422);
112
+ const { record, token } = await makeServiceToken(ctx, name, [], person.email, org);
113
+ // PlanetScale shows the new token's ID and value once; the notice is where a person copies them from
114
+ 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.`);
115
+ }
116
+ const token = ctx.rowsRaw('ServiceToken').find((t) => t.id === form.token && !t._deleted_at);
117
+ if (!token)
118
+ return page(ctx, org, 'Choose a service token.', 422);
119
+ if (!ACCESS_OPTIONS.includes(form.access ?? ''))
120
+ return page(ctx, org, 'Choose an access.', 422);
121
+ const database = form.database ?? '';
122
+ const held = (token.service_token_accesses ?? []);
123
+ const resource = database ? { resource_type: 'Database', resource_name: database } : { resource_type: 'Organization', resource_name: org };
124
+ if (!held.some((a) => a.access === form.access && a.resource_type === resource.resource_type && a.resource_name === resource.resource_name)) {
125
+ const granted = accessOf(ctx, `${String(token.id)}-${held.length + 1}`, form.access, resource.resource_type, resource.resource_name);
126
+ await ctx.write('ServiceToken', String(token.id), { ...ctx.own(token), service_token_accesses: [...held, granted], updated_at: ctx.occurredAt }, 'service_token.grant');
127
+ // saving a token's database accesses is audited as "service_token … updated_bulk_database_access"
128
+ // (https://planetscale.com/docs/security/audit-log, "Audited organization events"); the table names no event for an
129
+ // organization access
130
+ if (database)
131
+ 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 } });
132
+ }
133
+ return page(await contextFor(new Request(request.url)), org, `${form.access} added to ${String(token.display_name)} on ${database || 'the organization'}.`);
134
+ }
@@ -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,11 @@
1
+ import type { SemanticsContext } from '@volter/world-core';
2
+ /** The person the request's session cookie names, or undefined when nobody is signed in. */
3
+ export declare function signedIn(ctx: SemanticsContext, request: Request): {
4
+ email: string;
5
+ organizations: string[];
6
+ role: string;
7
+ } | undefined;
8
+ /** Where a page asked for by nobody signed in sends its visitor: the sign-in, returning here after. */
9
+ export declare function toSignIn(request: Request): Response;
10
+ /** The sign-in's paths and the World's password door, or undefined for any other request. */
11
+ export declare function sessionFlow(request: Request, ctx: SemanticsContext): Promise<Response | undefined>;
@@ -0,0 +1,108 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ // PLANETSCALE'S SIGN-IN — the door before every app.planetscale.com page (docs/contributing/architecture.md, "Who is on a
3
+ // screen"), following github's (packages/twin/github/src/screens/session.tsx): app.planetscale.com/sign-in shows the form,
4
+ // which posts back to /sign-in; the right password for the account signs its person in, and a session cookie names them
5
+ // to every page after. A page asked for by nobody signed in sends its visitor to the sign-in with `return_to`, and the
6
+ // sign-in returns them there. Built from @volter/world-ui's sign-in piece under PlanetScale's skin; nothing of
7
+ // PlanetScale's page is copied.
8
+ //
9
+ // Where PlanetScale's documentation stops and the twin decides: a person's account is their email; their password and the
10
+ // organization they own are what the World gave them (`POST /_twin/users/:email/password` {password, organization},
11
+ // standing in for PlanetScale's sign-up, as github's door of the same path does); the session cookie is named
12
+ // `_planetscale_session` (PlanetScale does not document its cookie); a refused sign-in shows the form again with the
13
+ // message "Incorrect email or password." (answered 200), naming neither half; a session does not end, since no life signs
14
+ // out yet.
15
+ import { createHash } from 'node:crypto';
16
+ import { cookieOf, flowPage, SignIn, SIGN_IN_CSS } from '@volter/world-ui';
17
+ import { audit, createdById, personActorOf } from "../semantics/audit.js";
18
+ const COOKIE = '_planetscale_session';
19
+ const hex = (s) => createHash('sha256').update(s).digest('hex');
20
+ const passwordHash = (email, password) => hex(`password:${email.toLowerCase()}:${password}`);
21
+ // PlanetScale's skin over the sign-in: its near-black ink and dark primary button
22
+ const SKIN = `
23
+ body { background: #fafafa; color: #111111; }
24
+ .sign-in-mark { background: #111111; }
25
+ .sign-in-error { background: #fef2f2; border-color: #fecaca; color: #111111; }
26
+ .sign-in-box { background: #ffffff; border-color: #e5e5e5; }
27
+ .sign-in-hint { color: #525252; }
28
+ .sign-in-submit { background: #111111; border-color: #111111; color: #ffffff; }
29
+ `;
30
+ /** A return_to PlanetScale follows: a path on app.planetscale.com, never another site. */
31
+ const returnTo = (raw) => (raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/');
32
+ function page(back, email, error) {
33
+ return flowPage({
34
+ title: 'Sign in · PlanetScale',
35
+ css: [SIGN_IN_CSS, SKIN],
36
+ body: (_jsx(SignIn, { heading: "Sign in to PlanetScale", action: "/sign-in", fields: { return_to: back }, account: { name: 'email', label: 'Email', ...(email ? { value: email } : {}) }, password: { name: 'password', label: 'Password' }, submit: "Sign in", ...(error ? { error } : {}) })),
37
+ });
38
+ }
39
+ /** The World's door refusing an account it cannot make. */
40
+ function malformedAccount() {
41
+ return Response.json({ code: 'unprocessable_entity', message: 'an email, a password of at least 8 characters and an organization are required' }, { status: 422 });
42
+ }
43
+ /** The person the request's session cookie names, or undefined when nobody is signed in. */
44
+ export function signedIn(ctx, request) {
45
+ const token = cookieOf(request, COOKIE);
46
+ if (!token)
47
+ return undefined;
48
+ const session = ctx.rowsRaw('_web_session').find((s) => s.token === token);
49
+ if (!session)
50
+ return undefined;
51
+ const person = ctx.rowsRaw('_person').find((p) => p.email === session.email);
52
+ return person ? { email: String(person.email), organizations: (person.organizations ?? []), role: typeof person.role === 'string' ? person.role : 'admin' } : undefined;
53
+ }
54
+ /** Where a page asked for by nobody signed in sends its visitor: the sign-in, returning here after. */
55
+ export function toSignIn(request) {
56
+ const url = new URL(request.url);
57
+ return new Response(null, { status: 302, headers: { location: `/sign-in?return_to=${encodeURIComponent(url.pathname + url.search)}` } });
58
+ }
59
+ async function formOf(request) {
60
+ return Object.fromEntries(new URLSearchParams(await request.text()));
61
+ }
62
+ /** The sign-in's paths and the World's password door, or undefined for any other request. */
63
+ export async function sessionFlow(request, ctx) {
64
+ const url = new URL(request.url);
65
+ const path = url.pathname.replace(/\/+$/, '');
66
+ if (path === '/sign-in' && request.method === 'GET')
67
+ return page(returnTo(url.searchParams.get('return_to')));
68
+ if (path === '/sign-in' && request.method === 'POST') {
69
+ const f = await formOf(request);
70
+ const email = (f.email ?? '').trim().toLowerCase();
71
+ const back = returnTo(f.return_to);
72
+ const person = ctx.rowsRaw('_person').find((p) => p.email === email);
73
+ if (!email || !person || person.hash !== passwordHash(email, f.password ?? ''))
74
+ return page(back, email, 'Incorrect email or password.');
75
+ const n = ctx.rowsRaw('_web_session').length + 1;
76
+ const token = hex(`session:${email}:${ctx.occurredAt}:${n}`).slice(0, 48);
77
+ await ctx.record('_web_session', { token, email, created_at: ctx.occurredAt }, `websession:${token}`);
78
+ // "user … signed_in" (https://planetscale.com/docs/security/audit-log, "Audited organization events")
79
+ for (const org of (person.organizations ?? []))
80
+ await audit(ctx, org, 'user', 'signed_in', { type: 'User', id: createdById(email), name: email }, { actor: personActorOf(email) });
81
+ const headers = new Headers({ location: back });
82
+ headers.append('set-cookie', `${COOKIE}=${token}; path=/; HttpOnly; Secure; SameSite=Lax`);
83
+ return new Response(null, { status: 302, headers });
84
+ }
85
+ // POST /_twin/users/:email/password {password, organization, role?}: the password the World gives a person, the
86
+ // organization they belong to, and their role in it (`admin`, the default, as the person who made it is; or `member`),
87
+ // with which they sign in at /sign-in. PlanetScale's sign-up and its invitations are not modelled (the management API's
88
+ // spec has no invitation operation); the World keeps the password's hash, never the password.
89
+ const door = /^\/_twin\/users\/([^/]+)\/password$/.exec(path);
90
+ if (door && request.method === 'POST') {
91
+ const email = decodeURIComponent(door[1]).toLowerCase();
92
+ let body = {};
93
+ try {
94
+ body = JSON.parse(await request.text());
95
+ }
96
+ catch { /* refused below */ }
97
+ const password = typeof body.password === 'string' ? body.password : '';
98
+ const organization = typeof body.organization === 'string' ? body.organization : '';
99
+ const role = body.role === undefined ? 'admin' : String(body.role);
100
+ if (role !== 'admin' && role !== 'member')
101
+ return Response.json({ code: 'unprocessable_entity', message: 'role must be admin or member' }, { status: 422 });
102
+ if (!/^[^@\s]+@[^@\s]+$/.test(email) || password.length < 8 || !organization)
103
+ return malformedAccount();
104
+ await ctx.record('_person', { email, hash: passwordHash(email, password), organizations: [organization], ...(body.role === undefined ? {} : { role }), set_at: ctx.occurredAt }, `person:${email}`);
105
+ return new Response(null, { status: 204 });
106
+ }
107
+ return undefined;
108
+ }
@@ -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,31 @@
1
+ import type { SemanticsContext } from '@volter/world-core';
2
+ type Row = Record<string, unknown>;
3
+ export type AuditActor = {
4
+ type: 'ServiceToken' | 'User' | 'PlanetScale';
5
+ id: string;
6
+ name: string;
7
+ };
8
+ export type AuditObject = {
9
+ type: string;
10
+ id: string;
11
+ name: string;
12
+ };
13
+ /** The actor a call acts as: the service token its Authorization names, or the World's own token. */
14
+ export declare function callActor(ctx: SemanticsContext): AuditActor;
15
+ export declare const SYSTEM: AuditActor;
16
+ export declare const personOf: (email: string, id: string) => AuditActor;
17
+ /** A person's id, as a page names them in an actor (the twin's: a hash of their email). */
18
+ export declare const createdById: (email: string) => string;
19
+ /** The actor for a person on a page. */
20
+ export declare const personActorOf: (email: string) => AuditActor;
21
+ /** Record one event of the organization `org`. */
22
+ export declare function audit(ctx: SemanticsContext, org: string, event: string, action: string, target: AuditObject, o?: {
23
+ actor?: AuditActor;
24
+ auditable?: AuditObject;
25
+ metadata?: Row;
26
+ }): Promise<void>;
27
+ /** The organization a path names. */
28
+ export declare const orgOf: (ctx: SemanticsContext) => string;
29
+ /** list_audit_logs. */
30
+ export declare function listAuditLogs(ctx: SemanticsContext): Promise<Response>;
31
+ export {};
@@ -0,0 +1,80 @@
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
+ const RETENTION_MS = 15 * 86_400_000;
19
+ /** The actor a call acts as: the service token its Authorization names, or the World's own token. */
20
+ export function callActor(ctx) {
21
+ const id = /^([^:\s]+):/.exec(ctx.call.request.headers.get('authorization') ?? '')?.[1];
22
+ const token = id === undefined ? undefined : ctx.rowsRaw('ServiceToken').find((t) => t.id === id);
23
+ return token ? { type: 'ServiceToken', id: String(token.id), name: String(token.display_name) } : { type: 'ServiceToken', id: id ?? 'service-token', name: 'Service token' };
24
+ }
25
+ export const SYSTEM = { type: 'PlanetScale', id: 'planetscale', name: 'PlanetScale' };
26
+ export const personOf = (email, id) => ({ type: 'User', id, name: email });
27
+ /** A person's id, as a page names them in an actor (the twin's: a hash of their email). */
28
+ export const createdById = (email) => `u${createHash('sha256').update(email).digest('hex').slice(0, 12)}`;
29
+ /** The actor for a person on a page. */
30
+ export const personActorOf = (email) => personOf(email, createdById(email));
31
+ /** Record one event of the organization `org`. */
32
+ export async function audit(ctx, org, event, action, target, o = {}) {
33
+ const n = ctx.tree().filter((r) => r.type === '_audit_event').length + 1;
34
+ const id = `ale${String(n).padStart(8, '0')}`;
35
+ const actor = o.actor ?? callActor(ctx);
36
+ const auditable = o.auditable ?? { type: 'Organization', id: org, name: org };
37
+ await ctx.record('_audit_event', {
38
+ id, actor_id: actor.id, actor_type: actor.type, auditable_id: auditable.id, auditable_type: auditable.type,
39
+ target_id: target.id, target_type: target.type, location: null, target_display_name: target.name,
40
+ audit_action: `${event}.${action}`, action, actor_display_name: actor.name, auditable_display_name: auditable.name,
41
+ remote_ip: null, created_at: ctx.occurredAt, updated_at: ctx.occurredAt, metadata: o.metadata ?? null, _organization: org, _n: n,
42
+ }, id);
43
+ }
44
+ /** The organization a path names. */
45
+ export const orgOf = (ctx) => String(ctx.call.params.organization ?? '');
46
+ /** list_audit_logs. */
47
+ export async function listAuditLogs(ctx) {
48
+ const org = orgOf(ctx);
49
+ const q = new URL(ctx.call.request.url).searchParams;
50
+ const limitRaw = q.get('limit');
51
+ const limit = limitRaw === null ? 25 : Number(limitRaw);
52
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
53
+ return ctx.refuse({ status: 422, code: 'unprocessable_entity', message: 'limit must be between 1 and 100' });
54
+ const since = Date.parse(ctx.occurredAt) - RETENTION_MS;
55
+ const events = ctx.tree().filter((r) => r.type === '_audit_event').map((r) => ({ ...ctx.own(r), id: String(r.id) }))
56
+ .filter((e) => e._organization === org && Date.parse(String(e.created_at)) >= since)
57
+ .sort((a, b) => Number(b._n) - Number(a._n));
58
+ // a cursor is an event's id, which counts up, so it orders every event, kept or aged out of the log
59
+ const nOf = (id) => (id && /^ale\d+$/.test(id) ? Number(id.slice(3)) : undefined);
60
+ const after = nOf(q.get('starting_after'));
61
+ const before = nOf(q.get('ending_before'));
62
+ if ((q.get('starting_after') && after === undefined) || (q.get('ending_before') && before === undefined))
63
+ return ctx.refuse({ status: 422, code: 'unprocessable_entity', message: 'starting_after and ending_before take an audit log id' });
64
+ let from = after === undefined ? 0 : events.findIndex((e) => Number(e._n) < after);
65
+ if (from < 0)
66
+ from = events.length;
67
+ let to = events.length;
68
+ if (before !== undefined) {
69
+ to = events.findIndex((e) => Number(e._n) <= before);
70
+ if (to < 0)
71
+ to = events.length;
72
+ from = Math.max(from, to - limit);
73
+ }
74
+ const slice = events.slice(from, Math.min(to, from + limit));
75
+ const data = slice.map(({ _organization: _o, _n: _k, ...e }) => e);
76
+ return ctx.reply({
77
+ type: 'list', has_next: from + slice.length < events.length, has_prev: from > 0,
78
+ cursor_start: data[0]?.id ?? null, cursor_end: data.at(-1)?.id ?? null, data,
79
+ });
80
+ }