Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f547100
chore: project scaffold (package.json, tsconfig, tooling)
cuibonobo May 25, 2026
9f29a2a
feat: config, stack init, app factory, entry point
cuibonobo May 25, 2026
a10a6fd
feat: middleware (auth, errors) and lib (access control, serialization)
cuibonobo May 25, 2026
8da8490
feat: wellknown, health, types, entity routes
cuibonobo May 25, 2026
43de7ee
feat: records route (CRUD, query, associations, permissions, versions)
cuibonobo May 25, 2026
df2823e
feat: attachments route with file cleanup on delete
cuibonobo May 25, 2026
7d41d3c
fix: pass dbPath to attachmentRoutes; fix ownerEntityId null guard
cuibonobo May 25, 2026
83c2814
test: integration tests for all route groups
cuibonobo May 25, 2026
8340b66
fix: test helper API (ESM-safe cleanup, unified req() opts signature)
cuibonobo May 25, 2026
3d8a966
refactor: extract AppEnv to types.ts, inline requestId to avoid hono/…
cuibonobo May 25, 2026
7a1353b
fix: isolate each test in its own temp directory to prevent attachmen…
cuibonobo May 25, 2026
794c5ab
refactor(attachments): use adapter.getAttachmentMeta for MIME type an…
cuibonobo May 25, 2026
1f64dba
refactor(attachments): drop adapter.getAttachmentMeta guards now that…
cuibonobo May 26, 2026
87dd920
feat(attachments): upload size limit, filename support, clean up file…
cuibonobo May 26, 2026
2bbbeaf
docs: add MAX_ATTACHMENT_BYTES to .env.example
cuibonobo May 26, 2026
7ccb3c3
fix: replace O(n) isAttachmentPublic scan with attachmentFileId filter
cuibonobo Jun 15, 2026
2a404ac
feat: infer MIME type from filename extension when Content-Type is oc…
cuibonobo Jun 16, 2026
1fe5959
feat: replace static AUTH_TOKENS with DB-backed token management
cuibonobo Jun 16, 2026
19fa191
fix: attachment access follows union of referencing records' read per…
claude Jun 16, 2026
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
32 changes: 32 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Port to listen on (default: 3000)
PORT=3000

# Path to the SQLite database file.
# On first run (file does not exist), the database is initialized.
# On subsequent runs, the existing database is opened.
DB_PATH=./stack.db

# Owner entity ID. Required only on first run when the DB is being initialized.
# Generate a stable ID before first launch and keep it consistent.
ENTITY_ID=

# IANA timezone string. Used only on first run. Default: UTC
TIMEZONE=UTC

# Master bearer token for the stack owner. Used to bootstrap the server and
# manage other tokens via POST /tokens. Keep this secret and treat it like a
# root password. All other tokens are issued via the API and stored in the DB.
OWNER_TOKEN=

# Allowed CORS origins. Default: * (all origins)
# Use a comma-separated list to restrict: https://app.example.com,https://admin.example.com
CORS_ORIGINS=*

# Canonical base URL of this server (optional).
# Used in responses that reference the server's own URL.
# Auto-detected from the request if not set.
# Example: https://stack.example.com
BASE_URL=

# Maximum upload size for attachments in bytes (default: 52428800 = 50 MB).
MAX_ATTACHMENT_BYTES=52428800
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules/
dist/
*.db
*.db-wal
*.db-shm
attachments/
.env
.env.local
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist/
node_modules/
pnpm-lock.yaml
7 changes: 7 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}
18 changes: 18 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import prettierConfig from 'eslint-config-prettier';

export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
prettierConfig,
{
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
},
{
ignores: ['dist/**', 'node_modules/**'],
},
);
36 changes: 36 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "@haverstack/server",
"version": "0.1.0",
"description": "Reference server implementation for Haverstack",
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/index.js",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "eslint src tests"
},
"dependencies": {
"@haverstack/adapter-sqlite": "^0.1.0",
"@haverstack/core": "^0.1.0",
"@hono/node-server": "^1.13.7",
"hono": "^4.6.0",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0"
},
"devDependencies": {
"@eslint/js": "^10.0.0",
"@types/node": "^22.0.0",
"eslint": "^9.0.0",
"eslint-config-prettier": "^10.0.0",
"prettier": "^3.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.0",
"typescript-eslint": "^8.0.0",
"vitest": "^2.0.0"
}
}
51 changes: 51 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import type { Logger } from 'pino';
import type { StackContext } from './stack.js';
import type { Config } from './config.js';
import type { AppEnv } from './types.js';
import { authMiddleware } from './middleware/auth.js';
import { errorMiddleware } from './middleware/errors.js';
import { wellknownRoutes } from './routes/wellknown.js';
import { healthRoutes } from './routes/health.js';
import { recordRoutes } from './routes/records.js';
import { typeRoutes } from './routes/types.js';
import { attachmentRoutes } from './routes/attachments.js';
import { entityRoutes } from './routes/entity.js';
import { tokenRoutes } from './routes/tokens.js';

export type { AppEnv };

export function createApp(ctx: StackContext, config: Config, logger: Logger): Hono<AppEnv> {
const app = new Hono<AppEnv>();

// Assign a unique request ID to every request for log correlation.
app.use(async (c, next) => {
c.set('requestId', crypto.randomUUID());
await next();
});

app.use(
cors({
origin:
config.corsOrigins === '*' ? '*' : config.corsOrigins.split(',').map((s) => s.trim()),
allowMethods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'],
allowHeaders: ['Authorization', 'Content-Type', 'Content-Disposition'],
exposeHeaders: ['X-Request-Id', 'Content-Disposition'],
}),
);
app.use(errorMiddleware(logger));
app.use(authMiddleware(config.ownerToken, ctx));

app.route('/.well-known', wellknownRoutes(ctx));
app.route('/health', healthRoutes());
app.route('/records', recordRoutes(ctx));
app.route('/types', typeRoutes(ctx));
app.route('/attachments', attachmentRoutes(ctx, config.maxAttachmentBytes));
app.route('/entity', entityRoutes(ctx));
app.route('/tokens', tokenRoutes(ctx));

app.notFound((c) => c.json({ error: 'Not found' }, 404));

return app;
}
52 changes: 52 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { existsSync } from 'node:fs';

function required(name: string): string {
const val = process.env[name];
if (!val) throw new Error(`Missing required environment variable: ${name}`);
return val;
}

function optional(name: string, fallback: string): string {
return process.env[name] ?? fallback;
}

export type Config = {
port: number;
dbPath: string;
entityId: string | null;
timezone: string;
ownerToken: string;
corsOrigins: string;
baseUrl: string | null;
isNewDb: boolean;
maxAttachmentBytes: number;
};

export function loadConfig(): Config {
const dbPath = required('DB_PATH');
const isNewDb = !existsSync(dbPath);

const entityId = process.env['ENTITY_ID'] ?? null;
const timezone = optional('TIMEZONE', 'UTC');

if (isNewDb && !entityId) {
throw new Error(
'ENTITY_ID is required when initializing a new database (DB_PATH does not exist yet)',
);
}

return {
port: parseInt(optional('PORT', '3000'), 10),
dbPath,
entityId,
timezone,
ownerToken: required('OWNER_TOKEN'),
corsOrigins: optional('CORS_ORIGINS', '*'),
baseUrl: process.env['BASE_URL'] ?? null,
isNewDb,
maxAttachmentBytes: parseInt(
optional('MAX_ATTACHMENT_BYTES', String(50 * 1024 * 1024)),
10,
),
};
}
46 changes: 46 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { serve } from '@hono/node-server';
import pino from 'pino';
import { loadConfig } from './config.js';
import { initStack } from './stack.js';
import { createApp } from './app.js';

const logger = pino({
level: process.env['LOG_LEVEL'] ?? 'info',
transport:
process.env['NODE_ENV'] !== 'production'
? { target: 'pino-pretty', options: { colorize: true } }
: undefined,
});

async function main() {
const config = loadConfig();
const ctx = await initStack(config);
const app = createApp(ctx, config, logger);

logger.info(
{ dbPath: config.dbPath, isNewDb: config.isNewDb },
'Stack initialized',
);

const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
logger.info({ port: info.port }, 'Server listening');
});

const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutting down');
server.close(async () => {
await ctx.stack.flush();
await ctx.stack.close();
logger.info('Clean shutdown complete');
process.exit(0);
});
};

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}

main().catch((err) => {
logger.error({ err }, 'Fatal startup error');
process.exit(1);
});
61 changes: 61 additions & 0 deletions src/lib/access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { StackRecord, StackAdapter } from '@haverstack/core';

export type AccessMode = 'read' | 'write';

/**
* Check whether an entity has read or write access to a record.
*
* - No permissions (absent/empty): owner only.
* - public: anyone can read; write still requires an explicit grant.
* - entity: direct entityId match.
* - group: walk the _group record's relationship associations for membership.
*/
export async function checkAccess(
record: StackRecord,
requesterEntityId: string | null,
ownerEntityId: string | null,
mode: AccessMode,
adapter: StackAdapter,
): Promise<boolean> {
// Owner always has full access.
if (requesterEntityId && requesterEntityId === ownerEntityId) return true;

const perms = record.permissions;

// No permissions = private.
if (!perms || perms.length === 0) return false;

for (const p of perms) {
if (p.access === 'public' && mode === 'read') return true;

if (p.access === 'entity' && p.entityId === requesterEntityId) {
if (mode === 'read' && p.read) return true;
if (mode === 'write' && p.write) return true;
}

if (p.access === 'group' && requesterEntityId) {
const member = await isGroupMember(p.groupId, requesterEntityId, adapter);
if (member) {
if (mode === 'read' && p.read) return true;
if (mode === 'write' && p.write) return true;
}
}
}

return false;
}

async function isGroupMember(
groupRecordId: string,
entityId: string,
adapter: StackAdapter,
): Promise<boolean> {
const group = await adapter.getRecord(groupRecordId);
if (!group) return false;
return (group.associations ?? []).some(
(a) =>
a.kind === 'relationship' &&
(a.label === 'member' || a.label === 'admin') &&
a.recordId === entityId,
);
}
Loading