Skip to content
Open
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
33 changes: 33 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 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

# Bearer token to entity ID mapping.
# Format: token1:entityId1,token2:entityId2
# Each token grants access as the specified entity.
# The entity whose ID matches ENTITY_ID is the stack owner and has full access.
AUTH_TOKENS=

# 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"
}
}
49 changes: 49 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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';

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.tokens));

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.notFound((c) => c.json({ error: 'Not found' }, 404));

return app;
}
77 changes: 77 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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 TokenConfig = {
token: string;
entityId: string;
};

function parseTokens(raw: string): TokenConfig[] {
return raw.split(',').map((pair) => {
const i = pair.indexOf(':');
if (i === -1) {
throw new Error(
`Invalid AUTH_TOKENS format. Expected comma-separated "token:entityId" pairs, got: "${pair}"`,
);
}
const token = pair.slice(0, i).trim();
const entityId = pair.slice(i + 1).trim();
if (!token || !entityId) {
throw new Error(`Invalid AUTH_TOKENS entry "${pair}": both token and entityId are required`);
}
return { token, entityId };
});
}

export type Config = {
port: number;
dbPath: string;
entityId: string | null;
timezone: string;
tokens: TokenConfig[];
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)',
);
}

const rawTokens = required('AUTH_TOKENS');
const tokens = parseTokens(rawTokens);

return {
port: parseInt(optional('PORT', '3000'), 10),
dbPath,
entityId,
timezone,
tokens,
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