diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d033fd6..1b2688c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,11 @@ on: branches: [main] pull_request: +# Every job here only reads the checked-out code and runs local commands — +# none pushes, comments, or otherwise needs a writable GITHUB_TOKEN. +permissions: + contents: read + jobs: format: runs-on: ubuntu-latest @@ -53,3 +58,15 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd43b20..fab1dec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,7 @@ jobs: permissions: contents: write # push version bump + tag, create release packages: write # push Docker image to ghcr.io + id-token: write # OIDC for npm Trusted Publishing steps: - uses: actions/checkout@v4 @@ -26,6 +27,9 @@ jobs: node-version: 22 cache: pnpm + # npm's OIDC Trusted Publishing needs npm >= 11.5.1; setup-node ships older. + - run: npm install -g npm@latest + - run: pnpm install --frozen-lockfile # Inspect commit messages since the last tag (or all commits if no tag @@ -78,6 +82,16 @@ jobs: ghcr.io/${{ github.repository }}:${{ steps.version.outputs.new_version }} ghcr.io/${{ github.repository }}:latest + # Publishes the library surface (src/index.ts + src/testing.ts) so + # other packages (e.g. @haverstack/cli's server-path tests) can depend + # on it — the Docker image above is the deployable app; this is the + # importable half. prepublishOnly builds dist/ first. Auth is the + # repo's Trusted Publisher (OIDC) — no NPM_TOKEN. + - name: Publish to npm + run: npm publish --provenance + env: + NPM_CONFIG_PROVENANCE: 'true' + # Commit the package.json bump, tag it, and push. Requires that the # github-actions bot is allowed to push to main (configure via branch # protection → "Allow specific actors to bypass required pull requests"). diff --git a/Dockerfile b/Dockerfile index 52c2f51..5a4e089 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,4 +27,4 @@ ENV NODE_ENV=production ENV DB_PATH=/app/data/stack.db EXPOSE 3000 USER app -CMD ["node", "dist/index.js"] +CMD ["node", "dist/main.js"] diff --git a/package.json b/package.json index fb97f05..1c5c35b 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,30 @@ "engines": { "node": ">=22.9" }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./testing": { + "import": "./dist/testing.js", + "types": "./dist/testing.d.ts" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, "scripts": { - "dev": "tsx watch --env-file-if-exists=.env src/index.ts", + "dev": "tsx watch --env-file-if-exists=.env src/main.ts", "build": "tsc -p tsconfig.build.json", - "start": "node --env-file-if-exists=.env dist/index.js", + "start": "node --env-file-if-exists=.env dist/main.js", + "prepublishOnly": "pnpm run build", "test": "vitest run", "typecheck": "tsc --noEmit", "lint": "eslint src tests", diff --git a/src/index.ts b/src/index.ts index f96219f..afcd769 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,54 +1,16 @@ -import { serve } from '@hono/node-server'; -import pino from 'pino'; -import { loadConfig } from './config.js'; -import { initStack, type StackContext } from './stack.js'; -import { createApp } from './app.js'; -import { createShutdownHandler } from './shutdown.js'; - -const logger = pino({ - level: process.env['LOG_LEVEL'] ?? 'info', - transport: - process.env['NODE_ENV'] !== 'production' - ? { target: 'pino-pretty', options: { colorize: true } } - : undefined, -}); - -// Set once initStack() resolves, so the fatal-error handler below can flush -// a crash that happens after startup. Undefined before then: nothing to -// flush yet. -let ctx: StackContext | undefined; - -async function main() { - const config = loadConfig(); - ctx = await initStack(config, logger); - const app = createApp(ctx, config, logger); - - logger.info({ dbPath: config.dbPath }, 'Stack initialized'); - - const server = serve({ fetch: app.fetch, port: config.port }, (info) => { - logger.info({ port: info.port }, 'Server listening'); - }); - - const shutdown = createShutdownHandler(server, ctx, logger, config.shutdownTimeoutMs); - const onSignal = (signal: string) => { - shutdown(signal) - .then(() => process.exit(0)) - .catch((err) => { - logger.error({ err }, 'Error during shutdown'); - process.exit(1); - }); - }; - - process.on('SIGTERM', () => onSignal('SIGTERM')); - process.on('SIGINT', () => onSignal('SIGINT')); -} - -main().catch(async (err) => { - logger.error({ err }, 'Fatal startup error'); - if (ctx) { - await ctx.stack.flush().catch((flushErr) => { - logger.error({ err: flushErr }, 'Failed to flush during fatal-error shutdown'); - }); - } - process.exit(1); -}); +/** + * @haverstack/server's library surface. The package is primarily an app + * (`dist/main.js`, run via `pnpm start` / the Docker image — see + * src/main.ts), but these exports let a consumer build and run the same + * Hono app in-process: embedding it behind their own listener, or driving a + * real instance in tests. See `@haverstack/server/testing` for the latter. + */ + +export { createApp } from './app.js'; +export type { AppEnv } from './types.js'; +export { initStack } from './stack.js'; +export type { StackContext } from './stack.js'; +export { loadConfig } from './config.js'; +export type { Config } from './config.js'; +export { createShutdownHandler } from './shutdown.js'; +export type { ShutdownServer } from './shutdown.js'; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..f96219f --- /dev/null +++ b/src/main.ts @@ -0,0 +1,54 @@ +import { serve } from '@hono/node-server'; +import pino from 'pino'; +import { loadConfig } from './config.js'; +import { initStack, type StackContext } from './stack.js'; +import { createApp } from './app.js'; +import { createShutdownHandler } from './shutdown.js'; + +const logger = pino({ + level: process.env['LOG_LEVEL'] ?? 'info', + transport: + process.env['NODE_ENV'] !== 'production' + ? { target: 'pino-pretty', options: { colorize: true } } + : undefined, +}); + +// Set once initStack() resolves, so the fatal-error handler below can flush +// a crash that happens after startup. Undefined before then: nothing to +// flush yet. +let ctx: StackContext | undefined; + +async function main() { + const config = loadConfig(); + ctx = await initStack(config, logger); + const app = createApp(ctx, config, logger); + + logger.info({ dbPath: config.dbPath }, 'Stack initialized'); + + const server = serve({ fetch: app.fetch, port: config.port }, (info) => { + logger.info({ port: info.port }, 'Server listening'); + }); + + const shutdown = createShutdownHandler(server, ctx, logger, config.shutdownTimeoutMs); + const onSignal = (signal: string) => { + shutdown(signal) + .then(() => process.exit(0)) + .catch((err) => { + logger.error({ err }, 'Error during shutdown'); + process.exit(1); + }); + }; + + process.on('SIGTERM', () => onSignal('SIGTERM')); + process.on('SIGINT', () => onSignal('SIGINT')); +} + +main().catch(async (err) => { + logger.error({ err }, 'Fatal startup error'); + if (ctx) { + await ctx.stack.flush().catch((flushErr) => { + logger.error({ err: flushErr }, 'Failed to flush during fatal-error shutdown'); + }); + } + process.exit(1); +}); diff --git a/src/testing.ts b/src/testing.ts new file mode 100644 index 0000000..41fbf3e --- /dev/null +++ b/src/testing.ts @@ -0,0 +1,165 @@ +/** + * Test-only building blocks for driving a real @haverstack/server. This + * package's own route tests use `createTestContext`/`testConfig` with + * `createApp` + Hono's in-process `app.request()` (no socket — see + * tests/setup.ts's `buildTestApp`/`req`); `startTestServer` goes one step + * further and actually listens, for a consumer whose client (APIAdapter or + * otherwise) needs a real URL to fetch against rather than a mocked one. + * Not for production use: fixed entity/token values, single-worker pool. + */ + +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { rm } from 'node:fs/promises'; +import { mkdirSync } from 'node:fs'; +import { createServer } from 'node:net'; +import { serve } from '@hono/node-server'; +import { LocalAdapter, NativeTokenStore, defaultTokenStorePath } from '@haverstack/adapter-local'; +import { Stack } from '@haverstack/core'; +import { authOriginFromUrl } from '@haverstack/core/wire'; +import pino from 'pino'; +import type { Logger } from 'pino'; +import { createApp } from './app.js'; +import { createShutdownHandler } from './shutdown.js'; +import { AuthNonceStore, defaultNonceStorePath } from './lib/nonceStore.js'; +import { QueryWorkerPool } from './lib/queryWorker/pool.js'; +import { ChangeStreamRegistry } from './lib/changeStreams.js'; +import type { Config } from './config.js'; +import type { StackContext } from './stack.js'; + +export const TEST_ENTITY_ID = 'did:key:test-entity-id-00000001'; +export const TEST_TOKEN = 'test-bearer-token'; +export const OTHER_ENTITY_ID = 'did:key:other-entity-id-00000002'; +// Matches @haverstack/conformance-fixtures' AUTH_FIXTURE_ORIGIN — the auth +// handshake fixtures carry real signatures over this exact origin, so +// anything replaying them (createTestContext/testConfig, in-process +// app.request()) must present itself as it. startTestServer, which actually +// listens, uses its own real origin instead — see below. +export const TEST_BASE_URL = 'https://stack.example.com'; + +export const logger: Logger = pino({ level: 'silent' }); + +/** + * Each caller gets its own isolated temp directory so the SQLiteAdapter's + * sibling `attachments/` folder never collides between parallel test runs. + */ +export function tempDbPath(): string { + const dir = join(tmpdir(), `haverstack-test-${randomBytes(8).toString('hex')}`); + mkdirSync(dir, { recursive: true }); + return join(dir, 'stack.db'); +} + +export type TestContextOpts = { + /** + * IANA timezone string, or `undefined` to opt out of one. Note: a plain + * default parameter can't tell "omitted" from "explicitly undefined" + * (both trigger the default), so this must be an options bag — pass + * `{ timezone: undefined }` deliberately, not the bare value. + */ + timezone?: string; +}; + +export async function createTestContext( + dbPath: string, + opts: TestContextOpts = { timezone: 'UTC' }, +): Promise { + const adapter = await LocalAdapter.initialize({ + path: dbPath, + entityId: TEST_ENTITY_ID, + ...(opts.timezone !== undefined && { timezone: opts.timezone }), + }); + const stack = await Stack.create(adapter); + const tokens = await NativeTokenStore.open({ path: defaultTokenStorePath(dbPath) }); + const nonces = AuthNonceStore.open(defaultNonceStorePath(dbPath)); + const queryWorker = new QueryWorkerPool({ + init: { dbPath }, + poolSize: 1, + queueLimit: 64, + logger, + }); + return { adapter, stack, tokens, nonces, queryWorker, changeStreams: new ChangeStreamRegistry() }; +} + +export function testConfig(dbPath: string, opts: TestContextOpts = { timezone: 'UTC' }): Config { + return { + port: 3000, + dbPath, + entityId: TEST_ENTITY_ID, + ownerName: null, + ownerHandle: null, + timezone: opts.timezone, + ownerToken: TEST_TOKEN, + corsOrigins: '*', + baseUrl: TEST_BASE_URL, + authOrigin: authOriginFromUrl(TEST_BASE_URL), + maxAttachmentBytes: 50 * 1024 * 1024, + maxContentBytes: 1 * 1024 * 1024, + queryTimeoutMs: 10_000, + queryWorkerPoolSize: 1, + queryQueueLimit: 64, + seedCommonsTypes: false, + shutdownTimeoutMs: 10_000, + }; +} + +/** Asks the OS for a free localhost port, then immediately releases it. */ +async function getFreePort(): Promise { + return new Promise((resolve, reject) => { + const probe = createServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + const port = typeof address === 'object' && address ? address.port : undefined; + probe.close(() => { + if (port) resolve(port); + else reject(new Error('could not allocate a free port')); + }); + }); + }); +} + +export type TestServer = { + /** The real, reachable base URL — pass straight to APIAdapter.open({ url }). */ + url: string; + ctx: StackContext; + config: Config; + /** Stops accepting connections and releases every resource, including the temp db. */ + close(): Promise; +}; + +/** + * Spins up a real, listening @haverstack/server on an ephemeral localhost + * port, backed by a throwaway LocalAdapter file — for exercising an HTTP + * client against the actual wire protocol rather than a mocked fetch. + * Owner auth: `Authorization: Bearer ${TEST_TOKEN}`. + */ +export async function startTestServer( + opts: TestContextOpts = { timezone: 'UTC' }, +): Promise { + const dbPath = tempDbPath(); + const ctx = await createTestContext(dbPath, opts); + const port = await getFreePort(); + const url = `http://127.0.0.1:${port}`; + const config: Config = { + ...testConfig(dbPath, opts), + port, + baseUrl: url, + authOrigin: authOriginFromUrl(url), + }; + const app = createApp(ctx, config, logger); + + const server = await new Promise>((resolve) => { + const s = serve({ fetch: app.fetch, port }, () => resolve(s)); + }); + + // Reuses the exact production teardown sequence (see src/shutdown.ts) — + // a short grace period since a stuck test should fail fast, not hang. + const shutdown = createShutdownHandler(server, ctx, logger, 2_000); + const close = async () => { + await shutdown('test-teardown'); + await rm(dirname(dbPath), { recursive: true, force: true }).catch(() => {}); + }; + + return { url, ctx, config, close }; +} diff --git a/tests/setup.ts b/tests/setup.ts index 7a49efe..6087412 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,94 +1,33 @@ -import { tmpdir } from 'node:os'; -import { join, dirname } from 'node:path'; -import { randomBytes } from 'node:crypto'; +import { dirname } from 'node:path'; import { rm } from 'node:fs/promises'; -import { mkdirSync } from 'node:fs'; -import { LocalAdapter, NativeTokenStore, defaultTokenStorePath } from '@haverstack/adapter-local'; -import { Stack } from '@haverstack/core'; -import { authOriginFromUrl } from '@haverstack/core/wire'; -import pino from 'pino'; import { createApp } from '../src/app.js'; -import type { Config } from '../src/config.js'; import type { StackContext } from '../src/stack.js'; -import { AuthNonceStore, defaultNonceStorePath } from '../src/lib/nonceStore.js'; -import { QueryWorkerPool } from '../src/lib/queryWorker/pool.js'; -import { ChangeStreamRegistry } from '../src/lib/changeStreams.js'; import type { Hono } from 'hono'; import type { AppEnv } from '../src/app.js'; +import { + TEST_ENTITY_ID, + TEST_TOKEN, + OTHER_ENTITY_ID, + TEST_BASE_URL, + logger, + tempDbPath, + createTestContext, + testConfig, + type TestContextOpts, +} from '../src/testing.js'; -export const TEST_ENTITY_ID = 'did:key:test-entity-id-00000001'; -export const TEST_TOKEN = 'test-bearer-token'; -export const OTHER_ENTITY_ID = 'did:key:other-entity-id-00000002'; -// Matches @haverstack/conformance-fixtures' AUTH_FIXTURE_ORIGIN — the auth -// handshake fixtures carry real signatures over this exact origin, so the -// test harness must present itself as it. -export const TEST_BASE_URL = 'https://stack.example.com'; - -export const logger = pino({ level: 'silent' }); - -/** - * Each test gets its own isolated temp directory so the SQLiteAdapter's - * sibling `attachments/` folder never collides between parallel test runs. - */ -export function tempDbPath(): string { - const dir = join(tmpdir(), `haverstack-test-${randomBytes(8).toString('hex')}`); - mkdirSync(dir, { recursive: true }); - return join(dir, 'stack.db'); -} - -export type TestContextOpts = { - /** - * IANA timezone string, or `undefined` to opt out of one. Note: a plain - * default parameter can't tell "omitted" from "explicitly undefined" - * (both trigger the default), so this must be an options bag — pass - * `{ timezone: undefined }` deliberately, not the bare value. - */ - timezone?: string; +export { + TEST_ENTITY_ID, + TEST_TOKEN, + OTHER_ENTITY_ID, + TEST_BASE_URL, + logger, + tempDbPath, + createTestContext, + testConfig, + type TestContextOpts, }; -export async function createTestContext( - dbPath: string, - opts: TestContextOpts = { timezone: 'UTC' }, -): Promise { - const adapter = await LocalAdapter.initialize({ - path: dbPath, - entityId: TEST_ENTITY_ID, - ...(opts.timezone !== undefined && { timezone: opts.timezone }), - }); - const stack = await Stack.create(adapter); - const tokens = await NativeTokenStore.open({ path: defaultTokenStorePath(dbPath) }); - const nonces = AuthNonceStore.open(defaultNonceStorePath(dbPath)); - const queryWorker = new QueryWorkerPool({ - init: { dbPath }, - poolSize: 1, - queueLimit: 64, - logger, - }); - return { adapter, stack, tokens, nonces, queryWorker, changeStreams: new ChangeStreamRegistry() }; -} - -export function testConfig(dbPath: string, opts: TestContextOpts = { timezone: 'UTC' }): Config { - return { - port: 3000, - dbPath, - entityId: TEST_ENTITY_ID, - ownerName: null, - ownerHandle: null, - timezone: opts.timezone, - ownerToken: TEST_TOKEN, - corsOrigins: '*', - baseUrl: TEST_BASE_URL, - authOrigin: authOriginFromUrl(TEST_BASE_URL), - maxAttachmentBytes: 50 * 1024 * 1024, - maxContentBytes: 1 * 1024 * 1024, - queryTimeoutMs: 10_000, - queryWorkerPoolSize: 1, - queryQueueLimit: 64, - seedCommonsTypes: false, - shutdownTimeoutMs: 10_000, - }; -} - export type TestApp = { app: Hono; ctx: StackContext; diff --git a/tests/testing.test.ts b/tests/testing.test.ts new file mode 100644 index 0000000..f632375 --- /dev/null +++ b/tests/testing.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { startTestServer, TEST_TOKEN } from '../src/testing.js'; + +describe('startTestServer', () => { + it('actually listens: discovery and an authenticated round trip work over real HTTP', async () => { + const server = await startTestServer(); + try { + const discovery = await fetch(`${server.url}/.well-known/stack`); + expect(discovery.status).toBe(200); + const body = (await discovery.json()) as { entityId: string }; + expect(body.entityId).toBe(server.ctx.stack.ownerEntityId); + + const created = await fetch(`${server.url}/types`, { + method: 'GET', + headers: { Authorization: `Bearer ${TEST_TOKEN}` }, + }); + expect(created.status).toBe(200); + } finally { + await server.close(); + } + }); + + it('close() releases the port and the temp db', async () => { + const server = await startTestServer(); + const { url } = server; + await server.close(); + await expect(fetch(`${url}/.well-known/stack`)).rejects.toThrow(); + }); +});