Skip to content

Commit 305c0af

Browse files
committed
feat(kilo-chat): add SandboxRegistryDO for per-sandbox token registry
Foundation for replacing the shared KILOCHAT_API_TOKEN with per-sandbox tokens. A singleton Durable Object owns the (token_hash → sandbox_id) mapping used by the hot auth path, and a sandbox_id unique index supports mint/revoke operations by sandbox. Tokens are 24 random bytes (192 bits entropy) prefixed with "ksk_" so accidental leaks are easy to grep. SHA-256 hashes are stored — the plaintext token is only ever returned once at mint time. The DO itself does not validate sandbox_id format; callers MUST use isValidSandboxId at the admin boundary. Throwing inside a DO RPC method corrupts miniflare's storage isolation during tests, and validation belongs at the API layer anyway. No wiring yet — the DO is added but not referenced by auth or routes in this commit. Following commits swap the auth path and add the admin API that mints tokens.
1 parent 81556be commit 305c0af

8 files changed

Lines changed: 262 additions & 0 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { defineConfig } from 'drizzle-kit';
2+
export default defineConfig({
3+
out: './drizzle/sandbox-registry',
4+
schema: './src/db/sandbox-registry-schema.ts',
5+
dialect: 'sqlite',
6+
driver: 'durable-sqlite',
7+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CREATE TABLE `sandbox_tokens` (
2+
`token_hash` text PRIMARY KEY NOT NULL,
3+
`sandbox_id` text NOT NULL,
4+
`created_at` integer NOT NULL
5+
);
6+
--> statement-breakpoint
7+
CREATE UNIQUE INDEX `sandbox_tokens_sandbox_id_unique` ON `sandbox_tokens` (`sandbox_id`);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"version": "7",
3+
"dialect": "sqlite",
4+
"entries": [
5+
{
6+
"idx": 0,
7+
"version": "6",
8+
"when": 1776200000000,
9+
"tag": "0000_sandbox_registry",
10+
"breakpoints": true
11+
}
12+
]
13+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import journal from './meta/_journal.json';
2+
import m0000 from './0000_sandbox_registry.sql';
3+
4+
export default {
5+
journal,
6+
migrations: {
7+
m0000,
8+
},
9+
};
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { env } from 'cloudflare:test';
2+
import { describe, it, expect } from 'vitest';
3+
import {
4+
SANDBOX_TOKEN_PREFIX,
5+
hashToken,
6+
isValidSandboxId,
7+
type SandboxRegistryDO,
8+
} from '../do/sandbox-registry-do';
9+
import { getSandboxRegistryStub } from '../do/sandbox-registry-client';
10+
11+
function stub(): DurableObjectStub<SandboxRegistryDO> {
12+
return getSandboxRegistryStub(env.SANDBOX_REGISTRY_DO);
13+
}
14+
15+
describe('SandboxRegistryDO', () => {
16+
it('mintToken returns a prefixed token and matching SHA-256 hash', async () => {
17+
const result = await stub().mintToken('sbx_test_mint_1');
18+
expect(result.token.startsWith(SANDBOX_TOKEN_PREFIX)).toBe(true);
19+
// 24 random bytes → 32 base64url chars + prefix.
20+
expect(result.token.length).toBe(SANDBOX_TOKEN_PREFIX.length + 32);
21+
expect(result.tokenHash).toBe(await hashToken(result.token));
22+
expect(result.tokenHash).toMatch(/^[0-9a-f]{64}$/);
23+
});
24+
25+
it('lookupSandbox round-trips a freshly minted token', async () => {
26+
const { token } = await stub().mintToken('sbx_test_lookup_1');
27+
const sandboxId = await stub().lookupSandbox(token);
28+
expect(sandboxId).toBe('sbx_test_lookup_1');
29+
});
30+
31+
it('lookupSandbox returns null for tokens without the prefix', async () => {
32+
const sandboxId = await stub().lookupSandbox('not-a-ksk-token');
33+
expect(sandboxId).toBeNull();
34+
});
35+
36+
it('lookupSandbox returns null for a well-formed but unknown token', async () => {
37+
// Valid shape but never minted.
38+
const fake = `${SANDBOX_TOKEN_PREFIX}${'a'.repeat(32)}`;
39+
const sandboxId = await stub().lookupSandbox(fake);
40+
expect(sandboxId).toBeNull();
41+
});
42+
43+
it('minting for the same sandbox replaces the previous token atomically', async () => {
44+
const s = stub();
45+
const first = await s.mintToken('sbx_test_replace');
46+
const second = await s.mintToken('sbx_test_replace');
47+
expect(second.token).not.toBe(first.token);
48+
expect(await s.lookupSandbox(first.token)).toBeNull();
49+
expect(await s.lookupSandbox(second.token)).toBe('sbx_test_replace');
50+
});
51+
52+
it('revokeSandbox deletes the token and makes lookup return null', async () => {
53+
const s = stub();
54+
const { token } = await s.mintToken('sbx_test_revoke');
55+
expect(await s.revokeSandbox('sbx_test_revoke')).toBe(true);
56+
expect(await s.lookupSandbox(token)).toBeNull();
57+
});
58+
59+
it('revokeSandbox returns false for an unknown sandbox', async () => {
60+
expect(await stub().revokeSandbox('sbx_does_not_exist')).toBe(false);
61+
});
62+
63+
it('isValidSandboxId rejects malformed sandbox ids (enforced at admin boundary)', () => {
64+
// The DO intentionally does not re-validate — throwing inside a DO RPC
65+
// method corrupts miniflare's storage isolation. Callers MUST use
66+
// isValidSandboxId upstream.
67+
expect(isValidSandboxId('sbx_AZaz09-_')).toBe(true);
68+
expect(isValidSandboxId('')).toBe(false);
69+
expect(isValidSandboxId('has spaces')).toBe(false);
70+
expect(isValidSandboxId('a'.repeat(65))).toBe(false);
71+
expect(isValidSandboxId('tab\there')).toBe(false);
72+
expect(isValidSandboxId('colon:here')).toBe(false);
73+
});
74+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
2+
3+
/**
4+
* Per-sandbox API token registry.
5+
*
6+
* One row per sandbox; minting for an existing sandbox replaces the row
7+
* (old token is invalidated atomically). The token's SHA-256 hash is the
8+
* primary key so lookups on the hot auth path hit a b-tree index, while
9+
* sandbox_id has a UNIQUE index for mint/revoke by sandbox.
10+
*/
11+
export const sandbox_tokens = sqliteTable('sandbox_tokens', {
12+
token_hash: text('token_hash').primaryKey(),
13+
sandbox_id: text('sandbox_id').notNull().unique(),
14+
created_at: integer('created_at').notNull(),
15+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { SandboxRegistryDO } from './sandbox-registry-do';
2+
3+
/**
4+
* Helpers for reaching the singleton SandboxRegistryDO.
5+
*
6+
* All callers must use `getSandboxRegistryStub` so the fixed name stays
7+
* consistent across the worker. If we ever shard by hash prefix, this is
8+
* the only place that needs to change.
9+
*/
10+
11+
/** Fixed DO name — there is exactly one registry instance. */
12+
const REGISTRY_DO_NAME = 'registry';
13+
14+
export function getSandboxRegistryStub(
15+
binding: DurableObjectNamespace<SandboxRegistryDO>
16+
): DurableObjectStub<SandboxRegistryDO> {
17+
return binding.get(binding.idFromName(REGISTRY_DO_NAME));
18+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { DurableObject } from 'cloudflare:workers';
2+
import { drizzle } from 'drizzle-orm/durable-sqlite';
3+
import { migrate } from 'drizzle-orm/durable-sqlite/migrator';
4+
import { eq } from 'drizzle-orm';
5+
import { sandbox_tokens } from '../db/sandbox-registry-schema';
6+
import migrations from '../../drizzle/sandbox-registry/migrations';
7+
8+
/** Prefix makes accidental-leak grepping easy ("kilo sandbox key"). */
9+
export const SANDBOX_TOKEN_PREFIX = 'ksk_';
10+
/** 24 bytes → 32 base64url chars. ~192 bits entropy. */
11+
const TOKEN_RANDOM_BYTES = 24;
12+
13+
const SANDBOX_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
14+
15+
export type MintTokenResult = {
16+
/** Plaintext token — shown to the admin once, never retrievable again. */
17+
token: string;
18+
/** SHA-256 hex of the token — safe to log / return from list endpoints. */
19+
tokenHash: string;
20+
};
21+
22+
export function isValidSandboxId(sandboxId: string): boolean {
23+
return SANDBOX_ID_PATTERN.test(sandboxId);
24+
}
25+
26+
function bytesToHex(bytes: Uint8Array): string {
27+
let s = '';
28+
for (const b of bytes) {
29+
s += b.toString(16).padStart(2, '0');
30+
}
31+
return s;
32+
}
33+
34+
function base64urlEncode(bytes: Uint8Array): string {
35+
let s = '';
36+
for (const b of bytes) {
37+
s += String.fromCharCode(b);
38+
}
39+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
40+
}
41+
42+
export async function hashToken(token: string): Promise<string> {
43+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token));
44+
return bytesToHex(new Uint8Array(digest));
45+
}
46+
47+
function generateToken(): string {
48+
const bytes = new Uint8Array(TOKEN_RANDOM_BYTES);
49+
crypto.getRandomValues(bytes);
50+
return `${SANDBOX_TOKEN_PREFIX}${base64urlEncode(bytes)}`;
51+
}
52+
53+
/**
54+
* Singleton DO that owns the per-sandbox API token registry.
55+
*
56+
* All instances are keyed by a fixed name ("registry") — this is a single
57+
* point of contention for every authenticated bot request. If/when kilo-chat
58+
* outgrows a single DO's throughput, shard by token-hash prefix.
59+
*/
60+
export class SandboxRegistryDO extends DurableObject<Env> {
61+
private db;
62+
63+
constructor(ctx: DurableObjectState, env: Env) {
64+
super(ctx, env);
65+
this.db = drizzle(ctx.storage, { logger: false });
66+
void ctx.blockConcurrencyWhile(() => migrate(this.db, migrations));
67+
}
68+
69+
/**
70+
* Mint a fresh token for `sandboxId`. If a token already exists for this
71+
* sandbox it is atomically replaced — the old token stops working
72+
* immediately. The plaintext token is returned only here.
73+
*
74+
* Callers MUST validate `sandboxId` via `isValidSandboxId` at the admin
75+
* boundary. This method does not re-validate — throwing from a DO RPC
76+
* method leaves miniflare's storage isolation in a bad state during tests.
77+
*/
78+
async mintToken(sandboxId: string): Promise<MintTokenResult> {
79+
const token = generateToken();
80+
const tokenHash = await hashToken(token);
81+
this.db.delete(sandbox_tokens).where(eq(sandbox_tokens.sandbox_id, sandboxId)).run();
82+
this.db
83+
.insert(sandbox_tokens)
84+
.values({
85+
token_hash: tokenHash,
86+
sandbox_id: sandboxId,
87+
created_at: Date.now(),
88+
})
89+
.run();
90+
return { token, tokenHash };
91+
}
92+
93+
/**
94+
* Resolve a plaintext token to its sandbox. Returns `null` for unknown or
95+
* malformed tokens. Hot-path auth lookup — kept index-only (SHA-256 hash).
96+
*/
97+
async lookupSandbox(token: string): Promise<string | null> {
98+
if (!token.startsWith(SANDBOX_TOKEN_PREFIX)) return null;
99+
const tokenHash = await hashToken(token);
100+
const row = this.db
101+
.select()
102+
.from(sandbox_tokens)
103+
.where(eq(sandbox_tokens.token_hash, tokenHash))
104+
.get();
105+
return row?.sandbox_id ?? null;
106+
}
107+
108+
/** Revoke the token for `sandboxId`. Returns true if a row was deleted. */
109+
revokeSandbox(sandboxId: string): boolean {
110+
const existing = this.db
111+
.select()
112+
.from(sandbox_tokens)
113+
.where(eq(sandbox_tokens.sandbox_id, sandboxId))
114+
.get();
115+
if (!existing) return false;
116+
this.db.delete(sandbox_tokens).where(eq(sandbox_tokens.sandbox_id, sandboxId)).run();
117+
return true;
118+
}
119+
}

0 commit comments

Comments
 (0)