feat: establish tenant-safe Staff service foundation

This commit is contained in:
Ochenstarik 2026-09-10 23:29:26 +07:00
commit fcf014cf71
28 changed files with 4983 additions and 0 deletions

11
.editorconfig Normal file
View 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
View file

@ -0,0 +1,4 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.zip binary

11
.gitignore vendored Normal file
View 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
View file

@ -0,0 +1 @@
{ "singleQuote": true, "trailingComma": "all", "printWidth": 100, "endOfLine": "lf" }

10
eslint.config.js Normal file
View 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' } },
);

View 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;

View 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

File diff suppressed because it is too large Load diff

49
package.json Normal file
View 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"
}
}

View 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');

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>;
}

View 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
View 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 });

View 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();
});
});

View 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
View 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
View 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());
});

View 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
View file

@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true, "sourceMap": true },
"include": ["src"]
}

15
tsconfig.json Normal file
View 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"]
}