feat: establish tenant-safe Staff service foundation
This commit is contained in:
commit
fcf014cf71
28 changed files with 4983 additions and 0 deletions
11
.editorconfig
Normal file
11
.editorconfig
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
4
.gitattributes
vendored
Normal file
4
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.zip binary
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
node_modules/
|
||||
.npm-cache/
|
||||
dist/
|
||||
coverage/
|
||||
.env
|
||||
*.log
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
backups/
|
||||
restore-work/
|
||||
1
.prettierrc.json
Normal file
1
.prettierrc.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{ "singleQuote": true, "trailingComma": "all", "printWidth": 100, "endOfLine": "lf" }
|
||||
10
eslint.config.js
Normal file
10
eslint.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist/**', 'coverage/**', 'eslint.config.js', 'scripts/**'] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
{ languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname } } },
|
||||
{ rules: { '@typescript-eslint/require-await': 'off' } },
|
||||
);
|
||||
4
migrations/0001_infrastructure.down.sql
Normal file
4
migrations/0001_infrastructure.down.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
DROP TABLE IF EXISTS staff_audit_log;
|
||||
DROP TABLE IF EXISTS staff_idempotency_keys;
|
||||
DROP TABLE IF EXISTS staff_inbox;
|
||||
DROP TABLE IF EXISTS staff_outbox;
|
||||
64
migrations/0001_infrastructure.up.sql
Normal file
64
migrations/0001_infrastructure.up.sql
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
CREATE TABLE IF NOT EXISTS staff_outbox (
|
||||
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
|
||||
event_type TEXT NOT NULL,
|
||||
event_version INTEGER NOT NULL CHECK (event_version > 0),
|
||||
payload JSONB NOT NULL,
|
||||
correlation_id VARCHAR(26) NOT NULL,
|
||||
causation_id VARCHAR(26),
|
||||
occurred_at TIMESTAMPTZ NOT NULL,
|
||||
published_at TIMESTAMPTZ,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT
|
||||
);
|
||||
CREATE INDEX staff_outbox_pending_idx ON staff_outbox (tenant_id, occurred_at) WHERE published_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_inbox (
|
||||
event_id VARCHAR(26) NOT NULL,
|
||||
tenant_id VARCHAR(26) NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
event_version INTEGER NOT NULL,
|
||||
correlation_id VARCHAR(26) NOT NULL,
|
||||
causation_id VARCHAR(26),
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
processed_at TIMESTAMPTZ,
|
||||
PRIMARY KEY (tenant_id, event_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_idempotency_keys (
|
||||
tenant_id VARCHAR(26) NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
request_hash TEXT NOT NULL,
|
||||
response_status INTEGER,
|
||||
response_body JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
PRIMARY KEY (tenant_id, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_audit_log (
|
||||
id VARCHAR(26) PRIMARY KEY,
|
||||
tenant_id VARCHAR(26) NOT NULL,
|
||||
actor_id VARCHAR(26) NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id VARCHAR(26),
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
correlation_id VARCHAR(26) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX staff_audit_tenant_time_idx ON staff_audit_log (tenant_id, created_at DESC);
|
||||
|
||||
ALTER TABLE staff_outbox ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_outbox FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY staff_outbox_tenant_isolation ON staff_outbox USING (tenant_id = current_setting('app.tenant_id', true)) WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
ALTER TABLE staff_inbox ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_inbox FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY staff_inbox_tenant_isolation ON staff_inbox USING (tenant_id = current_setting('app.tenant_id', true)) WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
ALTER TABLE staff_idempotency_keys ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_idempotency_keys FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY staff_idempotency_tenant_isolation ON staff_idempotency_keys USING (tenant_id = current_setting('app.tenant_id', true)) WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
ALTER TABLE staff_audit_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE staff_audit_log FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY staff_audit_tenant_isolation ON staff_audit_log USING (tenant_id = current_setting('app.tenant_id', true)) WITH CHECK (tenant_id = current_setting('app.tenant_id', true));
|
||||
4507
package-lock.json
generated
Normal file
4507
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
49
package.json
Normal file
49
package.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"name": "@krona/staff",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": { "node": ">=22" },
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"start": "node dist/server.js",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"db:migrate": "node scripts/migrate.mjs up",
|
||||
"db:migrate:down": "node scripts/migrate.mjs down",
|
||||
"check:migrations": "node scripts/check-migrations.mjs",
|
||||
"check:contracts": "node scripts/check-contracts.mjs",
|
||||
"check": "npm run lint && npm run typecheck && npm run test && npm run check:migrations && npm run check:contracts && npm run build",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/swagger": "^9.5.1",
|
||||
"bullmq": "^5.58.5",
|
||||
"fastify": "^5.6.0",
|
||||
"ioredis": "^5.7.0",
|
||||
"pg": "^8.16.3",
|
||||
"ulid": "^3.0.1",
|
||||
"yaml": "^2.8.1",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
"@redocly/cli": "^2.0.8",
|
||||
"@types/node": "^22.18.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
"ajv": "^8.17.1",
|
||||
"eslint": "^9.35.0",
|
||||
"prettier": "^3.6.2",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.43.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
18
scripts/check-contracts.mjs
Normal file
18
scripts/check-contracts.mjs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import Ajv from 'ajv/dist/2020.js';
|
||||
|
||||
const manifest = JSON.parse(await readFile('module.json', 'utf8'));
|
||||
const settings = JSON.parse(await readFile('settings.schema.json', 'utf8'));
|
||||
const moduleSchema = JSON.parse(await readFile('schemas/module.schema.json', 'utf8'));
|
||||
const settingsSchema = JSON.parse(await readFile('schemas/settings.schema.schema.json', 'utf8'));
|
||||
const ajv = new Ajv({ allErrors: true });
|
||||
for (const [name, value, schema] of [['module.json', manifest, moduleSchema], ['settings.schema.json', settings, settingsSchema]]) {
|
||||
const validate = ajv.compile(schema);
|
||||
if (!validate(value)) throw new Error(`${name}: ${ajv.errorsText(validate.errors)}`);
|
||||
}
|
||||
if (manifest.configurable_entities.some((entity) => !manifest.entities.includes(entity))) throw new Error('Configurable entity must be declared');
|
||||
const lint = spawnSync(process.execPath, ['node_modules/@redocly/cli/bin/cli.js', 'lint', 'api/openapi.yaml'], { stdio: 'inherit' });
|
||||
if (lint.error) throw lint.error;
|
||||
if (lint.status !== 0) process.exit(lint.status ?? 1);
|
||||
console.log('module manifest, settings schema and OpenAPI contract validated');
|
||||
12
scripts/check-migrations.mjs
Normal file
12
scripts/check-migrations.mjs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { readFile, readdir } from 'node:fs/promises';
|
||||
|
||||
const files = await readdir('migrations');
|
||||
const ups = files.filter((f) => f.endsWith('.up.sql')).map((f) => f.replace('.up.sql', ''));
|
||||
const downs = new Set(files.filter((f) => f.endsWith('.down.sql')).map((f) => f.replace('.down.sql', '')));
|
||||
for (const migration of ups) if (!downs.has(migration)) throw new Error(`Missing down migration for ${migration}`);
|
||||
for (const file of files.filter((f) => f.endsWith('.sql'))) {
|
||||
const sql = await readFile(`migrations/${file}`, 'utf8');
|
||||
if (/\bUUID\b/i.test(sql)) throw new Error(`${file}: UUID is forbidden; use ULID VARCHAR(26)`);
|
||||
if (/\b(DECIMAL|NUMERIC|REAL|DOUBLE PRECISION)\b/i.test(sql)) throw new Error(`${file}: unsafe numeric money type detected`);
|
||||
}
|
||||
console.log(`validated ${ups.length} reversible migration(s)`);
|
||||
19
scripts/migrate.mjs
Normal file
19
scripts/migrate.mjs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import pg from 'pg';
|
||||
|
||||
const direction = process.argv[2] === 'down' ? 'down' : 'up';
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) throw new Error('DATABASE_URL is required');
|
||||
const files = (await readdir('migrations')).filter((name) => name.endsWith(`.${direction}.sql`)).sort();
|
||||
if (direction === 'down') files.reverse();
|
||||
const client = new pg.Client({ connectionString });
|
||||
await client.connect();
|
||||
try {
|
||||
for (const file of files) {
|
||||
const sql = await readFile(path.join('migrations', file), 'utf8');
|
||||
await client.query('BEGIN');
|
||||
try { await client.query(sql); await client.query('COMMIT'); console.log(`applied ${file}`); }
|
||||
catch (error) { await client.query('ROLLBACK'); throw error; }
|
||||
}
|
||||
} finally { await client.end(); }
|
||||
21
src/adapters/identity.ts
Normal file
21
src/adapters/identity.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { AppConfig } from '../config.js';
|
||||
import type { IdentityPort } from '../core/ports.js';
|
||||
import { parseUlid, type Ulid } from '../core/ids.js';
|
||||
|
||||
export class StandaloneIdentityAdapter implements IdentityPort {
|
||||
constructor(private readonly config: AppConfig) {}
|
||||
async resolve(_token: string | undefined, correlationId: Ulid) {
|
||||
return {
|
||||
tenantId: parseUlid(this.config.STANDALONE_TENANT_ID),
|
||||
actorId: parseUlid(this.config.STANDALONE_ACTOR_ID),
|
||||
permissions: new Set(['staff:health:read']),
|
||||
correlationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class PlatformIdentityAdapter implements IdentityPort {
|
||||
async resolve(): Promise<never> {
|
||||
throw new Error('Platform JWT adapter is intentionally pending the Core gateway/JWT integration contract');
|
||||
}
|
||||
}
|
||||
25
src/app.ts
Normal file
25
src/app.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import Fastify from 'fastify';
|
||||
import helmet from '@fastify/helmet';
|
||||
import sensible from '@fastify/sensible';
|
||||
import swagger from '@fastify/swagger';
|
||||
import type { AppConfig } from './config.js';
|
||||
import { Database } from './infrastructure/database.js';
|
||||
import { newUlid } from './core/ids.js';
|
||||
|
||||
export interface BuildAppOptions { config: AppConfig; database?: Database }
|
||||
|
||||
export async function buildApp({ config, database = new Database(config.DATABASE_URL) }: BuildAppOptions) {
|
||||
const app = Fastify({ logger: { level: config.LOG_LEVEL }, genReqId: (request) => request.headers['x-correlation-id']?.toString() ?? newUlid() });
|
||||
await app.register(helmet);
|
||||
await app.register(sensible);
|
||||
await app.register(swagger, { openapi: { info: { title: 'Krona Staff API', version: '0.1.0' }, servers: [{ url: '/api/v1/staff' }] } });
|
||||
|
||||
app.get('/live', { schema: { tags: ['Operations'], response: { 200: { type: 'object', required: ['status'], properties: { status: { type: 'string', const: 'ok' } } } } } }, async () => ({ status: 'ok' }));
|
||||
app.get('/ready', { schema: { tags: ['Operations'], response: { 200: { type: 'object', required: ['status'], properties: { status: { type: 'string', const: 'ready' } } } } } }, async () => { await database.ping(); return { status: 'ready' }; });
|
||||
app.get('/version', { schema: { tags: ['Operations'], response: { 200: { type: 'object', required: ['name', 'version', 'mode'], properties: { name: { type: 'string' }, version: { type: 'string' }, mode: { type: 'string' } } } } } }, async () => ({ name: 'krona-staff', version: '0.1.0', mode: config.STAFF_RUNTIME_MODE }));
|
||||
app.get('/openapi.json', async () => app.swagger());
|
||||
app.get('/', async (_request, reply) => reply.type('text/html').send('<!doctype html><html lang="ru"><meta charset="utf-8"><title>Krona Staff</title><body><main><h1>Krona Staff</h1><p>Архитектурная основа модуля. Предметные функции ожидают требований владельца.</p><nav><a href="/openapi.json">OpenAPI</a> · <a href="/live">Live</a> · <a href="/ready">Ready</a> · <a href="/version">Version</a></nav></main></body></html>'));
|
||||
|
||||
app.addHook('onClose', async () => database.close());
|
||||
return app;
|
||||
}
|
||||
27
src/config.ts
Normal file
27
src/config.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
const configSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(8080),
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
|
||||
STAFF_RUNTIME_MODE: z.enum(['standalone', 'platform']).default('standalone'),
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url(),
|
||||
PLATFORM_GATEWAY_URL: z.string().url().optional(),
|
||||
PLATFORM_JWT_ISSUER: z.string().url().optional(),
|
||||
PLATFORM_JWT_AUDIENCE: z.string().optional(),
|
||||
PLATFORM_JWKS_URL: z.string().url().optional(),
|
||||
STANDALONE_TENANT_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
||||
STANDALONE_ACTOR_ID: z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/),
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema>;
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
const config = configSchema.parse(env);
|
||||
if (config.STAFF_RUNTIME_MODE === 'platform') {
|
||||
const required = ['PLATFORM_GATEWAY_URL', 'PLATFORM_JWT_ISSUER', 'PLATFORM_JWT_AUDIENCE', 'PLATFORM_JWKS_URL'] as const;
|
||||
for (const key of required) if (!config[key]) throw new Error(`${key} is required in platform mode`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
9
src/core/context.ts
Normal file
9
src/core/context.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { Ulid } from './ids.js';
|
||||
|
||||
export interface RequestContext {
|
||||
tenantId: Ulid;
|
||||
actorId: Ulid;
|
||||
permissions: ReadonlySet<string>;
|
||||
correlationId: Ulid;
|
||||
causationId?: Ulid;
|
||||
}
|
||||
21
src/core/events.ts
Normal file
21
src/core/events.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import type { Ulid } from './ids.js';
|
||||
|
||||
export interface PlatformEvent<TPayload extends Record<string, unknown>> {
|
||||
id: Ulid;
|
||||
type: string;
|
||||
version: number;
|
||||
tenant_id: Ulid;
|
||||
occurred_at: string;
|
||||
subject: { type: string; id: Ulid };
|
||||
actor: { type: 'user' | 'service'; id: Ulid };
|
||||
data: TPayload;
|
||||
metadata: { correlation_id: Ulid; causation_id?: Ulid };
|
||||
}
|
||||
|
||||
export interface EventPublisher {
|
||||
enqueue<T extends Record<string, unknown>>(event: PlatformEvent<T>): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IdempotentEventConsumer {
|
||||
consume(event: PlatformEvent<Record<string, unknown>>): Promise<'processed' | 'duplicate'>;
|
||||
}
|
||||
13
src/core/ids.ts
Normal file
13
src/core/ids.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { ulid } from 'ulid';
|
||||
|
||||
export type Ulid = string & { readonly __ulid: unique symbol };
|
||||
export const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
||||
|
||||
export function newUlid(): Ulid {
|
||||
return ulid() as Ulid;
|
||||
}
|
||||
|
||||
export function parseUlid(value: string): Ulid {
|
||||
if (!ULID_PATTERN.test(value)) throw new Error('Expected a canonical 26-character ULID');
|
||||
return value as Ulid;
|
||||
}
|
||||
23
src/core/ports.ts
Normal file
23
src/core/ports.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { RequestContext } from './context.js';
|
||||
import type { Ulid } from './ids.js';
|
||||
|
||||
export interface IdentityPort {
|
||||
resolve(bearerToken: string | undefined, correlationId: Ulid): Promise<RequestContext>;
|
||||
}
|
||||
|
||||
export interface AuthorizationPort {
|
||||
require(context: RequestContext, permission: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SettingsPort {
|
||||
get(tenantId: Ulid, key: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface FileStoragePort {
|
||||
createUpload(tenantId: Ulid, objectKey: string, contentType: string): Promise<{ uploadUrl: string }>;
|
||||
createDownload(tenantId: Ulid, objectKey: string): Promise<{ downloadUrl: string }>;
|
||||
}
|
||||
|
||||
export interface NotificationPort {
|
||||
send(tenantId: Ulid, template: string, payload: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
30
src/infrastructure/database.ts
Normal file
30
src/infrastructure/database.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import pg from 'pg';
|
||||
import type { Ulid } from '../core/ids.js';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
export class Database {
|
||||
readonly pool: pg.Pool;
|
||||
constructor(connectionString: string) {
|
||||
this.pool = new Pool({ connectionString, max: 10, application_name: 'krona-staff' });
|
||||
}
|
||||
async ping(): Promise<void> {
|
||||
await this.pool.query('SELECT 1');
|
||||
}
|
||||
async tenantTransaction<T>(tenantId: Ulid, actorId: Ulid, run: (client: pg.PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query("SELECT set_config('app.tenant_id', $1, true), set_config('app.user_id', $2, true)", [tenantId, actorId]);
|
||||
const result = await run(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
async close(): Promise<void> { await this.pool.end(); }
|
||||
}
|
||||
13
src/server.ts
Normal file
13
src/server.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { buildApp } from './app.js';
|
||||
import { loadConfig } from './config.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const app = await buildApp({ config });
|
||||
|
||||
const shutdown = () => {
|
||||
void app.close().then(() => process.exit(0));
|
||||
};
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
await app.listen({ host: '0.0.0.0', port: config.PORT });
|
||||
17
tests/integration/health.test.ts
Normal file
17
tests/integration/health.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { buildApp } from '../../src/app.js';
|
||||
import { loadConfig } from '../../src/config.js';
|
||||
|
||||
const config = loadConfig({ DATABASE_URL: 'postgres://localhost/staff', REDIS_URL: 'redis://localhost:6379', STANDALONE_TENANT_ID: '01J00000000000000000000000', STANDALONE_ACTOR_ID: '01J00000000000000000000001', LOG_LEVEL: 'silent', NODE_ENV: 'test' });
|
||||
const database = { ping: vi.fn(async () => undefined), close: vi.fn(async () => undefined) } as never;
|
||||
|
||||
describe('operational endpoints', () => {
|
||||
it('reports liveness, readiness and version without domain APIs', async () => {
|
||||
const app = await buildApp({ config, database });
|
||||
expect((await app.inject('/live')).statusCode).toBe(200);
|
||||
expect((await app.inject('/ready')).statusCode).toBe(200);
|
||||
expect((await app.inject('/version')).json()).toMatchObject({ name: 'krona-staff', mode: 'standalone' });
|
||||
expect((await app.inject('/api/v1/staff/payroll')).statusCode).toBe(404);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
9
tests/unit/config.test.ts
Normal file
9
tests/unit/config.test.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { loadConfig } from '../../src/config.js';
|
||||
|
||||
const base = { DATABASE_URL: 'postgres://localhost/staff', REDIS_URL: 'redis://localhost:6379', STANDALONE_TENANT_ID: '01J00000000000000000000000', STANDALONE_ACTOR_ID: '01J00000000000000000000001' };
|
||||
|
||||
describe('runtime modes', () => {
|
||||
it('allows the compatible standalone adapter', () => expect(loadConfig(base).STAFF_RUNTIME_MODE).toBe('standalone'));
|
||||
it('fails closed when platform contracts are missing', () => expect(() => loadConfig({ ...base, STAFF_RUNTIME_MODE: 'platform' })).toThrow(/PLATFORM_GATEWAY_URL/));
|
||||
});
|
||||
19
tests/unit/events.test.ts
Normal file
19
tests/unit/events.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { PlatformEvent } from '../../src/core/events.js';
|
||||
|
||||
describe('event envelope', () => {
|
||||
it('retains tenant and trace identity in the platform shape', () => {
|
||||
const event: PlatformEvent<{ marker: string }> = {
|
||||
id: '01J00000000000000000000002' as never,
|
||||
type: 'staff.test.marker', version: 1,
|
||||
tenant_id: '01J00000000000000000000000' as never,
|
||||
occurred_at: '2026-09-10T00:00:00.000Z',
|
||||
subject: { type: 'staff.Test', id: '01J00000000000000000000003' as never },
|
||||
actor: { type: 'service', id: '01J00000000000000000000001' as never },
|
||||
data: { marker: 'architecture-only' },
|
||||
metadata: { correlation_id: '01J00000000000000000000004' as never },
|
||||
};
|
||||
expect(event.tenant_id).toHaveLength(26);
|
||||
expect(event.metadata.correlation_id).toHaveLength(26);
|
||||
});
|
||||
});
|
||||
7
tests/unit/ids.test.ts
Normal file
7
tests/unit/ids.test.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { newUlid, parseUlid, ULID_PATTERN } from '../../src/core/ids.js';
|
||||
|
||||
describe('platform identifiers', () => {
|
||||
it('generates canonical sortable ULIDs', () => expect(newUlid()).toMatch(ULID_PATTERN));
|
||||
it('rejects UUID compatibility drift', () => expect(() => parseUlid('550e8400-e29b-41d4-a716-446655440000')).toThrow());
|
||||
});
|
||||
19
tests/unit/tenant-sql.test.ts
Normal file
19
tests/unit/tenant-sql.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('tenant isolation migration', () => {
|
||||
const sql = readFileSync('migrations/0001_infrastructure.up.sql', 'utf8');
|
||||
const tables = ['staff_outbox', 'staff_inbox', 'staff_idempotency_keys', 'staff_audit_log'];
|
||||
it.each(tables)('%s is tenant scoped and protected by RLS', (table) => {
|
||||
const create = sql.slice(sql.indexOf(`CREATE TABLE IF NOT EXISTS ${table}`), sql.indexOf(';', sql.indexOf(`CREATE TABLE IF NOT EXISTS ${table}`)));
|
||||
expect(create).toContain('tenant_id VARCHAR(26) NOT NULL');
|
||||
expect(sql).toContain(`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY`);
|
||||
expect(sql).toContain(`ALTER TABLE ${table} FORCE ROW LEVEL SECURITY`);
|
||||
expect(sql).toMatch(new RegExp(`CREATE POLICY [a-z_]+ ON ${table} `));
|
||||
});
|
||||
it('sets tenant and actor locally inside each transaction', () => {
|
||||
const source = readFileSync('src/infrastructure/database.ts', 'utf8');
|
||||
expect(source).toContain("set_config('app.tenant_id', $1, true)");
|
||||
expect(source).toContain("set_config('app.user_id', $2, true)");
|
||||
});
|
||||
});
|
||||
5
tsconfig.build.json
Normal file
5
tsconfig.build.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true, "sourceMap": true },
|
||||
"include": ["src"]
|
||||
}
|
||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "tests", "scripts"]
|
||||
}
|
||||
Loading…
Reference in a new issue