Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,8 @@ Bearer token in the `Authorization` header. Token issuance is out of scope for t
Authorization: Bearer <token>
```

(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

```
Expand Down Expand Up @@ -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
88 changes: 84 additions & 4 deletions packages/adapter-sqlite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -52,6 +52,14 @@ export type SQLiteOpenOptions = {
path: string;
};

export type TokenInfo = {
id: string;
entityId: string;
label?: string;
createdAt: Date;
expiresAt?: Date;
};

// -------------------------------------------------------
// SQL — schema
// -------------------------------------------------------
Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand Down Expand Up @@ -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);
}

Expand All @@ -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);
}

Expand Down Expand Up @@ -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<TokenInfo[]> {
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<void> {
this.db.run('DELETE FROM tokens WHERE id = ?', [id]);
this.persist();
}

// -------------------------------------------------------
// Associations
// -------------------------------------------------------
Expand Down
96 changes: 96 additions & 0 deletions packages/adapter-sqlite/tests/sqlite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});