diff --git a/docs/spec.md b/docs/spec.md index 922106f..0dcde15 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -469,6 +469,8 @@ Bearer token in the `Authorization` header. Token issuance is out of scope for t Authorization: Bearer ``` +(As a non-normative example, `SQLiteAdapter` ships an optional hashed-token store — `createToken` / `lookupToken` / `listTokens` / `revokeToken` — for servers that want DB-backed bearer tokens without rolling their own storage. This is adapter-specific tooling, not part of the wire protocol; other adapters are free to manage tokens however they like, or not at all.) + ### Records ``` @@ -608,5 +610,4 @@ A convenience alias for the owner entity rather than requiring clients to look i ## Open Questions -- **Token issuance** — authentication mechanism for the API adapter is server-defined and out of scope for v1 - **Multi-stack patterns** — apps managing multiple stacks (personal + group stacks) will likely repeat common fan-out and merge patterns; a `StackWorkspace` abstraction is a likely future addition once real usage patterns emerge diff --git a/packages/adapter-sqlite/src/index.ts b/packages/adapter-sqlite/src/index.ts index a09f38f..6a860f4 100644 --- a/packages/adapter-sqlite/src/index.ts +++ b/packages/adapter-sqlite/src/index.ts @@ -17,7 +17,7 @@ import initSqlJs from 'sql.js'; import type { Database, SqlJsStatic } from 'sql.js'; -import { createHash } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; import { readFile, writeFile, unlink } from 'fs/promises'; import { join, dirname } from 'path'; @@ -52,6 +52,14 @@ export type SQLiteOpenOptions = { path: string; }; +export type TokenInfo = { + id: string; + entityId: string; + label?: string; + createdAt: Date; + expiresAt?: Date; +}; + // ------------------------------------------------------- // SQL — schema // ------------------------------------------------------- @@ -114,6 +122,15 @@ const SCHEMA_SQL = ` created_at INTEGER NOT NULL ) STRICT; + CREATE TABLE IF NOT EXISTS tokens ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + entity_id TEXT NOT NULL, + label TEXT, + created_at INTEGER NOT NULL, + expires_at INTEGER + ) STRICT; + -- Indexes CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id); CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id); @@ -126,6 +143,7 @@ const SCHEMA_SQL = ` CREATE INDEX IF NOT EXISTS idx_assoc_kind_label ON associations(kind, label); CREATE INDEX IF NOT EXISTS idx_assoc_kind_file_id ON associations(kind, file_id); CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id); + CREATE INDEX IF NOT EXISTS idx_tokens_hash ON tokens(token_hash); -- Full-text search (FTS4 — compatible with sql.js) CREATE VIRTUAL TABLE IF NOT EXISTS records_fts USING fts4( @@ -218,7 +236,7 @@ const buildWhereClause = (query: StackQuery): { sql: string; params: unknown[] } if (f.typeId !== undefined) { const ids = Array.isArray(f.typeId) ? f.typeId : [f.typeId]; - conditions.push(`r.type_id IN (${ids.map(() => '?').join(',')})`) + conditions.push(`r.type_id IN (${ids.map(() => '?').join(',')})`); params.push(...ids); } @@ -233,13 +251,13 @@ const buildWhereClause = (query: StackQuery): { sql: string; params: unknown[] } if (f.appId !== undefined) { const ids = Array.isArray(f.appId) ? f.appId : [f.appId]; - conditions.push(`r.app_id IN (${ids.map(() => '?').join(',')})`) + conditions.push(`r.app_id IN (${ids.map(() => '?').join(',')})`); params.push(...ids); } if (f.entityId !== undefined) { const ids = Array.isArray(f.entityId) ? f.entityId : [f.entityId]; - conditions.push(`r.entity_id IN (${ids.map(() => '?').join(',')})`) + conditions.push(`r.entity_id IN (${ids.map(() => '?').join(',')})`); params.push(...ids); } @@ -737,6 +755,68 @@ export class SQLiteAdapter implements StackAdapter { }; } + // ------------------------------------------------------- + // Tokens + // ------------------------------------------------------- + + async createToken( + entityId: string, + opts: { label?: string; expiresAt?: Date } = {}, + ): Promise<{ id: string; token: string }> { + const id = randomBytes(8).toString('hex'); + const token = randomBytes(32).toString('hex'); + const tokenHash = createHash('sha256').update(token).digest('hex'); + this.db.run( + 'INSERT INTO tokens (id, token_hash, entity_id, label, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)', + [ + id, + tokenHash, + entityId, + opts.label ?? null, + toMs(new Date()), + opts.expiresAt ? toMs(opts.expiresAt) : null, + ], + ); + this.persist(); + return { id, token }; + } + + async lookupToken(token: string): Promise<{ entityId: string } | null> { + const hash = createHash('sha256').update(token).digest('hex'); + const rows = this.execQuery<{ entity_id: string; expires_at: number | null }>( + 'SELECT entity_id, expires_at FROM tokens WHERE token_hash = ?', + [hash], + ); + if (!rows.length) return null; + const row = rows[0]; + if (row.expires_at !== null && Date.now() > row.expires_at) return null; + return { entityId: row.entity_id }; + } + + async listTokens(): Promise { + const rows = this.execQuery<{ + id: string; + entity_id: string; + label: string | null; + created_at: number; + expires_at: number | null; + }>( + 'SELECT id, entity_id, label, created_at, expires_at FROM tokens ORDER BY created_at DESC', + ); + return rows.map((row) => ({ + id: row.id, + entityId: row.entity_id, + ...(row.label && { label: row.label }), + createdAt: fromMs(row.created_at), + ...(row.expires_at !== null && { expiresAt: fromMs(row.expires_at) }), + })); + } + + async revokeToken(id: string): Promise { + this.db.run('DELETE FROM tokens WHERE id = ?', [id]); + this.persist(); + } + // ------------------------------------------------------- // Associations // ------------------------------------------------------- diff --git a/packages/adapter-sqlite/tests/sqlite.test.ts b/packages/adapter-sqlite/tests/sqlite.test.ts index f4d0774..2b06bb5 100644 --- a/packages/adapter-sqlite/tests/sqlite.test.ts +++ b/packages/adapter-sqlite/tests/sqlite.test.ts @@ -840,3 +840,99 @@ describe('attachments', () => { expect(await adapter.getAttachmentMeta(fileId)).toBeNull(); }); }); + +// ------------------------------------------------------- +// Tokens +// ------------------------------------------------------- + +describe('tokens', () => { + test('createToken returns id and plaintext token', async () => { + const adapter = await initAdapter(); + const { id, token } = await adapter.createToken('entity-abc'); + expect(typeof id).toBe('string'); + expect(id.length).toBeGreaterThan(0); + expect(typeof token).toBe('string'); + expect(token.length).toBeGreaterThan(0); + }); + + test('lookupToken returns entityId for valid token', async () => { + const adapter = await initAdapter(); + const { token } = await adapter.createToken('entity-abc'); + const result = await adapter.lookupToken(token); + expect(result).not.toBeNull(); + expect(result?.entityId).toBe('entity-abc'); + }); + + test('lookupToken returns null for invalid token', async () => { + const adapter = await initAdapter(); + const result = await adapter.lookupToken('not-a-real-token'); + expect(result).toBeNull(); + }); + + test('lookupToken returns null for expired token', async () => { + const adapter = await initAdapter(); + const { token } = await adapter.createToken('entity-abc', { + expiresAt: new Date(Date.now() - 1000), + }); + const result = await adapter.lookupToken(token); + expect(result).toBeNull(); + }); + + test('lookupToken returns entityId for non-expired token', async () => { + const adapter = await initAdapter(); + const { token } = await adapter.createToken('entity-abc', { + expiresAt: new Date(Date.now() + 60_000), + }); + const result = await adapter.lookupToken(token); + expect(result?.entityId).toBe('entity-abc'); + }); + + test('listTokens returns all created tokens', async () => { + const adapter = await initAdapter(); + await adapter.createToken('entity-a', { label: 'Token A' }); + await adapter.createToken('entity-b', { label: 'Token B' }); + const tokens = await adapter.listTokens(); + expect(tokens.length).toBe(2); + const labels = tokens.map((t) => t.label); + expect(labels).toContain('Token A'); + expect(labels).toContain('Token B'); + }); + + test('listTokens does not expose plaintext token values', async () => { + const adapter = await initAdapter(); + await adapter.createToken('entity-abc', { label: 'my-token' }); + const tokens = await adapter.listTokens(); + for (const t of tokens) { + expect(Object.keys(t)).not.toContain('token'); + } + }); + + test('revokeToken removes the token', async () => { + const adapter = await initAdapter(); + const { id, token } = await adapter.createToken('entity-abc'); + await adapter.revokeToken(id); + expect(await adapter.lookupToken(token)).toBeNull(); + }); + + test('listTokens returns empty after all tokens revoked', async () => { + const adapter = await initAdapter(); + const { id } = await adapter.createToken('entity-abc'); + await adapter.revokeToken(id); + expect(await adapter.listTokens()).toEqual([]); + }); + + test('createToken label is present in listTokens', async () => { + const adapter = await initAdapter(); + await adapter.createToken('entity-abc', { label: 'my-token' }); + const [t] = await adapter.listTokens(); + expect(t.label).toBe('my-token'); + }); + + test('createToken expiresAt is present in listTokens', async () => { + const adapter = await initAdapter(); + const expiresAt = new Date(Date.now() + 3600_000); + await adapter.createToken('entity-abc', { expiresAt }); + const [t] = await adapter.listTokens(); + expect(t.expiresAt?.getTime()).toBeCloseTo(expiresAt.getTime(), -2); + }); +});