Compare commits

..

4 Commits

Author SHA1 Message Date
Nikolai Giman 5976a03ce0 wip: invoices 2026-09-07 00:30:43 +02:00
Nikolai Giman 3403e1a71d wip: OAuth and mcp 2026-09-06 18:06:35 +02:00
Nikolai Giman e9fb4f6f1e fix: org permissions 2026-08-31 00:54:19 +02:00
Nikolai Giman a173a870d7 feat: OAuth 2026-08-30 23:39:25 +02:00
157 changed files with 12314 additions and 125 deletions
+9
View File
@@ -22,6 +22,15 @@ JWT_ALG=HS256
# SSO: comma-separated email domains that skip DNS/HTTP ownership proof (air-gapped installs)
#SSO_TRUSTED_DOMAINS=company.com,corp.local
# OAuth 2.1 for third-party MCP clients (ChatGPT, Claude connectors).
# Dynamic Client Registration is on by default; a cloud client cannot connect
# without it, since it has no way to pre-register with your instance. Turn it
# off on a private install that only uses manually seeded clients.
#OAUTH_DYNAMIC_REGISTRATION=false
# Public URL of this API. Used as the OAuth issuer in the discovery documents,
# so it must be the URL clients actually reach — set it behind a proxy.
#API_PUBLIC_URL=https://api.taskview.tech
# SMTP Configuration
SMTP_HOST=smtp.domain.com
SMTP_PORT=465
+2
View File
@@ -27,6 +27,7 @@
"@types/luxon": "^3.7.1",
"@types/node": "^22.10.3",
"@types/passport-apple": "^2.0.3",
"@types/pdfmake": "^0.3.3",
"@types/pg": "^8.15.5",
"@types/semver": "^7.5.8",
"@types/ua-parser-js": "^0.7.39",
@@ -68,6 +69,7 @@
"passport-apple": "^2.0.2",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"pdfmake": "^0.3.11",
"pg": "^8.16.3",
"pg-boss": "^12.14.0",
"pino": "^9.4.0",
@@ -0,0 +1,44 @@
import { describe, it, expect, vi } from 'vitest';
import type { Request, Response } from 'express';
import { RequireTokenPermission } from '../require-token-permission';
import { GoalPermissions } from '../../types/auth.types';
const runWith = (tokenPermissions: string[] | undefined) => {
const next = vi.fn();
const end = vi.fn();
const res = { status: vi.fn(() => ({ end })), end } as unknown as Response;
const req = { appUser: { getTokenPermissions: () => tokenPermissions } } as unknown as Request;
RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)(req, res, next);
return { next, res };
};
describe('RequireTokenPermission', () => {
it('lets a browser session through — it carries no token permissions', () => {
const { next, res } = runWith(undefined);
expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
it('lets an unrestricted token through, keeping existing integrations working', () => {
const { next } = runWith([]);
expect(next).toHaveBeenCalled();
});
it('lets a token holding the permission through', () => {
const { next } = runWith([GoalPermissions.ORG_CAN_MANAGE]);
expect(next).toHaveBeenCalled();
});
it('blocks a restricted token that was not given the permission', () => {
const { next, res } = runWith([GoalPermissions.TIMETRACKING_CAN_VIEW]);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
it('does not accept a neighbouring permission from the same group', () => {
const { next, res } = runWith([GoalPermissions.ORG_CAN_MANAGE_MEMBERS]);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(403);
});
});
+2 -1
View File
@@ -4,6 +4,7 @@ import { $logger } from '../modules/logget';
import AuthController from '../tv-modules/auth/AuthController';
import { getApiTokensManager } from '../tv-modules/api-tokens/ApiTokensManager';
import { TOKEN_PREFIX } from '../tv-modules/api-tokens/types';
import { OAUTH_ACCESS_TOKEN_PREFIX } from '../tv-modules/oauth/types';
export const appUserMiddleware = async (req: Request, res: Response, next: NextFunction) => {
if (req.method === 'OPTIONS') {
@@ -13,7 +14,7 @@ export const appUserMiddleware = async (req: Request, res: Response, next: NextF
const token = req.headers['authorization']?.split(' ')[1];
if (token && token.startsWith(TOKEN_PREFIX)) {
if (token && (token.startsWith(TOKEN_PREFIX) || token.startsWith(OAUTH_ACCESS_TOKEN_PREFIX))) {
const record = await getApiTokensManager().validateToken(token);
if (record) {
const authManager = new AppUser().authManager;
@@ -0,0 +1,34 @@
import type { NextFunction, Request, Response } from 'express';
import type { GoalPermissionType } from '../types/auth.types';
/**
* Narrows what a restricted API / OAuth token may do on surfaces that are guarded
* by an organization role or by project ownership rather than by the project RBAC
* — organizations, SSO configuration, webhooks. Those checks never consult
* GoalPermissionsFetcher, so without this the scope chosen when the token was
* issued would simply not apply to them.
*
* It only ever removes access. Put it AFTER the role or ownership guard, so that
* guard still has the final say on what the human behind the token may do:
*
* [IsLoggedIn, IsOrgAdmin, RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)]
*
* A browser session has no token permissions and passes. A token issued with an
* empty permission list is unrestricted by design — the same meaning it carries
* everywhere else — and also passes, which keeps existing integrations working.
*/
export const RequireTokenPermission = (permission: GoalPermissionType) => {
return (req: Request, res: Response, next: NextFunction) => {
const tokenPermissions = req.appUser.getTokenPermissions();
if (!tokenPermissions || tokenPermissions.length === 0) {
return next();
}
if (tokenPermissions.includes(permission)) {
return next();
}
return res.status(403).end();
};
};
+50
View File
@@ -750,5 +750,55 @@
"SSO configs require proving ownership of email_domain_restriction before login is allowed: DNS TXT taskview-sso-verify=<token> or https://<domain>/.well-known/taskview-sso-verify.txt. Air-gapped installs can skip this for listed domains via SSO_TRUSTED_DOMAINS.",
"Replaces the plain UNIQUE(email_domain_restriction) with a partial unique index over verified configs only, so an unverified config can no longer squat a domain and block its real owner — multiple orgs may hold a pending config for the same domain, but only one can verify it (first-to-verify wins)."
]
},
"59": {
"version": "1.64.0",
"name": "OAuth 2.1 authorization server",
"releaseDate": "20260830",
"scripts": [
"/1.64.0/0.create-oauth-clients.sql",
"/1.64.0/1.create-oauth-auth-codes.sql",
"/1.64.0/2.create-oauth-grants.sql",
"/1.64.0/3.alter-api-tokens-grant-id.sql"
],
"description": [
"OAuth 2.1 authorization server so third-party MCP clients (ChatGPT, Claude connectors) can act on a user's behalf without the user pasting a permanent tvk_ API token into them.",
"tv_auth.oauth_clients holds the client registry (manually seeded or created via RFC 7591 Dynamic Client Registration); public clients carry no secret and are authenticated by PKCE S256 alone.",
"tv_auth.oauth_auth_codes holds single-use 60-second authorization codes; tv_auth.oauth_grants is one row per connected app and owns the rotating refresh token, with the previous hash kept to detect replay.",
"Access tokens reuse tv_auth.api_tokens (new grant_id column) so validation, permission intersection and RejectApiTokenAuth all keep working unchanged; revoking a grant cascades to its live access tokens."
]
},
"60": {
"version": "1.65.0",
"name": "Organization-level permissions",
"releaseDate": "20260830",
"scripts": [
"/1.65.0/0.organization-permissions.sql"
],
"description": [
"New permission group 'organization' with org_can_view, org_can_manage, org_can_manage_members, sso_can_manage and webhooks_can_manage.",
"These surfaces were guarded only by an organization role or by project ownership, so they ignored the scope of an API or OAuth token: a token issued with a single permission could still create organizations, add admins, change SSO settings and create webhooks. The new keys make those actions narrowable like every other permission.",
"Backwards compatible: a token with an empty permission list stays unrestricted, so existing integrations keep working."
]
},
"61": {
"version": "1.66.0",
"name": "Billing: currencies, sellers, clients, invoices",
"releaseDate": "20260904",
"scripts": [
"/1.66.0/0.create-currencies.sql",
"/1.66.0/1.create-billing-sellers.sql",
"/1.66.0/2.create-billing-counterparties.sql",
"/1.66.0/3.create-invoices.sql",
"/1.66.0/4.create-invoice-lines.sql",
"/1.66.0/5.billing-permission.sql",
"/1.66.0/6.alter-invoices-lifecycle.sql"
],
"description": [
"New schema tv_billing with the reference table currencies (ISO 4217, seeded with 20 currencies), sellers (the organization's own companies that issue invoices, with bank details and free-form requisites), counterparties (clients that are invoiced) and invoices with invoice_lines.",
"Invoices keep JSONB snapshots of the seller and the client plus the project name, so editing a company, a client or deleting a project never changes an issued document. Companies and clients are archived rather than deleted once they have invoices.",
"New organization-level permission billing_can_manage (group 6) narrows what an API or OAuth token may do on the billing surfaces; access itself requires the organization owner or admin role.",
"Invoice lifecycle: issued_at, paid_at, voided_at, replaces_invoice_id (void and reissue chain), template_version and totals frozen at issue time (subtotal, discount_amount, tax_amount, total). Status transitions are validated by the API; only drafts can be edited or deleted."
]
}
}
@@ -0,0 +1,16 @@
-- OAuth 2.1 client registry. Clients are either seeded manually by an operator
-- or created through Dynamic Client Registration (RFC 7591) when it is enabled.
-- Public clients (MCP clients such as ChatGPT or Claude) hold no secret and are
-- authenticated by PKCE alone, so client_secret_hash stays NULL for them.
CREATE TABLE IF NOT EXISTS tv_auth.oauth_clients (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
client_id VARCHAR(64) NOT NULL UNIQUE,
client_secret_hash VARCHAR(64),
name VARCHAR(200) NOT NULL,
redirect_uris VARCHAR[] NOT NULL DEFAULT '{}',
created_via VARCHAR(16) NOT NULL DEFAULT 'manual',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT oauth_clients_created_via_check CHECK (created_via IN ('manual', 'dcr'))
);
CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON tv_auth.oauth_clients(client_id);
@@ -0,0 +1,24 @@
-- Short-lived, single-use authorization codes issued by the consent screen and
-- redeemed once at the token endpoint. Only the hash is stored, mirroring
-- tv_auth.api_tokens. used_at is set on redemption: a second redemption of the
-- same code is treated as replay and revokes the grant it produced.
CREATE TABLE IF NOT EXISTS tv_auth.oauth_auth_codes (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
code_hash VARCHAR(64) NOT NULL UNIQUE,
client_id VARCHAR(64) NOT NULL REFERENCES tv_auth.oauth_clients(client_id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
redirect_uri VARCHAR NOT NULL,
code_challenge VARCHAR(128) NOT NULL,
code_challenge_method VARCHAR(8) NOT NULL DEFAULT 'S256',
allowed_permissions VARCHAR[] NOT NULL DEFAULT '{}',
allowed_goal_ids INTEGER[] NOT NULL DEFAULT '{}',
resource VARCHAR,
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
grant_id INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT oauth_auth_codes_challenge_method_check CHECK (code_challenge_method = 'S256')
);
CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_code_hash ON tv_auth.oauth_auth_codes(code_hash);
CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_expires_at ON tv_auth.oauth_auth_codes(expires_at);
@@ -0,0 +1,33 @@
-- One row per (user, client) authorization — this is what the user sees and
-- revokes as a "connected app". The refresh token hangs off the grant and is
-- rotated on every use; refresh_token_prev_hash keeps the previous value so a
-- replayed refresh token can be detected and the whole grant revoked.
CREATE TABLE IF NOT EXISTS tv_auth.oauth_grants (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
user_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
client_id VARCHAR(64) NOT NULL REFERENCES tv_auth.oauth_clients(client_id) ON DELETE CASCADE,
allowed_permissions VARCHAR[] NOT NULL DEFAULT '{}',
allowed_goal_ids INTEGER[] NOT NULL DEFAULT '{}',
resource VARCHAR,
refresh_token_hash VARCHAR(64) UNIQUE,
refresh_token_prev_hash VARCHAR(64),
refresh_expires_at TIMESTAMP,
last_used_at TIMESTAMP,
revoked_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_oauth_grants_user_id ON tv_auth.oauth_grants(user_id);
CREATE INDEX IF NOT EXISTS idx_oauth_grants_refresh_token_hash ON tv_auth.oauth_grants(refresh_token_hash);
CREATE INDEX IF NOT EXISTS idx_oauth_grants_prev_refresh_hash ON tv_auth.oauth_grants(refresh_token_prev_hash);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'oauth_auth_codes_grant_id_fkey'
) THEN
ALTER TABLE tv_auth.oauth_auth_codes
ADD CONSTRAINT oauth_auth_codes_grant_id_fkey
FOREIGN KEY (grant_id) REFERENCES tv_auth.oauth_grants(id) ON DELETE SET NULL;
END IF;
END $$;
@@ -0,0 +1,19 @@
-- OAuth access tokens live in tv_auth.api_tokens alongside manually issued
-- tvk_ tokens: same opaque-token storage, same validation path, same permission
-- intersection. grant_id ties an access token to the OAuth grant that minted it,
-- so revoking a connected app deletes its live access tokens immediately.
ALTER TABLE tv_auth.api_tokens
ADD COLUMN IF NOT EXISTS grant_id INTEGER;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'api_tokens_grant_id_fkey'
) THEN
ALTER TABLE tv_auth.api_tokens
ADD CONSTRAINT api_tokens_grant_id_fkey
FOREIGN KEY (grant_id) REFERENCES tv_auth.oauth_grants(id) ON DELETE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_api_tokens_grant_id ON tv_auth.api_tokens(grant_id);
@@ -0,0 +1,86 @@
-- Permissions for the surfaces that were previously guarded only by an
-- organization role or by project ownership, and therefore ignored the scope of
-- an API / OAuth token entirely: a token issued with a single permission could
-- still create organizations, add admins, configure SSO and create webhooks.
--
-- These keys let a token be narrowed on those actions too. They never grant
-- anything: the role and ownership checks still run first, and RequireTokenPermission
-- only removes what the token was not given.
INSERT INTO tv_auth.permissions_group (id, name)
VALUES (6, 'organization')
ON CONFLICT (id) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'org_can_view',
'View the organization and its members',
6,
'{
"en": "View organization. See the organization and the list of its members.",
"ru": "Просмотр организации. Видеть организацию и список её участников.",
"de": "Organisation ansehen. Die Organisation und ihre Mitglieder sehen.",
"es": "Ver la organización. Ver la organización y la lista de sus miembros.",
"pt-BR": "Ver a organização. Ver a organização e a lista de seus membros."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'org_can_manage',
'Create, rename and delete organizations',
6,
'{
"en": "Manage organizations. Create, rename and delete organizations.",
"ru": "Управление организациями. Создавать, переименовывать и удалять организации.",
"de": "Organisationen verwalten. Organisationen erstellen, umbenennen und löschen.",
"es": "Gestionar organizaciones. Crear, renombrar y eliminar organizaciones.",
"pt-BR": "Gerenciar organizações. Criar, renomear e excluir organizações."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'org_can_manage_members',
'Add and remove organization members and change their roles',
6,
'{
"en": "Manage members. Add and remove organization members and change their roles.",
"ru": "Управление участниками. Добавлять и удалять участников организации, менять их роли.",
"de": "Mitglieder verwalten. Mitglieder hinzufügen, entfernen und deren Rollen ändern.",
"es": "Gestionar miembros. Añadir y quitar miembros de la organización y cambiar sus roles.",
"pt-BR": "Gerenciar membros. Adicionar e remover membros da organização e alterar seus papéis."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'sso_can_manage',
'Create and change the single sign-on configuration',
6,
'{
"en": "Manage SSO. Create and change the single sign-on configuration of the organization.",
"ru": "Управление SSO. Создавать и изменять настройки единого входа организации.",
"de": "SSO verwalten. Die Single-Sign-on-Konfiguration der Organisation erstellen und ändern.",
"es": "Gestionar SSO. Crear y cambiar la configuración de inicio de sesión único de la organización.",
"pt-BR": "Gerenciar SSO. Criar e alterar a configuração de login único da organização."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'webhooks_can_manage',
'Create, edit and delete project webhooks',
6,
'{
"en": "Manage webhooks. Create, edit and delete webhooks of a project.",
"ru": "Управление вебхуками. Создавать, изменять и удалять вебхуки проекта.",
"de": "Webhooks verwalten. Webhooks eines Projekts erstellen, bearbeiten und löschen.",
"es": "Gestionar webhooks. Crear, editar y eliminar webhooks de un proyecto.",
"pt-BR": "Gerenciar webhooks. Criar, editar e excluir webhooks de um projeto."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
@@ -0,0 +1,45 @@
-- Reference list of currencies (ISO 4217) for anything that carries money:
-- invoices, counterparties, seller profiles. Names are stored in English only;
-- the UI localises them through Intl.DisplayNames by code. decimal_digits
-- drives amount formatting (JPY and KRW have none). Rows are never deleted,
-- only deactivated, so existing references stay valid.
CREATE SCHEMA IF NOT EXISTS tv_billing;
CREATE TABLE IF NOT EXISTS tv_billing.currencies (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
code CHAR(3) NOT NULL UNIQUE,
numeric_code SMALLINT NOT NULL UNIQUE,
name VARCHAR(64) NOT NULL,
symbol VARCHAR(8) NOT NULL,
decimal_digits SMALLINT NOT NULL DEFAULT 2,
sort_order SMALLINT NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT currencies_code_upper_check CHECK (code = UPPER(code)),
CONSTRAINT currencies_decimal_digits_check CHECK (decimal_digits BETWEEN 0 AND 4)
);
CREATE INDEX IF NOT EXISTS idx_currencies_active_sort ON tv_billing.currencies(is_active, sort_order);
INSERT INTO tv_billing.currencies (code, numeric_code, name, symbol, decimal_digits, sort_order) VALUES
('USD', 840, 'US Dollar', '$', 2, 10),
('EUR', 978, 'Euro', '', 2, 20),
('GBP', 826, 'Pound Sterling', '£', 2, 30),
('JPY', 392, 'Japanese Yen', '¥', 0, 40),
('CNY', 156, 'Chinese Yuan', '¥', 2, 50),
('CHF', 756, 'Swiss Franc', 'CHF', 2, 60),
('CAD', 124, 'Canadian Dollar', 'CA$', 2, 70),
('AUD', 36, 'Australian Dollar', 'A$', 2, 80),
('RUB', 643, 'Russian Ruble', '', 2, 25),
('INR', 356, 'Indian Rupee', '', 2, 100),
('BRL', 986, 'Brazilian Real', 'R$', 2, 110),
('KRW', 410, 'South Korean Won', '', 0, 120),
('SGD', 702, 'Singapore Dollar', 'S$', 2, 130),
('HKD', 344, 'Hong Kong Dollar', 'HK$', 2, 140),
('SEK', 752, 'Swedish Krona', 'kr', 2, 150),
('NOK', 578, 'Norwegian Krone', 'kr', 2, 160),
('DKK', 208, 'Danish Krone', 'kr', 2, 170),
('PLN', 985, 'Polish Zloty', '', 2, 180),
('TRY', 949, 'Turkish Lira', '', 2, 190),
('AED', 784, 'UAE Dirham', 'د.إ', 2, 200)
ON CONFLICT (code) DO NOTHING;
@@ -0,0 +1,20 @@
CREATE TABLE IF NOT EXISTS tv_billing.sellers (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
legal_name VARCHAR(300) NOT NULL DEFAULT '',
address VARCHAR(1000) NOT NULL DEFAULT '',
email VARCHAR(320) NOT NULL DEFAULT '',
phone VARCHAR(50) NOT NULL DEFAULT '',
logo_url VARCHAR(1000) NOT NULL DEFAULT '',
currency_code CHAR(3) NOT NULL DEFAULT 'USD' REFERENCES tv_billing.currencies(code),
bank JSONB NOT NULL DEFAULT '{}'::jsonb,
requisites JSONB NOT NULL DEFAULT '[]'::jsonb,
default_terms VARCHAR(2000) NOT NULL DEFAULT '',
tax_note VARCHAR(500) NOT NULL DEFAULT '',
archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_billing_sellers_org ON tv_billing.sellers(organization_id, archived);
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS tv_billing.counterparties (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
kind VARCHAR(20) NOT NULL DEFAULT 'organization',
name VARCHAR(200) NOT NULL,
legal_name VARCHAR(300) NOT NULL DEFAULT '',
address VARCHAR(1000) NOT NULL DEFAULT '',
email VARCHAR(320) NOT NULL DEFAULT '',
phone VARCHAR(50) NOT NULL DEFAULT '',
contact_person VARCHAR(200) NOT NULL DEFAULT '',
requisites JSONB NOT NULL DEFAULT '[]'::jsonb,
archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT counterparties_kind_check CHECK (kind IN ('organization', 'person'))
);
CREATE INDEX IF NOT EXISTS idx_billing_counterparties_org ON tv_billing.counterparties(organization_id, archived);
@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS tv_billing.invoices (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
organization_id INTEGER NOT NULL REFERENCES tv_auth.organizations(id) ON DELETE CASCADE,
goal_id INTEGER REFERENCES tasks.goals(id) ON DELETE SET NULL,
goal_name VARCHAR(500) NOT NULL DEFAULT '',
seller_id INTEGER NOT NULL REFERENCES tv_billing.sellers(id) ON DELETE RESTRICT,
counterparty_id INTEGER NOT NULL REFERENCES tv_billing.counterparties(id) ON DELETE RESTRICT,
number VARCHAR(50) NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'draft',
reference VARCHAR(200) NOT NULL DEFAULT '',
currency_code CHAR(3) NOT NULL REFERENCES tv_billing.currencies(code),
issue_date DATE NOT NULL,
payment_terms VARCHAR(20) NOT NULL DEFAULT 'net14',
due_date DATE,
period_from DATE,
period_to DATE,
discount_type VARCHAR(10) NOT NULL DEFAULT 'percent',
discount_value NUMERIC(12, 2) NOT NULL DEFAULT 0,
tax_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
tax_exempt BOOLEAN NOT NULL DEFAULT FALSE,
tax_note VARCHAR(500) NOT NULL DEFAULT '',
notes VARCHAR(2000) NOT NULL DEFAULT '',
terms VARCHAR(2000) NOT NULL DEFAULT '',
seller_snapshot JSONB NOT NULL,
counterparty_snapshot JSONB NOT NULL,
created_by INTEGER REFERENCES tv_auth.users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT invoices_status_check CHECK (status IN ('draft', 'issued', 'paid', 'void')),
CONSTRAINT invoices_discount_type_check CHECK (discount_type IN ('percent', 'amount')),
CONSTRAINT invoices_payment_terms_check CHECK (payment_terms IN ('on_receipt', 'net7', 'net14', 'net30', 'custom')),
CONSTRAINT invoices_number_per_org UNIQUE (organization_id, number)
);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_org_issue ON tv_billing.invoices(organization_id, issue_date DESC);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_counterparty ON tv_billing.invoices(counterparty_id);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_seller ON tv_billing.invoices(seller_id);
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS tv_billing.invoice_lines (
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
invoice_id INTEGER NOT NULL REFERENCES tv_billing.invoices(id) ON DELETE CASCADE,
position SMALLINT NOT NULL DEFAULT 0,
task_id INTEGER REFERENCES tasks.tasks(id) ON DELETE SET NULL,
description VARCHAR(1000) NOT NULL,
unit VARCHAR(20) NOT NULL DEFAULT 'service',
quantity NUMERIC(12, 2) NOT NULL DEFAULT 1,
unit_price NUMERIC(12, 2) NOT NULL DEFAULT 0,
CONSTRAINT invoice_lines_unit_check CHECK (unit IN ('service', 'hours', 'pcs'))
);
CREATE INDEX IF NOT EXISTS idx_billing_invoice_lines_invoice ON tv_billing.invoice_lines(invoice_id, position);
@@ -0,0 +1,14 @@
INSERT INTO tv_auth.permissions (name, description, permission_group, description_locales)
VALUES (
'billing_can_manage',
'Manage invoices, clients and seller companies of the organization',
6,
'{
"en": "Manage billing. Create and edit invoices, clients and seller companies of the organization.",
"ru": "Управление биллингом. Создавать и редактировать инвойсы, клиентов и компании-продавцы организации.",
"de": "Abrechnung verwalten. Rechnungen, Kunden und Firmen der Organisation erstellen und bearbeiten.",
"es": "Gestionar facturación. Crear y editar facturas, clientes y empresas de la organización.",
"pt-BR": "Gerenciar faturamento. Criar e editar faturas, clientes e empresas da organização."
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
@@ -0,0 +1,11 @@
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS issued_at TIMESTAMP;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS paid_at TIMESTAMP;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS voided_at TIMESTAMP;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS replaces_invoice_id INTEGER REFERENCES tv_billing.invoices(id) ON DELETE SET NULL;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS template_version SMALLINT NOT NULL DEFAULT 1;
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS subtotal NUMERIC(12, 2);
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS discount_amount NUMERIC(12, 2);
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS tax_amount NUMERIC(12, 2);
ALTER TABLE tv_billing.invoices ADD COLUMN IF NOT EXISTS total NUMERIC(12, 2);
CREATE INDEX IF NOT EXISTS idx_billing_invoices_replaces ON tv_billing.invoices(replaces_invoice_id);
+8
View File
@@ -8,6 +8,8 @@ import NotificationsRoutes from '../tv-modules/notifications/NotificationsRoutes
import WebhooksRoutes from '../tv-modules/webhooks/WebhooksRoutes';
import MessagingRoutes from '../tv-modules/messaging/MessagingRoutes';
import ApiTokensRoutes from '../tv-modules/api-tokens/ApiTokensRoutes';
import OAuthRoutes from '../tv-modules/oauth/OAuthRoutes';
import OAuthWellKnownRoutes from '../tv-modules/oauth/OAuthWellKnownRoutes';
import SessionsRoutes from '../tv-modules/sessions/SessionsRoutes';
import KanbanRoutes from '../tv-modules/kanban/KanbanRoutes';
import GoalListRoutes from '../tv-modules/lists/GoalListRoutes';
@@ -22,6 +24,8 @@ import TimeTrackingRoutes from '../tv-modules/time-tracking/TimeTrackingRoutes';
import UiPreferencesRoutes from '../tv-modules/ui-preferences/UiPreferencesRoutes';
import SprintsRoutes from '../tv-modules/sprints/SprintsRoutes';
import RecurrenceRoutes from '../tv-modules/recurrence/RecurrenceRoutes';
import BillingRoutes from '../tv-modules/billing/BillingRoutes';
import InvoicesRoutes from '../tv-modules/invoices/InvoicesRoutes';
import type { Routable } from '../types/routable.type';
type RoutableConstructor = new (...args: any[]) => Routable;
@@ -42,6 +46,7 @@ const routes: Record<string, RoutableConstructor> = {
'/module/webhooks': WebhooksRoutes,
'/module/messaging': MessagingRoutes,
'/module/api-tokens': ApiTokensRoutes,
'/module/oauth': OAuthRoutes,
'/module/sessions': SessionsRoutes,
'/module/organizations': OrganizationRoutes,
'/module/sso': SsoRoutes,
@@ -50,7 +55,10 @@ const routes: Record<string, RoutableConstructor> = {
'/module/ui-preferences': UiPreferencesRoutes,
'/module/sprints': SprintsRoutes,
'/module/recurrence': RecurrenceRoutes,
'/module/billing': BillingRoutes,
'/module/invoices': InvoicesRoutes,
'/scim/v2': ScimRoutes,
'/.well-known': OAuthWellKnownRoutes,
};
export default routes;
@@ -2,7 +2,6 @@ import type { Request, Response } from 'express';
import { ArkErrors } from 'arktype';
import { getApiTokensManager } from './ApiTokensManager';
import { ApiTokenArkTypeCreate, ApiTokenArkTypeDelete } from './types';
import { Database } from '../../modules/db';
export class ApiTokensController {
private get manager() { return getApiTokensManager(); }
@@ -44,10 +43,7 @@ export class ApiTokensController {
};
fetchPermissions = async (_req: Request, res: Response) => {
const db = Database.getInstance();
const result = await db.query<{ id: number; name: string; description: string; permissionGroup: number }>(
`SELECT id, name, description, permission_group as "permissionGroup" FROM tv_auth.permissions WHERE permission_group <> 1 ORDER BY permission_group, id`
);
return res.tvJson(result?.rows ?? []);
const result = await this.manager.fetchSelectablePermissions();
return res.tvJson(result);
};
}
@@ -1,7 +1,7 @@
import { randomBytes, createHash } from 'crypto';
import { ApiTokensRepository } from './ApiTokensRepository';
import { TOKEN_PREFIX, type ApiTokenArgCreate } from './types';
import type { ApiTokensSchemaTypeForSelect } from 'taskview-db-schemas';
import type { ApiTokensSchemaTypeForSelect, PermissionsSchemaTypeForSelect } from 'taskview-db-schemas';
export type ApiTokenForClient = Omit<ApiTokensSchemaTypeForSelect, 'tokenHash'>;
@@ -42,6 +42,10 @@ export class ApiTokensManager {
return tokens.map((t) => this.toClient(t));
}
async fetchSelectablePermissions(): Promise<PermissionsSchemaTypeForSelect[]> {
return this.repository.fetchSelectablePermissions();
}
async validateToken(fullToken: string): Promise<ApiTokensSchemaTypeForSelect | null> {
const tokenHash = createHash('sha256').update(fullToken).digest('hex');
const record = await this.repository.findByTokenHash(tokenHash);
@@ -1,5 +1,10 @@
import { and, eq } from 'drizzle-orm';
import { ApiTokensSchema, type ApiTokensSchemaTypeForSelect } from 'taskview-db-schemas';
import { and, asc, eq, isNull, ne } from 'drizzle-orm';
import {
ApiTokensSchema,
PermissionsSchema,
type ApiTokensSchemaTypeForSelect,
type PermissionsSchemaTypeForSelect,
} from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { callWithCatch } from '../../utils/helpers';
@@ -20,15 +25,21 @@ export class ApiTokensRepository {
async delete(id: number, userId: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(ApiTokensSchema).where(
and(eq(ApiTokensSchema.id, id), eq(ApiTokensSchema.userId, userId))
and(eq(ApiTokensSchema.id, id), eq(ApiTokensSchema.userId, userId), isNull(ApiTokensSchema.grantId))
)
);
return !!result?.rowCount;
}
/**
* Only manually issued tokens. OAuth access tokens live in the same table but
* belong to a grant - they are listed and revoked as connected apps instead.
*/
async fetchByUserId(userId: number): Promise<ApiTokensSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(ApiTokensSchema).where(eq(ApiTokensSchema.userId, userId))
this.db.dbDrizzle.select().from(ApiTokensSchema).where(
and(eq(ApiTokensSchema.userId, userId), isNull(ApiTokensSchema.grantId))
)
);
return result ?? [];
}
@@ -40,6 +51,21 @@ export class ApiTokensRepository {
return result?.[0] ?? null;
}
/**
* Permissions offered when scoping a token. Group 1 is excluded: those keys
* exist in the table but are enforced nowhere in the code, so offering them
* would promise a restriction that never happens.
*/
async fetchSelectablePermissions(): Promise<PermissionsSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select()
.from(PermissionsSchema)
.where(ne(PermissionsSchema.permissionGroup, 1))
.orderBy(asc(PermissionsSchema.permissionGroup), asc(PermissionsSchema.id))
);
return result ?? [];
}
async updateLastUsedAt(id: number): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle.update(ApiTokensSchema)
@@ -0,0 +1,107 @@
import type { Request, Response } from 'express'
import { ArkErrors } from 'arktype'
import { BillingManager } from './BillingManager'
import {
BillingArkTypeArchive,
BillingArkTypeId,
BillingArkTypeList,
CounterpartyArkTypeCreate,
CounterpartyArkTypeUpdate,
SellerArkTypeCreate,
SellerArkTypeUpdate,
type DeleteResult,
} from './types'
const DELETE_STATUS: Record<DeleteResult, number> = { deleted: 200, in_use: 409, not_found: 404 }
export class BillingController {
private readonly manager = new BillingManager()
currencies = async (_req: Request, res: Response) => {
return res.tvJson(await this.manager.fetchCurrencies())
}
fetchSellers = async (req: Request, res: Response) => {
const data = BillingArkTypeList(req.query)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
return res.tvJson(await this.manager.fetchSellers(data))
}
createSeller = async (req: Request, res: Response) => {
const data = SellerArkTypeCreate(req.body)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.createSeller(data)
if (!result) return res.status(500).end()
return res.tvJson(result)
}
updateSeller = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
const data = SellerArkTypeUpdate(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.updateSeller({ sellerId: id.id, data })
if (!result) return res.status(404).end()
return res.tvJson(result)
}
archiveSeller = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
const data = BillingArkTypeArchive(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.setSellerArchived({ id: id.id, archived: data.archived })
if (!result) return res.status(404).end()
return res.tvJson(result)
}
deleteSeller = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.deleteSeller(id.id)
if (result !== 'deleted') return res.status(DELETE_STATUS[result]).end()
return res.tvJson(true)
}
fetchCounterparties = async (req: Request, res: Response) => {
const data = BillingArkTypeList(req.query)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
return res.tvJson(await this.manager.fetchCounterparties(data))
}
createCounterparty = async (req: Request, res: Response) => {
const data = CounterpartyArkTypeCreate(req.body)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.createCounterparty(data)
if (!result) return res.status(500).end()
return res.tvJson(result)
}
updateCounterparty = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
const data = CounterpartyArkTypeUpdate(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.updateCounterparty({ counterpartyId: id.id, data })
if (!result) return res.status(404).end()
return res.tvJson(result)
}
archiveCounterparty = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
const data = BillingArkTypeArchive(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.setCounterpartyArchived({ id: id.id, archived: data.archived })
if (!result) return res.status(404).end()
return res.tvJson(result)
}
deleteCounterparty = async (req: Request, res: Response) => {
const id = BillingArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.deleteCounterparty(id.id)
if (result !== 'deleted') return res.status(DELETE_STATUS[result]).end()
return res.tvJson(true)
}
}
@@ -0,0 +1,74 @@
import { BillingRepository } from './BillingRepository'
import type {
BillingArgList,
CounterpartyArgCreate,
CounterpartyForClient,
CounterpartyUpdateArgs,
DeleteResult,
SellerArgCreate,
SellerForClient,
SellerUpdateArgs,
SetArchivedArgs,
} from './types'
export class BillingManager {
public readonly repository: BillingRepository
constructor() {
this.repository = new BillingRepository()
}
fetchCurrencies() {
return this.repository.fetchCurrencies()
}
fetchSellers(args: BillingArgList): Promise<SellerForClient[]> {
return this.repository.fetchSellers(args)
}
fetchSellerById(sellerId: number): Promise<SellerForClient | null> {
return this.repository.fetchSellerById(sellerId)
}
createSeller(data: SellerArgCreate): Promise<SellerForClient | null> {
return this.repository.createSeller(data)
}
updateSeller(args: SellerUpdateArgs): Promise<SellerForClient | null> {
return this.repository.updateSeller(args)
}
setSellerArchived(args: SetArchivedArgs): Promise<SellerForClient | null> {
return this.repository.setSellerArchived(args)
}
async deleteSeller(sellerId: number): Promise<DeleteResult> {
if ((await this.repository.countInvoicesBySeller(sellerId)) > 0) return 'in_use'
return (await this.repository.deleteSeller(sellerId)) ? 'deleted' : 'not_found'
}
fetchCounterparties(args: BillingArgList): Promise<CounterpartyForClient[]> {
return this.repository.fetchCounterparties(args)
}
fetchCounterpartyById(counterpartyId: number): Promise<CounterpartyForClient | null> {
return this.repository.fetchCounterpartyById(counterpartyId)
}
createCounterparty(data: CounterpartyArgCreate): Promise<CounterpartyForClient | null> {
return this.repository.createCounterparty(data)
}
updateCounterparty(args: CounterpartyUpdateArgs): Promise<CounterpartyForClient | null> {
return this.repository.updateCounterparty(args)
}
setCounterpartyArchived(args: SetArchivedArgs): Promise<CounterpartyForClient | null> {
return this.repository.setCounterpartyArchived(args)
}
async deleteCounterparty(counterpartyId: number): Promise<DeleteResult> {
if ((await this.repository.countInvoicesByCounterparty(counterpartyId)) > 0) return 'in_use'
return (await this.repository.deleteCounterparty(counterpartyId)) ? 'deleted' : 'not_found'
}
}
@@ -0,0 +1,199 @@
import { and, asc, count, eq } from 'drizzle-orm'
import {
CounterpartiesSchema,
CurrenciesSchema,
InvoicesSchema,
SellersSchema,
type CounterpartiesSchemaTypeForSelect,
type CurrenciesSchemaTypeForSelect,
type SellersSchemaTypeForSelect,
} from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch } from '../../utils/helpers'
import type {
BillingArgList,
CounterpartyArgCreate,
CounterpartyArgUpdate,
CounterpartyUpdateArgs,
SellerArgCreate,
SellerArgUpdate,
SellerUpdateArgs,
SetArchivedArgs,
} from './types'
export class BillingRepository {
private readonly db: Database
constructor() {
this.db = Database.getInstance()
}
async fetchCurrencies(): Promise<CurrenciesSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(CurrenciesSchema)
.where(eq(CurrenciesSchema.isActive, true))
.orderBy(asc(CurrenciesSchema.sortOrder)),
)
return result ?? []
}
async fetchSellers({ organizationId, includeArchived }: BillingArgList): Promise<SellersSchemaTypeForSelect[]> {
const conditions = [eq(SellersSchema.organizationId, organizationId)]
if (!includeArchived) conditions.push(eq(SellersSchema.archived, false))
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(SellersSchema).where(and(...conditions)).orderBy(asc(SellersSchema.name)),
)
return result ?? []
}
async fetchSellerById(sellerId: number): Promise<SellersSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(SellersSchema).where(eq(SellersSchema.id, sellerId)),
)
return result?.[0] ?? null
}
private sellerColumns(data: SellerArgUpdate) {
return {
name: data.name,
legalName: data.legalName,
address: data.address,
email: data.email,
phone: data.phone,
logoUrl: data.logoUrl,
currencyCode: data.currencyCode,
bank: data.bank,
requisites: data.requisites,
defaultTerms: data.defaultTerms,
taxNote: data.taxNote,
}
}
private counterpartyColumns(data: CounterpartyArgUpdate) {
return {
kind: data.kind,
name: data.name,
legalName: data.legalName,
address: data.address,
email: data.email,
phone: data.phone,
contactPerson: data.contactPerson,
requisites: data.requisites,
}
}
async createSeller(data: SellerArgCreate): Promise<SellersSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(SellersSchema)
.values({ organizationId: data.organizationId, ...this.sellerColumns(data) })
.returning(),
)
return result?.[0] ?? null
}
async updateSeller({ sellerId, data }: SellerUpdateArgs): Promise<SellersSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(SellersSchema)
.set({ ...this.sellerColumns(data), updatedAt: new Date() })
.where(eq(SellersSchema.id, sellerId))
.returning(),
)
return result?.[0] ?? null
}
async setSellerArchived({ id, archived }: SetArchivedArgs): Promise<SellersSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(SellersSchema)
.set({ archived, updatedAt: new Date() })
.where(eq(SellersSchema.id, id))
.returning(),
)
return result?.[0] ?? null
}
async deleteSeller(sellerId: number): Promise<boolean> {
const result = await callWithCatch(() => this.db.dbDrizzle.delete(SellersSchema).where(eq(SellersSchema.id, sellerId)))
return !!result?.rowCount
}
async countInvoicesBySeller(sellerId: number): Promise<number> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select({ total: count() }).from(InvoicesSchema).where(eq(InvoicesSchema.sellerId, sellerId)),
)
return result?.[0]?.total ?? 0
}
async fetchCounterparties({ organizationId, includeArchived }: BillingArgList): Promise<CounterpartiesSchemaTypeForSelect[]> {
const conditions = [eq(CounterpartiesSchema.organizationId, organizationId)]
if (!includeArchived) conditions.push(eq(CounterpartiesSchema.archived, false))
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(CounterpartiesSchema)
.where(and(...conditions))
.orderBy(asc(CounterpartiesSchema.name)),
)
return result ?? []
}
async fetchCounterpartyById(counterpartyId: number): Promise<CounterpartiesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(CounterpartiesSchema).where(eq(CounterpartiesSchema.id, counterpartyId)),
)
return result?.[0] ?? null
}
async createCounterparty(data: CounterpartyArgCreate): Promise<CounterpartiesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.insert(CounterpartiesSchema)
.values({ organizationId: data.organizationId, ...this.counterpartyColumns(data) })
.returning(),
)
return result?.[0] ?? null
}
async updateCounterparty({ counterpartyId, data }: CounterpartyUpdateArgs): Promise<CounterpartiesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(CounterpartiesSchema)
.set({ ...this.counterpartyColumns(data), updatedAt: new Date() })
.where(eq(CounterpartiesSchema.id, counterpartyId))
.returning(),
)
return result?.[0] ?? null
}
async setCounterpartyArchived({ id, archived }: SetArchivedArgs): Promise<CounterpartiesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(CounterpartiesSchema)
.set({ archived, updatedAt: new Date() })
.where(eq(CounterpartiesSchema.id, id))
.returning(),
)
return result?.[0] ?? null
}
async deleteCounterparty(counterpartyId: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(CounterpartiesSchema).where(eq(CounterpartiesSchema.id, counterpartyId)),
)
return !!result?.rowCount
}
async countInvoicesByCounterparty(counterpartyId: number): Promise<number> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ total: count() })
.from(InvoicesSchema)
.where(eq(InvoicesSchema.counterpartyId, counterpartyId)),
)
return result?.[0]?.total ?? 0
}
}
@@ -0,0 +1,41 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin'
import { RequireTokenPermission } from '../../middlewares/require-token-permission'
import { GoalPermissions } from '../../types/auth.types'
import { BillingController } from './BillingController'
import { isOrgAdminForCounterparty, isOrgAdminForSeller } from './middlewares/is-org-admin-for-billing'
export default class BillingRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: BillingController
constructor() {
this.router = Router()
this.controller = new BillingController()
this.initRoutes()
}
getRouter() {
return this.router
}
private initRoutes() {
const canManage = RequireTokenPermission(GoalPermissions.BILLING_CAN_MANAGE)
this.router.get('/currencies', [IsLoggedIn], this.controller.currencies)
this.router.get('/sellers', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.fetchSellers)
this.router.post('/sellers', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.createSeller)
this.router.patch('/sellers/:id', [IsLoggedIn, isOrgAdminForSeller, canManage], this.controller.updateSeller)
this.router.patch('/sellers/:id/archive', [IsLoggedIn, isOrgAdminForSeller, canManage], this.controller.archiveSeller)
this.router.delete('/sellers/:id', [IsLoggedIn, isOrgAdminForSeller, canManage], this.controller.deleteSeller)
this.router.get('/counterparties', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.fetchCounterparties)
this.router.post('/counterparties', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.createCounterparty)
this.router.patch('/counterparties/:id', [IsLoggedIn, isOrgAdminForCounterparty, canManage], this.controller.updateCounterparty)
this.router.patch('/counterparties/:id/archive', [IsLoggedIn, isOrgAdminForCounterparty, canManage], this.controller.archiveCounterparty)
this.router.delete('/counterparties/:id', [IsLoggedIn, isOrgAdminForCounterparty, canManage], this.controller.deleteCounterparty)
}
}
@@ -0,0 +1,10 @@
import { BillingRepository } from '../BillingRepository'
import { isOrgAdminFor } from './is-org-admin-for'
const repository = new BillingRepository()
export const isOrgAdminForSeller = isOrgAdminFor(async (id) => (await repository.fetchSellerById(id))?.organizationId ?? null)
export const isOrgAdminForCounterparty = isOrgAdminFor(
async (id) => (await repository.fetchCounterpartyById(id))?.organizationId ?? null,
)
@@ -0,0 +1,20 @@
import type { NextFunction, Request, Response } from 'express'
import { ORG_ADMIN_ROLES, type OrgRole } from '../../organizations/types'
import { parsePositiveInt } from '../../../utils/helpers'
export type OrganizationIdResolver = (id: number) => Promise<number | null>
export const isOrgAdminFor = (resolveOrganizationId: OrganizationIdResolver) => {
return async (req: Request, res: Response, next: NextFunction) => {
const id = parsePositiveInt(req.params.id)
if (id === null) return res.status(400).end()
const organizationId = await resolveOrganizationId(id)
if (organizationId === null) return res.status(404).end()
const member = await req.appUser.organizationManager.getCurrentUserMember(organizationId)
if (!member || !ORG_ADMIN_ROLES.includes(member.role as OrgRole)) return res.status(403).end()
return next()
}
}
+103
View File
@@ -0,0 +1,103 @@
import { type } from 'arktype'
import type {
BillingBankDetails,
BillingRequisite,
CounterpartiesSchemaTypeForSelect,
SellersSchemaTypeForSelect,
} from 'taskview-db-schemas'
const NumberFromString = type('string|number').pipe((v) => Number(v))
const BooleanFromString = type('string|boolean|undefined').pipe((v) => {
if (v === undefined) return undefined
if (typeof v === 'boolean') return v
return v === 'true' || v === '1'
})
export const RequisiteArkType = type({
key: 'string<=64',
label: 'string<=100',
value: 'string<=300',
})
export const BankDetailsArkType = type({
bankName: 'string<=200',
accountNumber: 'string<=64',
iban: 'string<=64',
swift: 'string<=32',
correspondentAccount: 'string<=64',
})
export const SellerArkTypeCreate = type({
organizationId: 'number',
name: '1<=string<=200',
legalName: 'string<=300',
address: 'string<=1000',
email: 'string<=320',
phone: 'string<=50',
logoUrl: 'string<=1000',
currencyCode: /^[A-Z]{3}$/,
bank: BankDetailsArkType,
requisites: RequisiteArkType.array(),
defaultTerms: 'string<=2000',
taxNote: 'string<=500',
})
export type SellerArgCreate = typeof SellerArkTypeCreate.infer
export const SellerArkTypeUpdate = SellerArkTypeCreate.omit('organizationId')
export type SellerArgUpdate = typeof SellerArkTypeUpdate.infer
export const CounterpartyArkTypeCreate = type({
organizationId: 'number',
kind: "'organization' | 'person'",
name: '1<=string<=200',
legalName: 'string<=300',
address: 'string<=1000',
email: 'string<=320',
phone: 'string<=50',
contactPerson: 'string<=200',
requisites: RequisiteArkType.array(),
})
export type CounterpartyArgCreate = typeof CounterpartyArkTypeCreate.infer
export const CounterpartyArkTypeUpdate = CounterpartyArkTypeCreate.omit('organizationId')
export type CounterpartyArgUpdate = typeof CounterpartyArkTypeUpdate.infer
export const BillingArkTypeList = type({
organizationId: NumberFromString,
'includeArchived?': BooleanFromString,
})
export type BillingArgList = typeof BillingArkTypeList.infer
export const BillingArkTypeId = type({
id: NumberFromString,
})
export const BillingArkTypeArchive = type({
archived: 'boolean',
})
export type SellerUpdateArgs = {
sellerId: number
data: SellerArgUpdate
}
export type CounterpartyUpdateArgs = {
counterpartyId: number
data: CounterpartyArgUpdate
}
export type SetArchivedArgs = {
id: number
archived: boolean
}
export type SellerForClient = Omit<SellersSchemaTypeForSelect, 'bank' | 'requisites'> & {
bank: BillingBankDetails
requisites: BillingRequisite[]
}
export type CounterpartyForClient = Omit<CounterpartiesSchemaTypeForSelect, 'requisites'> & {
requisites: BillingRequisite[]
}
export type DeleteResult = 'deleted' | 'in_use' | 'not_found'
@@ -0,0 +1,233 @@
import pdfmakeModule from 'pdfmake'
import vfs from 'pdfmake/build/vfs_fonts.js'
import { invoiceLineAmount } from '../../utils/invoiceTotals'
import type { InvoiceForClient, InvoicePdfLang, PdfmakeServer } from './types'
const LABELS: Record<InvoicePdfLang, Record<string, string>> = {
en: {
title: 'INVOICE',
void: 'VOID',
issueDate: 'Issue date',
dueDate: 'Due date',
reference: 'Reference',
period: 'Period',
replaces: 'Replaces',
billTo: 'Bill to',
description: 'Description',
qty: 'Qty',
unit: 'Unit',
price: 'Price',
amount: 'Amount',
subtotal: 'Subtotal',
discount: 'Discount',
tax: 'Tax',
total: 'Total',
paymentDetails: 'Payment details',
bankName: 'Bank',
accountNumber: 'Account',
iban: 'IBAN',
swift: 'SWIFT / BIC',
correspondentAccount: 'Correspondent account',
service: 'service',
hours: 'h',
pcs: 'pcs',
},
ru: {
title: 'СЧЁТ',
void: 'АННУЛИРОВАН',
issueDate: 'Дата выставления',
dueDate: 'Срок оплаты',
reference: 'Основание',
period: 'Период',
replaces: 'Взамен',
billTo: 'Плательщик',
description: 'Описание',
qty: 'Кол-во',
unit: 'Ед.',
price: 'Цена',
amount: 'Сумма',
subtotal: 'Промежуточная сумма',
discount: 'Скидка',
tax: 'Налог',
total: 'Итого',
paymentDetails: 'Реквизиты для оплаты',
bankName: 'Банк',
accountNumber: 'Расчётный счёт',
iban: 'IBAN',
swift: 'SWIFT / БИК',
correspondentAccount: 'Корр. счёт',
service: 'усл.',
hours: 'ч',
pcs: 'шт.',
},
}
const FONT_FILES = ['Roboto-Regular.ttf', 'Roboto-Medium.ttf', 'Roboto-Italic.ttf', 'Roboto-MediumItalic.ttf']
const MUTED = '#6b7280'
const RULE = '#d4d4d8'
export class InvoicePdfRenderer {
private static fontsReady = false
private readonly pdfmake = pdfmakeModule as unknown as PdfmakeServer
constructor() {
if (!InvoicePdfRenderer.fontsReady) {
for (const name of FONT_FILES) this.pdfmake.virtualfs.writeFileSync(name, Buffer.from(vfs[name], 'base64'))
this.pdfmake.setFonts({
Roboto: {
normal: 'Roboto-Regular.ttf',
bold: 'Roboto-Medium.ttf',
italics: 'Roboto-Italic.ttf',
bolditalics: 'Roboto-MediumItalic.ttf',
},
})
InvoicePdfRenderer.fontsReady = true
}
}
render(invoice: InvoiceForClient, lang: InvoicePdfLang): Promise<Buffer> {
return this.pdfmake.createPdf(this.buildDocument(invoice, lang)).getBuffer()
}
private buildDocument(invoice: InvoiceForClient, lang: InvoicePdfLang) {
const t = LABELS[lang]
const money = (amount: number) => this.formatMoney(amount, invoice.currencyCode, lang)
const date = (value: string | null) => this.formatDate(value, lang)
const metaRows: [string, string][] = [[t.issueDate, date(invoice.issueDate)]]
if (invoice.dueDate) metaRows.push([t.dueDate, date(invoice.dueDate)])
if (invoice.reference) metaRows.push([t.reference, invoice.reference])
if (invoice.periodFrom || invoice.periodTo) metaRows.push([t.period, `${date(invoice.periodFrom)} ${date(invoice.periodTo)}`])
const lineRows = invoice.lines.map((line) => [
{ text: line.description },
{ text: String(line.quantity), alignment: 'right' },
{ text: t[line.unit] ?? line.unit, color: MUTED },
{ text: money(line.unitPrice), alignment: 'right' },
{ text: money(invoiceLineAmount(line.quantity, line.unitPrice)), alignment: 'right' },
])
const totalsRows: unknown[] = [[{ text: t.subtotal, color: MUTED }, { text: money(invoice.totals.subtotal), alignment: 'right' }]]
if (invoice.totals.discount > 0) totalsRows.push([{ text: t.discount, color: MUTED }, { text: `${money(invoice.totals.discount)}`, alignment: 'right' }])
if (!invoice.taxExempt) totalsRows.push([{ text: `${t.tax} ${invoice.taxRate}%`, color: MUTED }, { text: money(invoice.totals.tax), alignment: 'right' }])
totalsRows.push([{ text: t.total, bold: true, fontSize: 12 }, { text: money(invoice.totals.total), bold: true, fontSize: 12, alignment: 'right' }])
const bank = invoice.seller.bank
const bankRows = (
[
[t.bankName, bank.bankName],
[t.accountNumber, bank.accountNumber],
[t.iban, bank.iban],
[t.swift, bank.swift],
[t.correspondentAccount, bank.correspondentAccount],
] as [string, string][]
).filter(([, value]) => value.trim().length > 0)
const content: unknown[] = [
{
columns: [
{
width: '*',
stack: [
{ text: invoice.seller.legalName || invoice.seller.name, fontSize: 14, bold: true },
...this.partyLines(invoice.seller),
],
},
{
width: 'auto',
stack: [
{ text: t.title, fontSize: 20, bold: true, alignment: 'right' },
{ text: invoice.number, alignment: 'right', margin: [0, 2, 0, 8] },
{
table: { body: metaRows.map(([label, value]) => [{ text: label, color: MUTED }, { text: value, alignment: 'right' }]) },
layout: 'noBorders',
fontSize: 9,
},
],
},
],
columnGap: 24,
},
{ canvas: [{ type: 'line', x1: 0, y1: 0, x2: 515, y2: 0, lineWidth: 0.5, lineColor: RULE }], margin: [0, 12, 0, 12] },
{ text: t.billTo.toUpperCase(), fontSize: 8, bold: true, color: MUTED },
{ text: invoice.counterparty.legalName || invoice.counterparty.name, bold: true, margin: [0, 2, 0, 0] },
...(invoice.counterparty.contactPerson ? [{ text: invoice.counterparty.contactPerson }] : []),
...this.partyLines(invoice.counterparty),
{
table: {
headerRows: 1,
widths: ['*', 40, 44, 80, 90],
body: [
[
{ text: t.description.toUpperCase(), style: 'th' },
{ text: t.qty.toUpperCase(), style: 'th', alignment: 'right' },
{ text: t.unit.toUpperCase(), style: 'th' },
{ text: t.price.toUpperCase(), style: 'th', alignment: 'right' },
{ text: t.amount.toUpperCase(), style: 'th', alignment: 'right' },
],
...lineRows,
],
},
layout: {
hLineWidth: (index: number, node: { table: { body: unknown[] } }) => (index === 0 || index === node.table.body.length ? 0 : 0.5),
vLineWidth: () => 0,
hLineColor: () => RULE,
paddingTop: () => 6,
paddingBottom: () => 6,
},
margin: [0, 18, 0, 8],
},
{
columns: [
{ width: '*', text: '' },
{ width: 220, table: { widths: ['*', 'auto'], body: totalsRows }, layout: 'noBorders' },
],
},
]
if (bankRows.length > 0) {
content.push(
{ text: t.paymentDetails.toUpperCase(), fontSize: 8, bold: true, color: MUTED, margin: [0, 20, 0, 4] },
{ table: { body: bankRows.map(([label, value]) => [{ text: label, color: MUTED }, { text: value }]) }, layout: 'noBorders' },
)
}
if (invoice.taxExempt && invoice.taxNote) content.push({ text: invoice.taxNote, margin: [0, 16, 0, 0], color: '#374151' })
if (invoice.terms) content.push({ text: invoice.terms, margin: [0, 8, 0, 0], color: '#374151' })
if (invoice.notes) content.push({ text: invoice.notes, margin: [0, 8, 0, 0], color: '#374151' })
return {
pageSize: 'A4',
pageMargins: [40, 40, 40, 40],
info: { title: invoice.number },
...(invoice.status === 'void' ? { watermark: { text: t.void, color: '#ef4444', opacity: 0.12, bold: true } } : {}),
defaultStyle: { font: 'Roboto', fontSize: 10, color: '#18181b' },
styles: { th: { fontSize: 8, bold: true, color: MUTED } },
content,
}
}
private partyLines(party: InvoiceForClient['seller'] | InvoiceForClient['counterparty']) {
const lines: unknown[] = []
if (party.address) lines.push({ text: party.address, color: MUTED, fontSize: 9 })
for (const item of party.requisites) {
if (item.label.trim() && item.value.trim()) lines.push({ text: `${item.label}: ${item.value}`, color: MUTED, fontSize: 9 })
}
const contacts = [party.email, party.phone].filter(Boolean).join(' · ')
if (contacts) lines.push({ text: contacts, color: MUTED, fontSize: 9 })
return lines
}
private formatMoney(amount: number, currencyCode: string, lang: InvoicePdfLang): string {
try {
return new Intl.NumberFormat(lang === 'ru' ? 'ru-RU' : 'en-US', { style: 'currency', currency: currencyCode }).format(amount)
} catch {
return `${amount.toFixed(2)} ${currencyCode}`
}
}
private formatDate(value: string | null, lang: InvoicePdfLang): string {
if (!value) return ''
const [year, month, day] = value.split('-')
return lang === 'ru' ? `${day}.${month}.${year}` : `${day}.${month}.${year}`
}
}
@@ -0,0 +1,103 @@
import type { Request, Response } from 'express'
import { ArkErrors } from 'arktype'
import { InvoicesManager } from './InvoicesManager'
import {
InvoiceArkTypeCreate,
InvoiceArkTypeId,
InvoiceArkTypeList,
InvoiceArkTypePdf,
InvoiceArkTypeStatus,
InvoiceArkTypeUpdate,
type InvoiceDeleteResult,
type InvoiceTransitionResult,
type InvoiceWriteError,
} from './types'
const WRITE_ERROR_STATUS: Record<InvoiceWriteError, number> = {
seller_not_found: 422,
counterparty_not_found: 422,
goal_not_found: 422,
duplicate_number: 409,
not_found: 404,
not_draft: 409,
}
const TRANSITION_ERROR_STATUS = { not_found: 404, invalid_transition: 409, missing_requisites: 422 } as const
const DELETE_STATUS: Record<InvoiceDeleteResult, number> = { deleted: 200, not_draft: 409, not_found: 404 }
export class InvoicesController {
private readonly manager = new InvoicesManager()
fetch = async (req: Request, res: Response) => {
const data = InvoiceArkTypeList(req.query)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
return res.tvJson(await this.manager.fetchList(data))
}
getById = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.fetchById(id.id)
if (!result) return res.status(404).end()
return res.tvJson(result)
}
create = async (req: Request, res: Response) => {
const data = InvoiceArkTypeCreate(req.body)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.create({ data, createdBy: req.appUser.getUserData()?.id ?? null })
if ('error' in result) return res.status(WRITE_ERROR_STATUS[result.error]).send(result.error)
return res.tvJson(result.invoice)
}
update = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
const data = InvoiceArkTypeUpdate(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const result = await this.manager.update({ invoiceId: id.id, data })
if ('error' in result) return res.status(WRITE_ERROR_STATUS[result.error]).send(result.error)
return res.tvJson(result.invoice)
}
setStatus = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
const data = InvoiceArkTypeStatus(req.body)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
return this.sendTransition(res, await this.manager.transition({ invoiceId: id.id, status: data.status }))
}
reissue = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.reissue({ invoiceId: id.id, createdBy: req.appUser.getUserData()?.id ?? null })
return this.sendTransition(res, result)
}
pdf = async (req: Request, res: Response) => {
const data = InvoiceArkTypePdf({ ...req.params, ...req.query })
if (data instanceof ArkErrors) return res.status(400).send(data.summary)
const invoice = await this.manager.fetchById(data.id)
if (!invoice) return res.status(404).end()
const buffer = await this.manager.renderPdf({ invoiceId: data.id, lang: data.lang ?? 'en' })
if (!buffer) return res.status(404).end()
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(invoice.number)}.pdf"`)
return res.send(buffer)
}
private sendTransition(res: Response, result: InvoiceTransitionResult) {
if ('error' in result) return res.status(TRANSITION_ERROR_STATUS[result.error]).json(result)
return res.tvJson(result.invoice)
}
delete = async (req: Request, res: Response) => {
const id = InvoiceArkTypeId(req.params)
if (id instanceof ArkErrors) return res.status(400).send(id.summary)
const result = await this.manager.delete(id.id)
if (result !== 'deleted') return res.status(DELETE_STATUS[result]).end()
return res.tvJson(true)
}
}
@@ -0,0 +1,255 @@
import type {
CounterpartiesSchemaTypeForSelect,
InvoiceCounterpartySnapshot,
InvoiceSellerSnapshot,
InvoiceStatus,
SellersSchemaTypeForSelect,
} from 'taskview-db-schemas'
import { BillingRepository } from '../billing/BillingRepository'
import { computeInvoiceTotals } from '../../utils/invoiceTotals'
import { InvoicePdfRenderer } from './InvoicePdfRenderer'
import { InvoicesRepository } from './InvoicesRepository'
import {
INVOICE_TEMPLATE_VERSION,
INVOICE_TRANSITIONS,
type InvoiceArgList,
type InvoiceArgUpdate,
type InvoiceCreateArgs,
type InvoiceDeleteResult,
type InvoiceForClient,
type InvoiceMissingRequisite,
type InvoiceReissueArgs,
type InvoiceRenderPdfArgs,
type InvoiceSetStatusArgs,
type InvoiceSnapshots,
type InvoiceStatusPatch,
type InvoiceTransitionResult,
type InvoiceUpdateArgs,
type InvoiceWithLines,
type InvoiceWriteError,
type InvoiceWriteResult,
} from './types'
export class InvoicesManager {
public readonly repository: InvoicesRepository
private readonly billing: BillingRepository
private readonly pdf: InvoicePdfRenderer
constructor() {
this.repository = new InvoicesRepository()
this.billing = new BillingRepository()
this.pdf = new InvoicePdfRenderer()
}
async fetchList(args: InvoiceArgList): Promise<InvoiceForClient[]> {
return (await this.repository.fetchList(args)).map((invoice) => this.toClient(invoice))
}
async fetchById(invoiceId: number): Promise<InvoiceForClient | null> {
const invoice = await this.repository.fetchById(invoiceId)
return invoice ? this.toClient(invoice) : null
}
async create({ data, createdBy }: InvoiceCreateArgs): Promise<InvoiceWriteResult> {
const snapshots = await this.resolveSnapshots(data.organizationId, data)
if (typeof snapshots === 'string') return { error: snapshots }
const created = await this.repository.create({ data, createdBy, ...snapshots })
return this.toWriteResult(created)
}
async update({ invoiceId, data }: InvoiceUpdateArgs): Promise<InvoiceWriteResult> {
const existing = await this.repository.fetchById(invoiceId)
if (!existing) return { error: 'not_found' }
if (existing.status !== 'draft') return { error: 'not_draft' }
const snapshots = await this.resolveSnapshots(existing.organizationId, data)
if (typeof snapshots === 'string') return { error: snapshots }
const updated = await this.repository.update({ invoiceId, data, ...snapshots })
return this.toWriteResult(updated)
}
async transition({ invoiceId, status }: InvoiceSetStatusArgs): Promise<InvoiceTransitionResult> {
const existing = await this.repository.fetchById(invoiceId)
if (!existing) return { error: 'not_found' }
if (!INVOICE_TRANSITIONS[existing.status].includes(status)) {
return { error: 'invalid_transition', from: existing.status, to: status }
}
const patch: InvoiceStatusPatch = { status }
if (status === 'issued' && existing.status === 'draft') {
const missing = this.missingRequisites(existing)
if (missing.length > 0) return { error: 'missing_requisites', missing }
const totals = computeInvoiceTotals(existing)
patch.issuedAt = new Date()
patch.templateVersion = INVOICE_TEMPLATE_VERSION
patch.subtotal = String(totals.subtotal)
patch.discountAmount = String(totals.discount)
patch.taxAmount = String(totals.tax)
patch.total = String(totals.total)
}
if (status === 'issued' && existing.status === 'paid') patch.paidAt = null
if (status === 'paid') patch.paidAt = new Date()
if (status === 'void') patch.voidedAt = new Date()
const updated = await this.repository.applyStatus({ invoiceId, patch })
return updated ? { invoice: this.toClient(updated) } : { error: 'not_found' }
}
async reissue({ invoiceId, createdBy }: InvoiceReissueArgs): Promise<InvoiceTransitionResult> {
const original = await this.repository.fetchById(invoiceId)
if (!original) return { error: 'not_found' }
if (original.status === 'issued') {
const voided = await this.transition({ invoiceId, status: 'void' })
if ('error' in voided) return voided
} else if (original.status !== 'void') {
return { error: 'invalid_transition', from: original.status, to: 'void' }
}
const number = await this.repository.nextNumber({ organizationId: original.organizationId, base: original.number })
const created = await this.repository.create({
createdBy,
sellerSnapshot: original.sellerSnapshot,
counterpartySnapshot: original.counterpartySnapshot,
goalName: original.goalName,
data: {
organizationId: original.organizationId,
goalId: original.goalId,
sellerId: original.sellerId,
counterpartyId: original.counterpartyId,
number,
reference: original.reference,
currencyCode: original.currencyCode,
issueDate: new Date().toISOString().slice(0, 10),
paymentTerms: original.paymentTerms,
dueDate: original.dueDate,
periodFrom: original.periodFrom,
periodTo: original.periodTo,
discountType: original.discountType,
discountValue: Number(original.discountValue),
taxRate: Number(original.taxRate),
taxExempt: original.taxExempt,
taxNote: original.taxNote,
notes: original.notes,
terms: original.terms,
lines: original.lines.map((line) => ({
taskId: line.taskId,
description: line.description,
unit: line.unit,
quantity: Number(line.quantity),
unitPrice: Number(line.unitPrice),
})),
},
})
if (!created || created === 'duplicate_number') return { error: 'not_found' }
await this.repository.setReplaces({ invoiceId: created.id, replacesInvoiceId: original.id })
return { invoice: this.toClient({ ...created, replacesInvoiceId: original.id }) }
}
async delete(invoiceId: number): Promise<InvoiceDeleteResult> {
const existing = await this.repository.fetchById(invoiceId)
if (!existing) return 'not_found'
if (existing.status !== 'draft') return 'not_draft'
return (await this.repository.delete(invoiceId)) ? 'deleted' : 'not_found'
}
async renderPdf({ invoiceId, lang }: InvoiceRenderPdfArgs): Promise<Buffer | null> {
const invoice = await this.fetchById(invoiceId)
if (!invoice) return null
return this.pdf.render(invoice, lang)
}
private missingRequisites(invoice: InvoiceWithLines): InvoiceMissingRequisite[] {
const seller = invoice.sellerSnapshot
const counterparty = invoice.counterpartySnapshot
const missing: InvoiceMissingRequisite[] = []
if (!seller.name.trim() && !seller.legalName.trim()) missing.push('seller.name')
if (!seller.address.trim()) missing.push('seller.address')
if (!seller.bank.accountNumber.trim() && !seller.bank.iban.trim()) missing.push('seller.bank')
if (!counterparty.name.trim() && !counterparty.legalName.trim()) missing.push('counterparty.name')
if (!counterparty.address.trim()) missing.push('counterparty.address')
if (invoice.lines.length === 0) missing.push('lines')
return missing
}
private async resolveSnapshots(organizationId: number, data: InvoiceArgUpdate): Promise<InvoiceSnapshots | InvoiceWriteError> {
const seller = await this.billing.fetchSellerById(data.sellerId)
if (!seller || seller.organizationId !== organizationId) return 'seller_not_found'
const counterparty = await this.billing.fetchCounterpartyById(data.counterpartyId)
if (!counterparty || counterparty.organizationId !== organizationId) return 'counterparty_not_found'
let goalName = ''
if (data.goalId !== null) {
const goal = await this.repository.fetchGoal(data.goalId)
if (!goal || goal.organizationId !== organizationId) return 'goal_not_found'
goalName = goal.name ?? ''
}
return {
sellerSnapshot: this.snapshotSeller(seller),
counterpartySnapshot: this.snapshotCounterparty(counterparty),
goalName,
}
}
private snapshotSeller(seller: SellersSchemaTypeForSelect): InvoiceSellerSnapshot {
return {
name: seller.name,
legalName: seller.legalName,
address: seller.address,
email: seller.email,
phone: seller.phone,
logoUrl: seller.logoUrl,
bank: seller.bank,
requisites: seller.requisites,
}
}
private snapshotCounterparty(counterparty: CounterpartiesSchemaTypeForSelect): InvoiceCounterpartySnapshot {
return {
kind: counterparty.kind,
name: counterparty.name,
legalName: counterparty.legalName,
address: counterparty.address,
email: counterparty.email,
phone: counterparty.phone,
contactPerson: counterparty.contactPerson,
requisites: counterparty.requisites,
}
}
private toWriteResult(result: InvoiceWithLines | 'duplicate_number' | null): InvoiceWriteResult {
if (result === 'duplicate_number') return { error: 'duplicate_number' }
if (!result) return { error: 'not_found' }
return { invoice: this.toClient(result) }
}
private toClient(invoice: InvoiceWithLines): InvoiceForClient {
const { sellerSnapshot, counterpartySnapshot, lines, subtotal, discountAmount, taxAmount, total, ...rest } = invoice
const totalsFrozen = total !== null
const totals = totalsFrozen
? {
subtotal: Number(subtotal),
discount: Number(discountAmount),
taxable: Number(subtotal) - Number(discountAmount),
tax: Number(taxAmount),
total: Number(total),
}
: computeInvoiceTotals(invoice)
return {
...rest,
discountValue: Number(invoice.discountValue),
taxRate: Number(invoice.taxRate),
seller: sellerSnapshot,
counterparty: counterpartySnapshot,
totals,
totalsFrozen,
lines: lines.map((line) => ({
id: line.id,
taskId: line.taskId,
description: line.description,
unit: line.unit,
quantity: Number(line.quantity),
unitPrice: Number(line.unitPrice),
})),
}
}
}
export type { InvoiceStatus }
@@ -0,0 +1,228 @@
import { and, asc, desc, eq, inArray } from 'drizzle-orm'
import {
CounterpartiesSchema,
GoalsSchema,
InvoiceLinesSchema,
InvoicesSchema,
type InvoiceLinesSchemaTypeForSelect,
type InvoicesSchemaTypeForSelect,
} from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch } from '../../utils/helpers'
import type {
InvoiceArgList,
InvoiceArgUpdate,
InvoiceCreateRepoArgs,
InvoiceLineArg,
InvoiceNextNumberArgs,
InvoiceSetReplacesArgs,
InvoiceStatusRepoArgs,
InvoiceUpdateRepoArgs,
InvoiceWithLines,
} from './types'
const PG_UNIQUE_VIOLATION = '23505'
export class InvoicesRepository {
private readonly db: Database
constructor() {
this.db = Database.getInstance()
}
async fetchList({ organizationId, includeArchived }: InvoiceArgList): Promise<InvoiceWithLines[]> {
const conditions = [eq(InvoicesSchema.organizationId, organizationId)]
if (!includeArchived) conditions.push(eq(CounterpartiesSchema.archived, false))
const rows = await callWithCatch(() =>
this.db.dbDrizzle
.select({ invoice: InvoicesSchema })
.from(InvoicesSchema)
.innerJoin(CounterpartiesSchema, eq(CounterpartiesSchema.id, InvoicesSchema.counterpartyId))
.where(and(...conditions))
.orderBy(desc(InvoicesSchema.issueDate), desc(InvoicesSchema.id)),
)
const invoices = (rows ?? []).map((row) => row.invoice)
return this.attachLines(invoices)
}
async fetchById(invoiceId: number): Promise<InvoiceWithLines | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(InvoicesSchema).where(eq(InvoicesSchema.id, invoiceId)),
)
const invoice = result?.[0]
if (!invoice) return null
return (await this.attachLines([invoice]))[0]
}
async fetchOrganizationId(invoiceId: number): Promise<number | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ organizationId: InvoicesSchema.organizationId })
.from(InvoicesSchema)
.where(eq(InvoicesSchema.id, invoiceId)),
)
return result?.[0]?.organizationId ?? null
}
async fetchGoal(goalId: number): Promise<{ organizationId: number | null; name: string | null } | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select({ organizationId: GoalsSchema.organizationId, name: GoalsSchema.name })
.from(GoalsSchema)
.where(eq(GoalsSchema.id, goalId)),
)
return result?.[0] ?? null
}
private invoiceColumns(data: InvoiceArgUpdate) {
return {
goalId: data.goalId,
sellerId: data.sellerId,
counterpartyId: data.counterpartyId,
number: data.number,
reference: data.reference,
currencyCode: data.currencyCode,
issueDate: data.issueDate,
paymentTerms: data.paymentTerms,
dueDate: data.dueDate,
periodFrom: data.periodFrom,
periodTo: data.periodTo,
discountType: data.discountType,
discountValue: String(data.discountValue),
taxRate: String(data.taxRate),
taxExempt: data.taxExempt,
taxNote: data.taxNote,
notes: data.notes,
terms: data.terms,
}
}
async create({ data, createdBy, sellerSnapshot, counterpartySnapshot, goalName }: InvoiceCreateRepoArgs): Promise<InvoiceWithLines | 'duplicate_number' | null> {
try {
return await this.db.dbDrizzle.transaction(async (tx) => {
const inserted = await tx
.insert(InvoicesSchema)
.values({
organizationId: data.organizationId,
...this.invoiceColumns(data),
goalName,
sellerSnapshot,
counterpartySnapshot,
createdBy,
})
.returning()
const created = inserted[0]
const insertedLines = await tx.insert(InvoiceLinesSchema).values(this.toLineRows(created.id, data.lines)).returning()
return { ...created, lines: insertedLines }
})
} catch (error) {
if (this.isUniqueViolation(error)) return 'duplicate_number'
return null
}
}
async update({ invoiceId, data, sellerSnapshot, counterpartySnapshot, goalName }: InvoiceUpdateRepoArgs): Promise<InvoiceWithLines | 'duplicate_number' | null> {
try {
return await this.db.dbDrizzle.transaction(async (tx) => {
const updated = await tx
.update(InvoicesSchema)
.set({
...this.invoiceColumns(data),
goalName,
sellerSnapshot,
counterpartySnapshot,
updatedAt: new Date(),
})
.where(eq(InvoicesSchema.id, invoiceId))
.returning()
const current = updated[0]
if (!current) return null
await tx.delete(InvoiceLinesSchema).where(eq(InvoiceLinesSchema.invoiceId, invoiceId))
const insertedLines = await tx.insert(InvoiceLinesSchema).values(this.toLineRows(invoiceId, data.lines)).returning()
return { ...current, lines: insertedLines }
})
} catch (error) {
if (this.isUniqueViolation(error)) return 'duplicate_number'
return null
}
}
async applyStatus({ invoiceId, patch }: InvoiceStatusRepoArgs): Promise<InvoiceWithLines | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.update(InvoicesSchema)
.set({ ...patch, updatedAt: new Date() })
.where(eq(InvoicesSchema.id, invoiceId))
.returning(),
)
const invoice = result?.[0]
if (!invoice) return null
return (await this.attachLines([invoice]))[0]
}
async setReplaces({ invoiceId, replacesInvoiceId }: InvoiceSetReplacesArgs): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle.update(InvoicesSchema).set({ replacesInvoiceId }).where(eq(InvoicesSchema.id, invoiceId)),
)
}
async nextNumber({ organizationId, base }: InvoiceNextNumberArgs): Promise<string> {
const match = base.match(/^(.*?)(\d+)$/)
if (!match) return `${base}-1`
const [, prefix, digits] = match
const rows = await callWithCatch(() =>
this.db.dbDrizzle
.select({ number: InvoicesSchema.number })
.from(InvoicesSchema)
.where(eq(InvoicesSchema.organizationId, organizationId)),
)
let max = Number(digits)
for (const row of rows ?? []) {
if (!row.number.startsWith(prefix)) continue
const tail = row.number.slice(prefix.length)
if (/^\d+$/.test(tail)) max = Math.max(max, Number(tail))
}
return `${prefix}${String(max + 1).padStart(digits.length, '0')}`
}
async delete(invoiceId: number): Promise<boolean> {
const result = await callWithCatch(() => this.db.dbDrizzle.delete(InvoicesSchema).where(eq(InvoicesSchema.id, invoiceId)))
return !!result?.rowCount
}
private toLineRows(invoiceId: number, lines: InvoiceLineArg[]) {
return lines.map((line, index) => ({
invoiceId,
position: index,
taskId: line.taskId,
description: line.description,
unit: line.unit,
quantity: String(line.quantity),
unitPrice: String(line.unitPrice),
}))
}
private async attachLines(invoices: InvoicesSchemaTypeForSelect[]): Promise<InvoiceWithLines[]> {
if (invoices.length === 0) return []
const lines = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(InvoiceLinesSchema)
.where(inArray(InvoiceLinesSchema.invoiceId, invoices.map((invoice) => invoice.id)))
.orderBy(asc(InvoiceLinesSchema.invoiceId), asc(InvoiceLinesSchema.position)),
)
const byInvoice = new Map<number, InvoiceLinesSchemaTypeForSelect[]>()
for (const line of lines ?? []) {
const list = byInvoice.get(line.invoiceId) ?? []
list.push(line)
byInvoice.set(line.invoiceId, list)
}
return invoices.map((invoice) => ({ ...invoice, lines: byInvoice.get(invoice.id) ?? [] }))
}
private isUniqueViolation(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
const { code, cause } = error as { code?: string; cause?: { code?: string } }
return code === PG_UNIQUE_VIOLATION || cause?.code === PG_UNIQUE_VIOLATION
}
}
@@ -0,0 +1,36 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin'
import { RequireTokenPermission } from '../../middlewares/require-token-permission'
import { GoalPermissions } from '../../types/auth.types'
import { InvoicesController } from './InvoicesController'
import { isOrgAdminForInvoice } from './middlewares/is-org-admin-for-invoice'
export default class InvoicesRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: InvoicesController
constructor() {
this.router = Router()
this.controller = new InvoicesController()
this.initRoutes()
}
getRouter() {
return this.router
}
private initRoutes() {
const canManage = RequireTokenPermission(GoalPermissions.BILLING_CAN_MANAGE)
this.router.get('', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.fetch)
this.router.post('', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.create)
this.router.get('/:id', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.getById)
this.router.patch('/:id', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.update)
this.router.patch('/:id/status', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.setStatus)
this.router.post('/:id/reissue', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.reissue)
this.router.get('/:id/pdf', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.pdf)
this.router.delete('/:id', [IsLoggedIn, isOrgAdminForInvoice, canManage], this.controller.delete)
}
}
@@ -0,0 +1,260 @@
import axios from 'axios'
import type http from 'http'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import App from '../../../App'
const port = 1811
const api = axios.create({ baseURL: `http://localhost:${port}`, validateStatus: () => true })
const LOGIN = 'test@mail.dest'
const PASSWORD = 'user1!#Q'
let server: http.Server
let jwt = ''
let organizationId = 0
let goalId = 0
let sellerId = 0
let counterpartyId = 0
let invoiceId = 0
const auth = () => ({ headers: { Authorization: `Bearer ${jwt}` } })
const sellerPayload = () => ({
organizationId,
name: 'IT Seller',
legalName: 'IT Seller LLC',
address: 'Somewhere 1',
email: 'billing@seller.test',
phone: '',
logoUrl: '',
currencyCode: 'EUR',
bank: { bankName: 'Bank', accountNumber: '123', iban: 'DE00', swift: 'XXX', correspondentAccount: '' },
requisites: [{ key: 'vat', label: 'VAT ID', value: 'DE1' }],
defaultTerms: 'Net 14',
taxNote: '',
})
const counterpartyPayload = () => ({
organizationId,
kind: 'organization',
name: 'IT Client',
legalName: 'IT Client GmbH',
address: '',
email: 'ap@client.test',
phone: '',
contactPerson: 'Anna',
requisites: [],
})
const invoicePayload = (number: string) => ({
organizationId,
goalId,
sellerId,
counterpartyId,
number,
reference: 'PO-1',
currencyCode: 'EUR',
issueDate: '2026-09-01',
paymentTerms: 'net14',
dueDate: '2026-09-15',
periodFrom: null,
periodTo: null,
discountType: 'percent',
discountValue: 10,
taxRate: 19,
taxExempt: false,
taxNote: '',
notes: '',
terms: 'Net 14',
lines: [
{ taskId: null, description: 'Design', unit: 'service', quantity: 1, unitPrice: 1000 },
{ taskId: null, description: 'Dev', unit: 'hours', quantity: 10.5, unitPrice: 80 },
],
})
describe('billing and invoices', () => {
vi.mock('emailjs', () => ({
SMTPClient: vi.fn().mockImplementation(() => ({ sendAsync: vi.fn().mockResolvedValue(true) })),
}))
beforeAll(async () => {
server = new App(port).listen()
const login = await api.post('/module/auth/login', { login: LOGIN, password: PASSWORD })
expect(login.status).toBe(200)
jwt = login.data.access
const org = await api.post('/module/organizations', { name: `billing-it-${Date.now()}` }, auth())
expect(org.status).toBe(200)
organizationId = org.data.response.id
const goal = await api.post('/module/goals', { name: 'billing-it-goal', organizationId }, auth())
expect(goal.status).toBe(200)
goalId = goal.data.response.id ?? goal.data.response.goal?.id
})
afterAll(async () => {
if (organizationId) await api.delete(`/module/organizations/${organizationId}`, auth())
server?.close()
})
it('lists the seeded currencies', async () => {
const response = await api.get('/module/billing/currencies', auth())
expect(response.status).toBe(200)
expect(response.data.response.length).toBeGreaterThanOrEqual(20)
expect(response.data.response.find((c: { code: string }) => c.code === 'JPY').decimalDigits).toBe(0)
})
it('creates a seller and a counterparty for the organization', async () => {
const seller = await api.post('/module/billing/sellers', sellerPayload(), auth())
expect(seller.status).toBe(200)
sellerId = seller.data.response.id
expect(seller.data.response.bank.iban).toBe('DE00')
const counterparty = await api.post('/module/billing/counterparties', counterpartyPayload(), auth())
expect(counterparty.status).toBe(200)
counterpartyId = counterparty.data.response.id
const list = await api.get('/module/billing/counterparties', { params: { organizationId }, ...auth() })
expect(list.data.response.map((c: { id: number }) => c.id)).toContain(counterpartyId)
})
it('rejects an invalid payload', async () => {
const response = await api.post('/module/billing/sellers', { ...sellerPayload(), currencyCode: 'euro' }, auth())
expect(response.status).toBe(400)
})
it('creates an invoice with snapshots, lines and the project name', async () => {
const response = await api.post('/module/invoices', invoicePayload('INV-IT-1'), auth())
expect(response.status).toBe(200)
const invoice = response.data.response
invoiceId = invoice.id
expect(invoice.status).toBe('draft')
expect(invoice.goalName).toBe('billing-it-goal')
expect(invoice.seller.legalName).toBe('IT Seller LLC')
expect(invoice.counterparty.contactPerson).toBe('Anna')
expect(invoice.lines).toHaveLength(2)
expect(invoice.lines[1].quantity).toBe(10.5)
expect(invoice.discountValue).toBe(10)
expect(typeof invoice.taxRate).toBe('number')
})
it('refuses a duplicate number inside the organization', async () => {
const response = await api.post('/module/invoices', invoicePayload('INV-IT-1'), auth())
expect(response.status).toBe(409)
})
it('refuses a seller from another organization', async () => {
const response = await api.post('/module/invoices', { ...invoicePayload('INV-IT-2'), sellerId: 999999 }, auth())
expect(response.status).toBe(422)
})
it('keeps the seller snapshot when the seller is edited', async () => {
const update = await api.patch(`/module/billing/sellers/${sellerId}`, { ...sellerPayload(), organizationId: undefined, legalName: 'Renamed LLC' }, auth())
expect(update.status).toBe(200)
const invoice = await api.get(`/module/invoices/${invoiceId}`, auth())
expect(invoice.data.response.seller.legalName).toBe('IT Seller LLC')
})
it('replaces lines on update', async () => {
const data = { ...invoicePayload('INV-IT-1'), organizationId: undefined, lines: [{ taskId: null, description: 'Only', unit: 'pcs', quantity: 2, unitPrice: 5 }] }
const response = await api.patch(`/module/invoices/${invoiceId}`, data, auth())
expect(response.status).toBe(200)
expect(response.data.response.lines).toHaveLength(1)
expect(response.data.response.lines[0].description).toBe('Only')
})
it('refuses to delete a seller that has invoices', async () => {
const response = await api.delete(`/module/billing/sellers/${sellerId}`, auth())
expect(response.status).toBe(409)
})
it('hides invoices of an archived client unless asked for', async () => {
const archive = await api.patch(`/module/billing/counterparties/${counterpartyId}/archive`, { archived: true }, auth())
expect(archive.status).toBe(200)
expect(archive.data.response.archived).toBe(true)
const hidden = await api.get('/module/invoices', { params: { organizationId }, ...auth() })
expect(hidden.data.response.map((i: { id: number }) => i.id)).not.toContain(invoiceId)
const shown = await api.get('/module/invoices', { params: { organizationId, includeArchived: true }, ...auth() })
expect(shown.data.response.map((i: { id: number }) => i.id)).toContain(invoiceId)
await api.patch(`/module/billing/counterparties/${counterpartyId}/archive`, { archived: false }, auth())
})
it('refuses to pay a draft and reports what is missing before issuing', async () => {
const paid = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'paid' }, auth())
expect(paid.status).toBe(409)
expect(paid.data.error).toBe('invalid_transition')
const issued = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
expect(issued.status).toBe(422)
expect(issued.data.missing).toContain('counterparty.address')
})
it('issues the invoice, freezes the totals and locks editing', async () => {
const fixed = await api.patch(`/module/billing/counterparties/${counterpartyId}`, { ...counterpartyPayload(), organizationId: undefined, address: 'Client street 1' }, auth())
expect(fixed.status).toBe(200)
const resnap = await api.patch(`/module/invoices/${invoiceId}`, { ...invoicePayload('INV-IT-1'), organizationId: undefined }, auth())
expect(resnap.status).toBe(200)
const issued = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
expect(issued.status).toBe(200)
expect(issued.data.response.status).toBe('issued')
expect(issued.data.response.issuedAt).toBeTruthy()
expect(issued.data.response.totalsFrozen).toBe(true)
expect(issued.data.response.totals.total).toBe(1970.64)
const edit = await api.patch(`/module/invoices/${invoiceId}`, { ...invoicePayload('INV-IT-1'), organizationId: undefined }, auth())
expect(edit.status).toBe(409)
const del = await api.delete(`/module/invoices/${invoiceId}`, auth())
expect(del.status).toBe(409)
const back = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'draft' }, auth())
expect(back.status).toBe(409)
})
it('marks paid, unmarks and refuses to void a paid invoice', async () => {
const paid = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'paid' }, auth())
expect(paid.data.response.paidAt).toBeTruthy()
const voided = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'void' }, auth())
expect(voided.status).toBe(409)
const unpaid = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
expect(unpaid.data.response.paidAt).toBeNull()
})
it('renders the invoice as a PDF', async () => {
const response = await api.get(`/module/invoices/${invoiceId}/pdf`, { ...auth(), params: { lang: 'ru' }, responseType: 'arraybuffer' })
expect(response.status).toBe(200)
expect(response.headers['content-type']).toContain('application/pdf')
expect(Buffer.from(response.data).subarray(0, 4).toString()).toBe('%PDF')
})
it('reissues: voids the original and creates a draft copy with the next number', async () => {
const response = await api.post(`/module/invoices/${invoiceId}/reissue`, {}, auth())
expect(response.status).toBe(200)
const copy = response.data.response
expect(copy.status).toBe('draft')
expect(copy.number).toBe('INV-IT-2')
expect(copy.replacesInvoiceId).toBe(invoiceId)
expect(copy.lines).toHaveLength(2)
const original = await api.get(`/module/invoices/${invoiceId}`, auth())
expect(original.data.response.status).toBe('void')
expect(original.data.response.voidedAt).toBeTruthy()
const revive = await api.patch(`/module/invoices/${invoiceId}/status`, { status: 'issued' }, auth())
expect(revive.status).toBe(409)
const deleted = await api.delete(`/module/invoices/${copy.id}`, auth())
expect(deleted.status).toBe(200)
})
it('keeps a seller with a voided invoice undeletable', async () => {
const response = await api.delete(`/module/billing/sellers/${sellerId}`, auth())
expect(response.status).toBe(409)
})
it('rejects an anonymous request', async () => {
const response = await api.get('/module/invoices', { params: { organizationId } })
expect(response.status).toBe(401)
})
})
@@ -0,0 +1,6 @@
import { isOrgAdminFor } from '../../billing/middlewares/is-org-admin-for'
import { InvoicesRepository } from '../InvoicesRepository'
const repository = new InvoicesRepository()
export const isOrgAdminForInvoice = isOrgAdminFor((id) => repository.fetchOrganizationId(id))
+203
View File
@@ -0,0 +1,203 @@
import { type } from 'arktype'
import type {
InvoiceCounterpartySnapshot,
InvoiceLinesSchemaTypeForSelect,
InvoiceSellerSnapshot,
InvoiceStatus,
InvoicesSchemaTypeForSelect,
} from 'taskview-db-schemas'
import type { InvoiceTotalsResult } from '../../utils/invoiceTotals'
const NumberFromString = type('string|number').pipe((v) => Number(v))
const BooleanFromString = type('string|boolean|undefined').pipe((v) => {
if (v === undefined) return undefined
if (typeof v === 'boolean') return v
return v === 'true' || v === '1'
})
const DateString = type(/^\d{4}-\d{2}-\d{2}$/)
const DateStringOrNull = DateString.or('null')
export const InvoiceLineArkType = type({
taskId: 'number|null',
description: '1<=string<=1000',
unit: "'service' | 'hours' | 'pcs'",
quantity: 'number>=0',
unitPrice: 'number>=0',
})
export type InvoiceLineArg = typeof InvoiceLineArkType.infer
export const InvoiceArkTypeCreate = type({
organizationId: 'number',
goalId: 'number|null',
sellerId: 'number',
counterpartyId: 'number',
number: '1<=string<=50',
reference: 'string<=200',
currencyCode: /^[A-Z]{3}$/,
issueDate: DateString,
paymentTerms: "'on_receipt' | 'net7' | 'net14' | 'net30' | 'custom'",
dueDate: DateStringOrNull,
periodFrom: DateStringOrNull,
periodTo: DateStringOrNull,
discountType: "'percent' | 'amount'",
discountValue: 'number>=0',
taxRate: '0<=number<=100',
taxExempt: 'boolean',
taxNote: 'string<=500',
notes: 'string<=2000',
terms: 'string<=2000',
lines: InvoiceLineArkType.array().atLeastLength(1),
})
export type InvoiceArgCreate = typeof InvoiceArkTypeCreate.infer
export const InvoiceArkTypeUpdate = InvoiceArkTypeCreate.omit('organizationId')
export type InvoiceArgUpdate = typeof InvoiceArkTypeUpdate.infer
export const InvoiceArkTypeList = type({
organizationId: NumberFromString,
'includeArchived?': BooleanFromString,
})
export type InvoiceArgList = typeof InvoiceArkTypeList.infer
export const InvoiceArkTypeId = type({
id: NumberFromString,
})
export const InvoiceArkTypeStatus = type({
status: "'draft' | 'issued' | 'paid' | 'void'",
})
export type InvoiceArgStatus = typeof InvoiceArkTypeStatus.infer
export const InvoiceArkTypePdf = type({
id: NumberFromString,
'lang?': "'en' | 'ru'",
})
export type InvoiceArgPdf = typeof InvoiceArkTypePdf.infer
export const INVOICE_TRANSITIONS: Record<InvoiceStatus, InvoiceStatus[]> = {
draft: ['issued'],
issued: ['paid', 'void'],
paid: ['issued'],
void: [],
}
export const INVOICE_TEMPLATE_VERSION = 1
export type InvoiceMissingRequisite =
| 'seller.name'
| 'seller.address'
| 'seller.bank'
| 'counterparty.name'
| 'counterparty.address'
| 'lines'
export type InvoiceStatusPatch = {
status: InvoiceStatus
issuedAt?: Date | null
paidAt?: Date | null
voidedAt?: Date | null
templateVersion?: number
subtotal?: string | null
discountAmount?: string | null
taxAmount?: string | null
total?: string | null
}
export type InvoiceStatusRepoArgs = {
invoiceId: number
patch: InvoiceStatusPatch
}
export type InvoiceSetReplacesArgs = {
invoiceId: number
replacesInvoiceId: number
}
export type InvoiceReissueArgs = {
invoiceId: number
createdBy: number | null
}
export type InvoiceTransitionError =
| { error: 'not_found' }
| { error: 'invalid_transition'; from: InvoiceStatus; to: InvoiceStatus }
| { error: 'missing_requisites'; missing: InvoiceMissingRequisite[] }
export type InvoiceTransitionResult = { invoice: InvoiceForClient } | InvoiceTransitionError
export type InvoicePdfLang = 'en' | 'ru'
export type InvoiceRenderPdfArgs = {
invoiceId: number
lang: InvoicePdfLang
}
export type PdfmakeServer = {
virtualfs: { writeFileSync(filename: string, content: Buffer): void }
setFonts(fonts: Record<string, { normal: string; bold: string; italics: string; bolditalics: string }>): void
createPdf(docDefinition: unknown): { getBuffer(): Promise<Buffer> }
}
export type InvoiceSnapshots = {
sellerSnapshot: InvoiceSellerSnapshot
counterpartySnapshot: InvoiceCounterpartySnapshot
goalName: string
}
export type InvoiceCreateRepoArgs = InvoiceSnapshots & {
data: InvoiceArgCreate
createdBy: number | null
}
export type InvoiceUpdateRepoArgs = InvoiceSnapshots & {
invoiceId: number
data: InvoiceArgUpdate
}
export type InvoiceCreateArgs = {
data: InvoiceArgCreate
createdBy: number | null
}
export type InvoiceUpdateArgs = {
invoiceId: number
data: InvoiceArgUpdate
}
export type InvoiceSetStatusArgs = {
invoiceId: number
status: InvoiceArgStatus['status']
}
export type InvoiceWithLines = InvoicesSchemaTypeForSelect & {
lines: InvoiceLinesSchemaTypeForSelect[]
}
export type InvoiceLineForClient = Omit<InvoiceLinesSchemaTypeForSelect, 'invoiceId' | 'position' | 'quantity' | 'unitPrice'> & {
quantity: number
unitPrice: number
}
export type InvoiceForClient = Omit<
InvoicesSchemaTypeForSelect,
'discountValue' | 'taxRate' | 'sellerSnapshot' | 'counterpartySnapshot' | 'subtotal' | 'discountAmount' | 'taxAmount' | 'total'
> & {
discountValue: number
taxRate: number
seller: InvoiceSellerSnapshot
counterparty: InvoiceCounterpartySnapshot
lines: InvoiceLineForClient[]
totals: InvoiceTotalsResult
totalsFrozen: boolean
}
export type InvoiceWriteError = 'seller_not_found' | 'counterparty_not_found' | 'goal_not_found' | 'duplicate_number' | 'not_found' | 'not_draft'
export type InvoiceWriteResult = { invoice: InvoiceForClient } | { error: InvoiceWriteError }
export type InvoiceDeleteResult = 'deleted' | 'not_draft' | 'not_found'
export type InvoiceNextNumberArgs = {
organizationId: number
base: string
}
+299
View File
@@ -0,0 +1,299 @@
import type { Request, Response } from 'express'
import { ArkErrors } from 'arktype'
import { PublicApiUrl } from '../../modules/public-url'
import { OAuthManager } from './OAuthManager'
import {
type SendTokenErrorArgs,
type SendTokenSuccessArgs,
OAuthAuthorizeArkType,
OAuthConsentArkType,
OAuthRegisterArkType,
OAuthRevokeArkType,
OAuthTokenArkType,
} from './types'
import {
buildRedirectUrl,
isAcceptableRedirectUri,
isDcrEnabled,
} from './oauth.utils'
export class OAuthController {
private get manager() { return OAuthManager.getInstance() }
authorize = async (req: Request, res: Response) => {
const params = OAuthAuthorizeArkType(req.query)
if (params instanceof ArkErrors) {
return res.status(400).send(params.summary)
}
const client = await this.manager.validateRedirectUri({
clientId: params.client_id,
redirectUri: params.redirect_uri,
})
if (!client.ok) {
return res.status(400).send(client.description)
}
const consentUrl = buildRedirectUrl({
redirectUri: `${process.env.APP_URL}/oauth/consent`,
params: {
client_id: params.client_id,
client_name: client.value.name,
redirect_uri: params.redirect_uri,
code_challenge: params.code_challenge,
code_challenge_method: params.code_challenge_method,
state: params.state,
resource: params.resource,
},
})
return res.redirect(consentUrl)
}
consent = async (req: Request, res: Response) => {
const data = OAuthConsentArkType(req.body)
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary)
}
const userId = req.appUser.getUserData()?.id
if (!userId) return res.status(401).end()
const client = await this.manager.validateRedirectUri({
clientId: data.client_id,
redirectUri: data.redirect_uri,
})
if (!client.ok) {
return res.status(400).send(client.description)
}
// Empty means "do not narrow", matching how a tvk_ token with no
// permissions selected behaves. The user's own RBAC is still the ceiling.
const code = await this.manager.issueAuthCode({
clientId: data.client_id,
userId,
redirectUri: data.redirect_uri,
codeChallenge: data.code_challenge,
codeChallengeMethod: data.code_challenge_method,
allowedPermissions: data.allowedPermissions ?? [],
allowedGoalIds: data.allowedGoalIds ?? [],
resource: data.resource ?? null,
})
if (!code) return res.status(500).end()
return res.tvJson({
redirectUrl: buildRedirectUrl({
redirectUri: data.redirect_uri,
params: { code, state: data.state },
}),
})
}
denyConsent = async (req: Request, res: Response) => {
const data = OAuthConsentArkType(req.body)
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary)
}
const client = await this.manager.validateRedirectUri({
clientId: data.client_id,
redirectUri: data.redirect_uri,
})
if (!client.ok) {
return res.status(400).send(client.description)
}
return res.tvJson({
redirectUrl: buildRedirectUrl({
redirectUri: data.redirect_uri,
params: { error: 'access_denied', state: data.state },
}),
})
}
token = async (req: Request, res: Response) => {
const data = OAuthTokenArkType(req.body)
if (data instanceof ArkErrors) {
return this.sendTokenError({ res, status: 400, error: 'invalid_request', description: data.summary })
}
const basic = this.readBasicAuth(req)
const clientId = basic?.clientId ?? data.client_id
const clientSecret = basic?.clientSecret ?? data.client_secret
if (!clientId) {
return this.sendTokenError({ res, status: 401, error: 'invalid_client', description: 'client_id is required' })
}
const client = await this.manager.authenticateClient({ clientId, clientSecret })
if (!client.ok) {
return this.sendTokenError({ res, status: 401, error: client.error, description: client.description })
}
const resource = data.resource ?? null
if (data.grant_type === 'authorization_code') {
if (!data.code || !data.code_verifier || !data.redirect_uri) {
return this.sendTokenError({
res,
status: 400,
error: 'invalid_request',
description: 'code, code_verifier and redirect_uri are required',
})
}
const result = await this.manager.exchangeCode({
code: data.code,
codeVerifier: data.code_verifier,
clientId,
redirectUri: data.redirect_uri,
resource,
})
if (!result.ok) {
return this.sendTokenError({ res, status: 400, error: result.error, description: result.description })
}
return this.sendTokenSuccess({ res, body: result.value })
}
if (!data.refresh_token) {
return this.sendTokenError({ res, status: 400, error: 'invalid_request', description: 'refresh_token is required' })
}
const refreshed = await this.manager.refreshTokens({
refreshToken: data.refresh_token,
clientId,
resource,
})
if (!refreshed.ok) {
return this.sendTokenError({ res, status: 400, error: refreshed.error, description: refreshed.description })
}
return this.sendTokenSuccess({ res, body: refreshed.value })
}
register = async (req: Request, res: Response) => {
if (!isDcrEnabled()) {
return res.status(403).json({
error: 'access_denied',
error_description: 'Dynamic client registration is disabled on this instance',
})
}
const data = OAuthRegisterArkType(req.body)
if (data instanceof ArkErrors) {
return res.status(400).json({ error: 'invalid_client_metadata', error_description: data.summary })
}
const rejected = data.redirect_uris.find((uri) => !isAcceptableRedirectUri(uri))
if (rejected) {
return res.status(400).json({
error: 'invalid_redirect_uri',
error_description: `redirect_uri must be https, loopback http, or an app scheme, and carry no fragment: ${rejected}`,
})
}
const name = data.client_name?.trim() || 'Unnamed client'
const isPublic = (data.token_endpoint_auth_method ?? 'none') === 'none'
const result = await this.manager.registerClient({
name,
redirectUris: data.redirect_uris,
isPublic,
})
if (!result.ok) {
return res.status(500).json({ error: result.error, error_description: result.description })
}
return res.status(201).json({
client_id: result.value.clientId,
// RFC 7591 §3.2.1: client_secret_expires_at is REQUIRED whenever a
// secret is issued. 0 means it does not expire.
...(result.value.clientSecret
? { client_secret: result.value.clientSecret, client_secret_expires_at: 0 }
: {}),
client_id_issued_at: Math.floor(Date.now() / 1000),
client_name: name,
redirect_uris: data.redirect_uris,
token_endpoint_auth_method: isPublic ? 'none' : 'client_secret_post',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
})
}
revoke = async (req: Request, res: Response) => {
const data = OAuthRevokeArkType(req.body)
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary)
}
// RFC 7009: revocation always answers 200, even for an unknown token.
await this.manager.revokeToken(data.token)
return res.status(200).end()
}
fetchConnectedApps = async (req: Request, res: Response) => {
const userId = req.appUser.getUserData()?.id
if (!userId) return res.status(401).end()
const result = await this.manager.fetchConnectedApps(userId)
return res.tvJson(result)
}
revokeConnectedApp = async (req: Request, res: Response) => {
const userId = req.appUser.getUserData()?.id
if (!userId) return res.status(401).end()
const grantId = Number(req.body?.grantId)
if (!Number.isInteger(grantId) || grantId <= 0) {
return res.status(400).send('grantId must be a positive integer')
}
const result = await this.manager.revokeGrant({ grantId, userId })
return res.tvJson(result)
}
authorizationServerMetadata = async (req: Request, res: Response) => {
const issuer = PublicApiUrl.base(req)
return res.json({
issuer,
authorization_endpoint: `${issuer}/module/oauth/authorize`,
token_endpoint: `${issuer}/module/oauth/token`,
revocation_endpoint: `${issuer}/module/oauth/revoke`,
...(isDcrEnabled() ? { registration_endpoint: `${issuer}/module/oauth/register` } : {}),
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['none', 'client_secret_post', 'client_secret_basic'],
})
}
protectedResourceMetadata = async (req: Request, res: Response) => {
const issuer = PublicApiUrl.base(req)
return res.json({
resource: issuer,
authorization_servers: [issuer],
bearer_methods_supported: ['header'],
})
}
private readBasicAuth(req: Request): { clientId: string; clientSecret: string } | null {
const header = req.headers.authorization
if (!header?.toLowerCase().startsWith('basic ')) return null
const decoded = Buffer.from(header.slice(6).trim(), 'base64').toString('utf8')
const separator = decoded.indexOf(':')
if (separator < 0) return null
return {
clientId: decodeURIComponent(decoded.slice(0, separator)),
clientSecret: decodeURIComponent(decoded.slice(separator + 1)),
}
}
private sendTokenSuccess(args: SendTokenSuccessArgs) {
args.res.setHeader('Cache-Control', 'no-store')
args.res.setHeader('Pragma', 'no-cache')
return args.res.json(args.body)
}
private sendTokenError(args: SendTokenErrorArgs) {
args.res.setHeader('Cache-Control', 'no-store')
return args.res.status(args.status).json({
error: args.error,
error_description: args.description,
})
}
}
+259
View File
@@ -0,0 +1,259 @@
import { randomBytes } from 'crypto'
import type { OAuthClientsSchemaTypeForSelect, OAuthGrantsSchemaTypeForSelect } from 'taskview-db-schemas'
import { OAuthRepository } from './OAuthRepository'
import {
OAUTH_ACCESS_TOKEN_PREFIX,
OAUTH_ACCESS_TOKEN_TTL_SECONDS,
OAUTH_CODE_TTL_SECONDS,
OAUTH_REFRESH_TOKEN_TTL_SECONDS,
type AuthenticateClientArgs,
type ConnectedApp,
type CreateAuthCodeArgs,
type ExchangeCodeArgs,
type IssueTokenPairArgs,
type OAuthResult,
type OAuthTokenResponse,
type RefreshTokensArgs,
type RegisterClientArgs,
type RevokeOwnGrantArgs,
type ValidateRedirectUriArgs,
} from './types'
import {
matchesRegisteredRedirectUri,
randomToken,
resourceMatches,
safeCompareHex,
sha256Hex,
verifyPkce,
} from './oauth.utils'
export class OAuthManager {
private static instance: OAuthManager | null = null
public readonly repository: OAuthRepository
constructor() {
this.repository = new OAuthRepository()
}
static getInstance(): OAuthManager {
if (!OAuthManager.instance) OAuthManager.instance = new OAuthManager()
return OAuthManager.instance
}
async findClient(clientId: string): Promise<OAuthClientsSchemaTypeForSelect | null> {
return this.repository.findClientByClientId(clientId)
}
async validateRedirectUri(args: ValidateRedirectUriArgs): Promise<OAuthResult<OAuthClientsSchemaTypeForSelect>> {
const client = await this.repository.findClientByClientId(args.clientId)
if (!client) {
return { ok: false, error: 'invalid_client', description: 'Unknown client_id' }
}
if (!matchesRegisteredRedirectUri({ candidate: args.redirectUri, registered: client.redirectUris })) {
return { ok: false, error: 'invalid_request', description: 'redirect_uri does not match a registered URI' }
}
return { ok: true, value: client }
}
async issueAuthCode(args: CreateAuthCodeArgs): Promise<string | null> {
const code = randomToken()
const created = await this.repository.createAuthCode({
...args,
codeHash: sha256Hex(code),
expiresAt: new Date(Date.now() + OAUTH_CODE_TTL_SECONDS * 1000),
})
if (!created) return null
this.repository.deleteExpiredAuthCodes().catch(() => {})
return code
}
async exchangeCode(args: ExchangeCodeArgs): Promise<OAuthResult<OAuthTokenResponse>> {
const record = await this.repository.findAuthCodeByHash(sha256Hex(args.code))
if (!record) {
return { ok: false, error: 'invalid_grant', description: 'Authorization code is not valid' }
}
// A code presented twice means it leaked. Kill the grant it already produced.
if (record.usedAt) {
if (record.grantId) await this.repository.revokeGrant({ grantId: record.grantId })
return { ok: false, error: 'invalid_grant', description: 'Authorization code has already been used' }
}
if (record.expiresAt < new Date()) {
return { ok: false, error: 'invalid_grant', description: 'Authorization code has expired' }
}
if (record.clientId !== args.clientId) {
return { ok: false, error: 'invalid_grant', description: 'Authorization code was issued to another client' }
}
if (record.redirectUri !== args.redirectUri) {
return { ok: false, error: 'invalid_grant', description: 'redirect_uri does not match the authorization request' }
}
if (!verifyPkce({ codeVerifier: args.codeVerifier, codeChallenge: record.codeChallenge })) {
return { ok: false, error: 'invalid_grant', description: 'code_verifier does not match code_challenge' }
}
if (!resourceMatches({ granted: record.resource, requested: args.resource })) {
return { ok: false, error: 'invalid_target', description: 'resource does not match the authorization request' }
}
const client = await this.repository.findClientByClientId(record.clientId)
const grant = await this.repository.createGrant({
userId: record.userId,
clientId: record.clientId,
allowedPermissions: record.allowedPermissions,
allowedGoalIds: record.allowedGoalIds,
resource: record.resource,
})
if (!grant) {
return { ok: false, error: 'server_error', description: 'Could not create the grant' }
}
const consumed = await this.repository.consumeAuthCode({ id: record.id, grantId: grant.id })
if (!consumed) {
await this.repository.revokeGrant({ grantId: grant.id })
return { ok: false, error: 'invalid_grant', description: 'Authorization code has already been used' }
}
return this.issueTokenPair({ grant, clientName: client?.name ?? record.clientId })
}
async refreshTokens(args: RefreshTokensArgs): Promise<OAuthResult<OAuthTokenResponse>> {
const presentedHash = sha256Hex(args.refreshToken)
const grant = await this.repository.findGrantByRefreshHash(presentedHash)
if (!grant) {
return { ok: false, error: 'invalid_grant', description: 'Refresh token is not valid' }
}
if (grant.revokedAt) {
return { ok: false, error: 'invalid_grant', description: 'This authorization has been revoked' }
}
// The previous token in the rotation chain showing up means it leaked.
if (grant.refreshTokenPrevHash && safeCompareHex(grant.refreshTokenPrevHash, presentedHash)) {
await this.repository.revokeGrant({ grantId: grant.id })
return { ok: false, error: 'invalid_grant', description: 'Refresh token was reused; the authorization has been revoked' }
}
if (grant.refreshExpiresAt && grant.refreshExpiresAt < new Date()) {
return { ok: false, error: 'invalid_grant', description: 'Refresh token has expired' }
}
if (grant.clientId !== args.clientId) {
return { ok: false, error: 'invalid_grant', description: 'Refresh token was issued to another client' }
}
if (!resourceMatches({ granted: grant.resource, requested: args.resource })) {
return { ok: false, error: 'invalid_target', description: 'resource does not match the granted audience' }
}
const client = await this.repository.findClientByClientId(grant.clientId)
return this.issueTokenPair({ grant, clientName: client?.name ?? grant.clientId, prevRefreshHash: presentedHash })
}
async registerClient(args: RegisterClientArgs): Promise<OAuthResult<{ clientId: string; clientSecret: string | null }>> {
const clientId = randomBytes(16).toString('hex')
const clientSecret = args.isPublic ? null : randomToken()
const client = await this.repository.createClient({
clientId,
clientSecretHash: clientSecret ? sha256Hex(clientSecret) : null,
name: args.name.slice(0, 200),
redirectUris: args.redirectUris,
createdVia: 'dcr',
})
if (!client) {
return { ok: false, error: 'server_error', description: 'Could not register the client' }
}
return { ok: true, value: { clientId, clientSecret } }
}
async authenticateClient(args: AuthenticateClientArgs): Promise<OAuthResult<OAuthClientsSchemaTypeForSelect>> {
const client = await this.repository.findClientByClientId(args.clientId)
if (!client) {
return { ok: false, error: 'invalid_client', description: 'Unknown client_id' }
}
if (client.clientSecretHash) {
if (!args.clientSecret || !safeCompareHex(client.clientSecretHash, sha256Hex(args.clientSecret))) {
return { ok: false, error: 'invalid_client', description: 'Client authentication failed' }
}
}
return { ok: true, value: client }
}
async fetchConnectedApps(userId: number): Promise<ConnectedApp[]> {
const grants = await this.repository.fetchGrantsByUserId(userId)
if (!grants.length) return []
const clients = await this.repository.fetchClientsByClientIds(grants.map((grant) => grant.clientId))
const nameByClientId = new Map(clients.map((client) => [client.clientId, client.name]))
return grants.map((grant) => ({
grantId: grant.id,
clientId: grant.clientId,
clientName: nameByClientId.get(grant.clientId) ?? grant.clientId,
allowedPermissions: grant.allowedPermissions,
allowedGoalIds: grant.allowedGoalIds,
createdAt: grant.createdAt,
lastUsedAt: grant.lastUsedAt,
}))
}
async revokeGrant(args: RevokeOwnGrantArgs): Promise<boolean> {
return this.repository.revokeGrant(args)
}
/** RFC 7009: accepts either an access token or a refresh token. */
async revokeToken(token: string): Promise<void> {
const hash = sha256Hex(token)
const grantId = await this.repository.findGrantIdByAccessTokenHash(hash)
if (grantId) {
await this.repository.revokeGrant({ grantId })
return
}
const grant = await this.repository.findGrantByRefreshHash(hash)
if (grant) await this.repository.revokeGrant({ grantId: grant.id })
}
private async issueTokenPair(args: IssueTokenPairArgs): Promise<OAuthResult<OAuthTokenResponse>> {
// Copied straight from the grant: these are the RBAC keys the user
// ticked. An empty list means "do not narrow anything", the same thing
// it means for a manually issued tvk_ token.
const allowedPermissions = args.grant.allowedPermissions
const accessToken = OAUTH_ACCESS_TOKEN_PREFIX + randomToken()
const refreshToken = randomToken()
const now = Date.now()
const rotated = await this.repository.rotateRefreshToken({
grantId: args.grant.id,
refreshTokenHash: sha256Hex(refreshToken),
prevHash: args.prevRefreshHash ?? null,
refreshExpiresAt: new Date(now + OAUTH_REFRESH_TOKEN_TTL_SECONDS * 1000),
presentedHash: args.prevRefreshHash ?? null,
})
if (!rotated) {
return { ok: false, error: 'invalid_grant', description: 'This authorization has been revoked' }
}
const created = await this.repository.createAccessToken({
userId: args.grant.userId,
name: args.clientName.slice(0, 100),
tokenHash: sha256Hex(accessToken),
allowedPermissions,
allowedGoalIds: args.grant.allowedGoalIds,
grantId: args.grant.id,
expiresAt: new Date(now + OAUTH_ACCESS_TOKEN_TTL_SECONDS * 1000),
})
if (!created) {
return { ok: false, error: 'server_error', description: 'Could not issue the access token' }
}
return {
ok: true,
value: {
access_token: accessToken,
token_type: 'Bearer',
expires_in: OAUTH_ACCESS_TOKEN_TTL_SECONDS,
refresh_token: refreshToken,
},
}
}
}
+185
View File
@@ -0,0 +1,185 @@
import { and, eq, isNull, lt, or } from 'drizzle-orm'
import {
ApiTokensSchema,
OAuthAuthCodesSchema,
OAuthClientsSchema,
OAuthGrantsSchema,
type OAuthAuthCodesSchemaTypeForSelect,
type OAuthClientsSchemaTypeForSelect,
type OAuthGrantsSchemaTypeForInsert,
type OAuthGrantsSchemaTypeForSelect,
} from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch } from '../../utils/helpers'
import type {
ConsumeAuthCodeArgs,
CreateAccessTokenArgs,
CreateAuthCodeArgs,
CreateOAuthClientArgs,
RevokeGrantArgs,
RotateRefreshTokenArgs,
} from './types'
export class OAuthRepository {
private readonly db: Database
constructor() {
this.db = Database.getInstance()
}
async createClient(data: CreateOAuthClientArgs): Promise<OAuthClientsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.insert(OAuthClientsSchema).values(data).returning(),
)
return result?.[0] ?? null
}
async findClientByClientId(clientId: string): Promise<OAuthClientsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(OAuthClientsSchema).where(eq(OAuthClientsSchema.clientId, clientId)),
)
return result?.[0] ?? null
}
async createAuthCode(data: CreateAuthCodeArgs & { codeHash: string; expiresAt: Date }): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.insert(OAuthAuthCodesSchema).values({
codeHash: data.codeHash,
clientId: data.clientId,
userId: data.userId,
redirectUri: data.redirectUri,
codeChallenge: data.codeChallenge,
codeChallengeMethod: data.codeChallengeMethod,
allowedPermissions: data.allowedPermissions,
allowedGoalIds: data.allowedGoalIds,
resource: data.resource,
expiresAt: data.expiresAt,
}).returning(),
)
return !!result?.length
}
async findAuthCodeByHash(codeHash: string): Promise<OAuthAuthCodesSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(OAuthAuthCodesSchema).where(eq(OAuthAuthCodesSchema.codeHash, codeHash)),
)
return result?.[0] ?? null
}
/**
* Single-use redemption. The UPDATE only matches while used_at is still NULL,
* so two concurrent exchanges of the same code cannot both win.
*/
async consumeAuthCode(args: ConsumeAuthCodeArgs): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(OAuthAuthCodesSchema)
.set({ usedAt: new Date(), grantId: args.grantId })
.where(and(eq(OAuthAuthCodesSchema.id, args.id), isNull(OAuthAuthCodesSchema.usedAt))),
)
return !!result?.rowCount
}
async deleteExpiredAuthCodes(): Promise<void> {
await callWithCatch(() =>
this.db.dbDrizzle.delete(OAuthAuthCodesSchema).where(lt(OAuthAuthCodesSchema.expiresAt, new Date())),
)
}
async createGrant(data: OAuthGrantsSchemaTypeForInsert): Promise<OAuthGrantsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.insert(OAuthGrantsSchema).values(data).returning(),
)
return result?.[0] ?? null
}
/** Matches the current refresh token or the previous one, so replay is detectable. */
async findGrantByRefreshHash(refreshTokenHash: string): Promise<OAuthGrantsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(OAuthGrantsSchema).where(
or(
eq(OAuthGrantsSchema.refreshTokenHash, refreshTokenHash),
eq(OAuthGrantsSchema.refreshTokenPrevHash, refreshTokenHash),
),
),
)
return result?.[0] ?? null
}
async rotateRefreshToken(args: RotateRefreshTokenArgs): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(OAuthGrantsSchema)
.set({
refreshTokenHash: args.refreshTokenHash,
refreshTokenPrevHash: args.prevHash,
refreshExpiresAt: args.refreshExpiresAt,
lastUsedAt: new Date(),
})
.where(and(
eq(OAuthGrantsSchema.id, args.grantId),
isNull(OAuthGrantsSchema.revokedAt),
// The presented token must still be the current one. Without this,
// two concurrent refreshes both succeed and the second overwrites
// the first, silently orphaning the refresh token it just handed out.
args.presentedHash
? eq(OAuthGrantsSchema.refreshTokenHash, args.presentedHash)
: isNull(OAuthGrantsSchema.refreshTokenHash),
)),
)
return !!result?.rowCount
}
async fetchGrantsByUserId(userId: number): Promise<OAuthGrantsSchemaTypeForSelect[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(OAuthGrantsSchema).where(
and(eq(OAuthGrantsSchema.userId, userId), isNull(OAuthGrantsSchema.revokedAt)),
),
)
return result ?? []
}
async fetchClientsByClientIds(clientIds: string[]): Promise<OAuthClientsSchemaTypeForSelect[]> {
if (!clientIds.length) return []
const result = await callWithCatch(() =>
this.db.dbDrizzle.select().from(OAuthClientsSchema),
)
return (result ?? []).filter((client) => clientIds.includes(client.clientId))
}
/**
* Revoking a grant also deletes its live access tokens — that is the whole
* point of storing them as opaque rows instead of self-contained JWTs.
*/
async revokeGrant(args: RevokeGrantArgs): Promise<boolean> {
const where = args.userId === undefined
? eq(OAuthGrantsSchema.id, args.grantId)
: and(eq(OAuthGrantsSchema.id, args.grantId), eq(OAuthGrantsSchema.userId, args.userId))
const result = await callWithCatch(() =>
this.db.dbDrizzle.update(OAuthGrantsSchema)
.set({ revokedAt: new Date(), refreshTokenHash: null, refreshTokenPrevHash: null })
.where(where),
)
if (!result?.rowCount) return false
await callWithCatch(() =>
this.db.dbDrizzle.delete(ApiTokensSchema).where(eq(ApiTokensSchema.grantId, args.grantId)),
)
return true
}
async createAccessToken(data: CreateAccessTokenArgs): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.insert(ApiTokensSchema).values(data).returning(),
)
return !!result?.length
}
async findGrantIdByAccessTokenHash(tokenHash: string): Promise<number | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select({ grantId: ApiTokensSchema.grantId })
.from(ApiTokensSchema)
.where(eq(ApiTokensSchema.tokenHash, tokenHash)),
)
return result?.[0]?.grantId ?? null
}
}
+35
View File
@@ -0,0 +1,35 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { RejectApiTokenAuth } from '../api-tokens/middlewares/RejectApiTokenAuth'
import { OAuthController } from './OAuthController'
export default class OAuthRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: OAuthController
constructor() {
this.router = Router()
this.controller = new OAuthController()
this.initRoutes()
}
getRouter() {
return this.router
}
initRoutes() {
this.router.get('/authorize', this.controller.authorize)
this.router.post('/token', this.controller.token)
this.router.post('/revoke', this.controller.revoke)
this.router.post('/register', this.controller.register)
// Consent is the one place a real human decides. It must be a browser
// session, never an API token acting on the user's behalf.
this.router.post('/consent', [IsLoggedIn, RejectApiTokenAuth], this.controller.consent)
this.router.post('/consent/deny', [IsLoggedIn, RejectApiTokenAuth], this.controller.denyConsent)
this.router.get('/connected-apps', [IsLoggedIn, RejectApiTokenAuth], this.controller.fetchConnectedApps)
this.router.delete('/connected-apps', [IsLoggedIn, RejectApiTokenAuth], this.controller.revokeConnectedApp)
}
}
@@ -0,0 +1,27 @@
import { Router } from 'express'
import type { Routable } from '../../types/routable.type'
import { OAuthController } from './OAuthController'
/**
* RFC 8414 / RFC 9728 discovery. These must sit at the origin root, not under
* /module, because that is where clients look before they have any credentials.
*/
export default class OAuthWellKnownRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
private readonly controller: OAuthController
constructor() {
this.router = Router()
this.controller = new OAuthController()
this.initRoutes()
}
getRouter() {
return this.router
}
initRoutes() {
this.router.get('/oauth-authorization-server', this.controller.authorizationServerMetadata)
this.router.get('/oauth-protected-resource', this.controller.protectedResourceMetadata)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
import axios from 'axios';
import type http from 'http';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import App from '../../../App';
const port = 1812;
const url = `http://localhost:${port}`;
let server: http.Server;
const api = axios.create({ baseURL: url, validateStatus: () => true });
/**
* OAUTH_DYNAMIC_REGISTRATION=false is the lever a self-hosted operator pulls to
* keep the client registry closed. It has to do two things: refuse registration,
* and stop advertising the endpoint — a client that reads the metadata should
* never attempt a registration this instance will reject.
*/
describe('OAuth with dynamic client registration disabled', () => {
vi.mock('emailjs', () => ({
SMTPClient: vi.fn().mockImplementation(() => ({ sendAsync: vi.fn().mockResolvedValue(true) })),
}));
beforeAll(() => {
process.env.OAUTH_DYNAMIC_REGISTRATION = 'false';
server = new App(port).listen();
});
afterAll(() => {
server?.close();
delete process.env.OAUTH_DYNAMIC_REGISTRATION;
});
it('refuses to register a client', async () => {
const response = await api.post('/module/oauth/register', {
client_name: 'Should be refused',
redirect_uris: ['https://client.test/cb'],
});
expect(response.status).toBe(403);
expect(response.data.error).toBe('access_denied');
});
it('stops advertising the registration endpoint in the metadata', async () => {
const response = await api.get('/.well-known/oauth-authorization-server');
expect(response.status).toBe(200);
expect(response.data.registration_endpoint).toBeUndefined();
// The rest of the flow stays available for manually seeded clients.
expect(response.data.authorization_endpoint).toContain('/module/oauth/authorize');
expect(response.data.token_endpoint).toContain('/module/oauth/token');
});
});
@@ -0,0 +1,328 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createHash, randomBytes } from 'crypto'
const repositoryMock = {
findClientByClientId: vi.fn(),
findAuthCodeByHash: vi.fn(),
consumeAuthCode: vi.fn(),
createGrant: vi.fn(),
createAccessToken: vi.fn(),
rotateRefreshToken: vi.fn(),
revokeGrant: vi.fn(),
findGrantByRefreshHash: vi.fn(),
findGrantIdByAccessTokenHash: vi.fn(),
createAuthCode: vi.fn(),
deleteExpiredAuthCodes: vi.fn(),
}
vi.mock('../OAuthRepository', () => ({
OAuthRepository: vi.fn(() => repositoryMock),
}))
const { OAuthManager } = await import('../OAuthManager')
const sha256 = (value: string) => createHash('sha256').update(value).digest('hex')
const VERIFIER = randomBytes(40).toString('base64url')
const CHALLENGE = createHash('sha256').update(VERIFIER).digest('base64url')
const validCode = (overrides: Record<string, unknown> = {}) => ({
id: 1,
codeHash: sha256('the-code'),
clientId: 'client-a',
userId: 7,
redirectUri: 'https://app.example.com/cb',
codeChallenge: CHALLENGE,
codeChallengeMethod: 'S256',
allowedPermissions: ['goal_can_watch_content'],
allowedGoalIds: [],
resource: null,
expiresAt: new Date(Date.now() + 60_000),
usedAt: null,
grantId: null,
createdAt: new Date(),
...overrides,
})
const validGrant = (overrides: Record<string, unknown> = {}) => ({
id: 42,
userId: 7,
clientId: 'client-a',
allowedPermissions: ['goal_can_watch_content'],
allowedGoalIds: [],
resource: null,
refreshTokenHash: sha256('current-refresh'),
refreshTokenPrevHash: null,
refreshExpiresAt: new Date(Date.now() + 86_400_000),
lastUsedAt: null,
revokedAt: null,
createdAt: new Date(),
...overrides,
})
const exchangeArgs = (overrides: Record<string, unknown> = {}) => ({
code: 'the-code',
codeVerifier: VERIFIER,
clientId: 'client-a',
redirectUri: 'https://app.example.com/cb',
resource: null,
...overrides,
})
describe('OAuthManager.exchangeCode', () => {
let manager: InstanceType<typeof OAuthManager>
beforeEach(() => {
vi.clearAllMocks()
manager = new OAuthManager()
repositoryMock.findClientByClientId.mockResolvedValue({ clientId: 'client-a', name: 'Client A' })
repositoryMock.createGrant.mockResolvedValue(validGrant())
repositoryMock.consumeAuthCode.mockResolvedValue(true)
repositoryMock.rotateRefreshToken.mockResolvedValue(true)
repositoryMock.createAccessToken.mockResolvedValue(true)
repositoryMock.revokeGrant.mockResolvedValue(true)
})
it('issues a tvo_ access token and a refresh token on a valid exchange', async () => {
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
const result = await manager.exchangeCode(exchangeArgs())
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value.access_token.startsWith('tvo_')).toBe(true)
expect(result.value.refresh_token).toBeTruthy()
expect(result.value.token_type).toBe('Bearer')
})
it('stores only the hash of the access token, never the token itself', async () => {
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
const result = await manager.exchangeCode(exchangeArgs())
expect(result.ok).toBe(true)
if (!result.ok) return
const stored = repositoryMock.createAccessToken.mock.calls[0][0]
expect(stored.tokenHash).toBe(sha256(result.value.access_token))
expect(JSON.stringify(stored)).not.toContain(result.value.access_token)
})
it('rejects a wrong code_verifier', async () => {
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
const result = await manager.exchangeCode(exchangeArgs({ codeVerifier: randomBytes(40).toString('base64url') }))
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
expect(repositoryMock.createGrant).not.toHaveBeenCalled()
})
it('rejects a redirect_uri that differs from the authorization request', async () => {
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
const result = await manager.exchangeCode(exchangeArgs({ redirectUri: 'https://app.example.com/other' }))
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
})
it('rejects a code presented by a different client', async () => {
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
const result = await manager.exchangeCode(exchangeArgs({ clientId: 'client-b' }))
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
})
it('rejects an expired code', async () => {
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode({ expiresAt: new Date(Date.now() - 1000) }))
const result = await manager.exchangeCode(exchangeArgs())
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
})
it('revokes the original grant when a used code is replayed', async () => {
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode({ usedAt: new Date(), grantId: 42 }))
const result = await manager.exchangeCode(exchangeArgs())
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
expect(repositoryMock.revokeGrant).toHaveBeenCalledWith({ grantId: 42 })
})
it('rolls back the grant when the code was consumed by a concurrent request', async () => {
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode())
repositoryMock.consumeAuthCode.mockResolvedValue(false)
const result = await manager.exchangeCode(exchangeArgs())
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
expect(repositoryMock.revokeGrant).toHaveBeenCalledWith({ grantId: 42 })
})
it('rejects a resource that does not match the authorization request', async () => {
repositoryMock.findAuthCodeByHash.mockResolvedValue(validCode({ resource: 'https://mcp.example.com' }))
const result = await manager.exchangeCode(exchangeArgs({ resource: 'https://other.example.com' }))
expect(result).toMatchObject({ ok: false, error: 'invalid_target' })
})
it('rejects an unknown code', async () => {
repositoryMock.findAuthCodeByHash.mockResolvedValue(null)
const result = await manager.exchangeCode(exchangeArgs())
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
})
})
describe('OAuthManager.refreshTokens', () => {
let manager: InstanceType<typeof OAuthManager>
beforeEach(() => {
vi.clearAllMocks()
manager = new OAuthManager()
repositoryMock.findClientByClientId.mockResolvedValue({ clientId: 'client-a', name: 'Client A' })
repositoryMock.rotateRefreshToken.mockResolvedValue(true)
repositoryMock.createAccessToken.mockResolvedValue(true)
repositoryMock.revokeGrant.mockResolvedValue(true)
})
it('rotates the refresh token and keeps the presented one as the previous hash', async () => {
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant())
const result = await manager.refreshTokens({
refreshToken: 'current-refresh',
clientId: 'client-a',
resource: null,
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value.refresh_token).not.toBe('current-refresh')
const rotation = repositoryMock.rotateRefreshToken.mock.calls[0][0]
expect(rotation.prevHash).toBe(sha256('current-refresh'))
expect(rotation.refreshTokenHash).toBe(sha256(result.value.refresh_token))
})
it('revokes the whole grant when a superseded refresh token is replayed', async () => {
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({
refreshTokenHash: sha256('rotated-refresh'),
refreshTokenPrevHash: sha256('leaked-refresh'),
}))
const result = await manager.refreshTokens({
refreshToken: 'leaked-refresh',
clientId: 'client-a',
resource: null,
})
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
expect(repositoryMock.revokeGrant).toHaveBeenCalledWith({ grantId: 42 })
})
it('refuses a revoked grant', async () => {
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({ revokedAt: new Date() }))
const result = await manager.refreshTokens({
refreshToken: 'current-refresh',
clientId: 'client-a',
resource: null,
})
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
})
it('refuses an expired refresh token', async () => {
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({
refreshExpiresAt: new Date(Date.now() - 1000),
}))
const result = await manager.refreshTokens({
refreshToken: 'current-refresh',
clientId: 'client-a',
resource: null,
})
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
})
it('refuses a refresh token presented by another client', async () => {
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant())
const result = await manager.refreshTokens({
refreshToken: 'current-refresh',
clientId: 'client-b',
resource: null,
})
expect(result).toMatchObject({ ok: false, error: 'invalid_grant' })
})
it('carries the consented permission keys onto the reissued token', async () => {
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({
allowedPermissions: ['timetracking_can_view', 'task_can_edit_status'],
}))
await manager.refreshTokens({ refreshToken: 'current-refresh', clientId: 'client-a', resource: null })
const stored = repositoryMock.createAccessToken.mock.calls[0][0]
expect(stored.allowedPermissions).toEqual(['timetracking_can_view', 'task_can_edit_status'])
})
it('keeps an unrestricted grant unrestricted, which is what "all permissions" means', async () => {
repositoryMock.findGrantByRefreshHash.mockResolvedValue(validGrant({ allowedPermissions: [] }))
await manager.refreshTokens({ refreshToken: 'current-refresh', clientId: 'client-a', resource: null })
const stored = repositoryMock.createAccessToken.mock.calls[0][0]
expect(stored.allowedPermissions).toEqual([])
})
})
describe('OAuthManager.authenticateClient', () => {
let manager: InstanceType<typeof OAuthManager>
beforeEach(() => {
vi.clearAllMocks()
manager = new OAuthManager()
})
it('accepts a public client with no secret', async () => {
repositoryMock.findClientByClientId.mockResolvedValue({ clientId: 'pub', clientSecretHash: null })
const result = await manager.authenticateClient({ clientId: 'pub' })
expect(result.ok).toBe(true)
})
it('rejects a confidential client presenting the wrong secret', async () => {
repositoryMock.findClientByClientId.mockResolvedValue({
clientId: 'conf',
clientSecretHash: sha256('right'),
})
const result = await manager.authenticateClient({ clientId: 'conf', clientSecret: 'wrong' })
expect(result).toMatchObject({ ok: false, error: 'invalid_client' })
})
it('rejects a confidential client presenting no secret at all', async () => {
repositoryMock.findClientByClientId.mockResolvedValue({
clientId: 'conf',
clientSecretHash: sha256('right'),
})
const result = await manager.authenticateClient({ clientId: 'conf' })
expect(result).toMatchObject({ ok: false, error: 'invalid_client' })
})
it('rejects an unknown client', async () => {
repositoryMock.findClientByClientId.mockResolvedValue(null)
const result = await manager.authenticateClient({ clientId: 'nope' })
expect(result).toMatchObject({ ok: false, error: 'invalid_client' })
})
})
@@ -0,0 +1,158 @@
import { describe, it, expect } from 'vitest'
import { createHash, randomBytes } from 'crypto'
import {
buildRedirectUrl,
isAcceptableRedirectUri,
matchesRegisteredRedirectUri,
resourceMatches,
verifyPkce,
} from '../oauth.utils'
const challengeFor = (verifier: string) =>
createHash('sha256').update(verifier).digest('base64url')
describe('verifyPkce', () => {
const verifier = randomBytes(40).toString('base64url')
it('accepts the verifier that produced the challenge', () => {
expect(verifyPkce({ codeVerifier: verifier, codeChallenge: challengeFor(verifier) })).toBe(true)
})
it('rejects a different verifier', () => {
const other = randomBytes(40).toString('base64url')
expect(verifyPkce({ codeVerifier: other, codeChallenge: challengeFor(verifier) })).toBe(false)
})
it('rejects a verifier shorter than the RFC 7636 minimum', () => {
const short = 'abc'
expect(verifyPkce({ codeVerifier: short, codeChallenge: challengeFor(short) })).toBe(false)
})
it('rejects a verifier longer than the RFC 7636 maximum', () => {
const long = 'a'.repeat(129)
expect(verifyPkce({ codeVerifier: long, codeChallenge: challengeFor(long) })).toBe(false)
})
})
describe('matchesRegisteredRedirectUri', () => {
it('accepts an exact match', () => {
expect(matchesRegisteredRedirectUri({
candidate: 'https://chat.example.com/callback',
registered: ['https://chat.example.com/callback'],
})).toBe(true)
})
it('rejects a different path on the same host', () => {
expect(matchesRegisteredRedirectUri({
candidate: 'https://chat.example.com/evil',
registered: ['https://chat.example.com/callback'],
})).toBe(false)
})
it('rejects an attacker host that merely prefixes the registered one', () => {
expect(matchesRegisteredRedirectUri({
candidate: 'https://chat.example.com.evil.test/callback',
registered: ['https://chat.example.com/callback'],
})).toBe(false)
})
it('allows any loopback port for the same path (RFC 8252)', () => {
expect(matchesRegisteredRedirectUri({
candidate: 'http://127.0.0.1:55123/callback',
registered: ['http://127.0.0.1:8080/callback'],
})).toBe(true)
})
it('does not extend the loopback port carve-out to other hosts', () => {
expect(matchesRegisteredRedirectUri({
candidate: 'https://example.com:9999/callback',
registered: ['https://example.com:443/callback'],
})).toBe(false)
})
it('rejects a loopback candidate whose path differs', () => {
expect(matchesRegisteredRedirectUri({
candidate: 'http://127.0.0.1:55123/other',
registered: ['http://127.0.0.1:8080/callback'],
})).toBe(false)
})
it('rejects an unparseable candidate', () => {
expect(matchesRegisteredRedirectUri({
candidate: 'not a url',
registered: ['https://chat.example.com/callback'],
})).toBe(false)
})
})
describe('isAcceptableRedirectUri', () => {
it('accepts https', () => {
expect(isAcceptableRedirectUri('https://example.com/cb')).toBe(true)
})
it('accepts loopback http', () => {
expect(isAcceptableRedirectUri('http://127.0.0.1:1234/cb')).toBe(true)
})
it('rejects a URI carrying a fragment', () => {
expect(isAcceptableRedirectUri('https://example.com/cb#token')).toBe(false)
})
it('accepts a private-use scheme, which is how native apps come back (RFC 8252)', () => {
expect(isAcceptableRedirectUri('com.example.app://oauth/callback')).toBe(true)
expect(isAcceptableRedirectUri('myapp://callback')).toBe(true)
})
it('rejects schemes where redirecting would execute something', () => {
expect(isAcceptableRedirectUri('javascript:alert(1)')).toBe(false)
expect(isAcceptableRedirectUri('data:text/html,<script>alert(1)</script>')).toBe(false)
expect(isAcceptableRedirectUri('vbscript:msgbox(1)')).toBe(false)
expect(isAcceptableRedirectUri('file:///etc/passwd')).toBe(false)
expect(isAcceptableRedirectUri('blob:https://example.com/x')).toBe(false)
expect(isAcceptableRedirectUri('about:blank')).toBe(false)
})
it('still refuses plaintext http off loopback', () => {
expect(isAcceptableRedirectUri('http://evil.example.com/cb')).toBe(false)
})
it('still refuses a fragment on any scheme', () => {
expect(isAcceptableRedirectUri('com.example.app://cb#token')).toBe(false)
})
it('rejects a string that is not a URL at all', () => {
expect(isAcceptableRedirectUri('not a url')).toBe(false)
})
})
describe('buildRedirectUrl', () => {
it('appends parameters and skips undefined ones', () => {
const url = buildRedirectUrl({
redirectUri: 'https://example.com/cb?existing=1',
params: { code: 'abc', state: undefined },
})
expect(url).toBe('https://example.com/cb?existing=1&code=abc')
})
it('encodes parameter values', () => {
const url = buildRedirectUrl({
redirectUri: 'https://example.com/cb',
params: { state: 'a b&c' },
})
expect(url).toContain('state=a+b%26c')
})
})
describe('resourceMatches', () => {
it('ignores a trailing slash', () => {
expect(resourceMatches({ granted: 'https://mcp.example.com/', requested: 'https://mcp.example.com' })).toBe(true)
})
it('rejects a token replayed against another resource', () => {
expect(resourceMatches({ granted: 'https://mcp.example.com', requested: 'https://other.example.com' })).toBe(false)
})
it('is permissive when the grant carries no audience', () => {
expect(resourceMatches({ granted: null, requested: 'https://mcp.example.com' })).toBe(true)
})
})
+116
View File
@@ -0,0 +1,116 @@
import { createHash, randomBytes, timingSafeEqual } from 'crypto'
import type {
BuildRedirectUrlArgs,
MatchRedirectUriArgs,
ResourceMatchArgs,
VerifyPkceArgs,
} from './types'
export function isDcrEnabled(): boolean {
const raw = process.env.OAUTH_DYNAMIC_REGISTRATION
if (raw === undefined || raw.trim() === '') return true
return raw.trim().toLowerCase() === 'true'
}
export function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex')
}
export function randomToken(): string {
return randomBytes(32).toString('hex')
}
export function safeCompareHex(a: string, b: string): boolean {
if (a.length !== b.length) return false
return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'))
}
export function verifyPkce(args: VerifyPkceArgs): boolean {
if (args.codeVerifier.length < 43 || args.codeVerifier.length > 128) return false
const digest = createHash('sha256').update(args.codeVerifier).digest('base64url')
return safeCompareHex(digest, args.codeChallenge)
}
function isLoopbackUrl(url: URL): boolean {
return url.hostname === '127.0.0.1'
|| url.hostname === '::1'
|| url.hostname === '[::1]'
|| url.hostname === 'localhost'
}
/**
* Exact match, with one carve-out: RFC 8252 lets a native client bind an
* arbitrary loopback port, so the port is ignored for 127.0.0.1 / ::1 only.
* Everything else must match the registered string exactly — this is the guard
* against turning /authorize into an open redirect.
*/
export function matchesRegisteredRedirectUri(args: MatchRedirectUriArgs): boolean {
if (args.registered.includes(args.candidate)) return true
let candidateUrl: URL
try {
candidateUrl = new URL(args.candidate)
} catch {
return false
}
if (!isLoopbackUrl(candidateUrl)) return false
return args.registered.some((registered) => {
try {
const registeredUrl = new URL(registered)
return (
isLoopbackUrl(registeredUrl)
&& registeredUrl.protocol === candidateUrl.protocol
&& registeredUrl.hostname === candidateUrl.hostname
&& registeredUrl.pathname === candidateUrl.pathname
)
} catch {
return false
}
})
}
/**
* Schemes where "redirecting" means executing something rather than handing
* control to an application. Everything else is allowed: RFC 8252 §7.1 has
* native apps return through a private-use scheme (com.example.app://), and
* refusing those locks every mobile client out of the flow. Interception of a
* private-use scheme by another app on the device is what PKCE — mandatory here,
* S256 only — exists to make useless.
*/
const DANGEROUS_REDIRECT_SCHEMES = ['javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:']
export function isAcceptableRedirectUri(raw: string): boolean {
let parsed: URL
try {
parsed = new URL(raw)
} catch {
return false
}
if (parsed.hash) return false
if (DANGEROUS_REDIRECT_SCHEMES.includes(parsed.protocol)) return false
if (parsed.protocol === 'https:') return true
// http stays loopback-only. There is deliberately no NODE_ENV escape hatch:
// an install running without NODE_ENV=production would otherwise let any
// client register a plaintext redirect to a host it does not control.
if (parsed.protocol === 'http:') return isLoopbackUrl(parsed)
return true
}
export function buildRedirectUrl(args: BuildRedirectUrlArgs): string {
const url = new URL(args.redirectUri)
for (const [key, value] of Object.entries(args.params)) {
if (value !== undefined) url.searchParams.set(key, value)
}
return url.toString()
}
/**
* RFC 8707 audience binding: a token minted for one MCP resource must not be
* replayable against another. Compared on origin + path, ignoring trailing slash.
*/
export function resourceMatches(args: ResourceMatchArgs): boolean {
if (!args.granted || !args.requested) return true
const normalize = (value: string) => value.replace(/\/+$/, '').toLowerCase()
return normalize(args.granted) === normalize(args.requested)
}
+207
View File
@@ -0,0 +1,207 @@
import type { Response } from 'express'
import type { OAuthGrantsSchemaTypeForSelect } from 'taskview-db-schemas'
import { type } from 'arktype'
export const OAUTH_ACCESS_TOKEN_PREFIX = 'tvo_'
export const OAUTH_CODE_TTL_SECONDS = 60
export const OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60
export const OAUTH_REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30
export const OAuthAuthorizeArkType = type({
response_type: "'code'",
client_id: 'string > 0',
redirect_uri: 'string > 0',
code_challenge: 'string > 0',
code_challenge_method: "'S256'",
'state?': 'string',
'resource?': 'string',
})
export type OAuthAuthorizeArgs = typeof OAuthAuthorizeArkType.infer
export const OAuthConsentArkType = type({
client_id: 'string > 0',
redirect_uri: 'string > 0',
code_challenge: 'string > 0',
code_challenge_method: "'S256'",
'allowedPermissions?': 'string[]',
'allowedGoalIds?': 'number[]',
'state?': 'string',
'resource?': 'string',
})
export type OAuthConsentArgs = typeof OAuthConsentArkType.infer
export const OAuthTokenArkType = type({
grant_type: "'authorization_code'|'refresh_token'",
'client_id?': 'string',
'client_secret?': 'string',
'code?': 'string',
'code_verifier?': 'string',
'redirect_uri?': 'string',
'refresh_token?': 'string',
'resource?': 'string',
})
export type OAuthTokenArgs = typeof OAuthTokenArkType.infer
export const OAuthRegisterArkType = type({
'client_name?': 'string',
redirect_uris: 'string[] > 0',
'token_endpoint_auth_method?': 'string',
'grant_types?': 'string[]',
'response_types?': 'string[]',
})
export type OAuthRegisterArgs = typeof OAuthRegisterArkType.infer
export const OAuthRevokeArkType = type({
token: 'string > 0',
'token_type_hint?': 'string',
})
export type OAuthRevokeArgs = typeof OAuthRevokeArkType.infer
export type CreateAuthCodeArgs = {
clientId: string
userId: number
redirectUri: string
codeChallenge: string
codeChallengeMethod: string
allowedPermissions: string[]
allowedGoalIds: number[]
resource: string | null
}
export type ExchangeCodeArgs = {
code: string
codeVerifier: string
clientId: string
redirectUri: string
resource: string | null
}
export type RefreshTokensArgs = {
refreshToken: string
clientId: string
resource: string | null
}
export type RegisterClientArgs = {
name: string
redirectUris: string[]
isPublic: boolean
}
export type OAuthTokenResponse = {
access_token: string
token_type: 'Bearer'
expires_in: number
refresh_token: string
}
export type OAuthFailure = { ok: false; error: string; description: string }
export type OAuthSuccess<T> = { ok: true; value: T }
export type OAuthResult<T> = OAuthSuccess<T> | OAuthFailure
export type ConnectedApp = {
grantId: number
clientId: string
clientName: string
allowedPermissions: string[]
allowedGoalIds: number[]
createdAt: Date
lastUsedAt: Date | null
}
export type CreateOAuthClientArgs = {
clientId: string
clientSecretHash: string | null
name: string
redirectUris: string[]
createdVia: string
}
export type ConsumeAuthCodeArgs = {
id: number
grantId: number
}
export type RotateRefreshTokenArgs = {
grantId: number
refreshTokenHash: string
prevHash: string | null
refreshExpiresAt: Date
/** Hash the caller presented; null on the first issue, when the grant has none yet. */
presentedHash: string | null
}
export type RevokeGrantArgs = {
grantId: number
/** Omitted for server-side revocation; set when the owner revokes from the UI. */
userId?: number
}
export type RevokeOwnGrantArgs = {
grantId: number
userId: number
}
export type CreateAccessTokenArgs = {
userId: number
name: string
tokenHash: string
allowedPermissions: string[]
allowedGoalIds: number[]
grantId: number
expiresAt: Date
}
export type ValidateRedirectUriArgs = {
clientId: string
redirectUri: string
}
export type AuthenticateClientArgs = {
clientId: string
clientSecret?: string
}
export type IssueTokenPairArgs = {
grant: OAuthGrantsSchemaTypeForSelect
clientName: string
prevRefreshHash?: string
}
export type VerifyPkceArgs = {
codeVerifier: string
codeChallenge: string
}
export type MatchRedirectUriArgs = {
candidate: string
registered: string[]
}
export type BuildRedirectUrlArgs = {
redirectUri: string
params: Record<string, string | undefined>
}
export type ResourceMatchArgs = {
granted: string | null
requested: string | null
}
export type SendTokenSuccessArgs = {
res: Response
body: Record<string, unknown>
}
export type SendTokenErrorArgs = {
res: Response
status: number
error: string
description: string
}
@@ -5,6 +5,8 @@ import { OrganizationController } from './OrganizationController'
import { IsOrgAdmin } from './middlewares/IsOrgAdmin'
import { IsOrgMember } from './middlewares/IsOrgMember'
import { IsOrgOwner } from './middlewares/IsOrgOwner'
import { RequireTokenPermission } from '../../middlewares/require-token-permission'
import { GoalPermissions } from '../../types/auth.types'
export default class OrganizationRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
@@ -21,16 +23,20 @@ export default class OrganizationRoutes implements Routable {
}
initRoutes() {
this.router.post('', [IsLoggedIn], this.controller.create)
const canManage = RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE)
const canManageMembers = RequireTokenPermission(GoalPermissions.ORG_CAN_MANAGE_MEMBERS)
const canView = RequireTokenPermission(GoalPermissions.ORG_CAN_VIEW)
this.router.post('', [IsLoggedIn, canManage], this.controller.create)
this.router.get('', [IsLoggedIn], this.controller.fetch)
this.router.post('/members', [IsLoggedIn, IsOrgAdmin], this.controller.addMember)
this.router.patch('/members/role', [IsLoggedIn, IsOrgAdmin], this.controller.updateMemberRole)
this.router.delete('/members', [IsLoggedIn, IsOrgAdmin], this.controller.removeMember)
this.router.post('/members', [IsLoggedIn, IsOrgAdmin, canManageMembers], this.controller.addMember)
this.router.patch('/members/role', [IsLoggedIn, IsOrgAdmin, canManageMembers], this.controller.updateMemberRole)
this.router.delete('/members', [IsLoggedIn, IsOrgAdmin, canManageMembers], this.controller.removeMember)
this.router.get('/:orgId', [IsLoggedIn, IsOrgMember], this.controller.getById)
this.router.patch('/:orgId', [IsLoggedIn, IsOrgAdmin], this.controller.update)
this.router.delete('/:orgId', [IsLoggedIn, IsOrgOwner], this.controller.delete)
this.router.get('/:orgId/members', [IsLoggedIn, IsOrgAdmin], this.controller.fetchMembers)
this.router.get('/:orgId', [IsLoggedIn, IsOrgMember, canView], this.controller.getById)
this.router.patch('/:orgId', [IsLoggedIn, IsOrgAdmin, canManage], this.controller.update)
this.router.delete('/:orgId', [IsLoggedIn, IsOrgOwner, canManage], this.controller.delete)
this.router.get('/:orgId/members', [IsLoggedIn, IsOrgAdmin, canView], this.controller.fetchMembers)
}
}
@@ -0,0 +1,187 @@
import axios from 'axios';
import fs from 'fs/promises';
import type http from 'http';
import { join } from 'path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import App from '../../../App';
import { Database } from '../../../modules/db';
import { GoalPermissions } from '../../../types/auth.types';
const port = 1811;
const url = `http://localhost:${port}`;
const MIGRATION_DIR = join(__dirname, '../../../migrations/taskview/sql/1.65.0');
const LOGIN = 'test@mail.dest';
const PASSWORD = 'user1!#Q';
let server: http.Server;
let jwt = '';
const createdTokenIds: number[] = [];
const createdOrgIds: number[] = [];
let ownedGoalId = 0;
const api = axios.create({ baseURL: url, validateStatus: () => true });
const asUser = () => ({ headers: { Authorization: `Bearer ${jwt}` } });
async function issueToken(allowedPermissions: string[]) {
const response = await api.post(
'/module/api-tokens',
{ name: 'scope-probe', allowedPermissions, allowedGoalIds: [] },
asUser(),
);
expect(response.status).toBe(200);
createdTokenIds.push(response.data.response.item.id);
return { headers: { Authorization: `Bearer ${response.data.response.token}` } };
}
async function createOrg(auth: { headers: Record<string, string> }, name: string) {
const response = await api.post('/module/organizations', { name }, auth);
const id = response.data?.response?.id;
if (id) createdOrgIds.push(id);
return response;
}
describe('API token scope on organization-level surfaces', () => {
vi.mock('emailjs', () => ({
SMTPClient: vi.fn().mockImplementation(() => ({ sendAsync: vi.fn().mockResolvedValue(true) })),
}));
beforeAll(async () => {
const db = Database.getInstance();
const client = await db.getClient();
for (const file of (await fs.readdir(MIGRATION_DIR)).sort()) {
await client.query(await fs.readFile(join(MIGRATION_DIR, file), 'utf-8'));
}
client.release();
server = new App(port).listen();
const login = await api.post('/module/auth/login', { login: LOGIN, password: PASSWORD });
expect(login.status).toBe(200);
jwt = login.data.access;
// A goal the caller actually owns: otherwise IsGoalOwnerByGoalId answers 403
// on its own and the webhook tests below would pass without the token check.
const goal = await api.post('/module/goals', { name: `scope-goal-${Date.now()}` }, asUser());
expect(goal.status).toBe(200);
ownedGoalId = goal.data.response.id;
});
afterAll(async () => {
if (ownedGoalId) {
await api.delete('/module/goals', { ...asUser(), data: { goalId: ownedGoalId } });
}
for (const id of createdOrgIds) {
await api.delete(`/module/organizations/${id}`, asUser());
}
for (const id of createdTokenIds) {
await api.delete('/module/api-tokens', { ...asUser(), data: { id } });
}
server?.close();
});
it('seeds the organization permission group', async () => {
const db = Database.getInstance();
const result = await db.query<{ name: string }>(
'SELECT name FROM tv_auth.permissions WHERE permission_group = 6 ORDER BY name',
);
expect(result?.rows.map((row) => row.name)).toEqual([
'org_can_manage', 'org_can_manage_members', 'org_can_view', 'sso_can_manage', 'webhooks_can_manage',
]);
});
it('offers the new permissions for selection, with localized descriptions', async () => {
const response = await api.get('/module/api-tokens/permissions', asUser());
expect(response.status).toBe(200);
const rows = response.data.response as {
name: string;
permissionGroup: number;
descriptionLocales: Record<string, string> | null;
}[];
const orgView = rows.find((row) => row.name === 'org_can_view');
expect(orgView?.permissionGroup).toBe(6);
expect(orgView?.descriptionLocales?.ru).toBeTruthy();
// Group 1 is enforced nowhere, so it must not be offered as a restriction.
expect(rows.some((row) => row.permissionGroup === 1)).toBe(false);
});
it('blocks a narrowly scoped token from creating an organization', async () => {
const token = await issueToken([GoalPermissions.TIMETRACKING_CAN_VIEW]);
const response = await createOrg(token, 'scope-probe-denied');
expect(response.status).toBe(403);
});
it('allows a token that was given org_can_manage', async () => {
const token = await issueToken([GoalPermissions.ORG_CAN_MANAGE]);
const response = await createOrg(token, 'scope-probe-allowed');
expect(response.status).toBe(200);
});
it('keeps an unrestricted token working, so existing integrations do not break', async () => {
const token = await issueToken([]);
const response = await createOrg(token, 'scope-probe-unrestricted');
expect(response.status).toBe(200);
});
it('does not restrict a browser session', async () => {
const response = await createOrg(asUser(), 'scope-probe-session');
expect(response.status).toBe(200);
});
it('separates managing the organization from managing its members', async () => {
const token = await issueToken([GoalPermissions.ORG_CAN_MANAGE]);
const org = await createOrg(token, 'scope-probe-members');
expect(org.status).toBe(200);
const response = await api.post(
'/module/organizations/members',
{ organizationId: org.data.response.id, email: LOGIN, role: 'admin' },
token,
);
expect(response.status).toBe(403);
});
it('lets a token holding org_can_manage_members add one', async () => {
const owner = await issueToken([GoalPermissions.ORG_CAN_MANAGE]);
const org = await createOrg(owner, 'scope-probe-members-ok');
expect(org.status).toBe(200);
const member = await issueToken([GoalPermissions.ORG_CAN_MANAGE_MEMBERS]);
const response = await api.post(
'/module/organizations/members',
{ organizationId: org.data.response.id, email: `member-${Date.now()}@test.dest`, role: 'member' },
member,
);
// A precise status, not merely "not 403" — that would also pass on a 500.
expect(response.status).toBe(200);
});
it('blocks a narrowly scoped token from reaching webhooks of a goal it owns', async () => {
const token = await issueToken([GoalPermissions.TIMETRACKING_CAN_VIEW]);
const response = await api.get(`/module/webhooks?goalId=${ownedGoalId}`, token);
expect(response.status).toBe(403);
});
it('lets webhooks_can_manage through on that same goal, proving the 403 came from the token', async () => {
const token = await issueToken([GoalPermissions.WEBHOOKS_CAN_MANAGE]);
const response = await api.get(`/module/webhooks?goalId=${ownedGoalId}`, token);
expect(response.status).toBe(200);
});
});
+13 -9
View File
@@ -5,6 +5,8 @@ import { IsOrgAdmin } from '../organizations/middlewares/IsOrgAdmin'
import { IsSsoConfigAdmin } from './middlewares/IsSsoConfigAdmin'
import { RequireLoginMethod } from '../auth/middlewares/require-login-method'
import { SsoController } from './SsoController'
import { RequireTokenPermission } from '../../middlewares/require-token-permission'
import { GoalPermissions } from '../../types/auth.types'
export default class SsoRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
@@ -26,15 +28,17 @@ export default class SsoRoutes implements Routable {
this.router.get('/callback/:configId', [RequireLoginMethod('sso')], this.controller.handleCallback)
this.router.post('/callback/:configId', [RequireLoginMethod('sso')], this.controller.handleCallback)
const canManageSso = RequireTokenPermission(GoalPermissions.SSO_CAN_MANAGE)
this.router.get('/admin/public-urls', [IsLoggedIn], this.controller.getPublicUrls)
this.router.get('/admin/metadata', [IsLoggedIn, IsOrgAdmin], this.controller.parseMetadata)
this.router.get('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.listConfigs)
this.router.post('/admin/configs', [IsLoggedIn, IsOrgAdmin], this.controller.createConfig)
this.router.patch('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin], this.controller.updateConfig)
this.router.delete('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin], this.controller.deleteConfig)
this.router.post('/admin/configs/:configId/verify-domain', [IsLoggedIn, IsSsoConfigAdmin], this.controller.startDomainVerification)
this.router.post('/admin/configs/:configId/verify-domain/check', [IsLoggedIn, IsSsoConfigAdmin], this.controller.checkDomainVerification)
this.router.post('/admin/configs/:configId/scim-token', [IsLoggedIn, IsSsoConfigAdmin], this.controller.generateScimToken)
this.router.patch('/admin/configs/:configId/scim', [IsLoggedIn, IsSsoConfigAdmin], this.controller.toggleScim)
this.router.get('/admin/metadata', [IsLoggedIn, IsOrgAdmin, canManageSso], this.controller.parseMetadata)
this.router.get('/admin/configs', [IsLoggedIn, IsOrgAdmin, canManageSso], this.controller.listConfigs)
this.router.post('/admin/configs', [IsLoggedIn, IsOrgAdmin, canManageSso], this.controller.createConfig)
this.router.patch('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin, canManageSso], this.controller.updateConfig)
this.router.delete('/admin/configs/:configId', [IsLoggedIn, IsSsoConfigAdmin, canManageSso], this.controller.deleteConfig)
this.router.post('/admin/configs/:configId/verify-domain', [IsLoggedIn, IsSsoConfigAdmin, canManageSso], this.controller.startDomainVerification)
this.router.post('/admin/configs/:configId/verify-domain/check', [IsLoggedIn, IsSsoConfigAdmin, canManageSso], this.controller.checkDomainVerification)
this.router.post('/admin/configs/:configId/scim-token', [IsLoggedIn, IsSsoConfigAdmin, canManageSso], this.controller.generateScimToken)
this.router.patch('/admin/configs/:configId/scim', [IsLoggedIn, IsSsoConfigAdmin, canManageSso], this.controller.toggleScim)
}
}
+12 -8
View File
@@ -3,6 +3,8 @@ import type { Routable } from '../../types/routable.type'
import { IsLoggedIn } from '../auth/middlewares/is-logged-in'
import { WebhooksController } from './WebhooksController'
import { IsGoalOwnerByGoalId, IsGoalOwnerByWebhookId } from './middlewares/IsGoalOwner'
import { RequireTokenPermission } from '../../middlewares/require-token-permission';
import { GoalPermissions } from '../../types/auth.types';
export default class WebhooksRoutes implements Routable {
private readonly router: ReturnType<typeof Router>
@@ -19,13 +21,15 @@ export default class WebhooksRoutes implements Routable {
}
initRoutes() {
this.router.get('', [IsLoggedIn, IsGoalOwnerByGoalId], this.controller.fetch)
this.router.post('', [IsLoggedIn, IsGoalOwnerByGoalId], this.controller.create)
this.router.patch('', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.update)
this.router.delete('', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.delete)
this.router.post('/rotate-secret', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.rotateSecret)
this.router.post('/test', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.testDelivery)
this.router.get('/deliveries/:id', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.fetchDeliveries)
this.router.post('/retry', [IsLoggedIn, IsGoalOwnerByWebhookId], this.controller.retryDelivery)
const canManageWebhooks = RequireTokenPermission(GoalPermissions.WEBHOOKS_CAN_MANAGE)
this.router.get('', [IsLoggedIn, IsGoalOwnerByGoalId, canManageWebhooks], this.controller.fetch)
this.router.post('', [IsLoggedIn, IsGoalOwnerByGoalId, canManageWebhooks], this.controller.create)
this.router.patch('', [IsLoggedIn, IsGoalOwnerByWebhookId, canManageWebhooks], this.controller.update)
this.router.delete('', [IsLoggedIn, IsGoalOwnerByWebhookId, canManageWebhooks], this.controller.delete)
this.router.post('/rotate-secret', [IsLoggedIn, IsGoalOwnerByWebhookId, canManageWebhooks], this.controller.rotateSecret)
this.router.post('/test', [IsLoggedIn, IsGoalOwnerByWebhookId, canManageWebhooks], this.controller.testDelivery)
this.router.get('/deliveries/:id', [IsLoggedIn, IsGoalOwnerByWebhookId, canManageWebhooks], this.controller.fetchDeliveries)
this.router.post('/retry', [IsLoggedIn, IsGoalOwnerByWebhookId, canManageWebhooks], this.controller.retryDelivery)
}
}
+7
View File
@@ -194,6 +194,13 @@ export const GoalPermissions = {
TIMETRACKING_CAN_LOG: 'timetracking_can_log',
TIMETRACKING_CAN_MANAGE_ALL: 'timetracking_can_manage_all',
ORG_CAN_VIEW: 'org_can_view',
ORG_CAN_MANAGE: 'org_can_manage',
ORG_CAN_MANAGE_MEMBERS: 'org_can_manage_members',
SSO_CAN_MANAGE: 'sso_can_manage',
WEBHOOKS_CAN_MANAGE: 'webhooks_can_manage',
BILLING_CAN_MANAGE: 'billing_can_manage',
SPRINT_CAN_VIEW: 'sprint_can_view',
SPRINT_CAN_MANAGE: 'sprint_can_manage',
SPRINT_CAN_ASSIGN_TASKS: 'sprint_can_assign_tasks',
+4
View File
@@ -0,0 +1,4 @@
declare module 'pdfmake/build/vfs_fonts.js' {
const vfs: Record<string, string>
export default vfs
}
+33
View File
@@ -0,0 +1,33 @@
export type InvoiceTotalsInput = {
lines: { quantity: number | string; unitPrice: number | string }[]
discountType: 'percent' | 'amount'
discountValue: number | string
taxRate: number | string
taxExempt: boolean
}
export type InvoiceTotalsResult = {
subtotal: number
discount: number
taxable: number
tax: number
total: number
}
export function round2(value: number): number {
return Math.round(value * 100) / 100
}
export function invoiceLineAmount(quantity: number | string, unitPrice: number | string): number {
return round2(Number(quantity) * Number(unitPrice))
}
export function computeInvoiceTotals(input: InvoiceTotalsInput): InvoiceTotalsResult {
const subtotal = round2(input.lines.reduce((sum, line) => sum + invoiceLineAmount(line.quantity, line.unitPrice), 0))
const discountValue = Number(input.discountValue)
const rawDiscount = input.discountType === 'percent' ? subtotal * (discountValue / 100) : discountValue
const discount = round2(Math.min(Math.max(rawDiscount, 0), subtotal))
const taxable = round2(subtotal - discount)
const tax = input.taxExempt ? 0 : round2(taxable * (Number(input.taxRate) / 100))
return { subtotal, discount, taxable, tax, total: round2(taxable + tax) }
}
+4
View File
@@ -0,0 +1,4 @@
export type OAuthConsentSelection = {
allowedGoalIds: number[]
allowedPermissions: string[]
}
+41 -7
View File
@@ -1,6 +1,6 @@
---
title: MCP Server
description: Connect AI assistants like Claude Code and Claude Desktop to TaskView via the Model Context Protocol (MCP). Manage projects and tasks with a scoped API token.
description: Connect AI assistants like Claude Code, claude.ai and ChatGPT to TaskView via the Model Context Protocol (MCP). Authenticate with a scoped API token or over OAuth.
navigation:
icon: i-lucide-bot
---
@@ -15,7 +15,7 @@ Shared (HTTP): AI client ──HTTPS──▶ taskview-mcp container ──
```
- **Local (stdio)** — each user runs the server on their machine via `npx`; the token lives in the client config.
- **Shared (HTTP)** — one server container runs next to your TaskView instance; every user connects to its URL and authenticates with their own API token per request.
- **Shared (HTTP)** — one server container runs next to your TaskView instance; every user connects to its URL and authenticates with their own API token per request, or over [OAuth](#oauth-cloud-clients) for clients that cannot send a token header.
## Prerequisites
@@ -56,7 +56,8 @@ The `gimanhead/taskview-ce-mcp` image serves MCP over HTTP (Streamable HTTP tran
restart: unless-stopped
environment:
# Where this MCP server forwards requests; the docker-network address
# of your API service works best
# of your API service works best. For OAuth it must be the public API
# URL instead — see below.
TASKVIEW_URL: "http://taskview-api-server:1401"
ports:
- "3100:3100"
@@ -88,10 +89,43 @@ or in `.mcp.json` (Claude Code, Cursor, VS Code):
Put the port behind your reverse proxy with HTTPS for anything beyond local use.
::callout{icon="i-lucide-info" color="neutral"}
The claude.ai and Claude Desktop **custom connectors** UI supports only OAuth-based servers and has no field for a token header — it cannot connect to this endpoint yet. Claude Desktop users should use the local stdio configuration above instead.
::
## OAuth (cloud clients)
Cloud assistants have no field for a pasted token: claude.ai and Claude Desktop **custom connectors**, ChatGPT connectors and similar clients authorize over OAuth 2.1 instead. The shared HTTP server supports this with nothing extra to run. The MCP server presents itself as the protected resource and names your TaskView API as the authorization server; the API handles client registration, the consent screen and tokens.
What happens when a client connects:
1. The client calls `/mcp` without a token and gets `401` with a `WWW-Authenticate` header pointing at the resource metadata.
2. It reads `/.well-known/oauth-protected-resource` on the MCP server, then `/.well-known/oauth-authorization-server` on the API.
3. It registers itself (dynamic client registration, RFC 7591) and opens the TaskView consent screen in your browser.
4. You sign in and tick the permissions and projects the app may use. Ticking nothing grants your full access; your RBAC role is the ceiling either way.
5. The client receives a `tvo_` access token (valid 1 hour) and a refresh token (valid 30 days), and sends the access token to `/mcp` like any other bearer token.
To connect, paste the MCP URL (`https://mcp.your-domain.com/mcp`) into the client's custom connector form. When it asks you to authorize, the TaskView consent screen opens. Claude Code can use OAuth too: add the server without the `--header` flag and run `/mcp` to authenticate.
### Configuration for OAuth
- `MCP_PUBLIC_URL` on the MCP server — the public URL of the server exactly as clients reach it. This is the OAuth resource identifier. When unset it is derived from the request's `Host` and `X-Forwarded-Proto` headers.
- `TASKVIEW_URL` on the MCP server — advertised to clients as the authorization server, so for OAuth it must be the **public** API URL (`https://api.your-domain.com`), not the docker-network address from the example above.
- `API_PUBLIC_URL` and `APP_URL` on the API — the first names the authorization server in its metadata, the second is where the consent screen lives. See [environment variables](/docs/configuration/environment-variables#application).
- `OAUTH_DYNAMIC_REGISTRATION=false` on the API — stops new clients from registering themselves, which closes OAuth for new connections on a locked-down instance. Existing connected apps keep working.
**Mounted under a path.** When the MCP server sits behind a reverse proxy at `https://api.your-domain.com/mcp` rather than on its own host, put the full path into `MCP_PUBLIC_URL`. The resource metadata then also lives at `/.well-known/oauth-protected-resource/mcp` (RFC 9728), which is where a client that computes the address from the spec will look. The server publishes it at both that address and the root one, but the proxy has to route the path-inserted address to the MCP server too:
```nginx
location = /.well-known/oauth-protected-resource/mcp {
proxy_pass http://127.0.0.1:3100/.well-known/oauth-protected-resource/mcp;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
### Managing connected apps
Every app you authorized is listed in your account settings under **Connected apps**, with the permissions and projects you granted and when it was last used. Revoking one cuts off its access immediately. OAuth tokens do not appear in the API tokens list and cannot be deleted from there.
Manually issued `tvk_` tokens keep working exactly as before. OAuth is an additional way in, not a replacement: use `tvk_` for stdio, CLIs and CI, where there is no browser to complete an authorization flow.
## Permissions
The assistant can only do what the API token allows. Token permissions are scoped to selected projects and intersected with your RBAC role, so an AI client never exceeds your own access. Grant the minimum scope needed.
The assistant can only do what its credential allows. API token permissions are scoped to selected projects; an OAuth grant carries the permissions and projects you ticked on the consent screen. Both are intersected with your RBAC role, so an AI client never exceeds your own access. Grant the minimum scope needed.
@@ -51,6 +51,7 @@ Unlike everything else on this page, this variable is set on the **web app conta
| `AUTH_LOGIN_METHODS` | No | all enabled | Comma-separated list of login methods to offer: `magic-link`, `password`, `sso`, `social`. Disabled methods disappear from the login page and their API endpoints return 403. The API refuses to start if the list contains a typo or disables every method. |
| `PASSWORD_CHANGE_CONFIRMATION` | No | `email` | How account password changes are confirmed: `email` — a confirmation code is sent to the user's email (requires SMTP); `password` — the user confirms with their current password (works without SMTP, recommended for installs without a mail server). |
| `ALLOW_PUBLIC_REGISTRATION` | No | `true` | Set to `false` to close the instance: strangers can no longer create accounts — the registration endpoint returns 403, and magic-link / social sign-in stop auto-creating users. Emails invited to an organization or project can still sign in and get their account created on first login. |
| `OAUTH_DYNAMIC_REGISTRATION` | No | `true` | Whether AI clients may register themselves as OAuth clients (RFC 7591) when connecting to the [MCP server](/docs/integrations/mcp#oauth-cloud-clients). Set to `false` on a locked-down instance: the registration endpoint returns 403 and no new OAuth connection can be started; apps already connected keep working. |
| `SSO_TRUSTED_DOMAINS` | No | empty | Comma-separated email domains that skip DNS/HTTP ownership checks for SSO (air-gapped / closed-network installs). Example: `company.com,corp.local`. On a public instance leave this unset so every org must prove it owns the domain. |
::callout{icon="i-lucide-shield" color="warning"}
@@ -0,0 +1,89 @@
import { TvApi } from '@/tv';
import {
describe,
it,
expect,
beforeAll,
afterAll,
} from 'vitest';
import { initApi } from './init-api';
import { ymd } from './test-helpers';
/**
* The main screen endpoint. It answers "what do I have today" in one request and
* splits the day server-side in the caller's timezone — the reason a client must
* not fetch every project and compare deadlines itself.
*/
describe('Start screen state', () => {
let $api: TvApi;
let goalId: number;
beforeAll(async () => {
const { $tvApi } = await initApi();
$api = $tvApi;
const goal = await $api.goals.createGoal({ name: `Agenda test-${Date.now()}` });
goalId = goal!.id!;
});
afterAll(async () => {
await $api.goals.deleteGoal(goalId).catch(() => {});
});
it('returns every bucket the main screen shows', async () => {
const state = await $api.start.fetchAllState({ tz: 'UTC' });
expect(state).toBeDefined();
expect(Array.isArray(state!.tasksToday)).toBe(true);
expect(Array.isArray(state!.tasksUpcoming)).toBe(true);
expect(Array.isArray(state!.tasksLastCompleted)).toBe(true);
expect(Array.isArray(state!.tasks)).toBe(true);
});
it('puts a task due today into tasksToday', async () => {
const task = await $api.tasks.createTask({
goalId,
description: `Due today-${Date.now()}`,
});
await $api.tasks.updateTask({ id: task!.id, endDate: ymd(0) });
const state = await $api.start.fetchAllState({ tz: 'UTC' });
expect(state!.tasksToday.some((t) => t.id === task!.id)).toBe(true);
expect(state!.tasksUpcoming.some((t) => t.id === task!.id)).toBe(false);
});
it('puts a task due later into tasksUpcoming', async () => {
const task = await $api.tasks.createTask({
goalId,
description: `Due later-${Date.now()}`,
});
await $api.tasks.updateTask({ id: task!.id, endDate: ymd(7) });
const state = await $api.start.fetchAllState({ tz: 'UTC' });
expect(state!.tasksUpcoming.some((t) => t.id === task!.id)).toBe(true);
expect(state!.tasksToday.some((t) => t.id === task!.id)).toBe(false);
});
it('keeps a task with no deadline out of both dated buckets', async () => {
const task = await $api.tasks.createTask({
goalId,
description: `No deadline-${Date.now()}`,
});
const state = await $api.start.fetchAllState({ tz: 'UTC' });
expect(state!.tasksToday.some((t) => t.id === task!.id)).toBe(false);
expect(state!.tasksUpcoming.some((t) => t.id === task!.id)).toBe(false);
});
it('requires a timezone — the split is meaningless without one', async () => {
const status = await $api.start
.fetchAllState({ tz: '' })
.then(() => 200)
.catch((err) => err.status ?? err.response?.status);
expect(status).toBe(400);
});
});
@@ -0,0 +1,411 @@
import { TvApi } from '@/tv';
import axios from 'axios';
import {
describe,
it,
expect,
beforeAll,
afterAll,
} from 'vitest';
import { initApi, API_URL } from './init-api';
/**
* Organization, SSO and webhook endpoints are guarded by an organization role or
* by project ownership, not by the project RBAC, so before migration 1.65.0 they
* ignored the scope of an API token entirely: a token issued with a single
* permission could still create organizations and webhooks. These tests pin the
* fixed behaviour, including the deliberate escape hatch — a token with no
* permissions selected stays unrestricted.
*/
describe('API token scope on organization-level surfaces', () => {
let $api: TvApi;
const tokenIds: number[] = [];
const orgIds: number[] = [];
let goalId: number;
const clientFor = (token: string) => new TvApi(axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${token}` },
}));
const scopedClient = async (allowedPermissions: string[]) => {
const created = await $api.apiTokens.create({
name: `scope-${Date.now()}-${Math.random()}`,
allowedPermissions,
});
tokenIds.push(created!.item.id);
return clientFor(created!.token);
};
const statusOf = async (call: Promise<unknown>) =>
call.then(() => 200).catch((err) => err.status ?? err.response?.status);
/**
* A permitted call must answer 200. Asserting merely "not 403" would also pass
* on a 500, which is how a broken endpoint gets mistaken for a working guard.
*/
const expectAllowed = (status: number) => expect(status).toBe(200);
beforeAll(async () => {
const { $tvApi } = await initApi();
$api = $tvApi;
const goal = await $api.goals.createGoal({ name: `scope-goal-${Date.now()}` });
goalId = goal!.id!;
});
afterAll(async () => {
for (const id of orgIds) {
await $api.organizations.delete(id).catch(() => {});
}
for (const id of tokenIds) {
await $api.apiTokens.delete(id).catch(() => {});
}
await $api.goals.deleteGoal(goalId).catch(() => {});
});
describe('permission catalogue', () => {
it('offers the organization group for selection', async () => {
const permissions = await $api.apiTokens.fetchPermissions();
const names = permissions!.map((p) => p.name);
expect(names).toContain('org_can_view');
expect(names).toContain('org_can_manage');
expect(names).toContain('org_can_manage_members');
expect(names).toContain('sso_can_manage');
expect(names).toContain('webhooks_can_manage');
});
it('carries localized descriptions for them', async () => {
const permissions = await $api.apiTokens.fetchPermissions();
const orgManage = permissions!.find((p) => p.name === 'org_can_manage');
expect(orgManage!.permissionGroup).toBe(6);
expect(orgManage!.descriptionLocales?.en).toBeTruthy();
expect(orgManage!.descriptionLocales?.ru).toBeTruthy();
});
it('never offers group 1, which is enforced nowhere', async () => {
const permissions = await $api.apiTokens.fetchPermissions();
expect(permissions!.some((p) => p.permissionGroup === 1)).toBe(false);
});
});
describe('creating organizations', () => {
it('denies a token scoped to an unrelated permission', async () => {
const client = await scopedClient(['timetracking_can_view']);
const status = await statusOf(client.organizations.create({ name: `denied-${Date.now()}` }));
expect(status).toBe(403);
});
it('allows a token holding org_can_manage', async () => {
const client = await scopedClient(['org_can_manage']);
const org = await client.organizations.create({ name: `allowed-${Date.now()}` });
expect(org).toBeDefined();
orgIds.push(org!.id);
});
it('allows a token with no permissions selected — unrestricted by design', async () => {
const client = await scopedClient([]);
const org = await client.organizations.create({ name: `unrestricted-${Date.now()}` });
expect(org).toBeDefined();
orgIds.push(org!.id);
});
it('does not restrict a normal browser session', async () => {
const org = await $api.organizations.create({ name: `session-${Date.now()}` });
expect(org).toBeDefined();
orgIds.push(org!.id);
});
});
describe('managing members', () => {
it('separates org_can_manage from org_can_manage_members', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `members-${Date.now()}` });
orgIds.push(org!.id);
const status = await statusOf(owner.organizations.addMember({
organizationId: org!.id,
email: `member-${Date.now()}@test.dest`,
role: 'member',
}));
expect(status).toBe(403);
});
it('lets a token holding org_can_manage_members through the guard', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `members-ok-${Date.now()}` });
orgIds.push(org!.id);
const member = await scopedClient(['org_can_manage_members']);
const status = await statusOf(member.organizations.addMember({
organizationId: org!.id,
email: `member-${Date.now()}@test.dest`,
role: 'member',
}));
expectAllowed(status);
});
it('denies listing members without org_can_view', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `list-${Date.now()}` });
orgIds.push(org!.id);
const status = await statusOf(owner.organizations.fetchMembers(org!.id));
expect(status).toBe(403);
});
it('allows listing members with org_can_view', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `list-ok-${Date.now()}` });
orgIds.push(org!.id);
const viewer = await scopedClient(['org_can_view']);
const status = await statusOf(viewer.organizations.fetchMembers(org!.id));
expectAllowed(status);
});
});
describe('changing and deleting an organization', () => {
it('denies renaming without org_can_manage', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `rename-${Date.now()}` });
orgIds.push(org!.id);
const outsider = await scopedClient(['org_can_view']);
const status = await statusOf(outsider.organizations.update(org!.id, { name: 'renamed' }));
expect(status).toBe(403);
});
it('allows renaming with org_can_manage', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `rename-ok-${Date.now()}` });
orgIds.push(org!.id);
const status = await statusOf(owner.organizations.update(org!.id, { name: `renamed-${Date.now()}` }));
expectAllowed(status);
});
it('denies deleting without org_can_manage', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `delete-${Date.now()}` });
orgIds.push(org!.id);
const outsider = await scopedClient(['org_can_view']);
const status = await statusOf(outsider.organizations.delete(org!.id));
expect(status).toBe(403);
});
});
describe('changing and removing members', () => {
it('denies changing a role without org_can_manage_members', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `role-${Date.now()}` });
orgIds.push(org!.id);
const status = await statusOf(owner.organizations.updateMemberRole({
organizationId: org!.id,
email: `role-${Date.now()}@test.dest`,
role: 'admin',
}));
expect(status).toBe(403);
});
it('denies removing a member without org_can_manage_members', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `remove-${Date.now()}` });
orgIds.push(org!.id);
const status = await statusOf(owner.organizations.removeMember({
organizationId: org!.id,
email: `remove-${Date.now()}@test.dest`,
}));
expect(status).toBe(403);
});
it('lets org_can_manage_members past the guard on both', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `member-verbs-${Date.now()}` });
orgIds.push(org!.id);
const manager = await scopedClient(['org_can_manage_members']);
const email = `member-${Date.now()}@test.dest`;
await manager.organizations.addMember({ organizationId: org!.id, email, role: 'member' }).catch(() => {});
const roleStatus = await statusOf(manager.organizations.updateMemberRole({
organizationId: org!.id, email, role: 'admin',
}));
const removeStatus = await statusOf(manager.organizations.removeMember({
organizationId: org!.id, email,
}));
expectAllowed(roleStatus);
expectAllowed(removeStatus);
});
});
describe('SSO administration', () => {
it('denies listing configs without sso_can_manage', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `sso-${Date.now()}` });
orgIds.push(org!.id);
const status = await statusOf(owner.sso.listConfigs(org!.id));
expect(status).toBe(403);
});
it('allows listing configs with sso_can_manage', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `sso-ok-${Date.now()}` });
orgIds.push(org!.id);
const admin = await scopedClient(['sso_can_manage']);
const status = await statusOf(admin.sso.listConfigs(org!.id));
expectAllowed(status);
});
it('denies every other admin verb on a real config without sso_can_manage', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `sso-verbs-${Date.now()}` });
orgIds.push(org!.id);
// A real config is required: IsSsoConfigAdmin runs first and would answer
// 404 for a made-up id, hiding whether the token check exists at all.
const admin = await scopedClient(['sso_can_manage']);
const config = await admin.sso.createConfig({
organizationId: org!.id,
protocol: 'oidc',
displayName: 'Scope probe',
emailDomainRestriction: `sso-${Date.now()}.test`,
});
expect(config).toBeDefined();
const configId = config!.id;
const results = await Promise.all([
statusOf(owner.sso.updateConfig(configId, { displayName: 'renamed' })),
statusOf(owner.sso.startDomainVerification(configId)),
statusOf(owner.sso.checkDomainVerification(configId)),
statusOf(owner.sso.generateScimToken(configId)),
statusOf(owner.sso.toggleScim(configId, true)),
statusOf(owner.sso.deleteConfig(configId)),
]);
expect(results).toEqual([403, 403, 403, 403, 403, 403]);
await admin.sso.deleteConfig(configId).catch(() => {});
});
it('lets sso_can_manage change and delete a config', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `sso-admin-${Date.now()}` });
orgIds.push(org!.id);
const admin = await scopedClient(['sso_can_manage']);
const config = await admin.sso.createConfig({
organizationId: org!.id,
protocol: 'oidc',
displayName: 'Editable',
emailDomainRestriction: `sso-ok-${Date.now()}.test`,
});
const updateStatus = await statusOf(admin.sso.updateConfig(config!.id, { displayName: 'Renamed' }));
const deleteStatus = await statusOf(admin.sso.deleteConfig(config!.id));
expectAllowed(updateStatus);
expectAllowed(deleteStatus);
});
it('denies creating a config without sso_can_manage', async () => {
const owner = await scopedClient(['org_can_manage']);
const org = await owner.organizations.create({ name: `sso-create-${Date.now()}` });
orgIds.push(org!.id);
const status = await statusOf(owner.sso.createConfig({
organizationId: org!.id,
protocol: 'oidc',
displayName: 'Should not be created',
emailDomainRestriction: `sso-${Date.now()}.test`,
}));
expect(status).toBe(403);
});
});
describe('webhooks', () => {
it('denies a token scoped to an unrelated permission', async () => {
const client = await scopedClient(['timetracking_can_view']);
const status = await statusOf(client.webhooks.fetch(goalId));
expect(status).toBe(403);
});
it('allows a token holding webhooks_can_manage', async () => {
const client = await scopedClient(['webhooks_can_manage']);
const status = await statusOf(client.webhooks.fetch(goalId));
expectAllowed(status);
});
it('allows an unrestricted token', async () => {
const client = await scopedClient([]);
const status = await statusOf(client.webhooks.fetch(goalId));
expectAllowed(status);
});
it('denies creating a webhook without webhooks_can_manage', async () => {
const client = await scopedClient(['timetracking_can_view']);
const status = await statusOf(client.webhooks.create({
goalId,
url: 'https://exfiltration.test/hook',
events: ['task.created'],
}));
expect(status).toBe(403);
});
it('allows the full webhook lifecycle with webhooks_can_manage', async () => {
const client = await scopedClient(['webhooks_can_manage']);
const created = await client.webhooks.create({
goalId,
url: `https://receiver.test/${Date.now()}`,
events: ['task.created'],
});
expect(created).toBeDefined();
const updateStatus = await statusOf(client.webhooks.update({
id: created!.webhook.id,
events: ['task.created', 'task.updated'],
}));
const deleteStatus = await statusOf(client.webhooks.delete({ id: created!.webhook.id }));
expectAllowed(updateStatus);
expectAllowed(deleteStatus);
});
});
});
@@ -26,4 +26,6 @@ export type ApiTokenPermission = {
name: string;
description: string;
permissionGroup: number;
/** Per-locale description; keys match the app locales ('en', 'ru', 'de', 'es', 'pt-BR'). */
descriptionLocales?: Record<string, string> | null;
};
@@ -0,0 +1,63 @@
import TvApiBase from './base'
import type { AppResponse } from './base.types'
import type {
BillingArgArchive,
BillingArgList,
CounterpartyArgCreate,
CounterpartyArgUpdate,
CounterpartyItem,
CurrencyItem,
SellerArgCreate,
SellerArgUpdate,
SellerItem,
} from './billing.types'
export default class TvBillingApi extends TvApiBase {
protected moduleUrl = '/module/billing'
public async fetchCurrencies() {
return this.request(this.$axios.get<AppResponse<CurrencyItem[]>>(`${this.moduleUrl}/currencies`))
}
public async fetchSellers(params: BillingArgList) {
return this.request(this.$axios.get<AppResponse<SellerItem[]>>(`${this.moduleUrl}/sellers`, { params }))
}
public async createSeller(data: SellerArgCreate) {
return this.request(this.$axios.post<AppResponse<SellerItem>>(`${this.moduleUrl}/sellers`, data))
}
public async updateSeller({ id, data }: SellerArgUpdate) {
return this.request(this.$axios.patch<AppResponse<SellerItem>>(`${this.moduleUrl}/sellers/${id}`, data))
}
public async archiveSeller({ id, archived }: BillingArgArchive) {
return this.request(this.$axios.patch<AppResponse<SellerItem>>(`${this.moduleUrl}/sellers/${id}/archive`, { archived }))
}
public async deleteSeller(id: number) {
return this.request(this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/sellers/${id}`))
}
public async fetchCounterparties(params: BillingArgList) {
return this.request(this.$axios.get<AppResponse<CounterpartyItem[]>>(`${this.moduleUrl}/counterparties`, { params }))
}
public async createCounterparty(data: CounterpartyArgCreate) {
return this.request(this.$axios.post<AppResponse<CounterpartyItem>>(`${this.moduleUrl}/counterparties`, data))
}
public async updateCounterparty({ id, data }: CounterpartyArgUpdate) {
return this.request(this.$axios.patch<AppResponse<CounterpartyItem>>(`${this.moduleUrl}/counterparties/${id}`, data))
}
public async archiveCounterparty({ id, archived }: BillingArgArchive) {
return this.request(
this.$axios.patch<AppResponse<CounterpartyItem>>(`${this.moduleUrl}/counterparties/${id}/archive`, { archived }),
)
}
public async deleteCounterparty(id: number) {
return this.request(this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/counterparties/${id}`))
}
}
@@ -0,0 +1,86 @@
export type CurrencyItem = {
id: number
code: string
numericCode: number
name: string
symbol: string
decimalDigits: number
sortOrder: number
isActive: boolean
createdAt: string
}
export type BillingRequisite = {
key: string
label: string
value: string
}
export type BillingBankDetails = {
bankName: string
accountNumber: string
iban: string
swift: string
correspondentAccount: string
}
export type CounterpartyKind = 'organization' | 'person'
export type SellerItem = {
id: number
organizationId: number
name: string
legalName: string
address: string
email: string
phone: string
logoUrl: string
currencyCode: string
bank: BillingBankDetails
requisites: BillingRequisite[]
defaultTerms: string
taxNote: string
archived: boolean
createdAt: string
updatedAt: string
}
export type SellerArgCreate = Omit<SellerItem, 'id' | 'archived' | 'createdAt' | 'updatedAt'>
export type SellerArgUpdate = {
id: number
data: Omit<SellerArgCreate, 'organizationId'>
}
export type CounterpartyItem = {
id: number
organizationId: number
kind: CounterpartyKind
name: string
legalName: string
address: string
email: string
phone: string
contactPerson: string
requisites: BillingRequisite[]
archived: boolean
createdAt: string
updatedAt: string
}
export type CounterpartyArgCreate = Omit<CounterpartyItem, 'id' | 'archived' | 'createdAt' | 'updatedAt'>
export type CounterpartyArgUpdate = {
id: number
data: Omit<CounterpartyArgCreate, 'organizationId'>
}
export type BillingArgList = {
organizationId: number
includeArchived?: boolean
}
export type BillingArgArchive = {
id: number
archived: boolean
}
@@ -0,0 +1,40 @@
import TvApiBase from './base'
import type { AppResponse } from './base.types'
import type { InvoiceArgCreate, InvoiceArgList, InvoiceArgPdf, InvoiceArgStatus, InvoiceArgUpdate, InvoiceItem } from './invoices.types'
export default class TvInvoicesApi extends TvApiBase {
protected moduleUrl = '/module/invoices'
public async fetch(params: InvoiceArgList) {
return this.request(this.$axios.get<AppResponse<InvoiceItem[]>>(`${this.moduleUrl}`, { params }))
}
public async fetchById(id: number) {
return this.request(this.$axios.get<AppResponse<InvoiceItem>>(`${this.moduleUrl}/${id}`))
}
public async create(data: InvoiceArgCreate) {
return this.request(this.$axios.post<AppResponse<InvoiceItem>>(`${this.moduleUrl}`, data))
}
public async update({ id, data }: InvoiceArgUpdate) {
return this.request(this.$axios.patch<AppResponse<InvoiceItem>>(`${this.moduleUrl}/${id}`, data))
}
public async setStatus({ id, status }: InvoiceArgStatus) {
return this.request(this.$axios.patch<AppResponse<InvoiceItem>>(`${this.moduleUrl}/${id}/status`, { status }))
}
public async reissue(id: number) {
return this.request(this.$axios.post<AppResponse<InvoiceItem>>(`${this.moduleUrl}/${id}/reissue`, {}))
}
public async fetchPdf({ id, lang }: InvoiceArgPdf) {
const response = await this.$axios.get<Blob>(`${this.moduleUrl}/${id}/pdf`, { params: { lang }, responseType: 'blob' })
return response.data
}
public async delete(id: number) {
return this.request(this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/${id}`))
}
}
@@ -0,0 +1,128 @@
import type { BillingBankDetails, BillingRequisite, CounterpartyKind } from './billing.types'
export type InvoiceStatus = 'draft' | 'issued' | 'paid' | 'void'
export type InvoiceUnit = 'service' | 'hours' | 'pcs'
export type InvoiceDiscountType = 'percent' | 'amount'
export type InvoicePaymentTerms = 'on_receipt' | 'net7' | 'net14' | 'net30' | 'custom'
export type InvoiceSellerSnapshot = {
name: string
legalName: string
address: string
email: string
phone: string
logoUrl: string
bank: BillingBankDetails
requisites: BillingRequisite[]
}
export type InvoiceCounterpartySnapshot = {
kind: CounterpartyKind
name: string
legalName: string
address: string
email: string
phone: string
contactPerson: string
requisites: BillingRequisite[]
}
export type InvoiceLineItem = {
id: number
taskId: number | null
description: string
unit: InvoiceUnit
quantity: number
unitPrice: number
}
export type InvoiceLineArg = Omit<InvoiceLineItem, 'id'>
export type InvoiceTotals = {
subtotal: number
discount: number
taxable: number
tax: number
total: number
}
export type InvoiceMissingRequisite =
| 'seller.name'
| 'seller.address'
| 'seller.bank'
| 'counterparty.name'
| 'counterparty.address'
| 'lines'
export type InvoiceTransitionError =
| { error: 'not_found' }
| { error: 'invalid_transition'; from: InvoiceStatus; to: InvoiceStatus }
| { error: 'missing_requisites'; missing: InvoiceMissingRequisite[] }
export type InvoicePdfLang = 'en' | 'ru'
export type InvoiceArgPdf = {
id: number
lang: InvoicePdfLang
}
export type InvoiceItem = {
id: number
organizationId: number
goalId: number | null
goalName: string
sellerId: number
counterpartyId: number
number: string
status: InvoiceStatus
reference: string
currencyCode: string
issueDate: string
paymentTerms: InvoicePaymentTerms
dueDate: string | null
periodFrom: string | null
periodTo: string | null
discountType: InvoiceDiscountType
discountValue: number
taxRate: number
taxExempt: boolean
taxNote: string
notes: string
terms: string
seller: InvoiceSellerSnapshot
counterparty: InvoiceCounterpartySnapshot
lines: InvoiceLineItem[]
totals: InvoiceTotals
totalsFrozen: boolean
issuedAt: string | null
paidAt: string | null
voidedAt: string | null
replacesInvoiceId: number | null
templateVersion: number
createdBy: number | null
createdAt: string
updatedAt: string
}
export type InvoiceArgCreate = Omit<
InvoiceItem,
| 'id' | 'goalName' | 'status' | 'seller' | 'counterparty' | 'lines' | 'createdBy' | 'createdAt' | 'updatedAt'
| 'totals' | 'totalsFrozen' | 'issuedAt' | 'paidAt' | 'voidedAt' | 'replacesInvoiceId' | 'templateVersion'
> & {
lines: InvoiceLineArg[]
}
export type InvoiceArgUpdate = {
id: number
data: Omit<InvoiceArgCreate, 'organizationId'>
}
export type InvoiceArgList = {
organizationId: number
includeArchived?: boolean
}
export type InvoiceArgStatus = {
id: number
status: InvoiceStatus
}
@@ -0,0 +1,35 @@
import TvApiBase from './base';
import type { AppResponse } from '@/api/base.types';
import type {
OAuthConnectedApp,
OAuthConsentRequest,
OAuthConsentResponse,
} from './oauth.types';
export default class TvOAuth extends TvApiBase {
protected moduleUrl = '/module/oauth';
public async approveConsent(data: OAuthConsentRequest) {
return this.request(
this.$axios.post<AppResponse<OAuthConsentResponse>>(`${this.moduleUrl}/consent`, data)
);
}
public async denyConsent(data: OAuthConsentRequest) {
return this.request(
this.$axios.post<AppResponse<OAuthConsentResponse>>(`${this.moduleUrl}/consent/deny`, data)
);
}
public async fetchConnectedApps() {
return this.request(
this.$axios.get<AppResponse<OAuthConnectedApp[]>>(`${this.moduleUrl}/connected-apps`)
);
}
public async revokeConnectedApp(grantId: number) {
return this.request(
this.$axios.delete<AppResponse<boolean>>(`${this.moduleUrl}/connected-apps`, { data: { grantId } })
);
}
}
@@ -0,0 +1,25 @@
export type OAuthConsentRequest = {
client_id: string;
redirect_uri: string;
code_challenge: string;
code_challenge_method: 'S256';
/** Empty means "do not narrow" — same semantics as a tvk_ token with no permissions picked. */
allowedPermissions?: string[];
allowedGoalIds?: number[];
state?: string;
resource?: string;
};
export type OAuthConsentResponse = {
redirectUrl: string;
};
export type OAuthConnectedApp = {
grantId: number;
clientId: string;
clientName: string;
allowedPermissions: string[];
allowedGoalIds: number[];
createdAt: string | null;
lastUsedAt: string | null;
};
@@ -0,0 +1,29 @@
import TvApiBase from './base';
import type { AppResponse } from '@/api/base.types';
import type { StartScreenState, StartStateArgs } from './start.types';
/**
* The main screen module. Its routes sit under /module/about for historical
* reasons, while the backend module itself is called `start`.
*/
export default class TvStartApi extends TvApiBase {
protected moduleUrl = '/module/about';
/**
* One request for what the main screen shows: today, upcoming and recently
* completed tasks across every project the caller can see. The split happens
* on the server in `tz`, so it matches the app — a client that fetches every
* project and compares deadlines itself gets a different answer for anyone
* outside UTC.
*/
public async fetchAllState(args: StartStateArgs) {
const params = new URLSearchParams({ tz: args.tz });
if (args.organizationId) {
params.set('organizationId', String(args.organizationId));
}
return this.request(
this.$axios.get<AppResponse<StartScreenState>>(`${this.moduleUrl}/fetchallstate?${params.toString()}`)
);
}
}
@@ -0,0 +1,30 @@
import type { Task } from './tasks.api.types';
export type StartScreenUsers = {
id: number;
name: string;
users: { id: number; email: string }[];
}[];
export type StartScreenAssignees = {
taskId: Task['id'];
collabUserId: number;
email: string;
}[];
/** Everything the main screen shows, split server-side in the caller's timezone. */
export type StartScreenState = {
tasks: Task[];
tasksToday: Task[];
tasksUpcoming: Task[];
tasksLastCompleted: Task[];
users: StartScreenUsers;
assignees: StartScreenAssignees;
listToGoal: Record<number, number>;
};
export type StartStateArgs = {
/** IANA timezone, e.g. "Europe/Belgrade". The API rejects the request without it. */
tz: string;
organizationId?: number;
};
@@ -12,8 +12,12 @@ export * from '@/api/kanban.types';
export * from '@/api/integrations.types';
export * from '@/api/notifications.api.types';
export * from '@/api/webhooks.types';
export * from '@/api/billing.types';
export * from '@/api/invoices.types';
export * from '@/api/messaging.types';
export * from '@/api/api-tokens.types';
export * from '@/api/oauth.types';
export * from '@/api/start.types';
export * from '@/api/sessions.types';
export * from '@/api/organizations.types';
export * from '@/api/sso.types';
+16
View File
@@ -11,6 +11,8 @@ import TvNotificationsApi from "./api/notifications";
import TvWebhooks from "./api/webhooks";
import TvMessagingApi from "./api/messaging";
import TvApiTokens from "./api/api-tokens";
import TvOAuth from "./api/oauth";
import TvStartApi from "./api/start";
import TvSessions from "./api/sessions";
import TvOrganizationsApi from "./api/organizations";
import TvSsoApi from "./api/sso";
@@ -19,6 +21,8 @@ import TvTimeTrackingApi from "./api/time-tracking";
import TvUiPreferencesApi from "./api/ui-preferences";
import TvSprintApi from "./api/sprints";
import TvRecurrenceApi from "./api/recurrence";
import TvBillingApi from "./api/billing";
import TvInvoicesApi from "./api/invoices";
export class TvApi {
@@ -47,6 +51,8 @@ export class TvApi {
public messaging: TvMessagingApi;
public apiTokens: TvApiTokens;
public oauth: TvOAuth;
public start: TvStartApi;
public sessions: TvSessions;
@@ -64,6 +70,10 @@ export class TvApi {
public recurrence: TvRecurrenceApi;
public billing: TvBillingApi;
public invoices: TvInvoicesApi;
constructor($axios: AxiosInstance) {
this.$axios = $axios;
@@ -90,6 +100,8 @@ export class TvApi {
this.messaging = new TvMessagingApi(this.$axios);
this.apiTokens = new TvApiTokens(this.$axios);
this.oauth = new TvOAuth(this.$axios);
this.start = new TvStartApi(this.$axios);
this.sessions = new TvSessions(this.$axios);
@@ -106,6 +118,10 @@ export class TvApi {
this.sprints = new TvSprintApi(this.$axios);
this.recurrence = new TvRecurrenceApi(this.$axios);
this.billing = new TvBillingApi(this.$axios);
this.invoices = new TvInvoicesApi(this.$axios);
}
public setBaseUrl(baseUrl: string) {
@@ -16,11 +16,17 @@ export * from './schemas/notification-preferences.schema';
export * from './schemas/webhooks.schema';
export * from './schemas/messaging.schema';
export * from './schemas/api-tokens.schema';
export * from './schemas/oauth-clients.schema';
export * from './schemas/oauth-auth-codes.schema';
export * from './schemas/oauth-grants.schema';
export * from './schemas/user-tokens.schema';
export * from './schemas/organizations.schema';
export * from './schemas/sso.schema';
export * from './schemas/time-entries.schema';
export * from './schemas/time-entries-history.schema';
export * from './schemas/currencies.schema';
export * from './schemas/billing.schema';
export * from './schemas/invoices.schema';
export * from './schemas/ui-preferences.schema';
export * from './schemas/sprints.schema';
export * from './schemas/sprint-task-outcomes.schema';
@@ -7,6 +7,7 @@ export const ApiTokensSchema = pgSchema('tv_auth').table('api_tokens', {
tokenHash: varchar('token_hash', { length: 64 }).notNull().unique(),
allowedPermissions: varchar('allowed_permissions').array().notNull().default([]),
allowedGoalIds: integer('allowed_goal_ids').array().notNull().default([]),
grantId: integer('grant_id'),
lastUsedAt: timestamp('last_used_at'),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').defaultNow(),
@@ -0,0 +1,57 @@
import { boolean, char, integer, jsonb, timestamp, varchar } from 'drizzle-orm/pg-core'
import { BillingPgSchema, CurrenciesSchema } from './currencies.schema'
import { OrganizationsSchema } from './organizations.schema'
export type BillingRequisite = {
key: string
label: string
value: string
}
export type BillingBankDetails = {
bankName: string
accountNumber: string
iban: string
swift: string
correspondentAccount: string
}
export const SellersSchema = BillingPgSchema.table('sellers', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
organizationId: integer('organization_id').notNull().references(() => OrganizationsSchema.id, { onDelete: 'cascade' }),
name: varchar({ length: 200 }).notNull(),
legalName: varchar('legal_name', { length: 300 }).notNull().default(''),
address: varchar({ length: 1000 }).notNull().default(''),
email: varchar({ length: 320 }).notNull().default(''),
phone: varchar({ length: 50 }).notNull().default(''),
logoUrl: varchar('logo_url', { length: 1000 }).notNull().default(''),
currencyCode: char('currency_code', { length: 3 }).notNull().default('USD').references(() => CurrenciesSchema.code),
bank: jsonb().$type<BillingBankDetails>().notNull(),
requisites: jsonb().$type<BillingRequisite[]>().notNull(),
defaultTerms: varchar('default_terms', { length: 2000 }).notNull().default(''),
taxNote: varchar('tax_note', { length: 500 }).notNull().default(''),
archived: boolean().notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const CounterpartiesSchema = BillingPgSchema.table('counterparties', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
organizationId: integer('organization_id').notNull().references(() => OrganizationsSchema.id, { onDelete: 'cascade' }),
kind: varchar({ length: 20 }).$type<'organization' | 'person'>().notNull().default('organization'),
name: varchar({ length: 200 }).notNull(),
legalName: varchar('legal_name', { length: 300 }).notNull().default(''),
address: varchar({ length: 1000 }).notNull().default(''),
email: varchar({ length: 320 }).notNull().default(''),
phone: varchar({ length: 50 }).notNull().default(''),
contactPerson: varchar('contact_person', { length: 200 }).notNull().default(''),
requisites: jsonb().$type<BillingRequisite[]>().notNull(),
archived: boolean().notNull().default(false),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export type SellersSchemaTypeForSelect = typeof SellersSchema.$inferSelect
export type SellersSchemaTypeForInsert = typeof SellersSchema.$inferInsert
export type CounterpartiesSchemaTypeForSelect = typeof CounterpartiesSchema.$inferSelect
export type CounterpartiesSchemaTypeForInsert = typeof CounterpartiesSchema.$inferInsert
@@ -0,0 +1,21 @@
import { boolean, char, integer, pgSchema, smallint, timestamp, varchar } from 'drizzle-orm/pg-core'
import { createInsertSchema } from 'drizzle-arktype'
export const BillingPgSchema = pgSchema('tv_billing')
export const CurrenciesSchema = BillingPgSchema.table('currencies', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
code: char({ length: 3 }).notNull().unique(),
numericCode: smallint('numeric_code').notNull().unique(),
name: varchar({ length: 64 }).notNull(),
symbol: varchar({ length: 8 }).notNull(),
decimalDigits: smallint('decimal_digits').notNull().default(2),
sortOrder: smallint('sort_order').notNull().default(0),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at').notNull().defaultNow(),
})
export type CurrenciesSchemaTypeForSelect = typeof CurrenciesSchema.$inferSelect
export type CurrenciesSchemaTypeForInsert = typeof CurrenciesSchema.$inferInsert
export const CurrenciesSchemaArkTypeInsert = createInsertSchema(CurrenciesSchema)
@@ -0,0 +1,89 @@
import { boolean, char, date, integer, jsonb, numeric, smallint, timestamp, varchar } from 'drizzle-orm/pg-core'
import { BillingPgSchema, CurrenciesSchema } from './currencies.schema'
import { CounterpartiesSchema, SellersSchema, type BillingBankDetails, type BillingRequisite } from './billing.schema'
import { OrganizationsSchema } from './organizations.schema'
import { GoalsSchema } from './goals.schema'
import { TasksSchema } from './tasks.schema'
import { UsersSchema } from './users.schema'
export type InvoiceStatus = 'draft' | 'issued' | 'paid' | 'void'
export type InvoiceDiscountType = 'percent' | 'amount'
export type InvoicePaymentTerms = 'on_receipt' | 'net7' | 'net14' | 'net30' | 'custom'
export type InvoiceLineUnit = 'service' | 'hours' | 'pcs'
export type InvoiceSellerSnapshot = {
name: string
legalName: string
address: string
email: string
phone: string
logoUrl: string
bank: BillingBankDetails
requisites: BillingRequisite[]
}
export type InvoiceCounterpartySnapshot = {
kind: 'organization' | 'person'
name: string
legalName: string
address: string
email: string
phone: string
contactPerson: string
requisites: BillingRequisite[]
}
export const InvoicesSchema = BillingPgSchema.table('invoices', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
organizationId: integer('organization_id').notNull().references(() => OrganizationsSchema.id, { onDelete: 'cascade' }),
goalId: integer('goal_id').references(() => GoalsSchema.id, { onDelete: 'set null' }),
goalName: varchar('goal_name', { length: 500 }).notNull().default(''),
sellerId: integer('seller_id').notNull().references(() => SellersSchema.id, { onDelete: 'restrict' }),
counterpartyId: integer('counterparty_id').notNull().references(() => CounterpartiesSchema.id, { onDelete: 'restrict' }),
number: varchar({ length: 50 }).notNull(),
status: varchar({ length: 10 }).$type<InvoiceStatus>().notNull().default('draft'),
reference: varchar({ length: 200 }).notNull().default(''),
currencyCode: char('currency_code', { length: 3 }).notNull().references(() => CurrenciesSchema.code),
issueDate: date('issue_date').notNull(),
paymentTerms: varchar('payment_terms', { length: 20 }).$type<InvoicePaymentTerms>().notNull().default('net14'),
dueDate: date('due_date'),
periodFrom: date('period_from'),
periodTo: date('period_to'),
discountType: varchar('discount_type', { length: 10 }).$type<InvoiceDiscountType>().notNull().default('percent'),
discountValue: numeric('discount_value', { precision: 12, scale: 2 }).notNull().default('0'),
taxRate: numeric('tax_rate', { precision: 5, scale: 2 }).notNull().default('0'),
taxExempt: boolean('tax_exempt').notNull().default(false),
taxNote: varchar('tax_note', { length: 500 }).notNull().default(''),
notes: varchar({ length: 2000 }).notNull().default(''),
terms: varchar({ length: 2000 }).notNull().default(''),
sellerSnapshot: jsonb('seller_snapshot').$type<InvoiceSellerSnapshot>().notNull(),
counterpartySnapshot: jsonb('counterparty_snapshot').$type<InvoiceCounterpartySnapshot>().notNull(),
createdBy: integer('created_by').references(() => UsersSchema.id, { onDelete: 'set null' }),
issuedAt: timestamp('issued_at'),
paidAt: timestamp('paid_at'),
voidedAt: timestamp('voided_at'),
replacesInvoiceId: integer('replaces_invoice_id'),
templateVersion: smallint('template_version').notNull().default(1),
subtotal: numeric({ precision: 12, scale: 2 }),
discountAmount: numeric('discount_amount', { precision: 12, scale: 2 }),
taxAmount: numeric('tax_amount', { precision: 12, scale: 2 }),
total: numeric({ precision: 12, scale: 2 }),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
})
export const InvoiceLinesSchema = BillingPgSchema.table('invoice_lines', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
invoiceId: integer('invoice_id').notNull().references(() => InvoicesSchema.id, { onDelete: 'cascade' }),
position: smallint().notNull().default(0),
taskId: integer('task_id').references(() => TasksSchema.id, { onDelete: 'set null' }),
description: varchar({ length: 1000 }).notNull(),
unit: varchar({ length: 20 }).$type<InvoiceLineUnit>().notNull().default('service'),
quantity: numeric({ precision: 12, scale: 2 }).notNull().default('1'),
unitPrice: numeric('unit_price', { precision: 12, scale: 2 }).notNull().default('0'),
})
export type InvoicesSchemaTypeForSelect = typeof InvoicesSchema.$inferSelect
export type InvoicesSchemaTypeForInsert = typeof InvoicesSchema.$inferInsert
export type InvoiceLinesSchemaTypeForSelect = typeof InvoiceLinesSchema.$inferSelect
export type InvoiceLinesSchemaTypeForInsert = typeof InvoiceLinesSchema.$inferInsert
@@ -0,0 +1,21 @@
import { pgSchema, integer, timestamp, varchar } from "drizzle-orm/pg-core";
export const OAuthAuthCodesSchema = pgSchema('tv_auth').table('oauth_auth_codes', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
codeHash: varchar('code_hash', { length: 64 }).notNull().unique(),
clientId: varchar('client_id', { length: 64 }).notNull(),
userId: integer('user_id').notNull(),
redirectUri: varchar('redirect_uri').notNull(),
codeChallenge: varchar('code_challenge', { length: 128 }).notNull(),
codeChallengeMethod: varchar('code_challenge_method', { length: 8 }).notNull().default('S256'),
allowedPermissions: varchar('allowed_permissions').array().notNull().default([]),
allowedGoalIds: integer('allowed_goal_ids').array().notNull().default([]),
resource: varchar(),
expiresAt: timestamp('expires_at').notNull(),
usedAt: timestamp('used_at'),
grantId: integer('grant_id'),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export type OAuthAuthCodesSchemaTypeForSelect = typeof OAuthAuthCodesSchema.$inferSelect;
export type OAuthAuthCodesSchemaTypeForInsert = typeof OAuthAuthCodesSchema.$inferInsert;
@@ -0,0 +1,14 @@
import { pgSchema, integer, timestamp, varchar } from "drizzle-orm/pg-core";
export const OAuthClientsSchema = pgSchema('tv_auth').table('oauth_clients', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
clientId: varchar('client_id', { length: 64 }).notNull().unique(),
clientSecretHash: varchar('client_secret_hash', { length: 64 }),
name: varchar({ length: 200 }).notNull(),
redirectUris: varchar('redirect_uris').array().notNull().default([]),
createdVia: varchar('created_via', { length: 16 }).notNull().default('manual'),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export type OAuthClientsSchemaTypeForSelect = typeof OAuthClientsSchema.$inferSelect;
export type OAuthClientsSchemaTypeForInsert = typeof OAuthClientsSchema.$inferInsert;
@@ -0,0 +1,19 @@
import { pgSchema, integer, timestamp, varchar } from "drizzle-orm/pg-core";
export const OAuthGrantsSchema = pgSchema('tv_auth').table('oauth_grants', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer('user_id').notNull(),
clientId: varchar('client_id', { length: 64 }).notNull(),
allowedPermissions: varchar('allowed_permissions').array().notNull().default([]),
allowedGoalIds: integer('allowed_goal_ids').array().notNull().default([]),
resource: varchar(),
refreshTokenHash: varchar('refresh_token_hash', { length: 64 }),
refreshTokenPrevHash: varchar('refresh_token_prev_hash', { length: 64 }),
refreshExpiresAt: timestamp('refresh_expires_at'),
lastUsedAt: timestamp('last_used_at'),
revokedAt: timestamp('revoked_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
});
export type OAuthGrantsSchemaTypeForSelect = typeof OAuthGrantsSchema.$inferSelect;
export type OAuthGrantsSchemaTypeForInsert = typeof OAuthGrantsSchema.$inferInsert;
@@ -17,7 +17,7 @@ export const PermissionsSchema = pgSchema('tv_auth').table('permissions', {
name: varchar().notNull(),
description: varchar(),
permissionGroup: integer('permission_group'),
descriptionLocales: jsonb('description_locales'),
descriptionLocales: jsonb('description_locales').$type<Record<string, string> | null>(),
});
export type PermissionsSchemaTypeForSelect = typeof PermissionsSchema.$inferSelect;
+43
View File
@@ -73,6 +73,8 @@ Then use `"command": "taskview-mcp"` (no `args` needed).
| `TASKVIEW_URL` | yes | TaskView API server URL (e.g. `https://api.taskview.tech`) |
| `TASKVIEW_TOKEN` | yes (stdio mode) | API token with `tvk_` prefix. Not used in HTTP mode — each caller sends their own token |
| `MCP_HTTP_PORT` | no (HTTP mode) | Port for the HTTP server, default `3100` |
| `MCP_PUBLIC_URL` | no (HTTP mode) | Public URL of this server as clients reach it — the OAuth resource identifier. Include the path when the server is mounted under one (`https://api.example.com/mcp`); derived from the request host when unset |
| `MCP_ALLOWED_ORIGINS` | no (HTTP mode) | Comma-separated browser origins allowed to call `/mcp`. Unset means no browser origin is allowed; non-browser clients send no `Origin` and are unaffected |
## HTTP server (remote / self-hosted)
@@ -112,6 +114,45 @@ Self-hosted instances run their own copy next to their API (see `Dockerfile`
in this package) — point `TASKVIEW_URL` at your API server and put the MCP
port behind your reverse proxy with HTTPS.
### OAuth (ChatGPT and other cloud clients)
Clients that will not accept a pasted API token — ChatGPT connectors among
them — authorize over OAuth 2.1 instead. Nothing extra runs here: this server
advertises itself as the protected resource and points at your TaskView API,
which is the authorization server.
- `GET /.well-known/oauth-protected-resource` returns the resource metadata
(RFC 9728), naming `TASKVIEW_URL` as the authorization server.
- A request with no token answers `401` with
`WWW-Authenticate: Bearer resource_metadata="…"`, which is what starts
discovery in the client.
- The client then registers, sends the user to the TaskView consent screen,
and comes back with a `tvo_` access token that it sends here like any other
bearer token.
Set `MCP_PUBLIC_URL` to the externally reachable URL of this server so the
advertised resource identifier matches what clients actually request.
**Mounted under a path.** When the server sits behind a reverse proxy at
`https://api.example.com/mcp` rather than on its own host, put the full path in
`MCP_PUBLIC_URL`. RFC 9728 then places the metadata at
`/.well-known/oauth-protected-resource/mcp`, which is where a client that
computes the address from the spec — instead of trusting the `WWW-Authenticate`
header — will look. The server publishes it at both that address and the root
one, but the proxy has to route the path-inserted address here too:
```nginx
location = /.well-known/oauth-protected-resource/mcp {
proxy_pass http://127.0.0.1:3100/.well-known/oauth-protected-resource/mcp;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
Manually issued `tvk_` tokens keep working exactly as before — OAuth is an
additional way in, not a replacement. Use `tvk_` for stdio, CLIs and CI, where
there is no browser to complete an authorization flow.
## Available tools
61 tools covering the full TaskView surface.
@@ -122,6 +163,8 @@ port behind your reverse proxy with HTTPS.
**Tasks**`list_tasks`, `get_task`, `create_task`, `update_task`, `delete_task`, `toggle_task_assignees`, `get_task_history`, `restore_task_from_history`
**Agenda**`get_agenda` (today, upcoming and recently completed across all projects in one call)
**Tags**`list_tags`, `create_tag`, `update_tag`, `delete_tag`, `toggle_task_tag`
**Kanban**`list_kanban_columns`, `create_kanban_column`, `update_kanban_column`, `delete_kanban_column`
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest'
import { registerGoalsTools } from '../tools/goals.js'
import { registerTasksTools } from '../tools/tasks.js'
import { registerListsTools } from '../tools/lists.js'
import { registerTagsTools } from '../tools/tags.js'
import { registerKanbanTools } from '../tools/kanban.js'
import { registerCollaborationTools } from '../tools/collaboration.js'
import { registerGraphTools } from '../tools/graph.js'
import { registerNotificationsTools } from '../tools/notifications.js'
import { registerStartTools } from '../tools/start.js'
import { registerOrganizationsTools } from '../tools/organizations.js'
import { registerTimeTrackingTools } from '../tools/time-tracking.js'
import { mockServer, mockApi, findTool } from './setup.js'
const REQUIRED_HINTS = ['readOnlyHint', 'destructiveHint', 'openWorldHint'] as const
function registerEverything() {
const { server, tools } = mockServer()
const api = mockApi()
registerGoalsTools(server, api)
registerTasksTools(server, api)
registerListsTools(server, api)
registerTagsTools(server, api)
registerKanbanTools(server, api)
registerCollaborationTools(server, api)
registerGraphTools(server, api)
registerNotificationsTools(server, api)
registerStartTools(server, api)
registerOrganizationsTools(server, api)
registerTimeTrackingTools(server, api)
return tools
}
describe('tool annotations', () => {
const tools = registerEverything()
it('gives every tool a human-readable title', () => {
// The Claude Connectors Directory flags any tool without a title and
// will not accept the submission until every one has it.
for (const tool of tools) {
expect(typeof tool.config.title, tool.name).toBe('string')
expect(tool.config.title?.trim().length, tool.name).toBeGreaterThan(0)
}
})
it('keeps every tool name within the 64-character directory limit', () => {
for (const tool of tools) {
expect(tool.name.length, tool.name).toBeLessThanOrEqual(64)
}
})
it('sets every safety hint explicitly on every tool', () => {
// ChatGPT refuses to list an app whose tools leave any hint unset, and the
// protocol defaults differ between clients — so nothing may rely on them.
for (const tool of tools) {
for (const hint of REQUIRED_HINTS) {
expect(typeof tool.config.annotations?.[hint], `${tool.name}.${hint}`).toBe('boolean')
}
}
})
it('marks every list and get tool as read-only', () => {
const readers = tools.filter((t) => /^(list|get)_/.test(t.name))
expect(readers.length).toBeGreaterThan(0)
for (const tool of readers) {
expect(tool.config.annotations?.readOnlyHint, tool.name).toBe(true)
expect(tool.config.annotations?.destructiveHint, tool.name).toBe(false)
}
})
it('never marks a writing tool as read-only', () => {
const writers = tools.filter((t) => !/^(list|get)_/.test(t.name))
for (const tool of writers) {
expect(tool.config.annotations?.readOnlyHint, tool.name).toBe(false)
}
})
it('marks deletion and removal as destructive', () => {
const removers = tools.filter((t) => /^(delete|remove)_/.test(t.name))
expect(removers.length).toBeGreaterThan(0)
for (const tool of removers) {
expect(tool.config.annotations?.destructiveHint, tool.name).toBe(true)
}
})
it('marks restoring a task from history as destructive, since it overwrites the current state', () => {
expect(findTool(tools, 'restore_task_from_history').config.annotations?.destructiveHint).toBe(true)
})
it('keeps every tool inside the user\'s own instance except the invitation email', () => {
for (const tool of tools) {
const expected = tool.name === 'invite_collaborator'
expect(tool.config.annotations?.openWorldHint, tool.name).toBe(expected)
}
})
})
@@ -0,0 +1,193 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
/**
* The HTTP transport is the entry point of OAuth discovery: a client knows only
* the /mcp URL, gets a 401, and follows WWW-Authenticate to the resource
* metadata. These run against the built dist/http.js as a real process, because
* that is what ships and what a client actually talks to.
*/
const PORT = 3199
const PUBLIC_URL = `http://127.0.0.1:${PORT}`
const TASKVIEW_URL = process.env.TASKVIEW_URL || 'http://127.0.0.1:11401'
let server: ChildProcess
async function waitForServer(attempts = 40) {
for (let i = 0; i < attempts; i++) {
try {
const response = await fetch(`${PUBLIC_URL}/health`)
if (response.ok) return
} catch {
// not listening yet
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
throw new Error('MCP HTTP server did not start')
}
describe('MCP HTTP transport — OAuth discovery', () => {
beforeAll(async () => {
server = spawn('node', [join(__dirname, '../../../dist/http.js')], {
env: {
...process.env,
TASKVIEW_URL,
MCP_PUBLIC_URL: PUBLIC_URL,
MCP_HTTP_PORT: String(PORT),
},
stdio: 'ignore',
})
await waitForServer()
})
afterAll(() => {
server?.kill()
})
it('answers an unauthenticated call with 401 and points at the resource metadata', async () => {
const response = await fetch(`${PUBLIC_URL}/mcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
expect(response.status).toBe(401)
expect(response.headers.get('www-authenticate')).toBe(
`Bearer resource_metadata="${PUBLIC_URL}/.well-known/oauth-protected-resource"`,
)
})
it('publishes protected resource metadata naming the TaskView API as the authorization server', async () => {
const response = await fetch(`${PUBLIC_URL}/.well-known/oauth-protected-resource`)
const body = await response.json() as Record<string, any>
expect(response.status).toBe(200)
expect(body.resource).toBe(PUBLIC_URL)
expect(body.authorization_servers).toEqual([TASKVIEW_URL])
expect(body.bearer_methods_supported).toEqual(['header'])
})
it('uses MCP_PUBLIC_URL rather than the Host header, so a proxy cannot leak an internal address', async () => {
const response = await fetch(`${PUBLIC_URL}/.well-known/oauth-protected-resource`, {
headers: { Host: 'internal-name:9999' },
})
const body = await response.json() as Record<string, any>
expect(body.resource).toBe(PUBLIC_URL)
})
it('keeps /health open', async () => {
const response = await fetch(`${PUBLIC_URL}/health`)
expect(response.status).toBe(200)
expect(((await response.json()) as { status: string }).status).toBe('ok')
})
it('does not allow an unlisted browser origin', async () => {
const response = await fetch(`${PUBLIC_URL}/mcp`, {
method: 'OPTIONS',
headers: { Origin: 'https://evil.example.com' },
})
expect(response.headers.get('access-control-allow-origin')).toBeNull()
})
describe('mounted under a path', () => {
const PATH_PORT = PORT + 2
const PATH_PUBLIC_URL = `http://127.0.0.1:${PATH_PORT}/mcp`
let pathServer: ChildProcess
beforeAll(async () => {
pathServer = spawn('node', [join(__dirname, '../../../dist/http.js')], {
env: {
...process.env,
TASKVIEW_URL,
MCP_PUBLIC_URL: PATH_PUBLIC_URL,
MCP_HTTP_PORT: String(PATH_PORT),
},
stdio: 'ignore',
})
for (let i = 0; i < 40; i++) {
try {
if ((await fetch(`http://127.0.0.1:${PATH_PORT}/health`)).ok) return
} catch {
// not listening yet
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
throw new Error('path-mounted MCP server did not start')
})
afterAll(() => {
pathServer?.kill()
})
it('points the 401 at the path-inserted metadata address', async () => {
const response = await fetch(`http://127.0.0.1:${PATH_PORT}/mcp`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
expect(response.status).toBe(401)
expect(response.headers.get('www-authenticate')).toBe(
`Bearer resource_metadata="http://127.0.0.1:${PATH_PORT}/.well-known/oauth-protected-resource/mcp"`,
)
})
it('serves the document where RFC 9728 says it lives', async () => {
const response = await fetch(
`http://127.0.0.1:${PATH_PORT}/.well-known/oauth-protected-resource/mcp`,
)
const body = await response.json() as Record<string, any>
expect(response.status).toBe(200)
// The identifier now matches the URL the client actually connected to.
expect(body.resource).toBe(PATH_PUBLIC_URL)
})
it('keeps serving the root address for clients that only follow the header', async () => {
const response = await fetch(
`http://127.0.0.1:${PATH_PORT}/.well-known/oauth-protected-resource`,
)
const body = await response.json() as Record<string, any>
expect(response.status).toBe(200)
expect(body.resource).toBe(PATH_PUBLIC_URL)
})
})
it('allows an origin listed in MCP_ALLOWED_ORIGINS', async () => {
const allowed = spawn('node', [join(__dirname, '../../../dist/http.js')], {
env: {
...process.env,
TASKVIEW_URL,
MCP_PUBLIC_URL: `http://127.0.0.1:${PORT + 1}`,
MCP_HTTP_PORT: String(PORT + 1),
MCP_ALLOWED_ORIGINS: 'https://good.example.com',
},
stdio: 'ignore',
})
try {
for (let i = 0; i < 40; i++) {
try {
if ((await fetch(`http://127.0.0.1:${PORT + 1}/health`)).ok) break
} catch {
// not listening yet
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
const response = await fetch(`http://127.0.0.1:${PORT + 1}/mcp`, {
method: 'OPTIONS',
headers: { Origin: 'https://good.example.com' },
})
expect(response.headers.get('access-control-allow-origin')).toBe('https://good.example.com')
} finally {
allowed.kill()
}
})
})
@@ -26,6 +26,7 @@ export function mockApi(overrides: Record<string, Record<string, unknown>> = {})
},
graph: { fetchAllEdges: noop, addEdge: noop, deleteEdge: noop },
notifications: { fetch: noop, markRead: noop, markAllRead: noop },
start: { fetchAllState: noop },
timeTracking: {
start: noop, stop: noop, getActive: noop, createManual: noop, update: noop, delete: noop,
fetchEntries: noop, summaryByTask: noop, summaryByGoal: noop,
@@ -52,7 +53,7 @@ export function apiThrow(message: string) {
type ToolEntry = {
name: string
config: { description?: string; inputSchema?: Record<string, unknown> }
config: { title?: string; description?: string; inputSchema?: Record<string, unknown>; annotations?: Record<string, unknown> }
cb: (args: Record<string, unknown>) => Promise<{ content: { type: string; text: string }[]; isError?: boolean }>
}
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest'
import { registerStartTools } from '../tools/start.js'
import { mockServer, mockApi, apiReturn, apiThrow, findTool, ts } from './setup.js'
describe('start tools', () => {
it('registers the agenda tool', () => {
const { server, tools } = mockServer()
registerStartTools(server, mockApi())
expect(tools.map((t) => t.name)).toEqual(['get_agenda'])
})
it('returns today, upcoming and recently completed in one call', async () => {
const { server, tools } = mockServer()
const data = {
tasks: [],
tasksToday: [{ id: 1, description: `Today ${ts()}` }],
tasksUpcoming: [{ id: 2, description: `Later ${ts()}` }],
tasksLastCompleted: [{ id: 3, description: `Done ${ts()}` }],
users: [],
assignees: [],
listToGoal: {},
}
registerStartTools(server, mockApi({ start: { fetchAllState: apiReturn(data) } }))
const result = await findTool(tools, 'get_agenda').cb({})
expect(result.content[0].text).toContain(data.tasksToday[0].description)
expect(result.content[0].text).toContain(data.tasksUpcoming[0].description)
expect(result.content[0].text).toContain(data.tasksLastCompleted[0].description)
})
it('passes the timezone through, since the server splits the day by it', async () => {
const { server, tools } = mockServer()
let captured: unknown
const fetchAllState = (args: unknown) => {
captured = args
return Promise.resolve({ response: {}, rid: `rid-${ts()}` })
}
registerStartTools(server, mockApi({ start: { fetchAllState } }))
await findTool(tools, 'get_agenda').cb({ timezone: 'Europe/Belgrade', organizationId: 7 })
expect(captured).toEqual({ tz: 'Europe/Belgrade', organizationId: 7 })
})
it('falls back to UTC when the caller does not know the timezone', async () => {
const { server, tools } = mockServer()
let captured: any
const fetchAllState = (args: unknown) => {
captured = args
return Promise.resolve({ response: {}, rid: `rid-${ts()}` })
}
registerStartTools(server, mockApi({ start: { fetchAllState } }))
await findTool(tools, 'get_agenda').cb({})
// The API rejects a request without tz, so the tool must always send one.
expect(captured.tz).toBe('UTC')
})
it('reports an API failure instead of throwing', async () => {
const { server, tools } = mockServer()
registerStartTools(server, mockApi({ start: { fetchAllState: apiThrow('boom') } }))
const result = await findTool(tools, 'get_agenda').cb({})
expect(result.isError).toBe(true)
})
})
+95 -14
View File
@@ -4,10 +4,22 @@ import axios from 'axios'
import { TvApi } from 'taskview-api'
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
import { createMcpServer } from './server.js'
import type { HandleMcpRequestArgs } from './http.types.js'
import type { CorsHeadersArgs, HandleMcpRequestArgs, UnauthorizedArgs } from './http.types.js'
const TASKVIEW_URL = process.env.TASKVIEW_URL
const PORT = Number(process.env.MCP_HTTP_PORT || 3100)
// Public URL of THIS server, as clients reach it — the OAuth resource
// identifier. It may carry a path when the server is mounted under one
// (https://api.example.com/mcp), which changes where the metadata lives.
const MCP_PUBLIC_URL = process.env.MCP_PUBLIC_URL?.replace(/\/+$/, '')
const METADATA_PREFIX = '/.well-known/oauth-protected-resource'
// Browser origins allowed to call /mcp. Unset means no browser origin is
// allowed — non-browser clients (ChatGPT, Claude, CLIs) send no Origin and are
// unaffected. Guards against DNS rebinding, which the MCP spec calls out.
const ALLOWED_ORIGINS = (process.env.MCP_ALLOWED_ORIGINS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean)
if (!TASKVIEW_URL) {
console.error('Required environment variable: TASKVIEW_URL')
@@ -15,11 +27,73 @@ if (!TASKVIEW_URL) {
process.exit(1)
}
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version',
'Access-Control-Expose-Headers': 'Mcp-Session-Id',
function corsHeaders({ origin }: CorsHeadersArgs): Record<string, string> {
const headers: Record<string, string> = {
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, Mcp-Session-Id, MCP-Protocol-Version',
'Access-Control-Expose-Headers': 'Mcp-Session-Id',
Vary: 'Origin',
}
if (origin && ALLOWED_ORIGINS.includes(origin)) {
headers['Access-Control-Allow-Origin'] = origin
}
return headers
}
function resourceUrl(req: IncomingMessage): string {
if (MCP_PUBLIC_URL) return MCP_PUBLIC_URL
const host = req.headers.host ?? `localhost:${PORT}`
const proto = (req.headers['x-forwarded-proto'] as string | undefined)?.split(',')[0]?.trim()
return `${proto || 'http'}://${host}/mcp`
}
/**
* RFC 9728 §3.1: for a resource identified by a URL with a path, the metadata
* lives at the well-known prefix followed by that path — not at the origin root.
* A client that computes the address from the spec instead of trusting the
* WWW-Authenticate header looks exactly there, so this is what makes a server
* mounted under /mcp discoverable.
*/
function metadataPathsFor(resource: string): string[] {
let path = ''
try {
path = new URL(resource).pathname.replace(/\/+$/, '')
} catch {
path = ''
}
// The root form stays served for clients that only follow the 401 header.
return path ? [`${METADATA_PREFIX}${path}`, METADATA_PREFIX] : [METADATA_PREFIX]
}
function metadataUrlFor(resource: string): string {
const [preferred] = metadataPathsFor(resource)
try {
return `${new URL(resource).origin}${preferred}`
} catch {
return `${resource}${METADATA_PREFIX}`
}
}
function protectedResourceMetadata(req: IncomingMessage) {
return {
resource: resourceUrl(req),
authorization_servers: [TASKVIEW_URL],
bearer_methods_supported: ['header'],
}
}
function sendUnauthorized({ req, res, message }: UnauthorizedArgs) {
const metadataUrl = metadataUrlFor(resourceUrl(req))
res.writeHead(401, {
'Content-Type': 'application/json',
'WWW-Authenticate': `Bearer resource_metadata="${metadataUrl}"`,
}).end(
JSON.stringify({
jsonrpc: '2.0',
error: { code: -32000, message },
id: null,
}),
)
}
function extractBearerToken(req: IncomingMessage): string | null {
@@ -53,7 +127,9 @@ async function handleMcpRequest({ req, res, token }: HandleMcpRequestArgs) {
}
const httpServer = createServer(async (req, res) => {
for (const [name, value] of Object.entries(CORS_HEADERS)) res.setHeader(name, value)
for (const [name, value] of Object.entries(corsHeaders({ origin: req.headers.origin }))) {
res.setHeader(name, value)
}
if (req.method === 'OPTIONS') {
res.writeHead(204).end()
@@ -67,6 +143,13 @@ const httpServer = createServer(async (req, res) => {
return
}
if (metadataPathsFor(resourceUrl(req)).includes(path)) {
res.writeHead(200, { 'Content-Type': 'application/json' }).end(
JSON.stringify(protectedResourceMetadata(req)),
)
return
}
if (path !== '/mcp') {
res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ message: 'Not found. MCP endpoint is /mcp' }))
return
@@ -74,13 +157,11 @@ const httpServer = createServer(async (req, res) => {
const token = extractBearerToken(req)
if (!token) {
res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer' }).end(
JSON.stringify({
jsonrpc: '2.0',
error: { code: -32000, message: 'Unauthorized: provide your TaskView API token as "Authorization: Bearer tvk_..."' },
id: null,
}),
)
sendUnauthorized({
req,
res,
message: 'Unauthorized: authorize with OAuth, or send a TaskView API token as "Authorization: Bearer tvk_..."',
})
return
}
@@ -5,3 +5,13 @@ export type HandleMcpRequestArgs = {
res: ServerResponse
token: string
}
export type CorsHeadersArgs = {
origin: string | undefined
}
export type UnauthorizedArgs = {
req: IncomingMessage
res: ServerResponse
message: string
}
+5 -1
View File
@@ -8,6 +8,7 @@ import { registerKanbanTools } from './tools/kanban.js'
import { registerCollaborationTools } from './tools/collaboration.js'
import { registerGraphTools } from './tools/graph.js'
import { registerNotificationsTools } from './tools/notifications.js'
import { registerStartTools } from './tools/start.js'
import { registerOrganizationsTools } from './tools/organizations.js'
import { registerTimeTrackingTools } from './tools/time-tracking.js'
@@ -23,7 +24,9 @@ DATA MODEL (top to bottom):
WORKFLOW: all IDs are numeric and must be resolved first — never guess them. Map a name to its id with the matching list_* tool (project → list_goals, list → list_lists, members → list_collaborators_for_goal, kanban columns → list_kanban_columns, tags → list_tags), then pass that id to the create/update/delete tools.
list_tasks is paginated: page is 0-based, ~30 tasks per page — request the next page until one returns fewer than 30. Completed tasks are hidden unless showCompleted is set. Use sortBy ("date" or "priority") with descending to control ordering.`
list_tasks is paginated: page is 0-based, ~30 tasks per page — request the next page until one returns fewer than 30. Completed tasks are hidden unless showCompleted is set. Use sortBy ("date" or "priority") with descending to control ordering.
AGENDA: for "what do I have today", "what is coming up" or "what did I finish recently", call get_agenda. It returns today, upcoming, recently completed and undated tasks across every project in a single request, with the day boundary computed on the server. Do NOT answer those questions by listing projects and walking their tasks — that is many times slower and gets the day wrong for anyone outside UTC. Pass the user's IANA timezone when you know it.`
export function createMcpServer(api: TvApi) {
const server = new McpServer(
@@ -42,6 +45,7 @@ export function createMcpServer(api: TvApi) {
registerCollaborationTools(server, api)
registerGraphTools(server, api)
registerNotificationsTools(server, api)
registerStartTools(server, api)
registerOrganizationsTools(server, api)
registerTimeTrackingTools(server, api)
@@ -1,12 +1,14 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { TvApi } from 'taskview-api'
import { z } from 'zod'
import { ok, err } from './helpers.js'
import { ok, err, toolAnnotations } from './helpers.js'
export function registerCollaborationTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_collaborators',
{
title: 'List collaborators',
annotations: toolAnnotations.readOnly,
description: 'List all collaborators across all projects',
},
async () => {
@@ -20,6 +22,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_collaborators_for_goal',
{
title: 'List project collaborators',
annotations: toolAnnotations.readOnly,
description: 'List collaborators for a specific project',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -36,6 +40,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
server.registerTool(
'invite_collaborator',
{
title: 'Invite collaborator',
annotations: toolAnnotations.writeExternal,
description: 'Invite a user to a project by email',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -54,6 +60,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
server.registerTool(
'remove_collaborator',
{
title: 'Remove collaborator',
annotations: toolAnnotations.destructive,
description: 'Remove a user from a project',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -71,6 +79,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
server.registerTool(
'toggle_collaborator_roles',
{
title: 'Toggle collaborator roles',
annotations: toolAnnotations.write,
description: 'Update roles for a collaborator in a project',
inputSchema: {
userId: z.coerce.number().describe('User ID'),
@@ -89,6 +99,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_roles',
{
title: 'List roles',
annotations: toolAnnotations.readOnly,
description: 'List all roles for a project',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -105,6 +117,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
server.registerTool(
'create_role',
{
title: 'Create role',
annotations: toolAnnotations.write,
description: 'Create a new role for a project',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -123,6 +137,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
server.registerTool(
'delete_role',
{
title: 'Delete role',
annotations: toolAnnotations.destructive,
description: 'Delete a role from a project',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -140,6 +156,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_role_permissions_for_goal',
{
title: 'List role permissions for project',
annotations: toolAnnotations.readOnly,
description: 'Get role-to-permission mappings for a project',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -156,6 +174,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_permissions',
{
title: 'List permissions',
annotations: toolAnnotations.readOnly,
description: 'List all available permissions',
},
async () => {
@@ -169,6 +189,8 @@ export function registerCollaborationTools(server: McpServer, api: TvApi) {
server.registerTool(
'toggle_role_permission',
{
title: 'Toggle role permission',
annotations: toolAnnotations.write,
description: 'Add or remove a permission from a role',
inputSchema: {
roleId: z.coerce.number().describe('Role ID'),
@@ -1,12 +1,14 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { TvApi } from 'taskview-api'
import { z } from 'zod'
import { ok, err } from './helpers.js'
import { ok, err, toolAnnotations } from './helpers.js'
export function registerGoalsTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_goals',
{
title: 'List projects',
annotations: toolAnnotations.readOnly,
description: 'List all projects (goals) accessible to the current user. Optionally filter by organization.',
inputSchema: {
organizationId: z.coerce.number().optional().describe('Filter by organization ID'),
@@ -23,6 +25,8 @@ export function registerGoalsTools(server: McpServer, api: TvApi) {
server.registerTool(
'create_goal',
{
title: 'Create project',
annotations: toolAnnotations.write,
description: 'Create a new project (goal). If organizationId is provided, creates within that organization.',
inputSchema: {
name: z.string().describe('Project name'),
@@ -43,6 +47,8 @@ export function registerGoalsTools(server: McpServer, api: TvApi) {
server.registerTool(
'update_goal',
{
title: 'Update project',
annotations: toolAnnotations.write,
description: 'Update a project (goal) — name, description, color',
inputSchema: {
id: z.coerce.number().describe('Project (goal) ID'),
@@ -64,6 +70,8 @@ export function registerGoalsTools(server: McpServer, api: TvApi) {
server.registerTool(
'delete_goal',
{
title: 'Delete project',
annotations: toolAnnotations.destructive,
description: 'Delete a project (goal)',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID to delete'),
@@ -1,12 +1,14 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { TvApi } from 'taskview-api'
import { z } from 'zod'
import { ok, err } from './helpers.js'
import { ok, err, toolAnnotations } from './helpers.js'
export function registerGraphTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_task_dependencies',
{
title: 'List task dependencies',
annotations: toolAnnotations.readOnly,
description: 'List all task dependency edges in a project',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -23,6 +25,8 @@ export function registerGraphTools(server: McpServer, api: TvApi) {
server.registerTool(
'add_task_dependency',
{
title: 'Add task dependency',
annotations: toolAnnotations.write,
description: 'Create a dependency between two tasks',
inputSchema: {
source: z.coerce.number().describe('Source task ID'),
@@ -40,6 +44,8 @@ export function registerGraphTools(server: McpServer, api: TvApi) {
server.registerTool(
'delete_task_dependency',
{
title: 'Delete task dependency',
annotations: toolAnnotations.destructive,
description: 'Delete a task dependency edge',
inputSchema: {
id: z.coerce.number().describe('Dependency edge ID'),
@@ -1,3 +1,19 @@
import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js'
/**
* Every tool declares all three hints explicitly. ChatGPT refuses to list an
* app whose tools leave any of them unset, and other clients use them to decide
* what needs confirmation. openWorldHint is false throughout: a tool only ever
* touches the user's own TaskView instance — the one exception reaches out to
* an email address that may belong to someone outside it.
*/
export const toolAnnotations = {
readOnly: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
write: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
destructive: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
writeExternal: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
} as const satisfies Record<string, ToolAnnotations>
export function ok(data: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }
}
@@ -1,12 +1,14 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { TvApi } from 'taskview-api'
import { z } from 'zod'
import { ok, err } from './helpers.js'
import { ok, err, toolAnnotations } from './helpers.js'
export function registerKanbanTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_kanban_columns',
{
title: 'List kanban columns',
annotations: toolAnnotations.readOnly,
description: 'Get all kanban columns for a project',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -23,6 +25,8 @@ export function registerKanbanTools(server: McpServer, api: TvApi) {
server.registerTool(
'create_kanban_column',
{
title: 'Create kanban column',
annotations: toolAnnotations.write,
description: 'Create a new kanban column in a project',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -40,6 +44,8 @@ export function registerKanbanTools(server: McpServer, api: TvApi) {
server.registerTool(
'update_kanban_column',
{
title: 'Update kanban column',
annotations: toolAnnotations.write,
description: 'Update a kanban column',
inputSchema: {
id: z.coerce.number().describe('Column ID'),
@@ -58,6 +64,8 @@ export function registerKanbanTools(server: McpServer, api: TvApi) {
server.registerTool(
'delete_kanban_column',
{
title: 'Delete kanban column',
annotations: toolAnnotations.destructive,
description: 'Delete a kanban column',
inputSchema: {
id: z.coerce.number().describe('Column ID to delete'),
@@ -1,12 +1,14 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { TvApi } from 'taskview-api'
import { z } from 'zod'
import { ok, err } from './helpers.js'
import { ok, err, toolAnnotations } from './helpers.js'
export function registerListsTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_lists',
{
title: 'List lists in project',
annotations: toolAnnotations.readOnly,
description: 'Get all task lists within a project',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -23,6 +25,8 @@ export function registerListsTools(server: McpServer, api: TvApi) {
server.registerTool(
'create_list',
{
title: 'Create list',
annotations: toolAnnotations.write,
description: 'Create a new task list within a project',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -41,6 +45,8 @@ export function registerListsTools(server: McpServer, api: TvApi) {
server.registerTool(
'update_list',
{
title: 'Update list',
annotations: toolAnnotations.write,
description: 'Update a task list',
inputSchema: {
id: z.coerce.number().describe('List ID'),
@@ -60,6 +66,8 @@ export function registerListsTools(server: McpServer, api: TvApi) {
server.registerTool(
'delete_list',
{
title: 'Delete list',
annotations: toolAnnotations.destructive,
description: 'Delete a task list',
inputSchema: {
id: z.coerce.number().describe('List ID to delete'),
@@ -1,12 +1,14 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { TvApi } from 'taskview-api'
import { z } from 'zod'
import { ok, err } from './helpers.js'
import { ok, err, toolAnnotations } from './helpers.js'
export function registerNotificationsTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_notifications',
{
title: 'List notifications',
annotations: toolAnnotations.readOnly,
description: 'Get user notifications',
inputSchema: {
cursor: z.coerce.number().optional().describe('Pagination cursor'),
@@ -23,6 +25,8 @@ export function registerNotificationsTools(server: McpServer, api: TvApi) {
server.registerTool(
'mark_notification_read',
{
title: 'Mark notification as read',
annotations: toolAnnotations.write,
description: 'Mark a notification as read',
inputSchema: {
notificationId: z.coerce.number().describe('Notification ID'),
@@ -39,6 +43,8 @@ export function registerNotificationsTools(server: McpServer, api: TvApi) {
server.registerTool(
'mark_all_notifications_read',
{
title: 'Mark all notifications as read',
annotations: toolAnnotations.write,
description: 'Mark all notifications as read',
},
async () => {
@@ -1,12 +1,14 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { TvApi } from 'taskview-api'
import { z } from 'zod'
import { ok, err } from './helpers.js'
import { ok, err, toolAnnotations } from './helpers.js'
export function registerOrganizationsTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_organizations',
{
title: 'List organizations',
annotations: toolAnnotations.readOnly,
description: 'List all organizations the current user belongs to',
},
async () => {
@@ -20,6 +22,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
server.registerTool(
'get_organization',
{
title: 'Get organization',
annotations: toolAnnotations.readOnly,
description: 'Get organization details by ID',
inputSchema: {
organizationId: z.coerce.number().describe('Organization ID'),
@@ -36,6 +40,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
server.registerTool(
'create_organization',
{
title: 'Create organization',
annotations: toolAnnotations.write,
description: 'Create a new organization. The creator becomes the owner.',
inputSchema: {
name: z.string().describe('Organization name'),
@@ -55,6 +61,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
server.registerTool(
'update_organization',
{
title: 'Update organization',
annotations: toolAnnotations.write,
description: 'Update organization name, slug, or logo. Requires admin or owner role.',
inputSchema: {
organizationId: z.coerce.number().describe('Organization ID'),
@@ -75,6 +83,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
server.registerTool(
'delete_organization',
{
title: 'Delete organization',
annotations: toolAnnotations.destructive,
description: 'Delete an organization. Only the owner can delete. Personal workspaces cannot be deleted.',
inputSchema: {
organizationId: z.coerce.number().describe('Organization ID to delete'),
@@ -91,6 +101,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_organization_members',
{
title: 'List organization members',
annotations: toolAnnotations.readOnly,
description: 'List all members of an organization',
inputSchema: {
organizationId: z.coerce.number().describe('Organization ID'),
@@ -107,6 +119,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
server.registerTool(
'add_organization_member',
{
title: 'Add organization member',
annotations: toolAnnotations.write,
description: 'Add a member to an organization by email. Requires admin or owner role. NOTE: organization membership alone does NOT grant access to any project — the user will not see a project until they are also added to it via invite_collaborator. After adding the member, invite them to the relevant project(s).',
inputSchema: {
organizationId: z.coerce.number().describe('Organization ID'),
@@ -126,6 +140,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
server.registerTool(
'update_organization_member_role',
{
title: 'Change organization member role',
annotations: toolAnnotations.write,
description: 'Change a member role in an organization. Cannot change the owner role.',
inputSchema: {
organizationId: z.coerce.number().describe('Organization ID'),
@@ -145,6 +161,8 @@ export function registerOrganizationsTools(server: McpServer, api: TvApi) {
server.registerTool(
'remove_organization_member',
{
title: 'Remove organization member',
annotations: toolAnnotations.destructive,
description: 'Remove a member from an organization. Cannot remove the owner.',
inputSchema: {
organizationId: z.coerce.number().describe('Organization ID'),
@@ -0,0 +1,44 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { TvApi } from 'taskview-api'
import { z } from 'zod'
import { ok, err, toolAnnotations } from './helpers.js'
export function registerStartTools(server: McpServer, api: TvApi) {
server.registerTool(
'get_agenda',
{
title: 'Get agenda',
annotations: toolAnnotations.readOnly,
description:
'Get the user\'s agenda across every project in one call: tasks due today, '
+ 'upcoming tasks, recently completed ones, and tasks with no deadline. '
+ 'Use this for questions like "what do I have today", "what is coming up" or '
+ '"what did I finish recently" instead of listing projects and their tasks one '
+ 'by one — this is the same data the app\'s main screen shows, and the split '
+ 'into today/upcoming is computed on the server in the given timezone.',
inputSchema: {
timezone: z
.string()
.optional()
.describe(
'IANA timezone deciding where "today" ends, e.g. "Europe/Belgrade". '
+ 'Defaults to UTC; pass the user\'s zone when it is known, otherwise the '
+ 'day boundary may be off by a few hours.',
),
organizationId: z.coerce
.number()
.optional()
.describe('Limit to one organization; omit for every organization the user belongs to'),
},
},
async ({ timezone, organizationId }) => {
try {
const result = await api.start.fetchAllState({
tz: timezone || 'UTC',
organizationId,
})
return ok(result)
} catch (e) { return err(e) }
},
)
}
@@ -1,12 +1,14 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { TvApi } from 'taskview-api'
import { z } from 'zod'
import { ok, err } from './helpers.js'
import { ok, err, toolAnnotations } from './helpers.js'
export function registerTagsTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_tags',
{
title: 'List tags',
annotations: toolAnnotations.readOnly,
description: 'List all tags accessible to the current user',
},
async () => {
@@ -20,6 +22,8 @@ export function registerTagsTools(server: McpServer, api: TvApi) {
server.registerTool(
'create_tag',
{
title: 'Create tag',
annotations: toolAnnotations.write,
description: 'Create a new tag',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -38,6 +42,8 @@ export function registerTagsTools(server: McpServer, api: TvApi) {
server.registerTool(
'update_tag',
{
title: 'Update tag',
annotations: toolAnnotations.write,
description: 'Update a tag',
inputSchema: {
id: z.coerce.number().describe('Tag ID'),
@@ -57,6 +63,8 @@ export function registerTagsTools(server: McpServer, api: TvApi) {
server.registerTool(
'delete_tag',
{
title: 'Delete tag',
annotations: toolAnnotations.destructive,
description: 'Delete a tag',
inputSchema: {
tagId: z.coerce.number().describe('Tag ID to delete'),
@@ -73,6 +81,8 @@ export function registerTagsTools(server: McpServer, api: TvApi) {
server.registerTool(
'toggle_task_tag',
{
title: 'Toggle tag on task',
annotations: toolAnnotations.write,
description: 'Add or remove a tag from a task',
inputSchema: {
tagId: z.coerce.number().describe('Tag ID'),
@@ -1,12 +1,14 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { type TvApi, ALL_TASKS_LIST_ID } from 'taskview-api'
import { z } from 'zod'
import { ok, err } from './helpers.js'
import { ok, err, toolAnnotations } from './helpers.js'
export function registerTasksTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_tasks',
{
title: 'List tasks',
annotations: toolAnnotations.readOnly,
description: 'List tasks for a specific project (goal). Returns paginated results.',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -39,6 +41,8 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
server.registerTool(
'get_task',
{
title: 'Get task',
annotations: toolAnnotations.readOnly,
description: 'Get a single task by its ID with full details',
inputSchema: {
taskId: z.coerce.number().describe('Task ID'),
@@ -56,6 +60,8 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
server.registerTool(
'create_task',
{
title: 'Create task',
annotations: toolAnnotations.write,
description: 'Create a new task in a project',
inputSchema: {
goalId: z.coerce.number().describe('Project (goal) ID'),
@@ -81,6 +87,8 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
server.registerTool(
'update_task',
{
title: 'Update task',
annotations: toolAnnotations.write,
description: 'Update an existing task (description, status, priority, dates, cost, etc.)',
inputSchema: {
id: z.coerce.number().describe('Task ID to update'),
@@ -111,6 +119,8 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
server.registerTool(
'delete_task',
{
title: 'Delete task',
annotations: toolAnnotations.destructive,
description: 'Delete a task',
inputSchema: {
taskId: z.coerce.number().describe('Task ID to delete'),
@@ -127,6 +137,8 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
server.registerTool(
'toggle_task_assignees',
{
title: 'Toggle task assignees',
annotations: toolAnnotations.write,
description: 'Assign or unassign users to/from a task',
inputSchema: {
taskId: z.coerce.number().describe('Task ID'),
@@ -144,6 +156,8 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
server.registerTool(
'get_task_history',
{
title: 'Get task history',
annotations: toolAnnotations.readOnly,
description: 'Get the change history of a task',
inputSchema: {
taskId: z.coerce.number().describe('Task ID'),
@@ -160,6 +174,8 @@ export function registerTasksTools(server: McpServer, api: TvApi) {
server.registerTool(
'restore_task_from_history',
{
title: 'Restore task from history',
annotations: toolAnnotations.destructive,
description: 'Restore a task to a previous state from its history',
inputSchema: {
taskId: z.coerce.number().describe('Task ID'),
@@ -1,12 +1,14 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { TvApi } from 'taskview-api'
import { z } from 'zod'
import { ok, err } from './helpers.js'
import { ok, err, toolAnnotations } from './helpers.js'
export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
server.registerTool(
'start_timer',
{
title: 'Start timer',
annotations: toolAnnotations.write,
description: 'Start a timer for the given task. Returns {entry, autoStoppedEntry} — autoStoppedEntry is the previous active timer that was stopped (null if there was no active timer).',
inputSchema: {
taskId: z.coerce.number().describe('Task ID to track time for'),
@@ -27,6 +29,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
server.registerTool(
'stop_timer',
{
title: 'Stop timer',
annotations: toolAnnotations.write,
description: 'Stop the active timer (or specific entry by id) and return the closed entry.',
inputSchema: {
entryId: z.coerce.number().optional().describe('Specific entry ID; if omitted, stops the user\'s active timer'),
@@ -43,6 +47,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
server.registerTool(
'get_active_timer',
{
title: 'Get active timer',
annotations: toolAnnotations.readOnly,
description: 'Get the user\'s currently running timer (if any).',
},
async () => {
@@ -56,6 +62,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
server.registerTool(
'log_time',
{
title: 'Log time',
annotations: toolAnnotations.write,
description: 'Log a manual time entry retroactively (no time-period restrictions).',
inputSchema: {
taskId: z.coerce.number().describe('Task ID'),
@@ -82,6 +90,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
server.registerTool(
'list_time_entries',
{
title: 'List time entries',
annotations: toolAnnotations.readOnly,
description: 'List time entries with optional filters (goalId, taskId, userId, date range).',
inputSchema: {
goalId: z.coerce.number().optional().describe('Filter by project'),
@@ -104,6 +114,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
server.registerTool(
'get_time_summary',
{
title: 'Get time summary',
annotations: toolAnnotations.readOnly,
description: 'Get aggregated time summary for a task or a project (goal).',
inputSchema: {
scope: z.enum(['task', 'goal']).describe('Aggregation scope'),
@@ -124,6 +136,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
server.registerTool(
'update_time_entry',
{
title: 'Update time entry',
annotations: toolAnnotations.write,
description: 'Update fields of an existing time entry (cannot edit started/ended of a running timer).',
inputSchema: {
id: z.coerce.number().describe('Time entry ID'),
@@ -144,6 +158,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
server.registerTool(
'get_time_report',
{
title: 'Get time report',
annotations: toolAnnotations.readOnly,
description:
'Aggregated time report. Choose scope: by-day (daily totals), by-user (per member), by-task (per task), summary (totals + billable). Filter by org, optional projects, optional user, date range.',
inputSchema: {
@@ -181,6 +197,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
server.registerTool(
'get_time_contributors',
{
title: 'Get time contributors',
annotations: toolAnnotations.readOnly,
description:
'List users who logged time in the given projects and date range, with total seconds and entry counts. Useful for "who worked on this project last month".',
inputSchema: {
@@ -208,6 +226,8 @@ export function registerTimeTrackingTools(server: McpServer, api: TvApi) {
server.registerTool(
'delete_time_entry',
{
title: 'Delete time entry',
annotations: toolAnnotations.destructive,
description: 'Delete a time entry (writes a record to history.time_entries before deletion).',
inputSchema: {
id: z.coerce.number().describe('Time entry ID'),

Some files were not shown because too many files have changed in this diff Show More