Extract API, migrations and portable NAS deployment
This commit is contained in:
commit
673255fa4f
57 files changed
+3799
No files matched your search
@@ -0,0 +1,8 @@
|
||||
*
|
||||
!.image/
|
||||
!.image/**
|
||||
!infra/
|
||||
!infra/synology/
|
||||
!infra/synology/nginx.conf
|
||||
!infra/synology/nginx-standalone.conf
|
||||
!infra/synology/start-standalone.mjs
|
||||
@@ -0,0 +1,29 @@
|
||||
# Local development only. Copy to .env and set your own passwords.
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3307
|
||||
DB_NAME=model_delivery
|
||||
DB_USER=model_app
|
||||
DB_PASSWORD=change-me-local-app
|
||||
DB_ROOT_PASSWORD=change-me-local-root
|
||||
API_HOST=127.0.0.1
|
||||
API_PORT=3001
|
||||
LOG_LEVEL=info
|
||||
# Local HTTP only; set true with trusted HTTPS deployment.
|
||||
COOKIE_SECURE=false
|
||||
APP_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:4173,http://127.0.0.1:4173
|
||||
# Generate locally: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
|
||||
TOKEN_ENCRYPTION_KEY=
|
||||
# One-time setup code for /local-admin; choose a random long value. Never commit it.
|
||||
LOCAL_ADMIN_SETUP_TOKEN=
|
||||
# Optional initial SSO settings; the guarded local-admin UI can override these.
|
||||
OIDC_ISSUER=
|
||||
OIDC_DISCOVERY_URL=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_REDIRECT_URI=http://localhost:5173/api/auth/callback
|
||||
OIDC_SCOPES=openid
|
||||
# Only permits HTTP on loopback for the automated OIDC test provider.
|
||||
OIDC_ALLOW_HTTP=false
|
||||
# Optional AD display-name lookup is disabled by default. Configure its internal
|
||||
# LDAPS URL, base DN, domain, read account/password and CA at /local-admin.
|
||||
# Directory credentials use TOKEN_ENCRYPTION_KEY and are stored only in MariaDB.
|
||||
@@ -0,0 +1,2 @@
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!infra/synology/.env.production.example
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
work/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.image/
|
||||
@@ -0,0 +1,23 @@
|
||||
# Model Delivery engineering rules
|
||||
|
||||
- Current authorized scope also includes planned delivery dates, a local dashboard/calendar with common-action shortcuts and operational stored-price summaries, delivery editing/cancellation, explicit conflict resolution, editable/archiveable customer profiles with contact details, structured addresses, delivery windows/comments/related records, Synology OIDC sign-in with a configurable group allowlist, a personal greeting, and one local recovery account with guarded SSO settings at /local-admin. Keep dispatch, courier, approval, accounting reports and WhatsApp work out of this slice.
|
||||
- Hebrew UI and RTL; English source, comments and technical documentation.
|
||||
- Use pnpm and strict TypeScript. This repository has a flat src/ layout. Repository ownership and shared package release rules are in docs/repositories.md.
|
||||
- All operational writes commit to IndexedDB with their outbox entry atomically before any network request. Authentication and SSO administration require an online server. Never perform network I/O inside an IndexedDB transaction.
|
||||
- Never expose MariaDB to browsers. Require server sessions for sync; the authentication foundation remains local/loopback until deployment hardening is completed. Keep each user's device cache separate and pause sync on 401 without losing queued writes.
|
||||
- Normal authentication uses OIDC Authorization Code + PKCE, state, nonce and signature/issuer/audience checks through openid-client. Provider tokens and client secrets stay encrypted on the API server. Never put them in browser storage or logs. offline_access is optional and does not enable offline API access.
|
||||
- OIDC group access uses the verified configured claim, exact normalized case-insensitive allowlist matching and a stored last-login snapshot. Reject unmatched users before provisioning or session creation. Keep restriction disabled until an allowlist is configured, keep the local recovery account exempt, and require a fresh SSO login to learn changed provider membership.
|
||||
- /local-admin is unlisted, authenticated and restricted to loopback requests. Never replace these controls with a secret URL or an unauthenticated bypass. Exactly one password-based account may be bootstrapped using LOCAL_ADMIN_SETUP_TOKEN; no shipped/default credentials. Deny the admin routes at a future public reverse proxy.
|
||||
- Optional AD lookup enriches display names only after verified OIDC; never use it to bypass sign-in or infer roles. Require configured issuer/domain matching, exact escaped user filters, verified LDAPS, bounded operations and encrypted read credentials. Keep directory settings/tests behind the same local-admin controls. Preserve manual names and use cached names during directory outages.
|
||||
- Apply DB changes only through checked-in migrations. Do not modify an already applied migration. MariaDB DDL implicitly commits; keep migrations restart-safe.
|
||||
- Idempotency receipts, entity writes, business-number allocation and change log entries share one server transaction. Hold the sync_state lock until commit so the delta feed cannot miss late commits.
|
||||
- UUIDs are entity identities. Delivery numbers are server-controlled, monotonically allocated and never reused after commit, even after future cancellation.
|
||||
- No weight field. Shipment-unit multipliers live in reference data, not hardcoded business logic. Store immutable fixed-point shipment-unit and pricing snapshots on deliveries.
|
||||
- Require each delivery address to belong to the selected customer, validated locally, by the API and by a composite foreign key.
|
||||
- No silent conflict resolution. Preserve the local draft and server conflict details. Use version checks for future edits. Synchronized deletions must use tombstones and change-log entries.
|
||||
- Pull cursors are opaque decimal strings. Never advance them from push receipts. Keep outbox entries until a valid server acknowledgment is durably applied.
|
||||
- Sync on launch, reconnect, foreground, manual request, and periodically while visible. Do not depend on background sync support.
|
||||
- Responsive layouts support phones and tablets in both orientations. Use logical CSS properties, 44px touch targets, safe areas and 100dvh.
|
||||
- Never commit .env, database files or secrets. Avoid logging request bodies and customer data.
|
||||
- Before delivery: pnpm typecheck, pnpm test, pnpm build; for sync changes also run the isolated MariaDB integration and Playwright offline/reconnect tests (README has setup).
|
||||
- Do not reset, drop, or truncate working databases to fix tests. Use model_delivery_test.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Model Delivery API
|
||||
|
||||
Fastify API, Synology OIDC, group access, encrypted server credentials, MariaDB migrations and sync transactions. Includes the provider-token/session-expiry fix.
|
||||
|
||||
```sh
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
# Copy .env.example to .env and set local credentials.
|
||||
pnpm db:up
|
||||
pnpm db:migrate
|
||||
pnpm db:seed
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The development database Compose project keeps the existing model-delivery name and volume. Never reset it. Production uses the NAS MariaDB service at 10.0.0.200:3306 and does not run a database container.
|
||||
|
||||
```sh
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
pnpm db:test:prepare
|
||||
pnpm test:integration
|
||||
```
|
||||
|
||||
Integration tests use only model_delivery_test. Browser E2E lives in delivery-app-web and starts this API.
|
||||
|
||||
## Single-image NAS deployment
|
||||
|
||||
Install both web and API dependencies first. The staging command builds each checkout and copies only production API dependencies, compiled code, database scripts and web assets into a fresh ignored .image directory. Existing .image output is never overwritten; move it aside before rebuilding. It intentionally runs outside Docker so private Git credentials never enter build layers. Current production dependencies are platform-independent JS; review this staging method if native Node addons are introduced.
|
||||
|
||||
Staging uses real dependency directories so Windows junctions cannot break the Linux image. It also rejects deployed dependency versions absent from the workstation's locked install. Keep the default isolated pnpm linker for development; the staging script selects its own hoisted deployment layout.
|
||||
|
||||
```sh
|
||||
pnpm image:stage ../delivery-app-web
|
||||
docker build --platform linux/amd64 --target standalone -t localhost/model-delivery:split-repos -f infra/synology/Dockerfile .
|
||||
docker save -o model-delivery-split-repos.tar localhost/model-delivery:split-repos
|
||||
```
|
||||
|
||||
Podman also works; use --format docker when building, and podman save --format docker-archive. Import on the NAS with docker load -i model-delivery-split-repos.tar. Reuse your working production environment and encryption key. For a new setup, copy infra/synology/standalone.env.example to .env.production and fill it locally. The supplied compose.yaml uses the automatically created bridge network. Port 38080 is public HTTP behind Web Station HTTPS; 38081 is the separate recovery/admin HTTP listener and must remain LAN-restricted. Host mappings default to NAS loopback. Preserve your already working Web Station routing and bindings if different. host.docker.internal is an optional Docker host-gateway alias; it does not change MariaDB listener/firewall permissions.
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
[Repository map](docs/repositories.md), [authentication](docs/authentication.md), [sync contract](docs/sync-contract.md).
|
||||
@@ -0,0 +1,15 @@
|
||||
name: model-delivery
|
||||
services:
|
||||
app:
|
||||
image: localhost/model-delivery:split-repos
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 20s
|
||||
env_file: .env.production
|
||||
ports:
|
||||
- "127.0.0.1:38080:38080"
|
||||
- "127.0.0.1:38081:38081"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
@@ -0,0 +1,67 @@
|
||||
CREATE TABLE IF NOT EXISTS sync_state (
|
||||
id TINYINT PRIMARY KEY,
|
||||
sequence BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
next_delivery_number INT UNSIGNED NOT NULL DEFAULT 1001
|
||||
);
|
||||
INSERT IGNORE INTO sync_state(id) VALUES (1);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS customers (
|
||||
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at VARCHAR(24) NOT NULL,
|
||||
deleted_at VARCHAR(24) NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS customer_addresses (
|
||||
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
customer_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
label VARCHAR(80) NOT NULL,
|
||||
address VARCHAR(300) NOT NULL,
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at VARCHAR(24) NOT NULL,
|
||||
deleted_at VARCHAR(24) NULL,
|
||||
UNIQUE KEY address_customer (id, customer_id),
|
||||
FOREIGN KEY (customer_id) REFERENCES customers(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS delivery_types (
|
||||
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
code VARCHAR(20) NOT NULL UNIQUE,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
shipment_unit_hundredths INT UNSIGNED NOT NULL,
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at VARCHAR(24) NOT NULL,
|
||||
deleted_at VARCHAR(24) NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS deliveries (
|
||||
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
delivery_number INT UNSIGNED NOT NULL UNIQUE,
|
||||
customer_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
address_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
delivery_type_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
delivery_type_version INT UNSIGNED NOT NULL,
|
||||
shipment_unit_hundredths INT UNSIGNED NOT NULL,
|
||||
unit_price_agorot INT UNSIGNED NOT NULL,
|
||||
total_price_agorot BIGINT UNSIGNED NOT NULL,
|
||||
notes VARCHAR(500) NOT NULL DEFAULT '',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'created',
|
||||
version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at VARCHAR(24) NOT NULL,
|
||||
deleted_at VARCHAR(24) NULL,
|
||||
FOREIGN KEY (customer_id) REFERENCES customers(id),
|
||||
FOREIGN KEY (address_id, customer_id) REFERENCES customer_addresses(id, customer_id),
|
||||
FOREIGN KEY (delivery_type_id) REFERENCES delivery_types(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sync_operations (
|
||||
operation_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
device_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
request_hash CHAR(64) NOT NULL,
|
||||
result_json JSON NOT NULL,
|
||||
created_at VARCHAR(24) NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sync_changes (
|
||||
sequence BIGINT UNSIGNED PRIMARY KEY,
|
||||
entity_type VARCHAR(20) NOT NULL,
|
||||
entity_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
change_json JSON NOT NULL,
|
||||
INDEX entity_lookup (entity_type, entity_id, sequence)
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
ALTER TABLE deliveries ADD COLUMN IF NOT EXISTS updated_at VARCHAR(24) NULL;
|
||||
ALTER TABLE deliveries ADD COLUMN IF NOT EXISTS cancelled_at VARCHAR(24) NULL;
|
||||
ALTER TABLE deliveries ADD COLUMN IF NOT EXISTS cancellation_reason VARCHAR(300) NULL;
|
||||
CREATE TABLE IF NOT EXISTS app_users (
|
||||
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
username VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL UNIQUE,
|
||||
display_name VARCHAR(80) NOT NULL,
|
||||
password_hash VARCHAR(256) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'staff',
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at VARCHAR(24) NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
user_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
expires_at DATETIME(3) NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES app_users(id),
|
||||
INDEX sessions_user (user_id),
|
||||
INDEX sessions_expiry (expires_at)
|
||||
);
|
||||
ALTER TABLE sync_operations ADD COLUMN IF NOT EXISTS actor_user_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL;
|
||||
@@ -0,0 +1,19 @@
|
||||
ALTER TABLE app_users MODIFY COLUMN password_hash VARCHAR(256) NULL;
|
||||
ALTER TABLE app_users ADD COLUMN IF NOT EXISTS oidc_identity_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL;
|
||||
ALTER TABLE app_users ADD COLUMN IF NOT EXISTS oidc_issuer VARCHAR(512) NULL;
|
||||
ALTER TABLE app_users ADD COLUMN IF NOT EXISTS oidc_subject VARCHAR(255) NULL;
|
||||
ALTER TABLE app_users ADD COLUMN IF NOT EXISTS username_display VARCHAR(255) NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_oidc_identity ON app_users(oidc_identity_hash);
|
||||
ALTER TABLE auth_sessions ADD COLUMN IF NOT EXISTS auth_provider VARCHAR(10) NOT NULL DEFAULT 'local';
|
||||
ALTER TABLE auth_sessions ADD COLUMN IF NOT EXISTS encrypted_tokens TEXT NULL;
|
||||
CREATE TABLE IF NOT EXISTS oidc_settings (
|
||||
id TINYINT PRIMARY KEY,
|
||||
encrypted_config TEXT NOT NULL,
|
||||
updated_by CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
updated_at VARCHAR(24) NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS oidc_transactions (
|
||||
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
|
||||
encrypted_data TEXT NOT NULL,
|
||||
expires_at DATETIME(3) NOT NULL
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE app_users ADD COLUMN IF NOT EXISTS display_name_override VARCHAR(80) NULL;
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE IF NOT EXISTS directory_settings (
|
||||
id TINYINT PRIMARY KEY,
|
||||
config_id CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
enabled BOOLEAN NOT NULL,
|
||||
issuer VARCHAR(512) NOT NULL,
|
||||
encrypted_config MEDIUMTEXT NOT NULL,
|
||||
updated_by CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
updated_at VARCHAR(24) NOT NULL
|
||||
);
|
||||
ALTER TABLE app_users ADD COLUMN IF NOT EXISTS directory_display_name VARCHAR(80) NULL;
|
||||
ALTER TABLE app_users ADD COLUMN IF NOT EXISTS directory_config_id CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL;
|
||||
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS contact_name VARCHAR(100) NOT NULL DEFAULT '';
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS phone VARCHAR(30) NOT NULL DEFAULT '';
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS comments VARCHAR(1000) NOT NULL DEFAULT '';
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS morning_start CHAR(5) NULL;
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS morning_end CHAR(5) NULL;
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS afternoon_start CHAR(5) NULL;
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS afternoon_end CHAR(5) NULL;
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS late_night_start CHAR(5) NULL;
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS late_night_end CHAR(5) NULL;
|
||||
ALTER TABLE customers ADD COLUMN IF NOT EXISTS updated_at VARCHAR(24) NULL;
|
||||
ALTER TABLE customer_addresses ADD COLUMN IF NOT EXISTS city VARCHAR(100) NOT NULL DEFAULT '';
|
||||
ALTER TABLE customer_addresses ADD COLUMN IF NOT EXISTS street VARCHAR(120) NOT NULL DEFAULT '';
|
||||
ALTER TABLE customer_addresses ADD COLUMN IF NOT EXISTS building_number VARCHAR(20) NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE deliveries ADD COLUMN IF NOT EXISTS scheduled_date DATE NULL AFTER address_id;
|
||||
UPDATE deliveries SET scheduled_date = DATE(LEFT(created_at, 10)) WHERE scheduled_date IS NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE app_users ADD COLUMN IF NOT EXISTS oidc_groups_json JSON NULL;
|
||||
UPDATE app_users SET oidc_groups_json = JSON_ARRAY() WHERE oidc_identity_hash IS NOT NULL AND oidc_groups_json IS NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE deliveries ADD COLUMN IF NOT EXISTS delivery_slot VARCHAR(20) NULL AFTER scheduled_date;
|
||||
ALTER TABLE deliveries ADD COLUMN IF NOT EXISTS specific_time CHAR(5) NULL AFTER delivery_slot;
|
||||
ALTER TABLE deliveries ADD COLUMN IF NOT EXISTS delivery_window_start CHAR(5) NULL AFTER specific_time;
|
||||
ALTER TABLE deliveries ADD COLUMN IF NOT EXISTS delivery_window_end CHAR(5) NULL AFTER delivery_window_start;
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{ "id": "10000000-0000-4000-8000-000000000001", "code": "delivery", "name": "אספקה", "shipmentUnitHundredths": 100 },
|
||||
{ "id": "10000000-0000-4000-8000-000000000002", "code": "pickup", "name": "איסוף", "shipmentUnitHundredths": 100 },
|
||||
{ "id": "10000000-0000-4000-8000-000000000003", "code": "double", "name": "כפולה", "shipmentUnitHundredths": 150 }
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
# Initial architecture
|
||||
|
||||
## Decisions
|
||||
|
||||
Use a pnpm monorepo, React 19 + Vite 8 + TypeScript 5.9, Dexie 4 and dexie-react-hooks for local reactivity, vite-plugin-pwa/Workbox for the static app shell, Fastify 5 and the official MariaDB Node driver. Shared Zod 4 contracts validate both user operations and server responses. Vitest, fake-indexeddb and Playwright cover consistency and the actual offline browser flow. The lockfile pins the complete resolved dependency graph. No ORM, NestJS, router or state framework is needed for this slice.
|
||||
|
||||
The selected Vite release supports the installed Node 22.16 runtime; see the [Vite requirements](https://vite.dev/guide/). Network calls stay outside Dexie transactions, following [Dexie's transaction guidance](https://dexie.org/docs/Tutorial/Best-Practices). The service worker caches the app shell; IndexedDB owns operational data and the outbox.
|
||||
|
||||
## Data model
|
||||
|
||||
| Table | Identity and contents |
|
||||
| --- | --- |
|
||||
| customers | UUID; name, contact, phone, comments, three preferred delivery windows, version and timestamps/tombstone |
|
||||
| customer_addresses | UUID; customer FK, label, formatted address plus city/street/building number, version and timestamps |
|
||||
| delivery_types | UUID; unique code, Hebrew label, configurable unit hundredths, version and timestamps |
|
||||
| deliveries | UUID; unique server business number, customer/address/type FKs, planned date, type version, immutable unit and price snapshots, notes, status, entity version and timestamps |
|
||||
| sync_state | Singleton serialization lock, committed sequence, next delivery number |
|
||||
| sync_operations | Globally unique operation UUID, device UUID, canonical payload hash, immutable acknowledgment and timestamp |
|
||||
| sync_changes | Ordered sequence, entity type/UUID and complete versioned record (including tombstone) |
|
||||
| schema_migrations | Ordered migration filename, SHA-256 checksum and application time |
|
||||
| app_users | UUID, display name, role, active flag and last verified OIDC group snapshot; recovery password hash or unique issuer/subject identity hash |
|
||||
| auth_sessions | Hashed session identifier, user FK, absolute expiry, provider and encrypted token bundle |
|
||||
| oidc_settings | Encrypted singleton SSO configuration and administrator audit metadata |
|
||||
| oidc_transactions | Hashed browser binding, encrypted state/nonce/PKCE verifier and ten-minute expiry |
|
||||
|
||||
The composite delivery FK `(address_id, customer_id)` references the assigned address/customer pair. JSON wire fields use camelCase; SQL uses snake_case. UUID identity exists before sync. Versions start at 0 locally and 1 after server creation. Creates require baseVersion 0. Customer and delivery changes require the exact current positive version and increment it. Customer removal writes a tombstone and preserves addresses/deliveries; archived customers cannot receive new deliveries. Delivery updates change customer/address/planned date/timing/notes. A named timing choice snapshots the customer's current delivery window; a custom choice stores one exact time. Cancellation stores a reason and server timestamp, retains the number/price and prevents further edits. Older records without a planned date fall back to their creation date in local summaries, and pre-v5 records display an unset timing until edited. There is no permanent-deletion API. Sync receipts include the authenticated actor's user UUID; legacy anonymous receipts remain readable for cache migration.
|
||||
|
||||
Money is stored as integer agorot (one hundredth of an ILS); shipment units as integer hundredths. `total = floor((unitPriceAgorot * shipmentUnitHundredths + 50) / 100)`. Input bounds keep multiplication within JavaScript safe-integer precision. The server checks the selected delivery type version and multiplier, then persists the supplied unit-price snapshot and calculated total. The first prototype allows explicit manual entry of the unit price, including zero; it has no pricing policy engine. Catalog version changes reject stale offline drafts for review instead of silently repricing them.
|
||||
|
||||
## Consistency
|
||||
|
||||
An IndexedDB transaction writes the optimistic entity and FIFO outbox entry together. A persisted device ID is generated atomically. Browser Web Locks serialize sync across tabs where supported; server idempotency provides duplicate protection independently.
|
||||
|
||||
The API locks sync_state before checking receipts and applying writes. Entity creation, number allocation, change log and receipt commit atomically. Serializing writes is a deliberate small-scale tradeoff: it guarantees sequence order follows commit order. An auto-increment change log without commit ordering can cause a client to skip an earlier, still-uncommitted change. At larger scale, replace this lock with a proven commit-ordered feed, not an uncoordinated sequence.
|
||||
|
||||
Migrations use a MariaDB advisory lock and checksum checks. DDL implicitly commits; initial CREATE statements are rerunnable after interruption. The small runner supports semicolon-separated SQL statements, not stored routines/DELIMITER syntax. Later migrations must account for interrupted DDL and use new files instead of altering applied ones.
|
||||
|
||||
OIDC login always requests Synology's `groups` scope. When group access is enabled, the verified ID-token/UserInfo group claim must contain at least one configured allowlist entry. Matching is exact after Unicode normalization and case folding. The validated group snapshot is stored on the user and checked on every online app-session read, so saving changed access settings invalidates or rejects ineligible SSO sessions. The local recovery administrator is outside this rule and remains able to repair configuration. Group membership changes at the provider are learned on the next SSO login; fully offline clients cannot learn revocation.
|
||||
|
||||
## Limits that remain
|
||||
|
||||
- One lab workspace, group-admitted users, separate device caches and role scaffolding; no tenant, role-specific action or record-level permission isolation. Keep both servers on loopback pending deployment work. See [authentication](authentication.md).
|
||||
- Explicit customer and delivery conflict review is implemented. No automatic resolution, address editing, permanent-deletion API, compaction or arbitrary outbox editing. One pending operation per editable entity; finish synchronization before its next edit.
|
||||
- Full delta history is retained. New devices start at cursor 0. A cursor ahead of the server is rejected; database replacement with a new history/epoch needs an explicit future bootstrap protocol. Do not reset a server database while reusing device stores.
|
||||
- IndexedDB durability does not protect against browser data clearing or storage eviction. Persistence permission is best effort.
|
||||
- Background OS execution is not required. Active tabs sync on reconnect, visibility, launch, manual request and every 30 seconds while visible. Transport failures keep entries pending; validation conflicts become explicit errors.
|
||||
- The PWA must be loaded online once before offline launch. Service-worker updates prompt for refresh; unsent outbox data remains in IndexedDB, but unfinished form text is not a saved draft.
|
||||
- Physical iPad/Samsung and Safari validation is separate from Chromium viewport checks.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Synology SSO and local recovery
|
||||
|
||||
## First setup on this workstation
|
||||
|
||||
1. Start the local stack and apply migrations. The existing ignored `.env` already contains random `TOKEN_ENCRYPTION_KEY` and `LOCAL_ADMIN_SETUP_TOKEN` values; preserve them.
|
||||
2. Open `http://localhost:5173/local-admin`. This URL is intentionally absent from normal navigation.
|
||||
3. Read `LOCAL_ADMIN_SETUP_TOKEN` locally from `.env` and enter it in the setup form. Choose your own username, display name and password (12–128 characters). No account or default password is shipped. Only one password-based account can be created; setup closes atomically after it exists. The setup token can then be removed from `.env` and the API restarted.
|
||||
4. Save the recovery credentials in your password manager. This account can configure SSO and open the app even when the identity provider is unavailable, provided the Model Delivery API is reachable.
|
||||
5. Complete the SSO form using your actual Synology values. Save, then use **בדיקת ההגדרות השמורות** to test discovery. This checks metadata connectivity and issuer matching, not the full interactive sign-in. Log out of the app and use its normal SSO button to verify the real login.
|
||||
|
||||
For a fresh installation elsewhere, generate a 32-byte random base64 encryption key and a separate long random setup token as described in `.env.example`. Preserve the encryption key with backups; losing it prevents decrypting saved SSO settings and provider tokens. No secret is returned by the settings API after saving.
|
||||
|
||||
## Synology settings
|
||||
|
||||
In Synology SSO Server, enable OIDC and register an OIDC application for Model Delivery. Record its Application ID, Application Secret and exact Redirect URI. Synology's [application documentation](https://kb.synology.com/en-global/DSM/help/SSOServer/sso_server_application_list?version=7) describes these fields. Obtain the Well-known URL from your installed SSO Server's OIDC service settings; copy the `issuer` value from that discovery document without altering its path or trailing slash.
|
||||
|
||||
| Model Delivery setting | Value to use |
|
||||
| --- | --- |
|
||||
| Issuer | Exact `issuer` from your server's discovery document |
|
||||
| Well-known URL | Synology's full discovery URL; optional if discovery from the issuer works |
|
||||
| Application ID / Secret | Values from the OIDC application registered in Synology |
|
||||
| Redirect URI | `http://localhost:5173/api/auth/callback` for current local development; `http://localhost:4173/api/auth/callback` for built preview; your HTTPS application URL plus `/api/auth/callback` after deployment |
|
||||
| Client authentication | Match the provider's advertised method: `client_secret_post` or `client_secret_basic`; `none` is available for explicitly registered public clients |
|
||||
| Scopes | Always `openid`; Model Delivery adds `groups` to each authorization request. Add only other scopes supported by the installed server |
|
||||
| Display-name claim | Default `name`; configure `username` if preferred. Fallbacks include given name, preferred username, Synology's username, email and subject |
|
||||
| Group claim | Default `groups`, matching Synology's documented OIDC group claim |
|
||||
| Allowed groups | One exact Synology/AD group name per line; a user may belong to any one of them |
|
||||
| Restrict by group | Leave off until the allowlist is ready and a test SSO login has shown the expected group in **החשבון שלי** |
|
||||
| offline_access | Optional, off by default. Enable only if your Synology version/application supports refresh tokens and this scope |
|
||||
| prompt=consent | Optional explicit consent request if required by the provider |
|
||||
|
||||
The server validates issuer, metadata endpoints and signatures using `openid-client`, with Authorization Code + PKCE S256, random state and nonce. Do not weaken verification to accommodate mismatched Synology URLs. Correct the server's externally advertised URLs and trusted certificate. This workstation's Synology sign-in and UserInfo lookup are now working; installed-version refresh-token support remains to be confirmed.
|
||||
|
||||
`APP_ORIGINS` is an allowlist in the server's `.env`, independent of the settings form. Callback origins must be in it. On deployment set the real HTTPS app origin, `COOKIE_SECURE=true`, and leave `OIDC_ALLOW_HTTP=false`. The latter is only for the loopback mock provider in automated tests, never a workaround for NAS certificates. Browser origins must stay consistent between app entry and callback.
|
||||
|
||||
## Sessions and offline work
|
||||
|
||||
The browser receives an opaque HttpOnly SameSite=Strict session cookie. A separate short-lived SameSite=Lax cookie binds the cross-site callback to the browser that initiated sign-in. Pending state/nonce/PKCE verifier are encrypted in one-use server transactions with a ten-minute expiry. The callback is excluded from service-worker navigation fallback and request logs omit its query string.
|
||||
|
||||
Access and refresh tokens stay AES-256-GCM encrypted in MariaDB, using the server-only encryption key. App session identifiers are stored as hashes. OIDC users are identified by a hash of `(issuer, subject)`, never linked to the recovery account by a matching username/email. Successful first SSO login provisions a staff profile only after group authorization succeeds; subsequent logins update the greeting and group snapshot from validated claims/UserInfo. Admitted users share the lab's operational data in this slice.
|
||||
|
||||
## Synology group access
|
||||
|
||||
[Synology's SSO Server documentation](https://kb.synology.com/en-af/PAS/help/SSOServer/sso_server_service?version=1_0) describes `groups` as the ID-token claim containing the groups to which the user belongs. Model Delivery always requests the `groups` scope and reads the configured claim from the validated ID token plus UserInfo response. In `/local-admin`, enter one group per line under **קבוצות מורשות**, then enable **הגבלת הכניסה לפי קבוצות Synology / AD**. A user is admitted when at least one returned group exactly matches an allowlist entry; comparison is Unicode-normalized and case-insensitive. Substrings and partial domain names do not match.
|
||||
|
||||
Keep the restriction disabled for the first diagnostic login. Open **החשבון שלי** after SSO login to see the group names actually returned by this Synology installation, copy the intended name into the allowlist, and then enable the restriction from the local recovery account. If Synology returns no group claim, group-restricted login is denied. The local recovery administrator is always exempt and can disable or repair the settings.
|
||||
|
||||
An unmatched user is rejected before a new app account or session is created. Saving changed SSO/group settings causes existing SSO sessions to fail their next online session check and require login. The server also checks each existing session against its last verified group snapshot. Provider-side membership changes with otherwise unchanged settings are learned during the next SSO login; an already-open app session can last up to seven days, and a fully offline device cannot learn a revocation. For immediate removal, change the allowlist (which invalidates existing SSO sessions) or disable the app user in the database until a dedicated user-management screen is built.
|
||||
|
||||
The app session has its own absolute seven-day lifetime from login, with no shorter idle timeout. If the provider issues no refresh token, expiration of its access token does not end this app session; no expired token is sent to a provider API. This supports Synology SSO configurations that return short-lived access tokens without refresh tokens. A fresh login is still required after the app session expires.
|
||||
|
||||
When an access token approaches expiry and a refresh token is present, the API renews it. A database lock prevents simultaneous requests from reusing a rotated refresh token. Network/provider errors keep the session for retry. An invalid refresh grant, disabled account, disabled SSO, changed SSO configuration or rejected stored group membership requires a fresh sign-in. Sync receives 401 and leaves its outbox untouched. Provider-side account/group changes are not polled for sessions without refresh tokens: the verified last-login group snapshot is checked against the current app allowlist on each authenticated request; changed provider membership is learned at a fresh login.
|
||||
|
||||
The local profile cache contains only user information and an absolute seven-day app-session expiry, never provider tokens or passwords. After an online login and app-shell installation, that unexpired profile permits offline work in the user's IndexedDB store. `offline_access` does not grant offline server access and is not needed for these saved local edits. After the local allowance expires, sign-in requires connectivity; pending data remains. Revocation cannot be learned while completely offline. Cached data is not encrypted or erased at logout; this is a trusted-device prototype, not a shared-device security boundary.
|
||||
|
||||
Logging out revokes this Model Delivery session, clears its cookie and cached profile, and retains account-specific outbox/data. It does not sign out of Synology or other SSO applications; the next SSO login may therefore be immediate. Provider-wide logout/revocation and recovery-password management UI are future work.
|
||||
|
||||
### Personal display name
|
||||
|
||||
Some Synology installations return only `sub`, `username` and `email` from UserInfo, with no full-name claim. Requesting `profile` cannot supply a field the provider does not support. In that case the initial greeting falls back to the username.
|
||||
|
||||
On 2026-09-04, a fresh live sign-in explicitly requested `name` (essential), `given_name` and `family_name` in both `id_token` and `userinfo` through the standard OIDC `claims` parameter. The validated ID token still contained only `aud`, `auth_time`, `email`, `exp`, `iat`, `iss`, `nonce`, `sub` and `username`; UserInfo still returned only `email`, `sub` and `username`. No name fields or Hebrew name were returned. The temporary request/capture code and encrypted snapshot were removed afterwards. This confirms the installed service did not supply those fields in that experiment; the protocol's claims parameter is optional ([OIDC Core](https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter)). Synology's documented custom attribute mappings are under SAML, rather than its OIDC application setup ([Synology application documentation](https://kb.synology.com/en-global/DSM/help/SSOServer/sso_server_application_list?version=7)).
|
||||
|
||||
Open **החשבון שלי** and set **שם לתצוגה** to choose the name used in the greeting. This online account setting is stored separately in `app_users.display_name_override`; the provider's original name remains in `display_name`. The preference takes precedence on session reads and future SSO logins. The username, issuer/subject identity, roles and operational records do not change. Successful saves update the cached profile immediately, including its next offline launch. `POST /auth/profile` accepts only `{displayName}`, requires a valid session, an allowed request origin/custom header and an `x-model-user` matching the session.
|
||||
|
||||
Choose **שימוש בשם הארגוני** to clear the personal override (`POST /auth/profile` with `{displayName:null}`) and use the directory/provider name again. This also refreshes the offline profile cache. It never edits the directory.
|
||||
|
||||
### Optional names from REZNIK Active Directory
|
||||
|
||||
The deployment target is the same NAS that hosts Synology SSO and Synology Directory Server (AD). Since the live OIDC responses omit name fields, the API can optionally read AD's `displayName` after a verified SSO login. Authentication, user identity and roles continue to come from the existing OIDC flow/app account; LDAP is used only for the greeting.
|
||||
|
||||
In `/local-admin`, open **שמות מתוך Active Directory** and configure:
|
||||
|
||||
| Setting | Value to supply |
|
||||
| --- | --- |
|
||||
| LDAPS URL | An internal NAS/domain-controller hostname reachable by the API, such as `ldaps://nas.example.internal:636`; the hostname must match the directory TLS certificate |
|
||||
| NetBIOS domain | `REZNIK`, matching the verified SSO subject prefix |
|
||||
| Base DN | The actual AD search root, for example `DC=example,DC=internal`; this example is not the discovered REZNIK domain |
|
||||
| Bind DN / UPN | A dedicated account permitted to read the relevant AD user attributes |
|
||||
| Password | Enter locally in the masked field; do not put it in source control or chat |
|
||||
| CA certificate | Optional public PEM certificate chain if the directory uses an internal CA |
|
||||
|
||||
Save, enable when the connection is ready, and test a full SSO subject such as `REZNIK\tom.ruff`. The test reads the saved configuration and returns only the resolved name or a safe failure code. It does not update accounts. Sign in through SSO again to apply the directory name. A personal override still takes priority; use **שימוש בשם הארגוני** to clear it when desired.
|
||||
|
||||
Co-location alone does not establish connectivity: a container's `localhost` points to the container, not the NAS. Choose a reachable internal NAS address and verify that **Synology Directory Server's** LDAPS service and TLS certificate are configured. The DSM web certificate/SSO HTTPS endpoint is not evidence that LDAP TLS is configured. No directory ports were opened, NAS services changed, or production deployment performed. The development PC did not discover the REZNIK domain and the earlier public-host LDAPS connection timed out. The full AD DNS domain, internal connection details and read credentials have not been supplied; the working feature remains disabled.
|
||||
|
||||
Implementation uses [ldapts](https://github.com/ldapts/ldapts) 9.0.0 with direct LDAPS, TLS 1.2 minimum, certificate/hostname verification, and two-second connection and operation timeouts. Plain LDAP and StartTLS are not offered in this slice. A successful bind precedes one bounded search for an exact `sAMAccountName` under the configured base DN, with literal equality filters, user/person constraints and a two-entry limit. The verified issuer must match the one bound to the directory configuration and the subject must have the configured `DOMAIN\username` form. Bare usernames, other domains, duplicate entries and referrals are not accepted. Only `displayName`, `givenName`, `sn` and `sAMAccountName` are requested; first name plus surname is a fallback if displayName is empty. No groups, roles or directory writes are involved.
|
||||
|
||||
Migration 005 stores encrypted directory settings separately from OIDC settings and caches the resolved name with its configuration fingerprint. The precedence is personal override, cached name from the active matching directory configuration, then OIDC name/username. A directory failure does not prevent a valid OIDC sign-in; the last known name remains available. Changing or disabling directory settings makes old cached directory names ineligible on the next online session read. Offline devices retain their last saved profile until online refresh. Saving directory settings does not invalidate SSO sessions. Blank password preserves the existing credential only while the server URL and bind account remain unchanged. LDAP library errors and credentials are never returned or logged.
|
||||
|
||||
The first local administrator claims a copy of the pre-authentication IndexedDB cache once, preserving existing UUIDs/outbox entries. The original database remains as a backup. Open the app with that recovery account and synchronize any earlier unsent work before switching to SSO. Other accounts get separate device caches and pull the common server records. An account-ID request header prevents one account's queued mutations being sent under another account's cookie.
|
||||
|
||||
## Hidden page protection and API
|
||||
|
||||
The hidden path is only a convenience. All recovery endpoints reject non-loopback connections; settings/discovery additionally require an authenticated local administrator. Initial setup requires the random setup token. Login/setup use rate limits and passwords use salted scrypt. State-changing endpoints require a custom request header and reject disallowed browser origins.
|
||||
|
||||
At a future public reverse proxy, **deny `/local-admin` and `/api/local-admin/*` on the public listener** and provide a separate local/admin access path. The API sees the proxy's loopback address, so its loopback check alone cannot distinguish public clients behind that proxy. Do not expose this development proxy as a production access boundary.
|
||||
|
||||
| API path (browser adds `/api`) | Method / purpose |
|
||||
| --- | --- |
|
||||
| `/auth/session` | GET current session or SSO availability |
|
||||
| `/auth/login` | POST begin OIDC; returns authorization URL |
|
||||
| `/auth/callback` | GET process one-use code callback and redirect to app |
|
||||
| `/auth/logout` | POST revoke current app session |
|
||||
| `/local-admin/status` | GET whether recovery account setup remains open |
|
||||
| `/local-admin/setup` | POST `{username,password,displayName,setupToken}` once |
|
||||
| `/local-admin/login` | POST `{username,password}` for recovery account |
|
||||
| `/local-admin/settings` | GET masked config; POST shared `SsoSettings` contract |
|
||||
| `/local-admin/discover` | POST validate saved issuer/discovery metadata |
|
||||
| `/local-admin/directory` | GET masked directory config; POST shared `DirectorySettings` contract |
|
||||
| `/local-admin/directory/test` | POST `{subject}` to read one name using saved settings |
|
||||
|
||||
Blank client-secret input preserves the saved secret; choosing public-client authentication clears it. Saving changed SSO configuration invalidates existing SSO sessions on their next server request while leaving the recovery account usable.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Model Delivery repositories
|
||||
|
||||
| Repository | Responsibility |
|
||||
| --- | --- |
|
||||
| delivery-app-web | React/Vite/Dexie PWA, browser tests |
|
||||
| delivery-app-api | Fastify, Synology SSO, MariaDB migrations, deployment |
|
||||
| delivery-app-mobile | React Native/Expo for iOS and Android |
|
||||
| delivery-app-shared | Versioned TypeScript/Zod contracts and pricing rules |
|
||||
|
||||
Clone from https://git.ruff.co.il/model-dlab/ using your own Git credential helper. Never put credentials in URLs or package manifests. Sibling checkouts are convenient, but ordinary installs/builds need no sibling source. Browser E2E and the combined NAS image explicitly use the web and API checkouts.
|
||||
|
||||
The shared dependency is pinned to an immutable Git commit, including its compiled dist files. There is no public npm publication or requirement for a private npm registry. Build, test and commit shared source AND dist together, tag releases, then explicitly update the Git SHA and pnpm lockfile in each consumer. TypeScript code can be reused on mobile; DOM, IndexedDB and Node-specific code cannot.
|
||||
|
||||
All clients use one API and the same operational data. SQLite on mobile will implement the existing sync protocol. Do not change deployed database identities, credentials, encryption keys, or OIDC configuration during this source split. The original model-app checkout remains available.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Repository split verification — 2026-09-07
|
||||
|
||||
The original model-app checkout is retained. Web source/assets, API business/auth/sync code, shared contracts and all nine migrations were compared byte-for-byte against it. Only API root/env path resolution and frontend Vite config paths changed to accommodate flat repositories. No working database was reset. No NAS deployment was performed.
|
||||
|
||||
Verified locally:
|
||||
|
||||
- Shared: strict typecheck/build and 3 published-contract tests.
|
||||
- Web: typecheck/build and 16 unit tests.
|
||||
- API: typecheck/build, 6 unit tests and 19 integration tests using model_delivery_test.
|
||||
- Cross-repository Chromium: all 5 existing browser tests passed, covering SSO/recovery, offline entry, offline customer/delivery creation, reconnect, archival, conflicts and cancellation.
|
||||
- Mobile: strict typecheck, 3 URL-configuration tests, iOS and Android Metro bundle exports, and all 21 Expo doctor checks passed.
|
||||
- Separate web/API checkouts staged and built a Linux/amd64 standalone image with Podman in Docker image format.
|
||||
- The actual image passed isolated MariaDB migration/seed/readiness, public/admin routing, secure recovery-cookie and healthcheck tests. A gateway failure stopped the container; restarting preserved the account, and graceful shutdown exited successfully.
|
||||
- Publish candidates were checked against local environment secret values. Environment files, database state, signing materials, dependency directories and generated build outputs are excluded. Shared dist is intentionally tracked for immutable Git dependencies.
|
||||
|
||||
Mobile verification does not include Xcode/Gradle compilation, signing, a device installation, visual inspection of Liquid Glass, or native SSO/offline behavior. Those remain required before mobile distribution. The mobile repository is a foundation with a read-only readiness check, not a replacement for the operational PWA yet.
|
||||
|
||||
The shared release is pinned to Git revision 9133b921a713b35c46a138f9e83d1ccfbf56417e (v0.1.0) in all three consumers. See each repository's README for reproducible commands and the API README for single-image staging.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Sync protocol v5
|
||||
|
||||
All push/pull requests require the app session cookie and `x-model-user` equal to that session's user ID. POST requests additionally require `x-model-request: 1` and an allowed Origin when present. Missing/expired/mismatched authentication returns 401, pauses synchronization and preserves the entire outbox. Provider tokens never travel in sync requests. Each user has a separate IndexedDB database. See [authentication](authentication.md).
|
||||
|
||||
## Local write
|
||||
|
||||
1. Generate `entityId` and `operationId` as UUIDs. Load/create the durable device UUID.
|
||||
2. Validate using the shared schema.
|
||||
3. In one IndexedDB transaction, save the local record (`version: 0`, `syncState: pending`, deliveryNumber: null) and its outbox entry.
|
||||
4. Only after commit, report success and attempt background synchronization.
|
||||
|
||||
Outbox queue order follows an IndexedDB auto-increment key so same-millisecond writes remain ordered. Create a customer before its address, and the address before a delivery. The UI permits only assigned addresses. Pending and error records survive a reload and are not overwritten by the delta feed.
|
||||
|
||||
## Push
|
||||
|
||||
`POST /sync/push`, JSON body:
|
||||
|
||||
```json
|
||||
{
|
||||
"operationId": "e209ee4f-22e8-4f82-a7b6-1ac092d9ec47",
|
||||
"deviceId": "a8380f2a-478c-4c68-93ac-b2f2e7d22080",
|
||||
"entityId": "1d9d9740-58bd-4f88-a102-d6d1231e04c5",
|
||||
"baseVersion": 0,
|
||||
"kind": "delivery.create",
|
||||
"data": {
|
||||
"customerId": "20000000-0000-4000-8000-000000000001",
|
||||
"addressId": "20000000-0000-4000-8000-000000000002",
|
||||
"deliveryTypeId": "10000000-0000-4000-8000-000000000003",
|
||||
"deliveryTypeVersion": 1,
|
||||
"shipmentUnitHundredths": 150,
|
||||
"unitPriceAgorot": 1001,
|
||||
"notes": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Other creates: `customer.create` takes `{name,contactName,phone,comments,deliveryWindows}`. `deliveryWindows` contains nullable `morning`, `afternoon` and `lateNight` entries, each `{start,end}` in 24-hour `HH:mm` form with end after start. `address.create` takes `{customerId,label,address,city,street,buildingNumber}`; `address` is the formatted compatibility/display value. Unrecognized fields and nonzero create base versions are rejected. A successful response is `{change: {entityType, record}, replayed: boolean}`. The complete record has its server version, timestamp and (for delivery) number and price total. Returning a receipt does not imply earlier changes have been pulled.
|
||||
|
||||
`customer.update` replaces those editable profile fields and requires the current positive `baseVersion`. `customer.remove` takes `{}`, writes `deletedAt`, and preserves the customer's existing addresses and deliveries. Both increment the customer version and set `updatedAt`. Removed customers and their addresses are excluded from new delivery validation. Customer conflicts return the current complete customer record and use the same explicit server/local review flow as deliveries.
|
||||
|
||||
`delivery.create` includes nullable `scheduledDate` as an ISO calendar date (`YYYY-MM-DD`); current clients always set it. It also selects `deliverySlot` as `morning`, `afternoon`, `lateNight` or `custom`. Named slots carry a nullable `{start,end}` `deliveryWindow` snapshot copied from the selected customer's current settings and have `specificTime: null`; `custom` requires an exact 24-hour `specificTime` and has `deliveryWindow: null`. `delivery.update` can replace the customer, address, date, timing and notes, while an older update that omits all timing fields preserves the current timing. `delivery.cancel` takes `{reason}`. Both require the current positive `baseVersion`. They preserve the delivery number, type/version, shipment units, unit price, total and creation timestamp. Each successful operation increments the version and sets `updatedAt`; cancellation also sets `status: cancelled`, `cancelledAt` and `cancellationReason`. Cancelled/deleted deliveries reject further changes with `409 DELIVERY_CANCELLED`. Price/type fields in update payloads are rejected rather than silently ignored.
|
||||
|
||||
The server hashes the validated, canonical operation object. Replaying an identical operation ID returns its immutable acknowledgment without changing records or numbers. Compatibility checks accept only exact earlier canonical representations for customer/address defaults, a null planned date and operations created before delivery timing; ownership checks remain mandatory. Reusing the ID with different content returns `409 OPERATION_REUSED`; replay from a different authenticated actor returns `409 OPERATION_OWNER_CHANGED`. A different operation creating an existing entity ID or updating a stale version returns `409 VERSION_CONFLICT` with the current server change as `{current: {entityType,record}}`. Neither silently overwrites data.
|
||||
|
||||
The local change and its outbox entry commit atomically. Editing requires a synchronized customer or delivery with no other pending operation for that entity. The version captured when the form opens is checked again at save, so a background refresh cannot silently overwrite a newer record. Optimistic edits keep the last acknowledged version until the server accepts them.
|
||||
|
||||
On a customer or delivery conflict, preserve both the local draft and server record in IndexedDB. The user explicitly chooses either (a) accept the reviewed server version and remove the queued change, or (b) submit the saved local change using a new operation ID and the reviewed server base version. Choice (b) is prohibited when the server archived/cancelled/deleted the entity. The next push can conflict again. A server version received during review invalidates that review until the new version is considered. Ordinary Retry never bypasses a stored conflict.
|
||||
|
||||
`422 ADDRESS_MISMATCH` or `CUSTOMER_MISSING` rejects invalid references. `409 CATALOG_CONFLICT` rejects changed delivery-type versions/multipliers. These remain explicit local errors. Manual retry preserves the exact operation and snapshots; it does not fabricate a new operation ID or silently change prices. Catalog-conflict and invalid-reference draft editing remain deferred.
|
||||
|
||||
For transport failures, timeouts, 429 or 5xx, leave the operation pending. While the browser reports online, retry with a 1-second exponential backoff capped at 30 seconds, in addition to the normal sync triggers. An online event may arrive before the server is reachable. If the server committed but its response was lost, retry the same operation. Only delete an outbox entry in the local transaction that also applies a validated acknowledgment. The API retains receipts indefinitely in this slice.
|
||||
|
||||
## Pull
|
||||
|
||||
`GET /sync/pull?cursor=0&limit=100` returns:
|
||||
|
||||
```text
|
||||
{
|
||||
changes: [{ sequence: "1", change: { entityType, record } }, ...],
|
||||
nextCursor: "100",
|
||||
hasMore: true
|
||||
}
|
||||
```
|
||||
|
||||
Sequences/cursors are decimal strings, preserving BIGINT precision. Limits are 1–200. The page includes only committed changes in sequence order. Repeat while `hasMore`; atomically apply a page and save its `nextCursor` in IndexedDB. Never derive or advance the pull cursor from a push receipt, or remote records may be skipped. Never replace a newer local acknowledged version with older historical feed entries/receipts. Pending local edits survive pulls; later server versions update their stored conflict details for review.
|
||||
|
||||
Records contain `deletedAt: null` while active. Customer archive appends a versioned tombstone through the same commit-ordered write mechanism. Clients retain tombstones locally and hide archived customers from operational selectors while allowing an archive/history view. Delivery numbers on deleted/cancelled records remain reserved.
|
||||
|
||||
A cursor ahead of server history returns `409 CURSOR_AHEAD`, preserving local data and requiring reconciliation. This protocol does not yet support server history replacement, epoch changes or log retention/compaction; add a bootstrap protocol before those operations.
|
||||
@@ -0,0 +1,22 @@
|
||||
name: model-delivery
|
||||
services:
|
||||
db:
|
||||
image: docker.io/library/mariadb:11.4
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_DATABASE: ${DB_NAME:-model_delivery}
|
||||
MARIADB_USER: ${DB_USER:-model_app}
|
||||
MARIADB_PASSWORD: ${DB_PASSWORD:?Set DB_PASSWORD in .env}
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:?Set DB_ROOT_PASSWORD in .env}
|
||||
ports:
|
||||
- "127.0.0.1:${DB_PORT:-3307}:3306"
|
||||
volumes:
|
||||
- mariadb_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 15s
|
||||
volumes:
|
||||
mariadb_data:
|
||||
@@ -0,0 +1,39 @@
|
||||
# Copy this file to the project root as .env.production on the NAS.
|
||||
# Keep the copied file private and never commit it.
|
||||
|
||||
# Synology MariaDB 10 package. Confirm its TCP port in the package settings;
|
||||
# 3307 is the common Synology default.
|
||||
# NAS end of the dedicated Compose bridge. Container localhost is not the NAS.
|
||||
# If you change the Compose subnet, update this address and the SQL host grant.
|
||||
DB_HOST=172.29.240.1
|
||||
DB_PORT=3307
|
||||
DB_NAME=model_delivery
|
||||
DB_USER=model_app
|
||||
DB_PASSWORD=REPLACE_WITH_A_UNIQUE_DATABASE_PASSWORD
|
||||
|
||||
# API loopback is shared with the gateway via network_mode: service:gateway.
|
||||
# Keep these values; Web Station selects gateway container port 38080.
|
||||
API_HOST=127.0.0.1
|
||||
API_PORT=3001
|
||||
LOG_LEVEL=info
|
||||
|
||||
# Replace both origins with the exact HTTPS addresses configured in DSM.
|
||||
# The second origin is the LAN-only administration portal.
|
||||
COOKIE_SECURE=true
|
||||
APP_ORIGINS=https://deliveries.example.com,https://deliveries-admin.example.com
|
||||
|
||||
# Generate once and preserve for the life of this database. Losing or changing
|
||||
# it makes saved SSO, directory credentials and provider tokens unreadable.
|
||||
TOKEN_ENCRYPTION_KEY=REPLACE_WITH_32_RANDOM_BYTES_IN_BASE64
|
||||
|
||||
# One-time code used to create the single recovery administrator account.
|
||||
LOCAL_ADMIN_SETUP_TOKEN=REPLACE_WITH_A_LONG_RANDOM_SETUP_CODE
|
||||
|
||||
# These can remain blank and be entered through the LAN-only /local-admin UI.
|
||||
OIDC_ISSUER=
|
||||
OIDC_DISCOVERY_URL=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_REDIRECT_URI=https://deliveries.example.com/api/auth/callback
|
||||
OIDC_SCOPES=openid groups
|
||||
OIDC_ALLOW_HTTP=false
|
||||
@@ -0,0 +1,18 @@
|
||||
# Build and stage on a trusted workstation first: pnpm image:stage ../delivery-app-web
|
||||
# The context contains production artifacts only; Git credentials never enter the image.
|
||||
FROM node:22.16.0-bookworm-slim AS standalone
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends nginx-light tini \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
ENV NODE_ENV=production COOKIE_SECURE=true
|
||||
WORKDIR /app
|
||||
COPY --chown=node:node .image/api/ /app/
|
||||
COPY .image/web/ /usr/share/nginx/html/
|
||||
COPY infra/synology/nginx.conf /etc/nginx/model-delivery.conf
|
||||
COPY infra/synology/nginx-standalone.conf /etc/nginx/nginx.conf
|
||||
COPY infra/synology/start-standalone.mjs /app/start-standalone.mjs
|
||||
USER node
|
||||
EXPOSE 38080 38081
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:38080/api/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "node", "/app/start-standalone.mjs"]
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Run as the MariaDB administrator after replacing the password placeholders.
|
||||
-- The application account owns only this database and receives no global grants.
|
||||
-- 172.29.240.2 is the static container address from both Compose files.
|
||||
-- Update this host if you change the bridge subnet. Do not use a wildcard host.
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS model_delivery
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE USER IF NOT EXISTS 'model_app'@'172.29.240.2'
|
||||
IDENTIFIED BY 'REPLACE_WITH_A_UNIQUE_DATABASE_PASSWORD';
|
||||
ALTER USER 'model_app'@'172.29.240.2'
|
||||
IDENTIFIED BY 'REPLACE_WITH_A_UNIQUE_DATABASE_PASSWORD';
|
||||
GRANT ALL PRIVILEGES ON model_delivery.* TO 'model_app'@'172.29.240.2';
|
||||
@@ -0,0 +1,17 @@
|
||||
worker_processes auto;
|
||||
pid /tmp/model-nginx.pid;
|
||||
error_log /dev/stderr warn;
|
||||
events { worker_connections 1024; }
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
# OIDC callback queries must not appear in access logs.
|
||||
access_log off;
|
||||
sendfile on;
|
||||
client_body_temp_path /tmp/model-client;
|
||||
proxy_temp_path /tmp/model-proxy;
|
||||
fastcgi_temp_path /tmp/model-fastcgi;
|
||||
uwsgi_temp_path /tmp/model-uwsgi;
|
||||
scgi_temp_path /tmp/model-scgi;
|
||||
include /etc/nginx/model-delivery.conf;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
gzip on;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 1024;
|
||||
gzip_types application/javascript application/json application/manifest+json image/svg+xml text/css text/plain;
|
||||
|
||||
upstream model_delivery_api {
|
||||
server 127.0.0.1:3001;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
# Public application listener. Local administration is deliberately unavailable.
|
||||
server {
|
||||
listen 38080;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 64k;
|
||||
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy same-origin always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
|
||||
location = /local-admin { return 404; }
|
||||
location ^~ /local-admin/ { return 404; }
|
||||
location = /api/local-admin { return 404; }
|
||||
location ^~ /api/local-admin/ { return 404; }
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://model_delivery_api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 20s;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
try_files $uri =404;
|
||||
default_type application/manifest+json;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
}
|
||||
|
||||
# Administration listener. Publish this through a separate DSM reverse-proxy
|
||||
# rule restricted to the LAN; never expose or port-forward it to the internet.
|
||||
server {
|
||||
listen 38081;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 64k;
|
||||
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy same-origin always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://model_delivery_api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 20s;
|
||||
add_header Cache-Control "no-store" always;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location = /manifest.webmanifest {
|
||||
try_files $uri =404;
|
||||
default_type application/manifest+json;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
# Enter these variables in Container Manager when creating the container.
|
||||
# Use the NAS LAN IP (or a reachable bridge gateway), never container localhost.
|
||||
DB_HOST=10.0.0.200
|
||||
DB_PORT=3306
|
||||
DB_NAME=model_delivery
|
||||
DB_USER=model_app
|
||||
DB_PASSWORD=REPLACE_WITH_DATABASE_PASSWORD
|
||||
|
||||
# Your final Web Station HTTPS origin; no subdirectory.
|
||||
APP_URL=https://deliveries.example.com
|
||||
# Separate HTTPS admin origin, restricted to your LAN, targeting port 38081.
|
||||
ADMIN_URL=https://deliveries-admin.example.com
|
||||
COOKIE_SECURE=true
|
||||
|
||||
# Generate two independent secrets. Preserve the encryption key with DB backups.
|
||||
TOKEN_ENCRYPTION_KEY=REPLACE_WITH_32_RANDOM_BYTES_IN_BASE64
|
||||
LOCAL_ADMIN_SETUP_TOKEN=REPLACE_WITH_RANDOM_SETUP_CODE
|
||||
|
||||
# Optional: configure SSO here, or through the guarded administration page.
|
||||
OIDC_ISSUER=
|
||||
OIDC_DISCOVERY_URL=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_SCOPES=openid groups
|
||||
# OIDC_REDIRECT_URI defaults to APP_URL + /api/auth/callback.
|
||||
# APP_ORIGINS defaults to APP_URL and ADMIN_URL. Both can be overridden explicitly.
|
||||
# Stored SSO settings in MariaDB take precedence over initial OIDC_* variables.
|
||||
@@ -0,0 +1,71 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const env = { ...process.env, API_HOST: '127.0.0.1', API_PORT: '3001' };
|
||||
function origin(name) {
|
||||
const value = env[name];
|
||||
if (!value) return undefined;
|
||||
const url = new URL(value);
|
||||
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
|
||||
throw new Error(`${name} must be an HTTP(S) origin without a path`);
|
||||
}
|
||||
if (env.COOKIE_SECURE !== 'false' && url.protocol !== 'https:') throw new Error(`${name} requires HTTPS with secure cookies`);
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
const children = new Set();
|
||||
let stopping = false;
|
||||
let exitCode = 0;
|
||||
let killTimer;
|
||||
function stop(code) {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
exitCode = code;
|
||||
process.exitCode = code;
|
||||
for (const child of children) child.kill('SIGTERM');
|
||||
if (children.size) {
|
||||
killTimer = setTimeout(() => { for (const child of children) child.kill('SIGKILL'); }, 10000);
|
||||
killTimer.unref();
|
||||
}
|
||||
}
|
||||
process.on('SIGTERM', () => stop(0));
|
||||
process.on('SIGINT', () => stop(0));
|
||||
|
||||
function run(command, args, service = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (stopping) { resolve(); return; }
|
||||
const child = spawn(command, args, { env, stdio: 'inherit' });
|
||||
children.add(child);
|
||||
child.once('error', () => { stop(1); reject(new Error(`Could not start ${command}`)); });
|
||||
child.once('close', code => {
|
||||
children.delete(child);
|
||||
if (service && !stopping) {
|
||||
console.error(`${command} exited unexpectedly; stopping the container`);
|
||||
stop(1);
|
||||
}
|
||||
if (!children.size && killTimer) clearTimeout(killTimer);
|
||||
if (!stopping && code !== 0) reject(new Error(`${command} failed with exit code ${code}`));
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const appUrl = origin('APP_URL');
|
||||
const adminUrl = origin('ADMIN_URL');
|
||||
if (!env.APP_ORIGINS && !appUrl) throw new Error('APP_URL (or APP_ORIGINS) is required');
|
||||
env.APP_ORIGINS ||= [...new Set([appUrl, adminUrl].filter(Boolean))].join(',');
|
||||
if (appUrl) env.OIDC_REDIRECT_URI ||= `${appUrl}/api/auth/callback`;
|
||||
if (!env.DB_HOST || !env.DB_PASSWORD) throw new Error('DB_HOST and DB_PASSWORD are required');
|
||||
if (!env.TOKEN_ENCRYPTION_KEY || Buffer.from(env.TOKEN_ENCRYPTION_KEY, 'base64').length !== 32) throw new Error('TOKEN_ENCRYPTION_KEY must contain 32 random bytes encoded as base64');
|
||||
await run('node', ['dist/migrate.js']);
|
||||
await run('node', ['dist/seed.js']);
|
||||
if (!stopping) await Promise.all([
|
||||
run('node', ['dist/server.js'], true),
|
||||
run('nginx', ['-g', 'daemon off;'], true),
|
||||
]);
|
||||
} catch (error) {
|
||||
// Configuration values, passwords, and tokens are never printed here.
|
||||
console.error(error instanceof Error ? error.message : 'Container startup failed');
|
||||
stop(1);
|
||||
}
|
||||
process.exitCode = exitCode;
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@model/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"migrate": "tsx src/migrate.ts",
|
||||
"seed": "tsx src/seed.ts",
|
||||
"test": "vitest run",
|
||||
"db:up": "node scripts/compose.mjs up -d --wait",
|
||||
"db:stop": "node scripts/compose.mjs stop",
|
||||
"db:test:prepare": "node scripts/prepare-test-db.mjs",
|
||||
"db:migrate": "pnpm migrate",
|
||||
"db:seed": "pnpm seed",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"image:stage": "node scripts/stage-image.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "11.1.2",
|
||||
"@fastify/rate-limit": "11.2.0",
|
||||
"@model/shared": "git+https://git.ruff.co.il/model-dlab/delivery-app-shared.git#9133b921a713b35c46a138f9e83d1ccfbf56417e",
|
||||
"dotenv": "^17.2.3",
|
||||
"fastify": "5.12.3",
|
||||
"ldapts": "9.0.0",
|
||||
"mariadb": "3.5.4",
|
||||
"openid-client": "6.8.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"@types/node": "^22.19.0",
|
||||
"vitest": "^4.1.0",
|
||||
"jose": "^6.2.11"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0",
|
||||
"engines": {
|
||||
"node": ">=22.12 <23",
|
||||
"pnpm": ">=10 <11"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"database"
|
||||
]
|
||||
}
|
||||
Generated
+1509
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,3 @@
|
||||
packages: []
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
@@ -0,0 +1,9 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
export function compose(args) {
|
||||
const engine = ['docker', 'podman'].find(command => spawnSync(command, ['version'], { stdio: 'ignore' }).status === 0);
|
||||
if (!engine) throw new Error('Start Docker Desktop or Podman, then retry.');
|
||||
const result = spawnSync(engine, ['compose', '--env-file', '.env', '-f', 'infra/compose.yaml', ...args], { stdio: 'inherit' });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`Compose failed (${result.status})`);
|
||||
}
|
||||
if (process.argv[1]?.replaceAll('\\', '/').endsWith('/compose.mjs')) compose(process.argv.slice(2));
|
||||
@@ -0,0 +1,9 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { compose } from './compose.mjs';
|
||||
// The fixed, separate database is never dropped. No credentials are passed on the command line.
|
||||
compose(['exec', '-T', 'db', 'sh', '-c', `export MYSQL_PWD="$MARIADB_ROOT_PASSWORD"; mariadb -u root -e "CREATE DATABASE IF NOT EXISTS model_delivery_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; GRANT ALL PRIVILEGES ON model_delivery_test.* TO '$MARIADB_USER'@'%';"`]);
|
||||
for (const script of ['db:migrate', 'db:seed']) {
|
||||
// pnpm exposes its JS entry point to scripts; invoking Node avoids Windows shell quoting.
|
||||
const result = spawnSync(process.execPath, [process.env.npm_execpath, script], { stdio: 'inherit', env: { ...process.env, DB_NAME: 'model_delivery_test' } });
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const apiRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const webRoot = resolve(process.argv[2] ?? join(apiRoot, '../delivery-app-web'));
|
||||
const output = join(apiRoot, '.image');
|
||||
if (existsSync(output)) throw new Error('Existing .image directory: move it aside before staging a new release.');
|
||||
if (JSON.parse(readFileSync(join(webRoot, 'package.json'), 'utf8')).name !== '@model/web') {
|
||||
throw new Error('Expected the delivery-app-web checkout as the argument.');
|
||||
}
|
||||
if (!process.env.npm_execpath) throw new Error('Run through pnpm image:stage.');
|
||||
function pnpm(cwd, args) {
|
||||
const result = spawnSync(process.execPath, [process.env.npm_execpath, ...args], { cwd, stdio: 'inherit' });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error('Staging command failed. No image was built.');
|
||||
}
|
||||
pnpm(apiRoot, ['build']);
|
||||
pnpm(webRoot, ['build']);
|
||||
mkdirSync(output);
|
||||
// A hoisted deployment contains real directories. Windows junctions from the
|
||||
// default isolated linker become dangling links when copied into a Linux image.
|
||||
pnpm(apiRoot, ['--config.node-linker=hoisted', '--filter', '@model/api', 'deploy', '--legacy', '--prod', join(output, 'api')]);
|
||||
// Legacy hoisted deploy may resolve again. Refuse any version that wasn't in the
|
||||
// workstation's locked install, so staging cannot silently ship untested updates.
|
||||
function packagesIn(modules, visit) {
|
||||
for (const entry of readdirSync(modules, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
const path = join(modules, entry.name);
|
||||
if (entry.name.startsWith('@')) { packagesIn(path, visit); continue; }
|
||||
if (!existsSync(join(path, 'package.json'))) continue;
|
||||
const pkg = JSON.parse(readFileSync(join(path, 'package.json'), 'utf8'));
|
||||
visit(`${pkg.name}@${pkg.version}`);
|
||||
if (existsSync(join(path, 'node_modules'))) packagesIn(join(path, 'node_modules'), visit);
|
||||
}
|
||||
}
|
||||
const testedPackages = new Set();
|
||||
const virtualStore = join(apiRoot, 'node_modules/.pnpm');
|
||||
for (const entry of readdirSync(virtualStore, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && existsSync(join(virtualStore, entry.name, 'node_modules'))) {
|
||||
packagesIn(join(virtualStore, entry.name, 'node_modules'), name => testedPackages.add(name));
|
||||
}
|
||||
}
|
||||
packagesIn(join(output, 'api/node_modules'), name => {
|
||||
if (!testedPackages.has(name)) throw new Error(`Deployment resolved an untested dependency: ${name}. Review and update the locked install before staging again.`);
|
||||
});
|
||||
cpSync(join(webRoot, 'dist'), join(output, 'web'), { recursive: true });
|
||||
function revision(cwd) {
|
||||
const result = spawnSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8' });
|
||||
return result.status === 0 ? result.stdout.trim() : 'uncommitted';
|
||||
}
|
||||
writeFileSync(join(output, 'release.json'), JSON.stringify({
|
||||
builtAt: new Date().toISOString(), api: revision(apiRoot), web: revision(webRoot),
|
||||
shared: JSON.parse(readFileSync(join(apiRoot, 'package.json'), 'utf8')).dependencies['@model/shared'],
|
||||
}, null, 2) + '\n');
|
||||
console.log('Staged production API dependencies and PWA assets in .image/. Build the standalone Dockerfile next.');
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import Fastify from 'fastify';
|
||||
import cookie from '@fastify/cookie';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import type { Pool } from 'mariadb';
|
||||
import { operationSchema } from '@model/shared';
|
||||
import { applyOperation, pullChanges, SyncError } from './sync.js';
|
||||
import { registerAuth } from './auth.js';
|
||||
|
||||
export function buildApp(pool: Pool, logging = true) {
|
||||
const app = Fastify({ logger: logging ? { level: process.env.LOG_LEVEL ?? 'info', redact: ['req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]'], serializers: { req: request => ({ method: request.method, url: request.url?.split('?')[0], remoteAddress: request.ip }) } } : false, bodyLimit: 32768, requestTimeout: 15000 });
|
||||
app.register(cookie);
|
||||
app.register(rateLimit, { global: false });
|
||||
registerAuth(app, pool);
|
||||
app.addHook('onRequest', async (request, reply) => { reply.header('x-request-id', request.id); });
|
||||
app.addHook('onSend', async (_request, reply) => { reply.header('cache-control', 'no-store'); });
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
app.get('/version', async () => ({ name: 'model-delivery', version: '0.6.0', syncProtocol: 5 }));
|
||||
app.get('/ready', async (_request, reply) => {
|
||||
try {
|
||||
await pool.query('SELECT sequence FROM sync_state WHERE id = 1');
|
||||
await pool.query('SELECT id FROM app_users LIMIT 1');
|
||||
return { status: 'ready' };
|
||||
} catch { return reply.code(503).send({ status: 'not_ready' }); }
|
||||
});
|
||||
app.post('/sync/push', async (request, reply) => {
|
||||
const parsed = operationSchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.code(400).send({ code: 'INVALID_OPERATION', message: 'Invalid operation', issues: parsed.error.issues, requestId: request.id });
|
||||
request.log.info({ operationId: parsed.data.operationId, deviceId: parsed.data.deviceId, entityId: parsed.data.entityId }, 'sync operation');
|
||||
return applyOperation(pool, parsed.data, request.authUser!.id);
|
||||
});
|
||||
app.get('/sync/pull', async (request, reply) => {
|
||||
const query = request.query as Record<string, unknown>;
|
||||
const cursor = query.cursor ?? '0';
|
||||
const limit = query.limit === undefined ? 100 : Number(query.limit);
|
||||
if (typeof cursor !== 'string' || !/^(0|[1-9]\d{0,19})$/.test(cursor) || BigInt(cursor) > 18446744073709551615n || !Number.isInteger(limit) || limit < 1 || limit > 200) {
|
||||
return reply.code(400).send({ code: 'INVALID_CURSOR', message: 'Invalid cursor or limit' });
|
||||
}
|
||||
return pullChanges(pool, cursor, limit);
|
||||
});
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof SyncError) return reply.code(error.statusCode).send({ code: error.code, message: error.message, current: error.current, requestId: request.id });
|
||||
const suppliedStatus = error instanceof Error && 'statusCode' in error ? Number(error.statusCode) : 503;
|
||||
const statusCode = suppliedStatus >= 400 && suppliedStatus < 500 ? suppliedStatus : 503;
|
||||
request.log.error({ err: error }, 'request failed');
|
||||
return reply.code(statusCode).send({ code: statusCode < 500 ? 'INVALID_REQUEST' : 'SERVICE_UNAVAILABLE', message: statusCode < 500 ? 'Invalid request' : 'Please retry later', requestId: request.id });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
import { createHash, randomBytes, randomUUID, scrypt, timingSafeEqual } from 'node:crypto';
|
||||
import type { Pool } from 'mariadb';
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { directorySettingsSchema, directoryTestInput, loginInput, profileInput, setupInput, ssoSettingsSchema, type Session, type User } from '@model/shared';
|
||||
import { lookupDirectoryName, readDirectorySettings, saveDirectorySettings, type DirectoryConfig } from './directory.js';
|
||||
import { seal, unseal } from './token-vault.js';
|
||||
import { checkDiscovery, defaultSettings, finishOidc, groupAccessAllowed, OidcGroupAccessDenied, readSettings, refreshOidc, saveSettings, settingsId, startOidc, type LoginTransaction, type TokenBundle } from './oidc.js';
|
||||
|
||||
declare module 'fastify' { interface FastifyRequest { authUser: User | null } }
|
||||
export const SESSION_COOKIE = 'model_session';
|
||||
const SESSION_SECONDS = 7 * 24 * 60 * 60;
|
||||
const directoryJoin = 'LEFT JOIN directory_settings d ON d.id = 1 AND d.enabled = TRUE AND d.config_id = u.directory_config_id AND BINARY d.issuer = BINARY u.oidc_issuer';
|
||||
const resolvedName = 'COALESCE(u.display_name_override, CASE WHEN d.id IS NOT NULL THEN u.directory_display_name END, u.display_name)';
|
||||
const publicDirectorySettings = (config: DirectoryConfig) => ({ ...config.settings, bindPassword: '', passwordConfigured: Boolean(config.settings.bindPassword), issuer: config.issuer });
|
||||
const hashToken = (token: string) => createHash('sha256').update(token).digest('hex');
|
||||
const derive = (password: string, salt: string) => new Promise<Buffer>((resolve, reject) => {
|
||||
scrypt(password, salt, 64, { N: 32768, r: 8, p: 3, maxmem: 64 * 1024 * 1024 }, (error, key) => error ? reject(error) : resolve(key));
|
||||
});
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
return `scrypt-v1:${salt}:${(await derive(password, salt)).toString('hex')}`;
|
||||
}
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const [format, salt, expected] = stored.split(':');
|
||||
if (format !== 'scrypt-v1' || !salt || !expected || !/^[a-f0-9]{128}$/.test(expected)) return false;
|
||||
return timingSafeEqual(await derive(password, salt), Buffer.from(expected, 'hex'));
|
||||
}
|
||||
function storedGroups(value: unknown): string[] {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
return Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === 'string').slice(0, 100) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
function userFromRow(row: { id: string; username: string; username_display?: string; display_name: string; role: User['role']; auth_provider?: User['authProvider']; oidc_groups_json?: unknown }): User {
|
||||
return { id: row.id, username: row.username_display || row.username, displayName: row.display_name, role: row.role, authProvider: row.auth_provider ?? 'local', groups: storedGroups(row.oidc_groups_json) };
|
||||
}
|
||||
const cookieOptions = () => ({ path: '/', httpOnly: true, sameSite: 'strict' as const, secure: process.env.COOKIE_SECURE === 'true', maxAge: SESSION_SECONDS });
|
||||
async function startSession(pool: Pool, user: User, reply: FastifyReply, tokens?: TokenBundle): Promise<Session> {
|
||||
const token = randomBytes(32).toString('base64url');
|
||||
const expiresAt = new Date(Date.now() + SESSION_SECONDS * 1000);
|
||||
await pool.query('DELETE FROM auth_sessions WHERE expires_at < UTC_TIMESTAMP(3)');
|
||||
await pool.query('INSERT INTO auth_sessions(token_hash, user_id, expires_at, auth_provider, encrypted_tokens) VALUES (?, ?, ?, ?, ?)', [hashToken(token), user.id, expiresAt.toISOString().slice(0, 23).replace('T', ' '), user.authProvider, tokens ? seal(tokens) : null]);
|
||||
reply.setCookie(SESSION_COOKIE, token, cookieOptions());
|
||||
return { user, expiresAt: expiresAt.toISOString() };
|
||||
}
|
||||
async function readSession(pool: Pool, token: string | undefined): Promise<Session | null> {
|
||||
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token)) return null;
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const rows = await conn.query(`SELECT u.id, u.username, u.username_display, ${resolvedName} AS display_name, u.role, u.oidc_groups_json, s.auth_provider, s.encrypted_tokens, DATE_FORMAT(s.expires_at, '%Y-%m-%dT%H:%i:%s.%fZ') AS expiry FROM auth_sessions s JOIN app_users u ON u.id = s.user_id ${directoryJoin} WHERE s.token_hash = ? AND s.expires_at > UTC_TIMESTAMP(3) AND u.active = TRUE FOR UPDATE`, [hashToken(token)]);
|
||||
if (!rows.length) { await conn.commit(); return null; }
|
||||
const row = rows[0];
|
||||
if (row.auth_provider === 'oidc') {
|
||||
const configRows = await conn.query('SELECT encrypted_config FROM oidc_settings WHERE id = 1');
|
||||
const settings = configRows.length ? ssoSettingsSchema.parse(unseal(configRows[0].encrypted_config)) : defaultSettings();
|
||||
const tokens = unseal<TokenBundle>(row.encrypted_tokens);
|
||||
if (!settings.enabled || settingsId(settings) !== tokens.configId) {
|
||||
await conn.query('DELETE FROM auth_sessions WHERE token_hash = ?', [hashToken(token)]); await conn.commit(); return null;
|
||||
}
|
||||
if (!groupAccessAllowed(settings, storedGroups(row.oidc_groups_json))) {
|
||||
await conn.query('DELETE FROM auth_sessions WHERE token_hash = ?', [hashToken(token)]); await conn.commit(); return null;
|
||||
}
|
||||
// The verified OIDC login establishes our own bounded app session.
|
||||
// Providers without refresh tokens (including Synology configurations)
|
||||
// must not turn a short access-token lifetime into an app idle timeout.
|
||||
// No provider API is called with an expired access token on this path.
|
||||
if (tokens.refreshToken && tokens.expiresAt <= Date.now() + 5000) {
|
||||
// Lock this session during refresh so rotated refresh tokens cannot be used twice.
|
||||
const refreshed = await refreshOidc(settings, tokens);
|
||||
if (!refreshed) { await conn.query('DELETE FROM auth_sessions WHERE token_hash = ?', [hashToken(token)]); await conn.commit(); return null; }
|
||||
await conn.query('UPDATE auth_sessions SET encrypted_tokens = ? WHERE token_hash = ?', [seal(refreshed), hashToken(token)]);
|
||||
}
|
||||
}
|
||||
await conn.commit();
|
||||
return { user: userFromRow(row), expiresAt: new Date(row.expiry).toISOString() };
|
||||
} catch { await conn.rollback(); throw new Error('Authentication service unavailable'); }
|
||||
finally { conn.release(); }
|
||||
}
|
||||
|
||||
export function registerAuth(app: FastifyInstance, pool: Pool) {
|
||||
app.decorateRequest('authUser', null);
|
||||
const origins = new Set((process.env.APP_ORIGINS ?? 'http://localhost:5173,http://127.0.0.1:5173,http://localhost:4173,http://127.0.0.1:4173').split(',').map(s => s.trim()));
|
||||
app.addHook('preHandler', async (request, reply) => {
|
||||
if (request.url.startsWith('/local-admin/') && !['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.ip)) return reply.code(403).send({ code: 'LOCAL_ADMIN_ONLY' });
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD' && request.method !== 'OPTIONS') {
|
||||
if (request.headers['x-model-request'] !== '1' || (request.headers.origin && !origins.has(request.headers.origin))) {
|
||||
return reply.code(403).send({ code: 'REQUEST_ORIGIN_REJECTED', message: 'Request origin rejected' });
|
||||
}
|
||||
}
|
||||
const profileRequest = request.url.split('?')[0] === '/auth/profile';
|
||||
if (request.url.startsWith('/sync/') || profileRequest || request.url.startsWith('/local-admin/directory') || ['/local-admin/settings', '/local-admin/discover'].includes(request.url.split('?')[0]!)) {
|
||||
const session = await readSession(pool, request.cookies[SESSION_COOKIE]);
|
||||
if (!session) return reply.code(401).send({ code: 'AUTH_REQUIRED', message: 'Please sign in' });
|
||||
if ((request.url.startsWith('/sync/') || profileRequest) && request.headers['x-model-user'] !== session.user.id) return reply.code(401).send({ code: 'ACCOUNT_CHANGED', message: 'Account changed; reload the session' });
|
||||
if (request.url.startsWith('/local-admin/') && (session.user.authProvider !== 'local' || session.user.role !== 'admin')) return reply.code(403).send({ code: 'LOCAL_ADMIN_REQUIRED' });
|
||||
request.authUser = session.user;
|
||||
}
|
||||
});
|
||||
app.get('/auth/session', async request => {
|
||||
const session = await readSession(pool, request.cookies[SESSION_COOKIE]);
|
||||
if (session) return session;
|
||||
const settings = await readSettings(pool);
|
||||
return { user: null, ssoConfigured: settings.enabled };
|
||||
});
|
||||
app.post('/auth/profile', async (request, reply) => {
|
||||
const parsed = profileInput.safeParse(request.body);
|
||||
if (!parsed.success) return reply.code(400).send({ code: 'INVALID_PROFILE' });
|
||||
// This preference changes presentation only, never the SSO identity or permissions.
|
||||
await pool.query('UPDATE app_users SET display_name_override = ? WHERE id = ?', [parsed.data.displayName, request.authUser!.id]);
|
||||
const rows = await pool.query(`SELECT ${resolvedName} AS display_name FROM app_users u ${directoryJoin} WHERE u.id = ?`, [request.authUser!.id]);
|
||||
return { user: { ...request.authUser!, displayName: rows[0].display_name } };
|
||||
});
|
||||
const authLimit = { rateLimit: { max: 10, timeWindow: '1 minute' } };
|
||||
app.get('/local-admin/status', async () => {
|
||||
const rows = await pool.query('SELECT COUNT(*) AS count FROM app_users WHERE password_hash IS NOT NULL');
|
||||
return { setupRequired: Number(rows[0].count) === 0 };
|
||||
});
|
||||
app.post('/local-admin/setup', { config: authLimit }, async (request, reply) => {
|
||||
const body = request.body as Record<string, unknown> | null;
|
||||
const { setupToken, ...input } = body ?? {};
|
||||
const expected = process.env.LOCAL_ADMIN_SETUP_TOKEN ?? '';
|
||||
if (typeof setupToken !== 'string' || !expected || !timingSafeEqual(Buffer.from(hashToken(setupToken)), Buffer.from(hashToken(expected)))) return reply.code(403).send({ code: 'SETUP_TOKEN_REQUIRED' });
|
||||
const parsed = setupInput.safeParse(input);
|
||||
if (!parsed.success) return reply.code(400).send({ code: 'INVALID_ACCOUNT' });
|
||||
const passwordHash = await hashPassword(parsed.data.password);
|
||||
const conn = await pool.getConnection();
|
||||
const user: User = { id: randomUUID(), username: parsed.data.username, displayName: parsed.data.displayName, role: 'admin', authProvider: 'local', groups: [] };
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
await conn.query('SELECT id FROM sync_state WHERE id = 1 FOR UPDATE');
|
||||
const existing = await conn.query('SELECT id FROM app_users WHERE password_hash IS NOT NULL LIMIT 1');
|
||||
if (existing.length) { await conn.rollback(); return reply.code(409).send({ code: 'SETUP_COMPLETE' }); }
|
||||
await conn.query('INSERT INTO app_users(id, username, display_name, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?)', [user.id, user.username, user.displayName, passwordHash, user.role, new Date().toISOString()]);
|
||||
await conn.commit();
|
||||
} catch (error) { await conn.rollback(); throw error; }
|
||||
finally { conn.release(); }
|
||||
return startSession(pool, user, reply);
|
||||
});
|
||||
app.post('/local-admin/login', { config: authLimit }, async (request, reply) => {
|
||||
const parsed = loginInput.safeParse(request.body);
|
||||
if (!parsed.success) return reply.code(401).send({ code: 'INVALID_CREDENTIALS' });
|
||||
const rows = await pool.query('SELECT id, username, COALESCE(display_name_override, display_name) AS display_name, password_hash, role, active FROM app_users WHERE username = ? AND password_hash IS NOT NULL', [parsed.data.username]);
|
||||
// Equal-cost derivation also runs for unknown accounts; no user-enumerating error messages.
|
||||
const stored = rows[0]?.password_hash ?? `scrypt-v1:${'0'.repeat(32)}:${'0'.repeat(128)}`;
|
||||
const valid = await verifyPassword(parsed.data.password, stored);
|
||||
if (!valid || !rows[0]?.active) return reply.code(401).send({ code: 'INVALID_CREDENTIALS' });
|
||||
const old = request.cookies[SESSION_COOKIE];
|
||||
if (old) await pool.query('DELETE FROM auth_sessions WHERE token_hash = ?', [hashToken(old)]);
|
||||
return startSession(pool, userFromRow(rows[0]), reply);
|
||||
});
|
||||
app.post('/auth/logout', async (request, reply) => {
|
||||
const token = request.cookies[SESSION_COOKIE];
|
||||
if (token) await pool.query('DELETE FROM auth_sessions WHERE token_hash = ?', [hashToken(token)]);
|
||||
reply.clearCookie(SESSION_COOKIE, { ...cookieOptions(), maxAge: 0 });
|
||||
return { ok: true };
|
||||
});
|
||||
app.get('/local-admin/settings', async () => {
|
||||
const settings = await readSettings(pool);
|
||||
return { ...settings, clientSecret: '', secretConfigured: Boolean(settings.clientSecret) };
|
||||
});
|
||||
app.post('/local-admin/settings', async (request, reply) => {
|
||||
const parsed = ssoSettingsSchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.code(400).send({ code: 'INVALID_SETTINGS' });
|
||||
const old = await readSettings(pool);
|
||||
const settings = { ...parsed.data, clientSecret: parsed.data.authMethod === 'none' ? '' : parsed.data.clientSecret || old.clientSecret };
|
||||
try { await saveSettings(pool, settings, request.authUser!.id); }
|
||||
catch { return reply.code(400).send({ code: 'INVALID_SETTINGS' }); }
|
||||
return { ...settings, clientSecret: '', secretConfigured: Boolean(settings.clientSecret) };
|
||||
});
|
||||
app.post('/local-admin/discover', { config: authLimit }, async (_request, reply) => {
|
||||
try { return await checkDiscovery(await readSettings(pool)); }
|
||||
catch { return reply.code(422).send({ code: 'DISCOVERY_FAILED' }); }
|
||||
});
|
||||
app.get('/local-admin/directory', async () => publicDirectorySettings(await readDirectorySettings(pool)));
|
||||
app.post('/local-admin/directory', async (request, reply) => {
|
||||
const parsed = directorySettingsSchema.safeParse(request.body);
|
||||
if (!parsed.success) return reply.code(400).send({ code: 'INVALID_DIRECTORY_SETTINGS' });
|
||||
const old = await readDirectorySettings(pool);
|
||||
// Do not forward a saved credential to a different host/account without re-entry.
|
||||
const sameAccount = old.settings.url === parsed.data.url && old.settings.bindDn === parsed.data.bindDn;
|
||||
const settings = { ...parsed.data, bindPassword: parsed.data.bindPassword || (sameAccount ? old.settings.bindPassword : '') };
|
||||
try {
|
||||
const sso = await readSettings(pool);
|
||||
return publicDirectorySettings(await saveDirectorySettings(pool, settings, sso.issuer, request.authUser!.id));
|
||||
} catch { return reply.code(400).send({ code: 'INVALID_DIRECTORY_SETTINGS' }); }
|
||||
});
|
||||
app.post('/local-admin/directory/test', { config: authLimit }, async (request, reply) => {
|
||||
const parsed = directoryTestInput.safeParse(request.body);
|
||||
if (!parsed.success) return reply.code(400).send({ code: 'INVALID_DIRECTORY_SUBJECT' });
|
||||
const config = await readDirectorySettings(pool);
|
||||
const sso = await readSettings(pool);
|
||||
const result = await lookupDirectoryName(config, { issuer: sso.issuer, subject: parsed.data.subject });
|
||||
return reply.code(result.code === 'FOUND' ? 200 : 422).send(result);
|
||||
});
|
||||
app.post('/auth/login', { config: authLimit }, async (_request, reply) => {
|
||||
try {
|
||||
const started = await startOidc(await readSettings(pool));
|
||||
const browserToken = randomBytes(32).toString('base64url');
|
||||
await pool.query('DELETE FROM oidc_transactions WHERE expires_at < UTC_TIMESTAMP(3)');
|
||||
await pool.query('INSERT INTO oidc_transactions(token_hash, encrypted_data, expires_at) VALUES (?, ?, DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 10 MINUTE))', [hashToken(browserToken), seal(started.transaction)]);
|
||||
reply.setCookie('model_oidc_flow', browserToken, { ...cookieOptions(), sameSite: 'lax', maxAge: 600 });
|
||||
return { url: started.url };
|
||||
} catch { return reply.code(503).send({ code: 'SSO_UNAVAILABLE' }); }
|
||||
});
|
||||
app.get('/auth/callback', async (request, reply) => {
|
||||
reply.header('referrer-policy', 'no-referrer');
|
||||
const browserToken = request.cookies.model_oidc_flow;
|
||||
reply.clearCookie('model_oidc_flow', { ...cookieOptions(), sameSite: 'lax', maxAge: 0 });
|
||||
try {
|
||||
if (!browserToken) throw new Error('Missing browser binding');
|
||||
const conn = await pool.getConnection();
|
||||
let transaction: LoginTransaction;
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const rows = await conn.query('SELECT encrypted_data FROM oidc_transactions WHERE token_hash = ? AND expires_at > UTC_TIMESTAMP(3) FOR UPDATE', [hashToken(browserToken)]);
|
||||
if (!rows.length) throw new Error('Login expired or already used');
|
||||
transaction = unseal<LoginTransaction>(rows[0].encrypted_data);
|
||||
await conn.query('DELETE FROM oidc_transactions WHERE token_hash = ?', [hashToken(browserToken)]); await conn.commit();
|
||||
} catch (error) { await conn.rollback(); throw error; }
|
||||
finally { conn.release(); }
|
||||
const result = await finishOidc(await readSettings(pool), transaction, new URL(request.url, 'http://internal').search);
|
||||
// Best-effort presentation lookup only after verified OIDC, outside database locks.
|
||||
const directory = await readDirectorySettings(pool);
|
||||
const directoryResult = await lookupDirectoryName(directory, result.tokens);
|
||||
const identity = hashToken(JSON.stringify([result.tokens.issuer, result.tokens.subject]));
|
||||
const identityConnection = await pool.getConnection();
|
||||
let user: User;
|
||||
try {
|
||||
await identityConnection.beginTransaction();
|
||||
await identityConnection.query('SELECT id FROM sync_state WHERE id = 1 FOR UPDATE');
|
||||
const rows = await identityConnection.query('SELECT id, active, role, display_name_override, directory_display_name, directory_config_id FROM app_users WHERE oidc_identity_hash = ?', [identity]);
|
||||
if (rows.length && !rows[0].active) throw new Error('Account disabled');
|
||||
const cachedName = directory.settings.enabled && directory.issuer === result.tokens.issuer && rows[0]?.directory_config_id === directory.id ? rows[0].directory_display_name : null;
|
||||
const directoryName = directoryResult.code === 'FOUND' ? directoryResult.displayName : cachedName;
|
||||
user = { id: rows[0]?.id ?? randomUUID(), username: result.username, displayName: rows[0]?.display_name_override ?? directoryName ?? result.displayName, role: rows[0]?.role ?? 'staff', authProvider: 'oidc', groups: result.groups };
|
||||
if (!rows.length) await identityConnection.query('INSERT INTO app_users(id, username, display_name, role, created_at, oidc_identity_hash, oidc_issuer, oidc_subject, username_display, oidc_groups_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [user.id, `oidc_${identity}`, result.displayName, user.role, new Date().toISOString(), identity, result.tokens.issuer, result.tokens.subject, user.username, JSON.stringify(result.groups)]);
|
||||
else await identityConnection.query('UPDATE app_users SET display_name = ?, username_display = ?, oidc_groups_json = ? WHERE id = ?', [result.displayName, user.username, JSON.stringify(result.groups), user.id]);
|
||||
if (directoryResult.code === 'FOUND') await identityConnection.query('UPDATE app_users SET directory_display_name = ?, directory_config_id = ? WHERE id = ?', [directoryResult.displayName, directory.id, user.id]);
|
||||
await identityConnection.commit();
|
||||
} catch (error) { await identityConnection.rollback(); throw error; }
|
||||
finally { identityConnection.release(); }
|
||||
if (request.cookies[SESSION_COOKIE]) await pool.query('DELETE FROM auth_sessions WHERE token_hash = ?', [hashToken(request.cookies[SESSION_COOKIE]!)]);
|
||||
await startSession(pool, user, reply, result.tokens);
|
||||
return reply.redirect('/');
|
||||
} catch (error) {
|
||||
request.log.warn({ code: 'OIDC_CALLBACK_FAILED' }, 'SSO callback rejected');
|
||||
return reply.redirect(`/?authError=${error instanceof OidcGroupAccessDenied ? 'group_denied' : 'signin_failed'}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const projectRoot = fileURLToPath(new URL('../', import.meta.url));
|
||||
loadEnv({ path: new URL('../.env', import.meta.url), quiet: true });
|
||||
export function getConfig() {
|
||||
const password = process.env.DB_PASSWORD;
|
||||
if (!password) throw new Error('DB_PASSWORD is required. Copy .env.example to .env and configure it.');
|
||||
return {
|
||||
db: { host: process.env.DB_HOST ?? '127.0.0.1', port: Number(process.env.DB_PORT ?? 3307), database: process.env.DB_NAME ?? 'model_delivery', user: process.env.DB_USER ?? 'model_app', password },
|
||||
host: process.env.API_HOST ?? '127.0.0.1', port: Number(process.env.API_PORT ?? 3001),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import mariadb from 'mariadb';
|
||||
import { getConfig } from './config.js';
|
||||
export function createPool() {
|
||||
return mariadb.createPool({ ...getConfig().db, connectionLimit: 5, acquireTimeout: 5000, connectTimeout: 5000, bigIntAsNumber: false, insertIdAsNumber: true });
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createSecureContext } from 'node:tls';
|
||||
import { AndFilter, Client, EqualityFilter, type Entry } from 'ldapts';
|
||||
import type { Pool } from 'mariadb';
|
||||
import { directorySettingsSchema, type DirectorySettings } from '@model/shared';
|
||||
import { seal, unseal } from './token-vault.js';
|
||||
|
||||
export type DirectoryConfig = { settings: DirectorySettings; issuer: string; id: string };
|
||||
export type DirectoryResult = { code: 'FOUND'; displayName: string } | { code: 'DISABLED' | 'IDENTITY_MISMATCH' | 'NOT_FOUND' | 'AMBIGUOUS' | 'NO_NAME' | 'UNAVAILABLE' };
|
||||
export const defaultDirectorySettings = (): DirectorySettings => ({ enabled: false, url: '', baseDn: '', domain: 'REZNIK', bindDn: '', bindPassword: '', caCertificate: '' });
|
||||
export async function readDirectorySettings(pool: Pool): Promise<DirectoryConfig> {
|
||||
const rows = await pool.query('SELECT config_id, issuer, encrypted_config FROM directory_settings WHERE id = 1');
|
||||
return rows.length ? { settings: directorySettingsSchema.parse(unseal(rows[0].encrypted_config)), issuer: rows[0].issuer, id: rows[0].config_id } : { settings: defaultDirectorySettings(), issuer: '', id: '' };
|
||||
}
|
||||
export function validateDirectorySettings(settings: DirectorySettings) {
|
||||
if (!settings.enabled) return;
|
||||
const url = new URL(settings.url);
|
||||
// Direct TLS only: a dropped StartTLS connection must never reconnect and bind in plaintext.
|
||||
if (url.protocol !== 'ldaps:' || !url.hostname || url.username || url.password || url.search || url.hash || (url.pathname && url.pathname !== '/')) throw new Error('Invalid directory URL');
|
||||
if (!settings.baseDn || !settings.domain || !settings.bindDn || !settings.bindPassword) throw new Error('Incomplete directory configuration');
|
||||
if (settings.caCertificate) {
|
||||
if (!settings.caCertificate.startsWith('-----BEGIN CERTIFICATE-----') || settings.caCertificate.includes('PRIVATE KEY')) throw new Error('Invalid CA certificate');
|
||||
createSecureContext({ ca: settings.caCertificate, minVersion: 'TLSv1.2' });
|
||||
}
|
||||
}
|
||||
export async function saveDirectorySettings(pool: Pool, settings: DirectorySettings, issuer: string, userId: string) {
|
||||
validateDirectorySettings(settings);
|
||||
if (settings.enabled && !issuer) throw new Error('Configure SSO first');
|
||||
const id = createHash('sha256').update(JSON.stringify([issuer, settings])).digest('hex');
|
||||
await pool.query('INSERT INTO directory_settings(id, config_id, enabled, issuer, encrypted_config, updated_by, updated_at) VALUES (1, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE config_id = VALUES(config_id), enabled = VALUES(enabled), issuer = VALUES(issuer), encrypted_config = VALUES(encrypted_config), updated_by = VALUES(updated_by), updated_at = VALUES(updated_at)', [id, settings.enabled, issuer, seal(settings), userId, new Date().toISOString()]);
|
||||
return { settings, issuer, id };
|
||||
}
|
||||
export function directoryUsername(config: DirectoryConfig, identity: { issuer: string; subject: string }): string | null {
|
||||
if (identity.issuer !== config.issuer) return null;
|
||||
const parts = identity.subject.split('\\');
|
||||
if (parts.length !== 2 || parts[0]?.toUpperCase() !== config.settings.domain.toUpperCase() || !parts[1]?.trim() || /[\x00-\x1f]/.test(parts[1])) return null;
|
||||
return parts[1];
|
||||
}
|
||||
function attribute(entry: Entry, name: string): string | undefined {
|
||||
const key = Object.keys(entry).find(key => key.toLowerCase() === name.toLowerCase());
|
||||
const value = key ? entry[key] : undefined;
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
export async function lookupDirectoryName(config: DirectoryConfig, identity: { issuer: string; subject: string }): Promise<DirectoryResult> {
|
||||
if (!config.settings.enabled) return { code: 'DISABLED' };
|
||||
const username = directoryUsername(config, identity);
|
||||
if (!username) return { code: 'IDENTITY_MISMATCH' };
|
||||
let client: Client | undefined;
|
||||
try {
|
||||
validateDirectorySettings(config.settings);
|
||||
client = new Client({ url: config.settings.url, timeout: 2000, connectTimeout: 2000, tlsOptions: { minVersion: 'TLSv1.2', rejectUnauthorized: true, ...(config.settings.caCertificate ? { ca: config.settings.caCertificate } : {}) } });
|
||||
await client.bind(config.settings.bindDn, config.settings.bindPassword);
|
||||
const result = await client.search(config.settings.baseDn, {
|
||||
scope: 'sub', sizeLimit: 2, timeLimit: 2,
|
||||
filter: new AndFilter({ filters: [
|
||||
new EqualityFilter({ attribute: 'objectCategory', value: 'person' }),
|
||||
new EqualityFilter({ attribute: 'objectClass', value: 'user' }),
|
||||
new EqualityFilter({ attribute: 'sAMAccountName', value: username }),
|
||||
] }),
|
||||
attributes: ['sAMAccountName', 'displayName', 'givenName', 'sn'],
|
||||
});
|
||||
// No referral chasing, account enumeration, role/group mapping or directory writes.
|
||||
if (result.searchReferences.length || result.searchEntries.length > 1) return { code: 'AMBIGUOUS' };
|
||||
const entry = result.searchEntries[0];
|
||||
if (!entry || attribute(entry, 'sAMAccountName')?.toLowerCase() !== username.toLowerCase()) return { code: 'NOT_FOUND' };
|
||||
const displayName = attribute(entry, 'displayName') ?? [attribute(entry, 'givenName'), attribute(entry, 'sn')].filter(Boolean).join(' ');
|
||||
return displayName ? { code: 'FOUND', displayName: displayName.slice(0, 80) } : { code: 'NO_NAME' };
|
||||
} catch {
|
||||
// LDAP errors may contain server data or credentials. Expose only this safe code.
|
||||
return { code: 'UNAVAILABLE' };
|
||||
} finally { await client?.unbind().catch(() => undefined); }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { projectRoot } from './config.js';
|
||||
import { createPool } from './db.js';
|
||||
|
||||
const pool = createPool();
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const locks = await conn.query("SELECT GET_LOCK('model_delivery_migrations', 30) AS acquired");
|
||||
if (Number(locks[0].acquired) !== 1) throw new Error('Could not acquire migration lock');
|
||||
await conn.query('CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(200) PRIMARY KEY, checksum CHAR(64) NOT NULL, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)');
|
||||
const directory = join(projectRoot, 'database/migrations');
|
||||
for (const name of (await readdir(directory)).filter(n => n.endsWith('.sql')).sort()) {
|
||||
const sql = await readFile(join(directory, name), 'utf8');
|
||||
const checksum = createHash('sha256').update(sql).digest('hex');
|
||||
const previous = await conn.query('SELECT checksum FROM schema_migrations WHERE name = ?', [name]);
|
||||
if (previous.length) {
|
||||
if (previous[0].checksum !== checksum) throw new Error(`Applied migration changed: ${name}`);
|
||||
continue;
|
||||
}
|
||||
// MariaDB DDL implicitly commits. Keep migrations rerunnable; do not claim DDL rollback.
|
||||
// This deliberately small runner accepts plain statements, not procedures or DELIMITER.
|
||||
for (const statement of sql.split(';').map(s => s.trim()).filter(Boolean)) await conn.query(statement);
|
||||
await conn.query('INSERT INTO schema_migrations (name, checksum) VALUES (?, ?)', [name, checksum]);
|
||||
console.log(`Applied ${name}`);
|
||||
}
|
||||
console.log('Migrations up to date');
|
||||
} finally {
|
||||
await conn.query("SELECT RELEASE_LOCK('model_delivery_migrations')").catch(() => undefined);
|
||||
conn.release();
|
||||
await pool.end();
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import * as client from 'openid-client';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool } from 'mariadb';
|
||||
import { ssoSettingsSchema, type SsoSettings } from '@model/shared';
|
||||
import { seal, unseal } from './token-vault.js';
|
||||
|
||||
export type TokenBundle = { accessToken: string; refreshToken?: string; expiresAt: number; issuer: string; subject: string; configId: string };
|
||||
export type LoginTransaction = { state: string; nonce: string; verifier: string; configId: string };
|
||||
export class OidcGroupAccessDenied extends Error {}
|
||||
export function groupsFromClaim(value: unknown): string[] {
|
||||
const values = Array.isArray(value) ? value : typeof value === 'string' ? [value] : [];
|
||||
return [...new Set(values.filter((entry): entry is string => typeof entry === 'string').map(entry => entry.trim()).filter(Boolean))].slice(0, 100);
|
||||
}
|
||||
const groupKey = (value: string) => value.normalize('NFKC').toLocaleLowerCase('en-US');
|
||||
export function groupAccessAllowed(settings: SsoSettings, groups: string[]): boolean {
|
||||
if (!settings.groupAccessEnabled) return true;
|
||||
const allowed = new Set(settings.allowedGroups.map(groupKey));
|
||||
return groups.some(group => allowed.has(groupKey(group)));
|
||||
}
|
||||
export const settingsId = (settings: SsoSettings) => createHash('sha256').update(JSON.stringify(settings)).digest('hex');
|
||||
export function defaultSettings(): SsoSettings {
|
||||
return { enabled: Boolean(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID), issuer: process.env.OIDC_ISSUER ?? '', discoveryUrl: process.env.OIDC_DISCOVERY_URL ?? '', clientId: process.env.OIDC_CLIENT_ID ?? '', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', redirectUri: process.env.OIDC_REDIRECT_URI ?? 'http://localhost:5173/api/auth/callback', scopes: process.env.OIDC_SCOPES ?? 'openid groups', authMethod: 'client_secret_post', offlineAccess: false, promptConsent: false, displayNameClaim: 'name', groupAccessEnabled: false, groupClaim: 'groups', allowedGroups: [] };
|
||||
}
|
||||
export async function readSettings(pool: Pool): Promise<SsoSettings> {
|
||||
const rows = await pool.query('SELECT encrypted_config FROM oidc_settings WHERE id = 1');
|
||||
return rows.length ? ssoSettingsSchema.parse(unseal(rows[0].encrypted_config)) : defaultSettings();
|
||||
}
|
||||
function validateProviderUrl(value: string) {
|
||||
const url = new URL(value);
|
||||
const localHttp = process.env.OIDC_ALLOW_HTTP === 'true' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
||||
if (url.username || url.password || url.hash || (url.protocol !== 'https:' && !(url.protocol === 'http:' && localHttp))) throw new Error('OIDC server must use HTTPS');
|
||||
return url;
|
||||
}
|
||||
export function validateSettings(settings: SsoSettings) {
|
||||
if (!settings.enabled) return;
|
||||
if (!settings.issuer || !settings.clientId || !settings.scopes.split(/\s+/).includes('openid')) throw new Error('Issuer, Client ID and openid scope are required');
|
||||
for (const value of [settings.issuer, settings.discoveryUrl].filter(Boolean)) {
|
||||
const url = validateProviderUrl(value);
|
||||
if (value === settings.issuer && url.search) throw new Error('Issuer cannot contain a query');
|
||||
}
|
||||
const redirect = new URL(settings.redirectUri);
|
||||
const origins = (process.env.APP_ORIGINS ?? 'http://localhost:5173,http://127.0.0.1:5173,http://localhost:4173,http://127.0.0.1:4173').split(',').map(v => v.trim());
|
||||
if (!origins.includes(redirect.origin) || redirect.pathname !== '/api/auth/callback' || redirect.search || redirect.hash || redirect.username || redirect.password) throw new Error('Callback must use an allowed app origin and /api/auth/callback');
|
||||
if (settings.authMethod !== 'none' && !settings.clientSecret) throw new Error('Client secret is required');
|
||||
}
|
||||
export async function saveSettings(pool: Pool, settings: SsoSettings, userId: string) {
|
||||
validateSettings(settings);
|
||||
await pool.query('INSERT INTO oidc_settings(id, encrypted_config, updated_by, updated_at) VALUES (1, ?, ?, ?) ON DUPLICATE KEY UPDATE encrypted_config = VALUES(encrypted_config), updated_by = VALUES(updated_by), updated_at = VALUES(updated_at)', [seal(settings), userId, new Date().toISOString()]);
|
||||
}
|
||||
const configurations = new Map<string, Promise<client.Configuration>>();
|
||||
async function configuration(settings: SsoSettings) {
|
||||
validateSettings(settings);
|
||||
if (!settings.enabled) throw new Error('SSO is not configured');
|
||||
const id = settingsId(settings);
|
||||
let promise = configurations.get(id);
|
||||
if (!promise) {
|
||||
configurations.clear();
|
||||
const auth = settings.authMethod === 'none' ? client.None() : settings.authMethod === 'client_secret_basic' ? client.ClientSecretBasic(settings.clientSecret) : client.ClientSecretPost(settings.clientSecret);
|
||||
promise = client.discovery(new URL(settings.discoveryUrl || settings.issuer), settings.clientId, settings.clientSecret || undefined, auth, { timeout: 10, execute: [...(process.env.OIDC_ALLOW_HTTP === 'true' ? [client.allowInsecureRequests] : []), client.enableNonRepudiationChecks] }).then(config => {
|
||||
if (config.serverMetadata().issuer !== settings.issuer) throw new Error('Discovery issuer does not match configured issuer');
|
||||
for (const endpoint of ['authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'] as const) {
|
||||
const value = config.serverMetadata()[endpoint];
|
||||
if (value) validateProviderUrl(value);
|
||||
}
|
||||
return config;
|
||||
});
|
||||
configurations.set(id, promise);
|
||||
promise.catch(() => configurations.delete(id));
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
export async function checkDiscovery(settings: SsoSettings) {
|
||||
const config = await configuration(settings);
|
||||
const metadata = config.serverMetadata();
|
||||
return { issuer: metadata.issuer, authorizationEndpoint: metadata.authorization_endpoint, tokenEndpoint: metadata.token_endpoint, scopes: metadata.scopes_supported ?? [], claims: metadata.claims_supported ?? [], pkceAdvertised: metadata.code_challenge_methods_supported?.includes('S256') ?? false };
|
||||
}
|
||||
export async function startOidc(settings: SsoSettings) {
|
||||
const config = await configuration(settings);
|
||||
const transaction = { state: client.randomState(), nonce: client.randomNonce(), verifier: client.randomPKCECodeVerifier(), configId: settingsId(settings) };
|
||||
const scopes = new Set(settings.scopes.split(/\s+/).filter(Boolean));
|
||||
scopes.add('groups');
|
||||
if (settings.offlineAccess) scopes.add('offline_access');
|
||||
const url = client.buildAuthorizationUrl(config, { redirect_uri: settings.redirectUri, scope: [...scopes].join(' '), state: transaction.state, nonce: transaction.nonce, code_challenge: await client.calculatePKCECodeChallenge(transaction.verifier), code_challenge_method: 'S256', ...(settings.promptConsent ? { prompt: 'consent' } : {}) });
|
||||
return { transaction, url: url.href };
|
||||
}
|
||||
export async function finishOidc(settings: SsoSettings, transaction: LoginTransaction, query: string) {
|
||||
if (transaction.configId !== settingsId(settings)) throw new Error('SSO settings changed during login');
|
||||
const config = await configuration(settings);
|
||||
const callback = new URL(settings.redirectUri); callback.search = query;
|
||||
const result = await client.authorizationCodeGrant(config, callback, { expectedState: transaction.state, expectedNonce: transaction.nonce, pkceCodeVerifier: transaction.verifier, idTokenExpected: true });
|
||||
const claims = result.claims()!;
|
||||
let profile: Record<string, unknown> = { ...claims };
|
||||
if (config.serverMetadata().userinfo_endpoint) profile = { ...profile, ...await client.fetchUserInfo(config, result.access_token, claims.sub) };
|
||||
const name = [profile[settings.displayNameClaim], profile.name, profile.given_name, profile.preferred_username, profile.username, profile.email, claims.sub].find(v => typeof v === 'string' && v.trim()) as string;
|
||||
const username = [profile.preferred_username, profile.username, profile.email, claims.sub].find(v => typeof v === 'string' && v.trim()) as string;
|
||||
const groups = groupsFromClaim(profile[settings.groupClaim]);
|
||||
if (!groupAccessAllowed(settings, groups)) throw new OidcGroupAccessDenied('User is not in an allowed group');
|
||||
const tokens: TokenBundle = { accessToken: result.access_token, refreshToken: result.refresh_token, expiresAt: Date.now() + (result.expiresIn() ?? Math.max(0, claims.exp - Date.now() / 1000)) * 1000, issuer: claims.iss, subject: claims.sub, configId: settingsId(settings) };
|
||||
return { tokens, displayName: name.slice(0, 80), username: username.slice(0, 255), groups };
|
||||
}
|
||||
export async function refreshOidc(settings: SsoSettings, tokens: TokenBundle): Promise<TokenBundle | null> {
|
||||
if (!tokens.refreshToken || settingsId(settings) !== tokens.configId) return null;
|
||||
try {
|
||||
const result = await client.refreshTokenGrant(await configuration(settings), tokens.refreshToken);
|
||||
const claims = result.claims();
|
||||
if (claims && (claims.sub !== tokens.subject || claims.iss !== tokens.issuer)) return null;
|
||||
return { ...tokens, accessToken: result.access_token, refreshToken: result.refresh_token ?? tokens.refreshToken, expiresAt: Date.now() + (result.expiresIn() ?? 60) * 1000 };
|
||||
} catch (error) {
|
||||
if (error instanceof client.ResponseBodyError && error.error === 'invalid_grant') return null;
|
||||
throw new Error('Identity provider unavailable; retry later');
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { deliveryTypeSchema, operationSchema } from '@model/shared';
|
||||
import { projectRoot } from './config.js';
|
||||
import { createPool } from './db.js';
|
||||
import { appendChange, applyOperation } from './sync.js';
|
||||
const pool = createPool();
|
||||
try {
|
||||
const types: unknown[] = JSON.parse(await readFile(join(projectRoot, 'database/seeds/reference-data.json'), 'utf8'));
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
await conn.query('SELECT id FROM sync_state WHERE id = 1 FOR UPDATE');
|
||||
for (const item of types) {
|
||||
const record = deliveryTypeSchema.parse({ ...(item as object), version: 1, createdAt: new Date().toISOString(), deletedAt: null });
|
||||
const existing = await conn.query('SELECT id FROM delivery_types WHERE id = ?', [record.id]);
|
||||
if (existing.length) continue;
|
||||
await conn.query('INSERT INTO delivery_types(id, code, name, shipment_unit_hundredths, created_at) VALUES (?, ?, ?, ?, ?)', [record.id, record.code, record.name, record.shipmentUnitHundredths, record.createdAt]);
|
||||
await appendChange(conn, { entityType: 'deliveryType', record });
|
||||
}
|
||||
await conn.commit();
|
||||
} catch (error) { await conn.rollback(); throw error; }
|
||||
finally { conn.release(); }
|
||||
const deviceId = '20000000-0000-4000-8000-000000000000';
|
||||
const customerId = '20000000-0000-4000-8000-000000000001';
|
||||
const addressId = '20000000-0000-4000-8000-000000000002';
|
||||
await applyOperation(pool, operationSchema.parse({ operationId: '30000000-0000-4000-8000-000000000001', deviceId, entityId: customerId, baseVersion: 0, kind: 'customer.create', data: { name: 'מרפאת הדגמה' } }));
|
||||
await applyOperation(pool, operationSchema.parse({ operationId: '30000000-0000-4000-8000-000000000002', deviceId, entityId: addressId, baseVersion: 0, kind: 'address.create', data: { customerId, label: 'מרפאה ראשית', address: 'כתובת לדוגמה בלבד · תל אביב' } }));
|
||||
console.log('Reference types and clearly marked demo customer are ready. No deliveries seeded.');
|
||||
} finally { await pool.end(); }
|
||||
@@ -0,0 +1,11 @@
|
||||
import { buildApp } from './app.js';
|
||||
import { createPool } from './db.js';
|
||||
import { getConfig } from './config.js';
|
||||
const pool = createPool();
|
||||
const app = buildApp(pool);
|
||||
const config = getConfig();
|
||||
async function stop() { await app.close(); await pool.end(); }
|
||||
process.once('SIGINT', () => { void stop(); });
|
||||
process.once('SIGTERM', () => { void stop(); });
|
||||
try { await app.listen({ host: config.host, port: config.port }); }
|
||||
catch (error) { app.log.error(error); await pool.end(); process.exitCode = 1; }
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool, PoolConnection } from 'mariadb';
|
||||
import { customerSchema, deliverySchema, totalPrice, type Change, type Delivery, type Operation, type PullResponse } from '@model/shared';
|
||||
|
||||
export class SyncError extends Error {
|
||||
constructor(public code: string, public statusCode: number, message: string, public current?: Change) { super(message); }
|
||||
}
|
||||
function json<T>(value: string | T): T { return typeof value === 'string' ? JSON.parse(value) as T : value; }
|
||||
function requestHashes(operation: Operation): string[] {
|
||||
const hashes = [createHash('sha256').update(JSON.stringify(operation)).digest('hex')];
|
||||
const addCompatibleHash = (compatible: unknown) => {
|
||||
const hash = createHash('sha256').update(JSON.stringify(compatible)).digest('hex');
|
||||
if (!hashes.includes(hash)) hashes.push(hash);
|
||||
};
|
||||
// V2 clients did not send the optional profile/structured-address defaults added in V3.
|
||||
// Accept only that exact old representation for an already-owned idempotency receipt.
|
||||
if (operation.kind === 'customer.create' && !operation.data.contactName && !operation.data.phone && !operation.data.comments && Object.values(operation.data.deliveryWindows).every(value => value === null)) {
|
||||
const legacy = { name: operation.data.name };
|
||||
addCompatibleHash({ ...operation, data: legacy });
|
||||
}
|
||||
if (operation.kind === 'address.create' && !operation.data.city && !operation.data.street && !operation.data.buildingNumber) {
|
||||
const legacy = { customerId: operation.data.customerId, label: operation.data.label, address: operation.data.address };
|
||||
addCompatibleHash({ ...operation, data: legacy });
|
||||
}
|
||||
let timingCompatible: Operation | undefined;
|
||||
if ((operation.kind === 'delivery.create' || operation.kind === 'delivery.update') && operation.data.deliverySlot == null && operation.data.specificTime == null && operation.data.deliveryWindow == null) {
|
||||
const { deliverySlot: _deliverySlot, specificTime: _specificTime, deliveryWindow: _deliveryWindow, ...legacy } = operation.data;
|
||||
timingCompatible = { ...operation, data: legacy } as Operation;
|
||||
addCompatibleHash(timingCompatible);
|
||||
}
|
||||
if (operation.kind === 'delivery.create' && operation.data.scheduledDate === null) {
|
||||
const { scheduledDate: _scheduledDate, ...legacy } = operation.data;
|
||||
addCompatibleHash({ ...operation, data: legacy });
|
||||
if (timingCompatible?.kind === 'delivery.create') {
|
||||
const { scheduledDate: _legacyScheduledDate, ...oldest } = timingCompatible.data;
|
||||
addCompatibleHash({ ...timingCompatible, data: oldest });
|
||||
}
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
export async function appendChange(conn: PoolConnection, change: Change) {
|
||||
// Caller holds sync_state FOR UPDATE through commit. Sequence order is commit order.
|
||||
await conn.query('UPDATE sync_state SET sequence = sequence + 1 WHERE id = 1');
|
||||
await conn.query('INSERT INTO sync_changes(sequence, entity_type, entity_id, change_json) SELECT sequence, ?, ?, ? FROM sync_state WHERE id = 1', [change.entityType, change.record.id, JSON.stringify(change)]);
|
||||
}
|
||||
|
||||
export async function applyOperation(pool: Pool, operation: Operation, actorUserId: string | null = null) {
|
||||
const [requestHash, ...compatibleHashes] = requestHashes(operation);
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const state = await conn.query('SELECT next_delivery_number FROM sync_state WHERE id = 1 FOR UPDATE');
|
||||
if (!state.length) throw new Error('Database is not migrated');
|
||||
const previous = await conn.query('SELECT request_hash, result_json, actor_user_id FROM sync_operations WHERE operation_id = ?', [operation.operationId]);
|
||||
if (previous.length) {
|
||||
if (previous[0].request_hash !== requestHash && !compatibleHashes.includes(previous[0].request_hash)) throw new SyncError('OPERATION_REUSED', 409, 'Operation ID has different content');
|
||||
if (previous[0].actor_user_id && previous[0].actor_user_id !== actorUserId) throw new SyncError('OPERATION_OWNER_CHANGED', 409, 'Operation belongs to another account');
|
||||
await conn.commit();
|
||||
return { change: json<Change>(previous[0].result_json), replayed: true };
|
||||
}
|
||||
const entityType = operation.kind.split('.')[0]!;
|
||||
const existing = await conn.query('SELECT change_json FROM sync_changes WHERE entity_type = ? AND entity_id = ? ORDER BY sequence DESC LIMIT 1', [entityType, operation.entityId]);
|
||||
if (existing.length && operation.kind.endsWith('.create')) throw new SyncError('VERSION_CONFLICT', 409, 'Entity already exists', json<Change>(existing[0].change_json));
|
||||
|
||||
const meta = { id: operation.entityId, version: 1, createdAt: new Date().toISOString(), deletedAt: null };
|
||||
let change: Change;
|
||||
if (operation.kind === 'customer.create') {
|
||||
const record = customerSchema.parse({ ...operation.data, ...meta, updatedAt: null });
|
||||
const windows = record.deliveryWindows;
|
||||
await conn.query('INSERT INTO customers(id, name, contact_name, phone, comments, morning_start, morning_end, afternoon_start, afternoon_end, late_night_start, late_night_end, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [record.id, record.name, record.contactName, record.phone, record.comments, windows.morning?.start ?? null, windows.morning?.end ?? null, windows.afternoon?.start ?? null, windows.afternoon?.end ?? null, windows.lateNight?.start ?? null, windows.lateNight?.end ?? null, record.createdAt]);
|
||||
change = { entityType: 'customer', record };
|
||||
} else if (operation.kind === 'customer.update' || operation.kind === 'customer.remove') {
|
||||
if (!existing.length) throw new SyncError('CUSTOMER_MISSING', 404, 'Customer is not available');
|
||||
const current = customerSchema.parse(json<Change>(existing[0].change_json).record);
|
||||
const currentChange: Change = { entityType: 'customer', record: current };
|
||||
if (current.version !== operation.baseVersion) throw new SyncError('VERSION_CONFLICT', 409, 'Customer changed on another device', currentChange);
|
||||
if (current.deletedAt) throw new SyncError('CUSTOMER_REMOVED', 409, 'Customer is archived', currentChange);
|
||||
const record = customerSchema.parse(operation.kind === 'customer.update'
|
||||
? { ...current, ...operation.data, version: current.version + 1, updatedAt: meta.createdAt }
|
||||
: { ...current, version: current.version + 1, updatedAt: meta.createdAt, deletedAt: meta.createdAt });
|
||||
const windows = record.deliveryWindows;
|
||||
await conn.query('UPDATE customers SET name = ?, contact_name = ?, phone = ?, comments = ?, morning_start = ?, morning_end = ?, afternoon_start = ?, afternoon_end = ?, late_night_start = ?, late_night_end = ?, version = ?, updated_at = ?, deleted_at = ? WHERE id = ? AND version = ?', [record.name, record.contactName, record.phone, record.comments, windows.morning?.start ?? null, windows.morning?.end ?? null, windows.afternoon?.start ?? null, windows.afternoon?.end ?? null, windows.lateNight?.start ?? null, windows.lateNight?.end ?? null, record.version, record.updatedAt, record.deletedAt, record.id, operation.baseVersion]);
|
||||
change = { entityType: 'customer', record };
|
||||
} else if (operation.kind === 'address.create') {
|
||||
const record = { ...operation.data, ...meta };
|
||||
const customer = await conn.query('SELECT id FROM customers WHERE id = ? AND deleted_at IS NULL', [record.customerId]);
|
||||
if (!customer.length) throw new SyncError('CUSTOMER_MISSING', 422, 'Customer is not available');
|
||||
await conn.query('INSERT INTO customer_addresses(id, customer_id, label, address, city, street, building_number, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [record.id, record.customerId, record.label, record.address, record.city, record.street, record.buildingNumber, record.createdAt]);
|
||||
change = { entityType: 'address', record };
|
||||
} else if (operation.kind === 'delivery.create') {
|
||||
const data = operation.data;
|
||||
const address = await conn.query('SELECT a.id FROM customer_addresses a JOIN customers c ON c.id = a.customer_id WHERE a.id = ? AND a.customer_id = ? AND a.deleted_at IS NULL AND c.deleted_at IS NULL', [data.addressId, data.customerId]);
|
||||
if (!address.length) throw new SyncError('ADDRESS_MISMATCH', 422, 'Select an active address assigned to this customer');
|
||||
const types = await conn.query('SELECT version, shipment_unit_hundredths FROM delivery_types WHERE id = ? AND deleted_at IS NULL', [data.deliveryTypeId]);
|
||||
if (!types.length || types[0].version !== data.deliveryTypeVersion || types[0].shipment_unit_hundredths !== data.shipmentUnitHundredths) {
|
||||
throw new SyncError('CATALOG_CONFLICT', 409, 'Delivery type changed; the saved draft needs review');
|
||||
}
|
||||
const record = deliverySchema.parse({ ...data, ...meta, deliveryNumber: state[0].next_delivery_number as number, totalPriceAgorot: totalPrice(data.unitPriceAgorot, data.shipmentUnitHundredths), status: 'created' });
|
||||
await conn.query('UPDATE sync_state SET next_delivery_number = next_delivery_number + 1 WHERE id = 1');
|
||||
await conn.query('INSERT INTO deliveries(id, delivery_number, customer_id, address_id, scheduled_date, delivery_slot, specific_time, delivery_window_start, delivery_window_end, delivery_type_id, delivery_type_version, shipment_unit_hundredths, unit_price_agorot, total_price_agorot, notes, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [record.id, record.deliveryNumber, record.customerId, record.addressId, record.scheduledDate, record.deliverySlot, record.specificTime, record.deliveryWindow?.start ?? null, record.deliveryWindow?.end ?? null, record.deliveryTypeId, record.deliveryTypeVersion, record.shipmentUnitHundredths, record.unitPriceAgorot, record.totalPriceAgorot, record.notes, record.createdAt]);
|
||||
change = { entityType: 'delivery', record };
|
||||
} else {
|
||||
if (!existing.length) throw new SyncError('DELIVERY_MISSING', 404, 'Delivery is not available');
|
||||
const saved = json<Change>(existing[0].change_json);
|
||||
const current = deliverySchema.parse(saved.record);
|
||||
const currentChange: Change = { entityType: 'delivery', record: current };
|
||||
if (current.version !== operation.baseVersion) throw new SyncError('VERSION_CONFLICT', 409, 'Delivery changed on another device', currentChange);
|
||||
if (current.status === 'cancelled' || current.deletedAt) throw new SyncError('DELIVERY_CANCELLED', 409, 'Cancelled deliveries cannot be changed', currentChange);
|
||||
let record: Delivery = { ...current, version: current.version + 1, updatedAt: meta.createdAt };
|
||||
if (operation.kind === 'delivery.update') {
|
||||
const data = operation.data;
|
||||
const address = await conn.query('SELECT a.id FROM customer_addresses a JOIN customers c ON c.id = a.customer_id WHERE a.id = ? AND a.customer_id = ? AND a.deleted_at IS NULL AND c.deleted_at IS NULL', [data.addressId, data.customerId]);
|
||||
if (!address.length) throw new SyncError('ADDRESS_MISMATCH', 422, 'Select an active address assigned to this customer');
|
||||
record = deliverySchema.parse({ ...record, ...data, scheduledDate: data.scheduledDate ?? current.scheduledDate });
|
||||
} else record = { ...record, status: 'cancelled', cancelledAt: meta.createdAt, cancellationReason: operation.data.reason };
|
||||
await conn.query('UPDATE deliveries SET customer_id = ?, address_id = ?, scheduled_date = ?, delivery_slot = ?, specific_time = ?, delivery_window_start = ?, delivery_window_end = ?, notes = ?, status = ?, version = ?, updated_at = ?, cancelled_at = ?, cancellation_reason = ? WHERE id = ? AND version = ?', [record.customerId, record.addressId, record.scheduledDate, record.deliverySlot, record.specificTime, record.deliveryWindow?.start ?? null, record.deliveryWindow?.end ?? null, record.notes, record.status, record.version, record.updatedAt, record.cancelledAt, record.cancellationReason, record.id, operation.baseVersion]);
|
||||
change = { entityType: 'delivery', record };
|
||||
}
|
||||
await appendChange(conn, change);
|
||||
await conn.query('INSERT INTO sync_operations(operation_id, device_id, request_hash, result_json, created_at, actor_user_id) VALUES (?, ?, ?, ?, ?, ?)', [operation.operationId, operation.deviceId, requestHash, JSON.stringify(change), meta.createdAt, actorUserId]);
|
||||
await conn.commit();
|
||||
return { change, replayed: false };
|
||||
} catch (error) {
|
||||
await conn.rollback();
|
||||
throw error;
|
||||
} finally { conn.release(); }
|
||||
}
|
||||
|
||||
export async function pullChanges(pool: Pool, cursor: string, limit = 100): Promise<PullResponse> {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const state = await conn.query('SELECT sequence FROM sync_state WHERE id = 1');
|
||||
if (BigInt(cursor) > BigInt(state[0].sequence)) throw new SyncError('CURSOR_AHEAD', 409, 'Server history is older than this device; restore or reconcile before syncing');
|
||||
const rows = await conn.query('SELECT sequence, change_json FROM sync_changes WHERE sequence > ? ORDER BY sequence LIMIT ?', [cursor, limit + 1]);
|
||||
const changes: PullResponse['changes'] = rows.slice(0, limit).map((row: { sequence: bigint; change_json: string }) => ({ sequence: String(row.sequence), change: json<Change>(row.change_json) }));
|
||||
return { changes, nextCursor: changes.at(-1)?.sequence ?? cursor, hasMore: rows.length > limit };
|
||||
} finally { conn.release(); }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
function key() {
|
||||
const value = process.env.TOKEN_ENCRYPTION_KEY ?? '';
|
||||
const decoded = Buffer.from(value, 'base64');
|
||||
if (decoded.length !== 32) throw new Error('Configure TOKEN_ENCRYPTION_KEY with 32 random bytes in base64');
|
||||
return decoded;
|
||||
}
|
||||
export function seal(value: unknown): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
||||
const data = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
|
||||
return [iv, cipher.getAuthTag(), data].map(v => v.toString('base64url')).join('.');
|
||||
}
|
||||
export function unseal<T>(value: string): T {
|
||||
const [iv, tag, data] = value.split('.');
|
||||
if (!iv || !tag || !data) throw new Error('Invalid encrypted value');
|
||||
const decipher = createDecipheriv('aes-256-gcm', key(), Buffer.from(iv, 'base64url'));
|
||||
decipher.setAuthTag(Buffer.from(tag, 'base64url'));
|
||||
return JSON.parse(Buffer.concat([decipher.update(Buffer.from(data, 'base64url')), decipher.final()]).toString('utf8')) as T;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Only used against model_delivery_test. This is not a default application account.
|
||||
export const testAccount = { username: 'test.admin', password: 'Test-only-password-932!', displayName: 'משתמש בדיקה' };
|
||||
@@ -0,0 +1,55 @@
|
||||
// Test-only identity provider. Never imported or started by application code.
|
||||
import { createServer } from 'node:http';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { generateKeyPair, exportJWK, SignJWT } from 'jose';
|
||||
|
||||
export async function startMockOidc(port = 0) {
|
||||
const { privateKey, publicKey } = await generateKeyPair('RS256');
|
||||
const jwk = { ...await exportJWK(publicKey), kid: 'test-key', use: 'sig', alg: 'RS256' };
|
||||
const codes = new Map(), refreshTokens = new Set(), accessTokens = new Set();
|
||||
const controls = { badNonce: false, denyRefresh: false, refreshCount: 0, omitName: false, omitRefreshToken: false, subject: 'synology-test-user', groups: ['model-delivery-users'] };
|
||||
let issuer = '';
|
||||
const server = createServer(async (req, res) => {
|
||||
const profile = { sub: controls.subject, username: 'test.sso', ...(controls.omitName ? {} : { name: 'טום בדיקה' }), email: 'test@example.invalid', groups: controls.groups };
|
||||
const json = (data, status = 200) => { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(data)); };
|
||||
const url = new URL(req.url, issuer);
|
||||
try {
|
||||
if (url.pathname === '/.well-known/openid-configuration') return json({ issuer, authorization_endpoint: `${issuer}/authorize`, token_endpoint: `${issuer}/token`, userinfo_endpoint: `${issuer}/userinfo`, jwks_uri: `${issuer}/jwks`, response_types_supported: ['code'], grant_types_supported: ['authorization_code', 'refresh_token'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], token_endpoint_auth_methods_supported: ['client_secret_post'], scopes_supported: ['openid', 'profile', 'email', 'groups', 'offline_access'], claims_supported: ['sub', 'username', 'name', 'email', 'groups'], code_challenge_methods_supported: ['S256'] });
|
||||
if (url.pathname === '/jwks') return json({ keys: [jwk] });
|
||||
if (url.pathname === '/authorize') {
|
||||
const values = Object.fromEntries(url.searchParams);
|
||||
if (values.client_id !== 'model-test' || values.redirect_uri !== 'http://localhost:4174/api/auth/callback' || values.code_challenge_method !== 'S256' || !values.code_challenge || !values.state || !values.nonce) return json({ error: 'invalid_request' }, 400);
|
||||
const code = randomBytes(24).toString('hex'); codes.set(code, values);
|
||||
const redirect = new URL(values.redirect_uri); redirect.searchParams.set('code', code); redirect.searchParams.set('state', values.state);
|
||||
res.writeHead(302, { location: redirect.href }); return res.end();
|
||||
}
|
||||
if (url.pathname === '/token' && req.method === 'POST') {
|
||||
let raw = ''; for await (const chunk of req) raw += chunk;
|
||||
const fields = new URLSearchParams(raw);
|
||||
if (fields.get('client_id') !== 'model-test' || fields.get('client_secret') !== 'mock-test-secret') return json({ error: 'invalid_client' }, 401);
|
||||
let values;
|
||||
if (fields.get('grant_type') === 'authorization_code') {
|
||||
values = codes.get(fields.get('code')); codes.delete(fields.get('code'));
|
||||
if (!values || values.redirect_uri !== fields.get('redirect_uri') || createHash('sha256').update(fields.get('code_verifier') ?? '').digest('base64url') !== values.code_challenge) return json({ error: 'invalid_grant' }, 400);
|
||||
} else if (fields.get('grant_type') === 'refresh_token') {
|
||||
if (controls.denyRefresh || !refreshTokens.delete(fields.get('refresh_token'))) return json({ error: 'invalid_grant' }, 400);
|
||||
controls.refreshCount++;
|
||||
} else return json({ error: 'unsupported_grant_type' }, 400);
|
||||
const access_token = randomBytes(32).toString('hex'), refresh_token = randomBytes(32).toString('hex');
|
||||
accessTokens.add(access_token); refreshTokens.add(refresh_token);
|
||||
const id_token = values ? await new SignJWT({ ...profile, nonce: controls.badNonce ? 'wrong-nonce' : values.nonce }).setProtectedHeader({ alg: 'RS256', kid: jwk.kid }).setIssuer(issuer).setAudience('model-test').setIssuedAt().setExpirationTime('1h').sign(privateKey) : undefined;
|
||||
return json({ access_token, ...(!controls.omitRefreshToken ? { refresh_token } : {}), id_token, token_type: 'Bearer', expires_in: 3600, scope: 'openid profile email groups offline_access' });
|
||||
}
|
||||
if (url.pathname === '/userinfo' && accessTokens.has(req.headers.authorization?.replace(/^Bearer /, ''))) return json(profile);
|
||||
return json({ error: 'not_found' }, 404);
|
||||
} catch { return json({ error: 'server_error' }, 500); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(port, '127.0.0.1', resolve));
|
||||
issuer = `http://localhost:${server.address().port}`;
|
||||
return { issuer, controls, close: () => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) };
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const provider = await startMockOidc(4005);
|
||||
console.log(`Test OIDC provider ready at ${provider.issuer}`);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { operationSchema, type Operation } from '@model/shared';
|
||||
import { createPool } from '../../src/db';
|
||||
import { buildApp } from '../../src/app';
|
||||
import { testAccount } from '../helpers/account';
|
||||
|
||||
const pool = createPool();
|
||||
const app = buildApp(pool, false);
|
||||
const deviceId = randomUUID();
|
||||
const customerId = randomUUID();
|
||||
const addressId = randomUUID();
|
||||
function operation(kind: Operation['kind'], data: unknown, entityId = randomUUID()) {
|
||||
return operationSchema.parse({ operationId: randomUUID(), deviceId, entityId, kind, baseVersion: 0, data });
|
||||
}
|
||||
let headers: Record<string, string> = { 'x-model-request': '1' };
|
||||
const push = (payload: Operation) => app.inject({ method: 'POST', url: '/sync/push', payload, headers });
|
||||
const get = (url: string) => app.inject({ url, headers });
|
||||
const deliveryData = { customerId, addressId, scheduledDate: '2026-09-06', deliverySlot: 'morning' as const, specificTime: null, deliveryWindow: { start: '08:00', end: '11:00' }, deliveryTypeId: '10000000-0000-4000-8000-000000000003', deliveryTypeVersion: 1, shipmentUnitHundredths: 150, unitPriceAgorot: 1001, notes: 'Integration test' };
|
||||
beforeAll(async () => {
|
||||
expect(process.env.DB_NAME).toBe('model_delivery_test');
|
||||
const state = (await app.inject('/local-admin/status')).json();
|
||||
const login = await app.inject({ method: 'POST', url: state.setupRequired ? '/local-admin/setup' : '/local-admin/login', headers, payload: state.setupRequired ? { ...testAccount, setupToken: process.env.LOCAL_ADMIN_SETUP_TOKEN } : { username: testAccount.username, password: testAccount.password } });
|
||||
expect(login.statusCode).toBe(200);
|
||||
headers = { ...headers, cookie: login.cookies.map(c => `${c.name}=${c.value}`).join('; '), 'x-model-user': login.json().user.id };
|
||||
expect((await push(operation('customer.create', { name: 'Integration test customer' }, customerId))).statusCode).toBe(200);
|
||||
expect((await push(operation('address.create', { customerId, label: 'Test', address: 'Test address' }, addressId))).statusCode).toBe(200);
|
||||
});
|
||||
afterAll(async () => { await app.close(); await pool.end(); });
|
||||
describe('MariaDB-backed API', () => {
|
||||
it('requires authentication and rejects a different account or unsafe request origin', async () => {
|
||||
expect((await app.inject('/sync/pull')).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: 'POST', url: '/sync/push', headers: { 'x-model-request': '1' }, payload: operation('delivery.create', deliveryData) })).statusCode).toBe(401);
|
||||
expect((await app.inject({ url: '/sync/pull', headers: { ...headers, 'x-model-user': randomUUID() } })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: 'POST', url: '/auth/logout', headers: { ...headers, origin: 'https://untrusted.example' } })).statusCode).toBe(403);
|
||||
expect((await app.inject({ method: 'POST', url: '/sync/push', headers: { cookie: headers.cookie! }, payload: operation('delivery.create', deliveryData) })).statusCode).toBe(403);
|
||||
});
|
||||
it('provides a personal session, closes initial setup, and invalidates logout sessions', async () => {
|
||||
const current = (await get('/auth/session')).json();
|
||||
expect(current.user.displayName).toBe(testAccount.displayName);
|
||||
const setup = await app.inject({ method: 'POST', url: '/local-admin/setup', headers, payload: { ...testAccount, username: 'another.admin', setupToken: process.env.LOCAL_ADMIN_SETUP_TOKEN } });
|
||||
expect(setup.statusCode).toBe(409);
|
||||
const login = await app.inject({ method: 'POST', url: '/local-admin/login', headers: { 'x-model-request': '1' }, payload: { username: testAccount.username, password: testAccount.password } });
|
||||
expect(login.statusCode).toBe(200);
|
||||
expect(login.headers['set-cookie']).toContain('HttpOnly');
|
||||
expect(login.headers['set-cookie']).toContain('SameSite=Strict');
|
||||
const otherHeaders = { ...headers, cookie: login.cookies.map(c => `${c.name}=${c.value}`).join('; ') };
|
||||
expect((await app.inject({ method: 'POST', url: '/auth/logout', headers: otherHeaders })).statusCode).toBe(200);
|
||||
expect((await app.inject({ url: '/sync/pull', headers: otherHeaders })).statusCode).toBe(401);
|
||||
const bad = await app.inject({ method: 'POST', url: '/local-admin/login', headers: { 'x-model-request': '1' }, payload: { username: testAccount.username, password: 'Incorrect-test-password' } });
|
||||
expect(bad.statusCode).toBe(401);
|
||||
});
|
||||
it('detects concurrent edits, applies explicit new-version retries, and keeps immutable values', async () => {
|
||||
const created = (await push(operation('delivery.create', deliveryData))).json().change.record;
|
||||
const edit = (notes: string, baseVersion: number, scheduledDate = '2026-09-07') => operationSchema.parse({ operationId: randomUUID(), deviceId, entityId: created.id, baseVersion, kind: 'delivery.update', data: { customerId, addressId, scheduledDate, deliverySlot: 'custom', specificTime: '14:35', deliveryWindow: null, notes } });
|
||||
const a = edit('Device A', 1), b = edit('Device B', 1);
|
||||
expect((await push(a)).statusCode).toBe(200);
|
||||
const conflict = await push(b);
|
||||
expect(conflict.statusCode).toBe(409);
|
||||
expect(conflict.json().current.record).toMatchObject({ version: 2, notes: 'Device A' });
|
||||
const retried = await push(edit('Device B', 2));
|
||||
expect(retried.json().change.record).toMatchObject({ version: 3, notes: 'Device B', scheduledDate: '2026-09-07', deliverySlot: 'custom', specificTime: '14:35', deliveryWindow: null, deliveryNumber: created.deliveryNumber, shipmentUnitHundredths: 150, unitPriceAgorot: 1001, totalPriceAgorot: 1502, createdAt: created.createdAt });
|
||||
expect((await pool.query("SELECT DATE_FORMAT(scheduled_date, '%Y-%m-%d') AS scheduled_date, delivery_slot, specific_time FROM deliveries WHERE id = ?", [created.id]))[0]).toMatchObject({ scheduled_date: '2026-09-07', delivery_slot: 'custom', specific_time: '14:35' });
|
||||
const tampered = await app.inject({ method: 'POST', url: '/sync/push', headers, payload: { ...edit('Reprice', 3), data: { customerId, addressId, notes: 'Reprice', unitPriceAgorot: 1 } } });
|
||||
expect(tampered.statusCode).toBe(400);
|
||||
const audit = await pool.query('SELECT actor_user_id FROM sync_operations WHERE operation_id = ?', [a.operationId]);
|
||||
expect(audit[0].actor_user_id).toBe(headers['x-model-user']);
|
||||
});
|
||||
it('cancels idempotently, retains its number and prevents further edits', async () => {
|
||||
const created = (await push(operation('delivery.create', deliveryData))).json().change.record;
|
||||
const op = operationSchema.parse({ operationId: randomUUID(), deviceId, entityId: created.id, baseVersion: 1, kind: 'delivery.cancel', data: { reason: 'Test cancellation' } });
|
||||
const first = await push(op), replay = await push(op);
|
||||
expect(first.json().change.record).toMatchObject({ status: 'cancelled', version: 2, deliveryNumber: created.deliveryNumber, totalPriceAgorot: 1502 });
|
||||
expect(replay.json().replayed).toBe(true);
|
||||
const edit = operationSchema.parse({ ...op, operationId: randomUUID(), baseVersion: 2, kind: 'delivery.update', data: { customerId, addressId, notes: 'Cannot reopen' } });
|
||||
expect((await push(edit)).json().code).toBe('DELIVERY_CANCELLED');
|
||||
const next = (await push(operation('delivery.create', deliveryData))).json().change.record;
|
||||
expect(next.deliveryNumber).toBeGreaterThan(created.deliveryNumber);
|
||||
});
|
||||
it('updates and archives full customer profiles while preserving associated records', async () => {
|
||||
const profileId = randomUUID(), profileAddressId = randomUUID();
|
||||
const details = { name: 'Profile test clinic', contactName: 'תום רוף', phone: '+972 50-123-4567', comments: 'Use the side entrance', deliveryWindows: { morning: { start: '08:00', end: '11:30' }, afternoon: { start: '13:00', end: '16:30' }, lateNight: null } };
|
||||
const created = await push(operation('customer.create', details, profileId));
|
||||
expect(created.json().change.record).toMatchObject({ ...details, version: 1, updatedAt: null });
|
||||
const address = await push(operation('address.create', { customerId: profileId, label: 'Main', address: 'Main 7, Haifa', city: 'Haifa', street: 'Main', buildingNumber: '7' }, profileAddressId));
|
||||
expect(address.json().change.record).toMatchObject({ city: 'Haifa', street: 'Main', buildingNumber: '7' });
|
||||
const profileDelivery = (await push(operation('delivery.create', { ...deliveryData, customerId: profileId, addressId: profileAddressId }))).json().change.record;
|
||||
const edit = (name: string, baseVersion: number) => operationSchema.parse({ operationId: randomUUID(), deviceId, entityId: profileId, baseVersion, kind: 'customer.update', data: { ...details, name } });
|
||||
expect((await push(edit('Updated profile clinic', 1))).json().change.record).toMatchObject({ version: 2, name: 'Updated profile clinic', contactName: details.contactName });
|
||||
const stale = await push(edit('Stale profile clinic', 1));
|
||||
expect(stale.statusCode).toBe(409); expect(stale.json().current.record).toMatchObject({ version: 2, name: 'Updated profile clinic' });
|
||||
const removed = operationSchema.parse({ operationId: randomUUID(), deviceId, entityId: profileId, baseVersion: 2, kind: 'customer.remove', data: {} });
|
||||
expect((await push(removed)).json().change.record).toMatchObject({ version: 3, name: 'Updated profile clinic' });
|
||||
expect((await push(operation('delivery.create', { ...deliveryData, customerId: profileId, addressId: profileAddressId }))).json().code).toBe('ADDRESS_MISMATCH');
|
||||
expect(Number((await pool.query('SELECT COUNT(*) AS count FROM deliveries WHERE id = ?', [profileDelivery.id]))[0].count)).toBe(1);
|
||||
expect(Number((await pool.query('SELECT COUNT(*) AS count FROM customer_addresses WHERE id = ?', [profileAddressId]))[0].count)).toBe(1);
|
||||
});
|
||||
it('provides health, readiness, version and request IDs', async () => {
|
||||
expect((await app.inject('/health')).json()).toEqual({ status: 'ok' });
|
||||
expect((await app.inject('/ready')).statusCode).toBe(200);
|
||||
const version = await app.inject('/version');
|
||||
expect(version.json().syncProtocol).toBe(5);
|
||||
expect(version.headers['x-request-id']).toBeTruthy();
|
||||
});
|
||||
it('replays concurrent identical operations exactly once and rejects changed reuse', async () => {
|
||||
const op = operation('delivery.create', deliveryData);
|
||||
const responses = await Promise.all([push(op), push(op), push(op)]);
|
||||
expect(responses.map(r => r.statusCode)).toEqual([200, 200, 200]);
|
||||
const numbers = responses.map(r => r.json().change.record.deliveryNumber);
|
||||
expect(new Set(numbers).size).toBe(1);
|
||||
expect(responses.filter(r => r.json().replayed === false)).toHaveLength(1);
|
||||
const records = await pool.query('SELECT COUNT(*) AS count FROM deliveries WHERE id = ?', [op.entityId]);
|
||||
expect(Number(records[0].count)).toBe(1);
|
||||
const changes = await pool.query('SELECT COUNT(*) AS count FROM sync_changes WHERE entity_id = ?', [op.entityId]);
|
||||
expect(Number(changes[0].count)).toBe(1);
|
||||
const changed = { ...op, data: { ...deliveryData, notes: 'Changed request' } } as Operation;
|
||||
expect((await push(changed)).json().code).toBe('OPERATION_REUSED');
|
||||
const versionConflict = { ...op, operationId: randomUUID() };
|
||||
const conflict = await push(versionConflict);
|
||||
expect(conflict.statusCode).toBe(409);
|
||||
expect(conflict.json().current.record.id).toBe(op.entityId);
|
||||
});
|
||||
it('allocates unique sequential numbers under concurrent creates', async () => {
|
||||
const replies = await Promise.all(Array.from({ length: 5 }, () => push(operation('delivery.create', deliveryData))));
|
||||
expect(replies.every(r => r.statusCode === 200)).toBe(true);
|
||||
const numbers: number[] = replies.map(r => r.json().change.record.deliveryNumber).sort((a: number, b: number) => a - b);
|
||||
expect(new Set(numbers).size).toBe(5);
|
||||
expect(numbers.at(-1)! - numbers[0]!).toBe(4);
|
||||
expect(replies[0]!.json().change.record.totalPriceAgorot).toBe(1502);
|
||||
});
|
||||
it('rejects another customer address and stale catalog without consuming a number', async () => {
|
||||
const before = await pool.query('SELECT next_delivery_number FROM sync_state');
|
||||
const badAddress = await push(operation('delivery.create', { ...deliveryData, customerId: randomUUID() }));
|
||||
expect(badAddress.statusCode).toBe(422);
|
||||
expect(badAddress.json().code).toBe('ADDRESS_MISMATCH');
|
||||
const stale = await push(operation('delivery.create', { ...deliveryData, deliveryTypeVersion: 99 }));
|
||||
expect(stale.json().code).toBe('CATALOG_CONFLICT');
|
||||
const after = await pool.query('SELECT next_delivery_number FROM sync_state');
|
||||
expect(after[0].next_delivery_number).toBe(before[0].next_delivery_number);
|
||||
});
|
||||
it('paginates committed changes in ascending order and rejects invalid cursors', async () => {
|
||||
let cursor = '0';
|
||||
const seen = new Set<string>();
|
||||
let pages = 0;
|
||||
while (true) {
|
||||
const response = await get(`/sync/pull?cursor=${cursor}&limit=2`);
|
||||
expect(response.statusCode).toBe(200);
|
||||
const page = response.json();
|
||||
for (const entry of page.changes) {
|
||||
expect(BigInt(entry.sequence)).toBeGreaterThan(BigInt(cursor));
|
||||
expect(seen.has(entry.sequence)).toBe(false);
|
||||
seen.add(entry.sequence);
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
pages++;
|
||||
if (!page.hasMore) break;
|
||||
if (pages > 1000) throw new Error('Pagination did not terminate');
|
||||
}
|
||||
expect(pages).toBeGreaterThan(1);
|
||||
expect((await get('/sync/pull?cursor=-1')).statusCode).toBe(400);
|
||||
expect((await get('/sync/pull?cursor=18446744073709551615')).json().code).toBe('CURSOR_AHEAD');
|
||||
});
|
||||
it('validates create-only versions and refuses arbitrary request fields', async () => {
|
||||
const invalid = await app.inject({ method: 'POST', url: '/sync/push', headers, payload: { ...operation('delivery.create', deliveryData), baseVersion: 1 } });
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
const arbitrary = await app.inject({ method: 'POST', url: '/sync/push', headers, payload: { ...operation('delivery.create', deliveryData), admin: true } });
|
||||
expect(arbitrary.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { Client } from 'ldapts';
|
||||
import { createPool } from '../../src/db';
|
||||
import { buildApp } from '../../src/app';
|
||||
import { defaultDirectorySettings, readDirectorySettings, saveDirectorySettings } from '../../src/directory';
|
||||
import { readSettings, saveSettings } from '../../src/oidc';
|
||||
import { startMockOidc } from '../helpers/mock-oidc.mjs';
|
||||
import { testAccount } from '../helpers/account';
|
||||
|
||||
const pool = createPool();
|
||||
process.env.OIDC_ALLOW_HTTP = 'true';
|
||||
process.env.APP_ORIGINS = 'http://localhost:4174';
|
||||
const app = buildApp(pool, false);
|
||||
const headers = { 'x-model-request': '1' };
|
||||
let admin: Record<string, string>;
|
||||
let adminId: string;
|
||||
let originalSso: Awaited<ReturnType<typeof readSettings>>;
|
||||
let originalDirectory: Awaited<ReturnType<typeof readDirectorySettings>>;
|
||||
let provider: Awaited<ReturnType<typeof startMockOidc>>;
|
||||
const directory = { ...defaultDirectorySettings(), enabled: true, url: 'ldaps://directory.example.invalid:636', baseDn: 'DC=example,DC=invalid', bindDn: 'reader@example.invalid', bindPassword: 'directory-test-secret' };
|
||||
const cookies = (response: { cookies: Array<{ name: string; value: string }> }) => response.cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
async function login() {
|
||||
const start = await app.inject({ method: 'POST', url: '/auth/login', headers });
|
||||
expect(start.statusCode).toBe(200);
|
||||
const response = await fetch(start.json().url, { redirect: 'manual' });
|
||||
const callback = new URL(response.headers.get('location')!);
|
||||
const complete = await app.inject({ url: `/auth/callback${callback.search}`, headers: { cookie: cookies(start) } });
|
||||
expect(complete.headers.location).toBe('/');
|
||||
const sessionHeaders = { ...headers, cookie: cookies(complete) };
|
||||
const session = (await app.inject({ url: '/auth/session', headers: sessionHeaders })).json();
|
||||
return { user: session.user, headers: { ...sessionHeaders, 'x-model-user': session.user.id } };
|
||||
}
|
||||
beforeAll(async () => {
|
||||
expect(process.env.DB_NAME).toBe('model_delivery_test');
|
||||
originalSso = await readSettings(pool); originalDirectory = await readDirectorySettings(pool);
|
||||
const status = (await app.inject('/local-admin/status')).json();
|
||||
const result = await app.inject({ method: 'POST', url: `/local-admin/${status.setupRequired ? 'setup' : 'login'}`, headers, payload: status.setupRequired ? { ...testAccount, setupToken: process.env.LOCAL_ADMIN_SETUP_TOKEN } : { username: testAccount.username, password: testAccount.password } });
|
||||
expect(result.statusCode).toBe(200);
|
||||
admin = { ...headers, cookie: cookies(result) }; adminId = result.json().user.id;
|
||||
provider = await startMockOidc(); provider.controls.subject = 'REZNIK\\tom.ruff'; provider.controls.omitName = true;
|
||||
await saveSettings(pool, { enabled: true, issuer: provider.issuer, discoveryUrl: `${provider.issuer}/.well-known/openid-configuration`, clientId: 'model-test', clientSecret: 'mock-test-secret', redirectUri: 'http://localhost:4174/api/auth/callback', scopes: 'openid email', authMethod: 'client_secret_post', offlineAccess: false, promptConsent: false, displayNameClaim: 'name' }, adminId);
|
||||
});
|
||||
afterAll(async () => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalDirectory) await saveDirectorySettings(pool, originalDirectory.settings, originalDirectory.issuer, adminId);
|
||||
if (originalSso) await saveSettings(pool, originalSso, adminId);
|
||||
await app.close(); await pool.end(); await provider?.close();
|
||||
});
|
||||
describe('directory settings and greeting', () => {
|
||||
it('guards settings and diagnostic routes, encrypts passwords and preserves them only for the same server/account', async () => {
|
||||
for (const url of ['/local-admin/directory', '/local-admin/directory/test']) {
|
||||
expect((await app.inject({ method: 'POST', url, headers, payload: {} })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: 'POST', url, headers: admin, remoteAddress: '192.0.2.1', payload: {} })).statusCode).toBe(403);
|
||||
expect((await app.inject({ method: 'POST', url, headers: { ...admin, origin: 'https://other.invalid' }, payload: {} })).statusCode).toBe(403);
|
||||
}
|
||||
const saved = await app.inject({ method: 'POST', url: '/local-admin/directory', headers: admin, payload: directory });
|
||||
expect(saved.statusCode).toBe(200);
|
||||
expect(saved.json()).toMatchObject({ bindPassword: '', passwordConfigured: true, issuer: provider.issuer });
|
||||
expect((await app.inject({ url: '/local-admin/directory', headers: admin })).body).not.toContain(directory.bindPassword);
|
||||
expect((await pool.query('SELECT encrypted_config FROM directory_settings WHERE id = 1'))[0].encrypted_config).not.toContain(directory.bindPassword);
|
||||
expect((await app.inject({ method: 'POST', url: '/local-admin/directory', headers: admin, payload: { ...directory, bindPassword: '' } })).statusCode).toBe(200);
|
||||
expect((await app.inject({ method: 'POST', url: '/local-admin/directory', headers: admin, payload: { ...directory, url: 'ldaps://other.invalid', bindPassword: '' } })).statusCode).toBe(400);
|
||||
});
|
||||
it('reads a name after OIDC, keeps it during outages, supports personal overrides and invalidates stale cache when disabled', async () => {
|
||||
const bind = vi.spyOn(Client.prototype, 'bind').mockResolvedValue();
|
||||
vi.spyOn(Client.prototype, 'search').mockResolvedValue({ searchEntries: [{ dn: 'CN=test', sAMAccountName: 'tom.ruff', displayName: 'תום רוף' }], searchReferences: [] });
|
||||
vi.spyOn(Client.prototype, 'unbind').mockResolvedValue();
|
||||
const test = await app.inject({ method: 'POST', url: '/local-admin/directory/test', headers: admin, payload: { subject: 'REZNIK\\tom.ruff' } });
|
||||
expect(test.json()).toEqual({ code: 'FOUND', displayName: 'תום רוף' });
|
||||
const first = await login();
|
||||
expect(first.user).toMatchObject({ displayName: 'תום רוף', role: 'staff', username: 'test.sso' });
|
||||
expect((await app.inject({ url: '/local-admin/directory', headers: first.headers })).statusCode).toBe(403);
|
||||
expect((await app.inject({ method: 'POST', url: '/local-admin/directory/test', headers: first.headers, payload: { subject: 'REZNIK\\other' } })).statusCode).toBe(403);
|
||||
const stored = (await pool.query('SELECT display_name, directory_display_name FROM app_users WHERE id = ?', [first.user.id]))[0];
|
||||
expect(stored).toMatchObject({ display_name: 'test.sso', directory_display_name: 'תום רוף' });
|
||||
bind.mockRejectedValue(new Error('directory-test-secret: temporary outage'));
|
||||
expect((await login()).user).toMatchObject({ id: first.user.id, displayName: 'תום רוף' });
|
||||
const custom = await app.inject({ method: 'POST', url: '/auth/profile', headers: first.headers, payload: { displayName: 'שם אישי' } });
|
||||
expect(custom.json().user.displayName).toBe('שם אישי');
|
||||
expect((await login()).user.displayName).toBe('שם אישי');
|
||||
const reset = await app.inject({ method: 'POST', url: '/auth/profile', headers: first.headers, payload: { displayName: null } });
|
||||
expect(reset.json().user.displayName).toBe('תום רוף');
|
||||
const failedTest = await app.inject({ method: 'POST', url: '/local-admin/directory/test', headers: admin, payload: { subject: 'REZNIK\\tom.ruff' } });
|
||||
expect(failedTest.statusCode).toBe(422); expect(failedTest.json()).toEqual({ code: 'UNAVAILABLE' });
|
||||
await saveDirectorySettings(pool, { ...directory, enabled: false }, provider.issuer, adminId);
|
||||
expect((await app.inject({ url: '/auth/session', headers: first.headers })).json().user.displayName).toBe('test.sso');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createPool } from '../../src/db';
|
||||
import { buildApp } from '../../src/app';
|
||||
import { readSettings, saveSettings } from '../../src/oidc';
|
||||
import { seal, unseal } from '../../src/token-vault';
|
||||
import { startMockOidc } from '../helpers/mock-oidc.mjs';
|
||||
import { testAccount } from '../helpers/account';
|
||||
|
||||
const pool = createPool();
|
||||
process.env.OIDC_ALLOW_HTTP = 'true';
|
||||
process.env.APP_ORIGINS = 'http://localhost:4174';
|
||||
const app = buildApp(pool, false);
|
||||
let provider: Awaited<ReturnType<typeof startMockOidc>>;
|
||||
let original: Awaited<ReturnType<typeof readSettings>>;
|
||||
let admin: Record<string, string>;
|
||||
const cookie = (response: { cookies: Array<{ name: string; value: string }> }) => response.cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
||||
async function begin() {
|
||||
const started = await app.inject({ method: 'POST', url: '/auth/login', headers: { 'x-model-request': '1' } });
|
||||
expect(started.statusCode).toBe(200);
|
||||
const authorizationUrl = new URL(started.json().url);
|
||||
expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256');
|
||||
expect(authorizationUrl.searchParams.get('scope')).toContain('offline_access');
|
||||
expect(authorizationUrl.searchParams.get('scope')).toContain('groups');
|
||||
expect(started.headers['set-cookie']).toContain('SameSite=Lax');
|
||||
const redirect = await fetch(authorizationUrl, { redirect: 'manual' });
|
||||
const callback = new URL(redirect.headers.get('location')!);
|
||||
return { url: `/auth/callback${callback.search}`, headers: { cookie: cookie(started) } };
|
||||
}
|
||||
async function login() {
|
||||
const callback = await app.inject(await begin());
|
||||
expect(callback.headers.location).toBe('/');
|
||||
return { cookie: cookie(callback), 'x-model-request': '1' };
|
||||
}
|
||||
beforeAll(async () => {
|
||||
expect(process.env.DB_NAME).toBe('model_delivery_test');
|
||||
provider = await startMockOidc();
|
||||
original = await readSettings(pool);
|
||||
const status = (await app.inject('/local-admin/status')).json();
|
||||
const result = await app.inject({ method: 'POST', url: `/local-admin/${status.setupRequired ? 'setup' : 'login'}`, headers: { 'x-model-request': '1' }, payload: status.setupRequired ? { ...testAccount, setupToken: process.env.LOCAL_ADMIN_SETUP_TOKEN } : { username: testAccount.username, password: testAccount.password } });
|
||||
expect(result.statusCode).toBe(200);
|
||||
admin = { cookie: cookie(result), 'x-model-request': '1' };
|
||||
const saved = await app.inject({ method: 'POST', url: '/local-admin/settings', headers: admin, payload: { enabled: true, issuer: provider.issuer, discoveryUrl: `${provider.issuer}/.well-known/openid-configuration`, clientId: 'model-test', clientSecret: 'mock-test-secret', redirectUri: 'http://localhost:4174/api/auth/callback', scopes: 'openid profile email groups', authMethod: 'client_secret_post', offlineAccess: true, promptConsent: true, displayNameClaim: 'name', groupAccessEnabled: true, groupClaim: 'groups', allowedGroups: ['model-delivery-users'] } });
|
||||
expect(saved.statusCode).toBe(200);
|
||||
expect(saved.json()).toMatchObject({ clientSecret: '', secretConfigured: true });
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (original) await saveSettings(pool, original, (await app.inject({ url: '/auth/session', headers: admin })).json().user.id);
|
||||
await app.close(); await pool.end(); await provider?.close();
|
||||
});
|
||||
describe('OIDC and local recovery', () => {
|
||||
it('keeps a personal display name across SSO logins when the provider returns only a username', async () => {
|
||||
provider.controls.omitName = true;
|
||||
const headers = await login();
|
||||
const before = (await app.inject({ url: '/auth/session', headers })).json();
|
||||
expect(before.user.displayName).toBe('test.sso');
|
||||
const payload = { displayName: 'תום רוף' };
|
||||
expect((await app.inject({ method: 'POST', url: '/auth/profile', headers: { 'x-model-request': '1' }, payload })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: 'POST', url: '/auth/profile', headers: { ...headers, 'x-model-user': 'another-account' }, payload })).statusCode).toBe(401);
|
||||
const ownHeaders = { ...headers, 'x-model-user': before.user.id };
|
||||
expect((await app.inject({ method: 'POST', url: '/auth/profile', headers: ownHeaders, payload: { ...payload, role: 'admin' } })).statusCode).toBe(400);
|
||||
expect((await app.inject({ method: 'POST', url: '/auth/profile', headers: ownHeaders, payload })).json().user).toMatchObject({ displayName: 'תום רוף', username: 'test.sso', role: 'staff' });
|
||||
const again = (await app.inject({ url: '/auth/session', headers: await login() })).json();
|
||||
expect(again.user).toMatchObject({ id: before.user.id, displayName: 'תום רוף', username: 'test.sso' });
|
||||
const stored = await pool.query('SELECT display_name, display_name_override FROM app_users WHERE id = ?', [before.user.id]);
|
||||
expect(stored[0]).toMatchObject({ display_name: 'test.sso', display_name_override: 'תום רוף' });
|
||||
await pool.query('UPDATE app_users SET display_name_override = NULL WHERE id = ?', [before.user.id]);
|
||||
provider.controls.omitName = false;
|
||||
});
|
||||
it('guards recovery setup/settings and masks/encrypts the client secret', async () => {
|
||||
expect((await app.inject('/local-admin/settings')).statusCode).toBe(401);
|
||||
expect((await app.inject({ url: '/local-admin/status', remoteAddress: '192.0.2.1' })).statusCode).toBe(403);
|
||||
expect((await app.inject({ method: 'POST', url: '/local-admin/setup', headers: { 'x-model-request': '1' }, payload: { ...testAccount, setupToken: 'wrong' } })).statusCode).toBe(403);
|
||||
const settings = await app.inject({ url: '/local-admin/settings', headers: admin });
|
||||
expect(settings.body).not.toContain('mock-test-secret');
|
||||
const stored = await pool.query('SELECT encrypted_config FROM oidc_settings');
|
||||
expect(stored[0].encrypted_config).not.toContain('mock-test-secret');
|
||||
expect((await app.inject({ method: 'POST', url: '/local-admin/discover', headers: admin })).json().pkceAdvertised).toBe(true);
|
||||
});
|
||||
it('binds sign-in to its browser, checks state/nonce and rejects callback replay', async () => {
|
||||
const pending = await begin();
|
||||
expect((await app.inject(pending.url)).headers.location).toBe('/?authError=signin_failed');
|
||||
const tampered = new URL(pending.url, 'http://test'); tampered.searchParams.set('state', 'wrong-state');
|
||||
expect((await app.inject({ ...pending, url: `${tampered.pathname}${tampered.search}` })).headers.location).toBe('/?authError=signin_failed');
|
||||
provider.controls.badNonce = true;
|
||||
expect((await app.inject(await begin())).headers.location).toBe('/?authError=signin_failed');
|
||||
provider.controls.badNonce = false;
|
||||
const valid = await begin();
|
||||
expect((await app.inject(valid)).headers.location).toBe('/');
|
||||
expect((await app.inject(valid)).headers.location).toBe('/?authError=signin_failed');
|
||||
});
|
||||
it('denies SSO users outside the configured Synology group allowlist', async () => {
|
||||
provider.controls.groups = ['unrelated-team'];
|
||||
const denied = await app.inject(await begin());
|
||||
expect(denied.headers.location).toBe('/?authError=group_denied');
|
||||
provider.controls.groups = ['MODEL-DELIVERY-USERS'];
|
||||
const allowedHeaders = await login();
|
||||
expect((await app.inject({ url: '/auth/session', headers: allowedHeaders })).json().user.groups).toEqual(['MODEL-DELIVERY-USERS']);
|
||||
provider.controls.groups = ['model-delivery-users'];
|
||||
});
|
||||
it('keeps a bounded app session after provider expiry when no refresh token was issued', async () => {
|
||||
provider.controls.omitRefreshToken = true;
|
||||
try {
|
||||
const headers = await login();
|
||||
const profile = (await app.inject({ url: '/auth/session', headers })).json();
|
||||
const hash = createHash('sha256').update(/(?:^|; )model_session=([^;]+)/.exec(headers.cookie)![1]!).digest('hex');
|
||||
const rows = await pool.query('SELECT encrypted_tokens FROM auth_sessions WHERE token_hash = ?', [hash]);
|
||||
const tokens = unseal<Record<string, unknown>>(rows[0].encrypted_tokens);
|
||||
expect(tokens.refreshToken).toBeUndefined();
|
||||
await pool.query('UPDATE auth_sessions SET encrypted_tokens = ? WHERE token_hash = ?', [seal({ ...tokens, expiresAt: Date.now() - 60 * 60 * 1000 }), hash]);
|
||||
const refreshCount = provider.controls.refreshCount;
|
||||
const resumed = await Promise.all(Array.from({ length: 3 }, () => app.inject({ url: '/auth/session', headers })));
|
||||
expect(resumed.every(r => r.json().user?.id === profile.user.id && r.json().expiresAt === profile.expiresAt)).toBe(true);
|
||||
expect(provider.controls.refreshCount).toBe(refreshCount);
|
||||
const syncHeaders = { ...headers, 'x-model-user': profile.user.id };
|
||||
expect((await app.inject({ url: '/sync/pull', headers: syncHeaders })).statusCode).toBe(200);
|
||||
|
||||
// Losing app access still blocks synchronization even without token refresh.
|
||||
await pool.query('UPDATE app_users SET active = FALSE WHERE id = ?', [profile.user.id]);
|
||||
try { expect((await app.inject({ url: '/sync/pull', headers: syncHeaders })).statusCode).toBe(401); }
|
||||
finally { await pool.query('UPDATE app_users SET active = TRUE WHERE id = ?', [profile.user.id]); }
|
||||
const settings = await readSettings(pool);
|
||||
const adminId = (await app.inject({ url: '/auth/session', headers: admin })).json().user.id;
|
||||
try {
|
||||
await saveSettings(pool, { ...settings, allowedGroups: ['another-team'] }, adminId);
|
||||
expect((await app.inject({ url: '/sync/pull', headers: syncHeaders })).statusCode).toBe(401);
|
||||
} finally { await saveSettings(pool, settings, adminId); }
|
||||
expect((await app.inject({ url: '/auth/session', headers })).json().user).toBeNull();
|
||||
|
||||
const expiredHeaders = await login();
|
||||
const expiredHash = createHash('sha256').update(/(?:^|; )model_session=([^;]+)/.exec(expiredHeaders.cookie)![1]!).digest('hex');
|
||||
await pool.query('UPDATE auth_sessions SET expires_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND) WHERE token_hash = ?', [expiredHash]);
|
||||
expect((await app.inject({ url: '/sync/pull', headers: { ...expiredHeaders, 'x-model-user': profile.user.id } })).statusCode).toBe(401);
|
||||
expect((await app.inject({ url: '/auth/session', headers: expiredHeaders })).json().user).toBeNull();
|
||||
|
||||
const logoutHeaders = await login();
|
||||
expect((await app.inject({ method: 'POST', url: '/auth/logout', headers: logoutHeaders })).statusCode).toBe(200);
|
||||
expect((await app.inject({ url: '/auth/session', headers: logoutHeaders })).json().user).toBeNull();
|
||||
} finally { provider.controls.omitRefreshToken = false; }
|
||||
});
|
||||
it('returns the greeting profile without tokens, refreshes once concurrently, and reauthenticates after revoked refresh', async () => {
|
||||
const headers = await login();
|
||||
const profile = (await app.inject({ url: '/auth/session', headers })).json();
|
||||
expect(profile.user).toMatchObject({ displayName: 'טום בדיקה', username: 'test.sso', authProvider: 'oidc', role: 'staff', groups: ['model-delivery-users'] });
|
||||
expect(Object.keys(profile).sort()).toEqual(['expiresAt', 'user']);
|
||||
expect((await app.inject({ url: '/local-admin/settings', headers })).statusCode).toBe(403);
|
||||
const token = /(?:^|; )model_session=([^;]+)/.exec(headers.cookie)![1]!;
|
||||
const hash = createHash('sha256').update(token).digest('hex');
|
||||
async function expire() {
|
||||
const rows = await pool.query('SELECT encrypted_tokens FROM auth_sessions WHERE token_hash = ?', [hash]);
|
||||
const tokens = unseal<Record<string, unknown>>(rows[0].encrypted_tokens);
|
||||
expect(rows[0].encrypted_tokens).not.toContain(tokens.refreshToken);
|
||||
await pool.query('UPDATE auth_sessions SET encrypted_tokens = ? WHERE token_hash = ?', [seal({ ...tokens, expiresAt: Date.now() - 1000 }), hash]);
|
||||
}
|
||||
await expire();
|
||||
const replies = await Promise.all(Array.from({ length: 3 }, () => app.inject({ url: '/auth/session', headers })));
|
||||
expect(replies.every(r => r.json().user?.id === profile.user.id)).toBe(true);
|
||||
expect(provider.controls.refreshCount).toBe(1);
|
||||
await expire(); provider.controls.denyRefresh = true;
|
||||
expect((await app.inject({ url: '/sync/pull', headers: { ...headers, 'x-model-user': profile.user.id } })).statusCode).toBe(401);
|
||||
expect((await app.inject({ url: '/auth/session', headers })).json().user).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Client } from 'ldapts';
|
||||
import { createServer } from 'node:net';
|
||||
import { defaultDirectorySettings, directoryUsername, lookupDirectoryName, validateDirectorySettings, type DirectoryConfig } from '../../src/directory';
|
||||
|
||||
const config: DirectoryConfig = { id: 'test-config', issuer: 'https://sso.example.invalid', settings: { ...defaultDirectorySettings(), enabled: true, url: 'ldaps://nas.example.invalid:636', baseDn: 'DC=example,DC=invalid', bindDn: 'reader@example.invalid', bindPassword: 'test-only-secret' } };
|
||||
const identity = { issuer: config.issuer, subject: 'REZNIK\\tom.ruff' };
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
function mockDirectory(entries: Record<string, string>[], references: string[] = []) {
|
||||
const bind = vi.spyOn(Client.prototype, 'bind').mockResolvedValue();
|
||||
const search = vi.spyOn(Client.prototype, 'search').mockResolvedValue({ searchEntries: entries.map(entry => ({ dn: 'CN=test', ...entry })), searchReferences: references });
|
||||
const unbind = vi.spyOn(Client.prototype, 'unbind').mockResolvedValue();
|
||||
return { bind, search, unbind };
|
||||
}
|
||||
describe('AD display-name enrichment', () => {
|
||||
it('requires the exact configured issuer and domain-qualified subject before connecting', async () => {
|
||||
const ldap = mockDirectory([]);
|
||||
expect(directoryUsername(config, { ...identity, subject: 'reznik\\tom.ruff' })).toBe('tom.ruff');
|
||||
for (const subject of ['tom.ruff', 'RUFF\\tom.ruff', 'REZNIK\\', 'REZNIK\\tom\\other']) expect(await lookupDirectoryName(config, { ...identity, subject })).toEqual({ code: 'IDENTITY_MISMATCH' });
|
||||
expect(await lookupDirectoryName(config, { ...identity, issuer: 'https://other.invalid' })).toEqual({ code: 'IDENTITY_MISMATCH' });
|
||||
expect(await lookupDirectoryName({ ...config, settings: defaultDirectorySettings() }, identity)).toEqual({ code: 'DISABLED' });
|
||||
expect(ldap.bind).not.toHaveBeenCalled();
|
||||
});
|
||||
it('rejects plaintext, embedded credentials, URL paths and missing bind credentials', () => {
|
||||
for (const url of ['ldap://nas:389', 'https://nas', 'ldaps://user:password@nas', 'ldaps://nas/directory', 'ldaps://nas?query=1']) expect(() => validateDirectorySettings({ ...config.settings, url })).toThrow();
|
||||
expect(() => validateDirectorySettings({ ...config.settings, bindPassword: '' })).toThrow();
|
||||
expect(() => validateDirectorySettings(config.settings)).not.toThrow();
|
||||
});
|
||||
it('reads Hebrew displayName and encodes special characters as a literal equality value', async () => {
|
||||
const username = 'tom*)(sAMAccountName=*)';
|
||||
const ldap = mockDirectory([{ sAMAccountName: username, displayName: 'תום רוף' }]);
|
||||
expect(await lookupDirectoryName(config, { ...identity, subject: `REZNIK\\${username}` })).toEqual({ code: 'FOUND', displayName: 'תום רוף' });
|
||||
const options = ldap.search.mock.calls[0]![1]!;
|
||||
expect(String(options.filter)).toContain('tom\\2a\\29\\28sAMAccountName=\\2a\\29');
|
||||
expect(options).toMatchObject({ scope: 'sub', sizeLimit: 2, timeLimit: 2 });
|
||||
expect(ldap.unbind).toHaveBeenCalledOnce();
|
||||
});
|
||||
it('joins first/surname and refuses ambiguous or mismatched results', async () => {
|
||||
const ldap = mockDirectory([{ samaccountname: 'TOM.RUFF', givenName: 'תום', sn: 'רוף' }]);
|
||||
expect(await lookupDirectoryName(config, identity)).toEqual({ code: 'FOUND', displayName: 'תום רוף' });
|
||||
ldap.search.mockResolvedValue({ searchEntries: [{ dn: 'a' }, { dn: 'b' }], searchReferences: [] });
|
||||
expect(await lookupDirectoryName(config, identity)).toEqual({ code: 'AMBIGUOUS' });
|
||||
ldap.search.mockResolvedValue({ searchEntries: [{ dn: 'a', sAMAccountName: 'other', displayName: 'Someone else' }], searchReferences: [] });
|
||||
expect(await lookupDirectoryName(config, identity)).toEqual({ code: 'NOT_FOUND' });
|
||||
ldap.search.mockResolvedValue({ searchEntries: [], searchReferences: ['ldaps://other.invalid'] });
|
||||
expect(await lookupDirectoryName(config, identity)).toEqual({ code: 'AMBIGUOUS' });
|
||||
});
|
||||
it('sanitizes failures and always releases the connection', async () => {
|
||||
const ldap = mockDirectory([]);
|
||||
ldap.bind.mockRejectedValue(new Error('test-only-secret: bind rejected'));
|
||||
expect(await lookupDirectoryName(config, identity)).toEqual({ code: 'UNAVAILABLE' });
|
||||
expect(ldap.unbind).toHaveBeenCalledOnce();
|
||||
});
|
||||
it('times out when a reachable host never completes TLS', async () => {
|
||||
const sockets = new Set<import('node:net').Socket>();
|
||||
const server = createServer(socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); });
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const port = (server.address() as import('node:net').AddressInfo).port;
|
||||
const started = Date.now();
|
||||
expect(await lookupDirectoryName({ ...config, settings: { ...config.settings, url: `ldaps://127.0.0.1:${port}` } }, identity)).toEqual({ code: 'UNAVAILABLE' });
|
||||
expect(Date.now() - started).toBeLessThan(5000);
|
||||
} finally { for (const socket of sockets) socket.destroy(); await new Promise<void>(resolve => server.close(() => resolve())); }
|
||||
}, 7000);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({ test: { include: ['tests/unit/**/*.test.ts'], environment: 'node' } });
|
||||
@@ -0,0 +1,2 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({ test: { include: ['tests/integration/**/*.test.ts'], environment: 'node', env: { DB_NAME: 'model_delivery_test', LOCAL_ADMIN_SETUP_TOKEN: 'isolated-test-bootstrap' }, testTimeout: 15000, hookTimeout: 15000, fileParallelism: false } });
|
||||
Reference in new issue
Block a user