@volter/twin-turbopuffer 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 +145 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +27 -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 +11 -0
- package/dist/src/index.js +65 -0
- package/dist/src/key-gate.d.ts +3 -0
- package/dist/src/key-gate.js +39 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +28 -0
- package/dist/src/screens/dashboard.d.ts +11 -0
- package/dist/src/screens/dashboard.js +191 -0
- package/dist/src/semantics/namespaces.d.ts +5 -0
- package/dist/src/semantics/namespaces.js +17 -0
- package/dist/src/turbopuffer-capabilities.d.ts +6 -0
- package/dist/src/turbopuffer-capabilities.js +442 -0
- package/dist/src/turbopuffer-conformance.d.ts +8 -0
- package/dist/src/turbopuffer-conformance.js +102 -0
- package/dist/src/turbopuffer-connector.d.ts +34 -0
- package/dist/src/turbopuffer-connector.js +152 -0
- package/dist/src/turbopuffer-filter.d.ts +57 -0
- package/dist/src/turbopuffer-filter.js +286 -0
- package/dist/src/turbopuffer-server.d.ts +23 -0
- package/dist/src/turbopuffer-server.js +72 -0
- package/dist/src/turbopuffer-stem.d.ts +1 -0
- package/dist/src/turbopuffer-stem.js +133 -0
- package/dist/src/turbopuffer-store.d.ts +80 -0
- package/dist/src/turbopuffer-store.js +1304 -0
- package/dist/src/turbopuffer-text.d.ts +44 -0
- package/dist/src/turbopuffer-text.js +189 -0
- package/dist/src/turbopuffer-twin.d.ts +25 -0
- package/dist/src/turbopuffer-twin.js +406 -0
- package/package.json +56 -0
- package/src/cli.ts +28 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +92 -0
- package/src/key-gate.ts +39 -0
- package/src/manifest.ts +63 -0
- package/src/screens/dashboard.tsx +214 -0
- package/src/semantics/namespaces.ts +30 -0
- package/src/turbopuffer-capabilities.ts +455 -0
- package/src/turbopuffer-conformance.ts +101 -0
- package/src/turbopuffer-connector.ts +153 -0
- package/src/turbopuffer-filter.ts +277 -0
- package/src/turbopuffer-server.ts +81 -0
- package/src/turbopuffer-stem.ts +104 -0
- package/src/turbopuffer-store.ts +1157 -0
- package/src/turbopuffer-text.ts +204 -0
- package/src/turbopuffer-twin.ts +429 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// TURBOPUFFER'S DASHBOARD — the pages an organization's person signs in to and makes its API keys on
|
|
3
|
+
// (docs/contributing/architecture.md, "Screens" and "Who is on a screen"). No API operation makes or expires a key: "All
|
|
4
|
+
// API calls require authenticating with your API key. You can create and expire tokens in the dashboard"
|
|
5
|
+
// (https://turbopuffer.com/docs/auth), so every key a customer uses comes from here. Built from @volter/world-ui's
|
|
6
|
+
// sign-in and settings pieces under turbopuffer's skin; nothing of turbopuffer's page is copied.
|
|
7
|
+
//
|
|
8
|
+
// - turbopuffer.com/login: the sign-in (turbopuffer's page asks for an Email and has Continue). A dashboard page asked for
|
|
9
|
+
// by nobody signed in sends its visitor here, and the sign-in returns them there.
|
|
10
|
+
// - turbopuffer.com/dashboard/api-keys: the organization's API keys. Create API key with its Name and its permissions,
|
|
11
|
+
// shown once in the page's notice; each live key's Expire. The permissions are the ones turbopuffer's pages name:
|
|
12
|
+
// "API Key read/write permissions" (https://turbopuffer.com/docs/roadmap, March 2024), "New API key permission to
|
|
13
|
+
// `list` namespaces" (the same, May 2026), and admin ("available to API keys with list or admin permissions",
|
|
14
|
+
// https://turbopuffer.com/docs/namespaces). A key is named ("Named API Keys", the same changelog) and marked expired
|
|
15
|
+
// (the audit log's `api-key-marked-as-expired`, https://turbopuffer.com/docs/audit-logs).
|
|
16
|
+
// - POST /_twin/users/:email/password {password, organization, plan}: stands in for turbopuffer's sign-up
|
|
17
|
+
// (turbopuffer.com/join: Email, a plan, Continue), as upstash's and planetscale's doors of the same path do. The World
|
|
18
|
+
// keeps the password's hash, never the password.
|
|
19
|
+
//
|
|
20
|
+
// Where turbopuffer's documentation stops and the twin decides: a person signs in with an email and the password the
|
|
21
|
+
// World gave them (turbopuffer's own sign-in continues by email or GitHub or Google, which the twin does not model); the
|
|
22
|
+
// session cookie is named `tpuf_session`; the keys page is at /dashboard/api-keys; a key's permissions are four
|
|
23
|
+
// checkboxes (Read, Write, List, Admin; turbopuffer does not document the control) and a key needs at least one; a key
|
|
24
|
+
// is `tpuf_` and 32 letters and digits (the length is the twin's; the prefix is the docs' own placeholder for a key,
|
|
25
|
+
// `"source_api_key": "tpuf_A1..."` in https://turbopuffer.com/docs/write's copy example); a refused sign-in shows the form again with
|
|
26
|
+
// "Incorrect email or password." (200); a name already used by a live key, or none, answers the page with a notice (422).
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
28
|
+
import { cookieOf, flowPage, Portal, PORTAL_CSS, SignIn, SIGN_IN_CSS } from '@volter/world-ui';
|
|
29
|
+
export const DASHBOARD_HOST = 'turbopuffer.com';
|
|
30
|
+
const COOKIE = 'tpuf_session';
|
|
31
|
+
const KEYS = '/dashboard/api-keys';
|
|
32
|
+
export const GRANTS = ['read', 'write', 'list', 'admin'];
|
|
33
|
+
const LABEL = { read: 'Read', write: 'Write', list: 'List', admin: 'Admin' };
|
|
34
|
+
const hex = (s) => createHash('sha256').update(s).digest('hex');
|
|
35
|
+
const passwordHash = (email, password) => hex(`password:${email.toLowerCase()}:${password}`);
|
|
36
|
+
const ALNUM = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
37
|
+
// turbopuffer's skin: its warm off-white page and near-black ink and buttons
|
|
38
|
+
const SKIN = `
|
|
39
|
+
body { background: #faf8f5; color: #111111; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
40
|
+
.sign-in-mark { background: #111111; }
|
|
41
|
+
.sign-in-box { background: #ffffff; border-color: #e5e1da; }
|
|
42
|
+
.sign-in-error { background: #fff4f2; border-color: #f3c7bf; color: #111111; }
|
|
43
|
+
.sign-in-submit { background: #111111; border-color: #111111; color: #ffffff; }
|
|
44
|
+
.portal-side { background: #f3f0ea; border-right: 1px solid #e5e1da; }
|
|
45
|
+
.portal-section h2 { border-color: #e5e1da; color: #57534e; }
|
|
46
|
+
.portal-notice { background: #f3f0ea; border: 1px solid #e5e1da; word-break: break-all; }
|
|
47
|
+
.portal-button { background: #ffffff; border: 1px solid #d6d1c8; border-radius: 4px; padding: 6px 12px; }
|
|
48
|
+
.portal-primary { background: #111111; border-color: #111111; color: #ffffff; }
|
|
49
|
+
`;
|
|
50
|
+
/** A return_to the dashboard follows: a path on turbopuffer.com, never another site. */
|
|
51
|
+
const returnTo = (raw) => (raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : KEYS);
|
|
52
|
+
function signInPage(back, email, error) {
|
|
53
|
+
return flowPage({
|
|
54
|
+
title: 'Log in · turbopuffer',
|
|
55
|
+
css: [SIGN_IN_CSS, SKIN],
|
|
56
|
+
body: (_jsx(SignIn, { heading: "Log in", action: "/login", fields: { return_to: back }, account: { name: 'email', label: 'Email', ...(email ? { value: email } : {}) }, password: { name: 'password', label: 'Password' }, submit: "Continue", ...(error ? { error } : {}) })),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** The person the request's session cookie names, with their organization, or undefined. */
|
|
60
|
+
function signedIn(ctx, request) {
|
|
61
|
+
const token = cookieOf(request, COOKIE);
|
|
62
|
+
const session = token ? ctx.rowsRaw('_web_session').find((s) => s.token === token) : undefined;
|
|
63
|
+
return session ? ctx.rowsRaw('_person').find((p) => p.email === session.email) : undefined;
|
|
64
|
+
}
|
|
65
|
+
/** The organization's live and expired keys, oldest first. */
|
|
66
|
+
export function keysOf(ctx, organization) {
|
|
67
|
+
return ctx.rowsRaw('_api_key').filter((k) => k.organization === organization);
|
|
68
|
+
}
|
|
69
|
+
function keysPage(ctx, person, notice, status = 200) {
|
|
70
|
+
const organization = String(person.organization);
|
|
71
|
+
const keys = keysOf(ctx, organization);
|
|
72
|
+
const grantsOf = (k) => (Array.isArray(k.grants) ? k.grants.map((g) => LABEL[g]).join(', ') : '');
|
|
73
|
+
return flowPage({
|
|
74
|
+
title: 'API keys · turbopuffer',
|
|
75
|
+
css: [PORTAL_CSS, SKIN],
|
|
76
|
+
status,
|
|
77
|
+
body: (_jsx(Portal, { merchant: `${organization} · ${String(person.email)}`, back: { href: '/dashboard', label: 'Dashboard' }, ...(notice ? { notice } : {}), sections: [{
|
|
78
|
+
heading: 'API keys',
|
|
79
|
+
empty: 'No API keys yet.',
|
|
80
|
+
items: keys.map((k) => ({
|
|
81
|
+
title: String(k.name),
|
|
82
|
+
detail: grantsOf(k),
|
|
83
|
+
note: k.expired_at ? `Expired ${String(k.expired_at).slice(0, 10)}` : `Created ${String(k.created_at).slice(0, 10)} · ends ${String(k.suffix)}`,
|
|
84
|
+
...(k.expired_at ? { badge: 'Expired' } : { actions: [{ label: 'Expire', action: `${KEYS}/expire`, tone: 'danger', fields: { name: String(k.name) } }] }),
|
|
85
|
+
})),
|
|
86
|
+
}], forms: [{
|
|
87
|
+
heading: 'Create API key',
|
|
88
|
+
action: KEYS,
|
|
89
|
+
fields: [
|
|
90
|
+
{ id: 'name', label: 'Name' },
|
|
91
|
+
...GRANTS.map((g) => ({ id: g, label: LABEL[g], type: 'checkbox' })),
|
|
92
|
+
],
|
|
93
|
+
submit: { label: 'Create API key' },
|
|
94
|
+
}] })),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/** A key's value: `tpuf_` and 32 letters and digits, derived from the organization, the name and the moment. */
|
|
98
|
+
function keyValue(organization, name, at, n) {
|
|
99
|
+
const digest = createHash('sha256').update(`apikey:${organization}:${name}:${at}:${n}`).digest();
|
|
100
|
+
return `tpuf_${Array.from(digest.subarray(0, 32), (b) => ALNUM[b % ALNUM.length]).join('')}`;
|
|
101
|
+
}
|
|
102
|
+
async function formOf(request) {
|
|
103
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
104
|
+
}
|
|
105
|
+
async function createKey(ctx, person, form) {
|
|
106
|
+
const organization = String(person.organization);
|
|
107
|
+
const name = (form.name ?? '').trim();
|
|
108
|
+
const grants = GRANTS.filter((g) => form[g] !== undefined);
|
|
109
|
+
if (!name)
|
|
110
|
+
return keysPage(ctx, person, 'An API key needs a name.', 422);
|
|
111
|
+
if (keysOf(ctx, organization).some((k) => k.name === name && !k.expired_at))
|
|
112
|
+
return keysPage(ctx, person, `There is already an API key named ${name}.`, 422);
|
|
113
|
+
if (!grants.length)
|
|
114
|
+
return keysPage(ctx, person, 'Choose at least one permission.', 422);
|
|
115
|
+
const n = ctx.rowsRaw('_api_key').length + 1;
|
|
116
|
+
const key = keyValue(organization, name, ctx.occurredAt, n);
|
|
117
|
+
await ctx.record('_api_key', {
|
|
118
|
+
organization, name, grants, created_at: ctx.occurredAt, created_by: person.email, expired_at: null,
|
|
119
|
+
suffix: key.slice(-4), key_sha256: hex(key),
|
|
120
|
+
}, `apikey:${organization}:${n}`);
|
|
121
|
+
return keysPage(ctx, person, `API key ${name} created: ${key} — copy it now, it will not be shown again.`);
|
|
122
|
+
}
|
|
123
|
+
/** Expire: the key stops working at once (key-gate.ts admits no expired key). */
|
|
124
|
+
async function expireKey(ctx, person, name) {
|
|
125
|
+
const organization = String(person.organization);
|
|
126
|
+
const key = keysOf(ctx, organization).find((k) => k.name === name && !k.expired_at);
|
|
127
|
+
if (!key)
|
|
128
|
+
return keysPage(ctx, person, `There is no live API key named ${name}.`, 422);
|
|
129
|
+
await ctx.record('_api_key', { ...ctx.own(key), expired_at: ctx.occurredAt, expired_by: person.email }, String(key.id));
|
|
130
|
+
return keysPage(ctx, person, `API key ${name} expired.`);
|
|
131
|
+
}
|
|
132
|
+
/** turbopuffer.com's pages and the World's sign-up door, or undefined for any other path. `contextFor` gives the
|
|
133
|
+
* pack's semantics context for a request. */
|
|
134
|
+
export async function dashboard(request, contextFor) {
|
|
135
|
+
const url = new URL(request.url);
|
|
136
|
+
const path = url.pathname.replace(/\/+$/, '') || '/';
|
|
137
|
+
const ctx = await contextFor(new Request(request.url));
|
|
138
|
+
if (path === '/login' && request.method === 'GET')
|
|
139
|
+
return signInPage(returnTo(url.searchParams.get('return_to')));
|
|
140
|
+
if (path === '/login' && request.method === 'POST') {
|
|
141
|
+
const f = await formOf(request);
|
|
142
|
+
const email = (f.email ?? '').trim().toLowerCase();
|
|
143
|
+
const back = returnTo(f.return_to);
|
|
144
|
+
const person = ctx.rowsRaw('_person').find((p) => p.email === email);
|
|
145
|
+
if (!email || !person || person.hash !== passwordHash(email, f.password ?? ''))
|
|
146
|
+
return signInPage(back, email, 'Incorrect email or password.');
|
|
147
|
+
const token = hex(`session:${email}:${ctx.occurredAt}:${ctx.rowsRaw('_web_session').length + 1}`).slice(0, 48);
|
|
148
|
+
await ctx.record('_web_session', { token, email, created_at: ctx.occurredAt }, `websession:${token}`);
|
|
149
|
+
const headers = new Headers({ location: back });
|
|
150
|
+
headers.append('set-cookie', `${COOKIE}=${token}; path=/; HttpOnly; Secure; SameSite=Lax`);
|
|
151
|
+
return new Response(null, { status: 302, headers });
|
|
152
|
+
}
|
|
153
|
+
const door = /^\/_twin\/users\/([^/]+)\/password$/.exec(path);
|
|
154
|
+
if (door && request.method === 'POST')
|
|
155
|
+
return signUpDoor(ctx, decodeURIComponent(door[1]).toLowerCase(), request);
|
|
156
|
+
// a path the host's pages do not have is the caller's (its 404)
|
|
157
|
+
return path === '/dashboard' || path === KEYS || path === `${KEYS}/expire` ? keysPages(ctx, request, url, path) : undefined;
|
|
158
|
+
}
|
|
159
|
+
/** /dashboard (to the keys page), the keys page, its Create API key and its Expire, for the person signed in. */
|
|
160
|
+
async function keysPages(ctx, request, url, path) {
|
|
161
|
+
const person = signedIn(ctx, request);
|
|
162
|
+
if (!person)
|
|
163
|
+
return new Response(null, { status: 302, headers: { location: `/login?return_to=${encodeURIComponent(url.pathname + url.search)}` } });
|
|
164
|
+
if (request.method === 'GET')
|
|
165
|
+
return path === '/dashboard' ? new Response(null, { status: 302, headers: { location: KEYS } }) : keysPage(ctx, person);
|
|
166
|
+
if (request.method !== 'POST' || path === '/dashboard')
|
|
167
|
+
return undefined;
|
|
168
|
+
const form = await formOf(request);
|
|
169
|
+
return path === KEYS ? createKey(ctx, person, form) : expireKey(ctx, person, (form.name ?? '').trim());
|
|
170
|
+
}
|
|
171
|
+
/** The sign-up door's refusal of a call missing what it needs. */
|
|
172
|
+
function badSignUp() {
|
|
173
|
+
return Response.json({ status: 'error', error: 'an email, a password of at least 8 characters, an organization and a plan (launch or scale) are required' }, { status: 400 });
|
|
174
|
+
}
|
|
175
|
+
/** POST /_twin/users/:email/password {password, organization, plan}: the person, their organization and its plan, as
|
|
176
|
+
* turbopuffer's sign-up makes them (turbopuffer.com/join: "Select plan launch ($16/mo minimum) … scale ($256/mo
|
|
177
|
+
* minimum)"); 204. */
|
|
178
|
+
async function signUpDoor(ctx, email, request) {
|
|
179
|
+
let body = {};
|
|
180
|
+
try {
|
|
181
|
+
body = JSON.parse(await request.text());
|
|
182
|
+
}
|
|
183
|
+
catch { /* refused below */ }
|
|
184
|
+
const password = typeof body.password === 'string' ? body.password : '';
|
|
185
|
+
const organization = typeof body.organization === 'string' ? body.organization.trim() : '';
|
|
186
|
+
const plan = body.plan === undefined ? 'launch' : body.plan;
|
|
187
|
+
if (!/^[^@\s]+@[^@\s]+$/.test(email) || password.length < 8 || !organization || (plan !== 'launch' && plan !== 'scale'))
|
|
188
|
+
return badSignUp();
|
|
189
|
+
await ctx.record('_person', { email, hash: passwordHash(email, password), organization, plan, set_at: ctx.occurredAt }, `person:${email}`);
|
|
190
|
+
return new Response(null, { status: 204 });
|
|
191
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DerivedHandler } from '@volter/world-core';
|
|
2
|
+
/** The operations served here, every one the twin serves. */
|
|
3
|
+
export declare const HANDLED: readonly ["get_v1_namespaces", "get_v2_namespaces_namespace_metadata", "get_v1_namespaces_namespace_metadata", "get_v1_namespaces_namespace_hint_cache_warm", "delete_v2_namespaces_namespace", "get_v1_namespaces_namespace_operations_token", "get_v1_namespaces_namespace_schema", "post_v1_namespaces_namespace_schema", "post_v2_namespaces_namespace", "post_v2_namespaces_namespace_query"];
|
|
4
|
+
/** A handler per operation above, answering through `answer` (the pack's request handler as a fetch). */
|
|
5
|
+
export declare function turbopufferHandlers(answer: (request: Request) => Promise<Response>): Record<string, DerivedHandler>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The operations served here, every one the twin serves. */
|
|
2
|
+
export const HANDLED = [
|
|
3
|
+
// namespaces
|
|
4
|
+
'get_v1_namespaces', 'get_v2_namespaces_namespace_metadata', 'get_v1_namespaces_namespace_metadata', 'get_v1_namespaces_namespace_hint_cache_warm',
|
|
5
|
+
'delete_v2_namespaces_namespace',
|
|
6
|
+
// an asynchronous copy's operation, polled (the API overview's Asynchronous requests)
|
|
7
|
+
'get_v1_namespaces_namespace_operations_token',
|
|
8
|
+
// a namespace's schema
|
|
9
|
+
'get_v1_namespaces_namespace_schema', 'post_v1_namespaces_namespace_schema',
|
|
10
|
+
// documents: the write (upserts, patches and deletes, by id or by filter) and the query (one query, or several)
|
|
11
|
+
'post_v2_namespaces_namespace', 'post_v2_namespaces_namespace_query',
|
|
12
|
+
];
|
|
13
|
+
/** A handler per operation above, answering through `answer` (the pack's request handler as a fetch). */
|
|
14
|
+
export function turbopufferHandlers(answer) {
|
|
15
|
+
const serve = ({ request }) => answer(request);
|
|
16
|
+
return Object.fromEntries(HANDLED.map((id) => [id, serve]));
|
|
17
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type CapabilityReport, type CapabilitySpec } from '@volter/world-tooling';
|
|
2
|
+
export declare const TURBOPUFFER_CAPABILITIES: CapabilitySpec[];
|
|
3
|
+
/** Committed area census: the SDK's surface groups (write, query, filters, full-text, vectors,
|
|
4
|
+
* schema options, namespace management) plus the protocol and connector areas. */
|
|
5
|
+
export declare const TURBOPUFFER_AREAS: readonly ["auth", "conformance", "connector", "errors", "filters", "fts", "legacy", "namespaces", "query", "schema", "vectors", "wire", "write"];
|
|
6
|
+
export declare function turbopufferCapabilities(): Promise<CapabilityReport>;
|