Compare commits

...

50 Commits

Author SHA1 Message Date
Nikolai Giman 171f269ee2 version: 1.53.0 2026-08-27 20:42:36 +02:00
Nikolai Giman f79614ebc5 fix(security): enforce collaboration access permissions 2026-08-27 20:29:54 +02:00
Nikolai Giman a4c465469d wip: updated locales 2026-08-26 23:41:46 +02:00
Nikolai Giman f3e2663ebe Merge branch 'main' into fix/issues 2026-08-26 23:35:04 +02:00
Nikolai Giman 5ce2fe99f0 Merge pull request #108 from murilorosa/feat/pt-BR_locale
feat: add pt-BR locale
2026-08-26 23:27:46 +02:00
Nikolai Giman e11aba4d79 fix: time tracking edit 2026-08-26 23:20:16 +02:00
Murilo Biassio Rosa 0b6f95ec3d feat: add pt-BR locale 2026-08-25 22:21:10 -03:00
Nikolai Giman a596e023db fix: performance issue in graph and update layout building 2026-08-24 22:05:39 +02:00
Nikolai Giman c1685e42b8 feat: task dependencies in detailed panel 2026-08-24 14:21:46 +02:00
Nikolai Giman 9635120e66 fix: add security md 2026-08-23 17:56:17 +02:00
Nikolai Giman 19e0212edf fix: add task on the main screen when no project is created 2026-08-22 22:06:52 +02:00
Nikolai Giman 5dc5b387de fix: disable analytics 2026-08-22 16:43:48 +02:00
Nikolai Giman 8011da1259 fix: #106 2026-08-22 09:01:33 +02:00
Nikolai Giman a6329e998d fix: disabled analytics 2026-08-21 22:51:57 +02:00
Nikolai Giman b372854636 fix: custom server url normalization 2026-08-21 10:16:22 +02:00
Nikolai Giman a243f9ec56 fix: normalize server url 2026-08-21 10:06:54 +02:00
Nikolai Giman ebd25a94ea fix: sso email resolving and switcher for sso 2026-08-20 19:53:29 +02:00
Nikolai Giman 4b142619c0 fix: #6126 2026-08-20 16:13:23 +02:00
Nikolai Giman 6f576e7297 Merge pull request #104 from Gimanh/fix/issues
chore: version
2026-08-20 14:44:26 +02:00
Nikolai Giman 15050541fa chore: version 2026-08-20 14:40:23 +02:00
Nikolai Giman 3afdca99d9 Merge pull request #103 from Gimanh/fix/issues
fix: sso domain verification and #77
2026-08-20 14:37:05 +02:00
Nikolai Giman aaa876412d fix: #101 2026-08-20 13:51:32 +02:00
Nikolai Giman 915e8d9f9f fix: sso domain verification and #77 2026-08-18 18:57:17 +02:00
Nikolai Giman 55ded8bac9 Merge pull request #99 from Gimanh/fix/98
fix: #98
2026-08-04 20:45:47 +02:00
Nikolai Giman 784652ef5b chore: version 2026-08-04 20:44:27 +02:00
Nikolai Giman 1f1a1b770f fix: #98 2026-08-04 20:40:31 +02:00
Nikolai Giman e80ab33dda Merge pull request #97 from Gimanh/feat/http-mcp
feat: http mcp
2026-08-03 00:38:50 +02:00
Nikolai Giman 8c7be7362f feat: http mcp 2026-08-03 00:33:13 +02:00
Nikolai Giman 57ec7c01b6 Merge pull request #96 from Gimanh/feat/email-notification
feat: email notification
2026-08-02 19:10:15 +02:00
Nikolai Giman d0f664f78e feat: email notification 2026-08-02 10:11:22 +02:00
Nikolai Giman 7bbb36d45e Merge pull request #94 from Gimanh/feat/gitea-integration
feat: gitea integration
2026-07-28 19:54:07 +02:00
Nikolai Giman 9934bf06d8 feat: gitea integration 2026-07-28 00:45:50 +02:00
Nikolai Giman fd13b33915 Merge pull request #93 from Gimanh/tests/e2e
tests: add attrs
2026-07-26 19:12:27 +02:00
Nikolai Giman 64089fd6e7 tests: add attrs 2026-07-25 23:09:08 +02:00
Nikolai Giman b7a50049bb Merge pull request #92 from Gimanh/feat/4745-public-api-url
feat: public API URL fix for sso callback
2026-07-18 14:53:34 +02:00
Nikolai Giman 504ae503dd feat: public API URL fix for sso callback 2026-07-18 14:53:00 +02:00
Nikolai Giman 263883f32d Merge pull request #91 from Gimanh/feat/5047-disable-public-registration
feat: ALLOW_PUBLIC_REGISTRATION
2026-07-18 14:18:16 +02:00
Nikolai Giman 90b55fd82a feat: ALLOW_PUBLIC_REGISTRATION 2026-07-18 14:15:28 +02:00
Nikolai Giman 2e9a4945ce Merge pull request #89 from Gimanh/chore/refactor
chore: bootstrap
2026-07-18 10:55:01 +02:00
Nikolai Giman 0d70023fb3 Merge pull request #90 from Gimanh/fix/gh-88-pm2-db-connections
fix: #88
2026-07-16 22:17:46 +02:00
Nikolai Giman 645f2e21e5 fix: #88 2026-07-16 22:16:41 +02:00
Nikolai Giman adce016a05 chore: bootstrap 2026-07-16 19:38:24 +02:00
Nikolai Giman b7f2380d45 Merge pull request #87 from Gimanh/fix/5315-logout-state-reset
fix: clear stores on logout
2026-07-15 21:24:14 +02:00
Nikolai Giman 58eb93e564 fix: clear stores on logout 2026-07-15 21:23:26 +02:00
Nikolai Giman 4e5e5fb579 Merge pull request #86 from Gimanh/feat/recurring-by-completion
wip: recurring
2026-07-14 17:46:05 +02:00
Nikolai Giman 314e5a6377 merge: resolve 2026-07-13 20:27:25 +02:00
Nikolai Giman 955697f40f Merge branch 'main' into feat/recurring-by-completion 2026-07-13 20:23:52 +02:00
Nikolai Giman e5dbba8e2a wip: recurring 2026-07-13 20:15:55 +02:00
Nikolai Giman bb28bac9f7 Merge pull request #85 from Gimanh/chore/ver-1-50-2
chore: 1.50.2
2026-07-12 22:50:30 +02:00
Nikolai Giman b296046605 Merge pull request #84 from Gimanh/fix/83-sso-login-by-code-gate
fix: allow login-by-code redemption for SSO and social logins (#83)
2026-07-12 22:46:15 +02:00
239 changed files with 8665 additions and 1357 deletions
+1 -1
View File
@@ -428,7 +428,7 @@ For commercial licensing questions, hosted service permissions, or other use cas
Do not publish security vulnerabilities in public GitHub issues.
Report security issues privately using the contact information provided in the repository or on the TaskView website.
Report security issues privately — see [SECURITY.md](SECURITY.md) for the reporting channels, response times, scope, and safe-harbor terms.
When running TaskView in production:
+46
View File
@@ -0,0 +1,46 @@
# Security Policy
## Reporting a vulnerability
Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.
Report them privately using one of these channels:
- **GitHub private vulnerability reporting** (preferred): open the **Security** tab of this repository and click **Report a vulnerability**.
- **Email**: [support@taskview.tech](mailto:support@taskview.tech) with `[security]` in the subject.
Please include as much of the following as you can:
- A description of the issue and its impact
- Affected component (API, web app, MCP server, mobile app) and version
- Steps to reproduce, or a proof of concept
- Any suggested mitigation
## What to expect
- We will acknowledge your report within **5 business days**.
- We will keep you informed about progress and aim to release a fix for confirmed issues within **90 days** of the report, sooner for critical issues.
- Once a fix is released, we publish a GitHub Security Advisory for the affected versions and credit the reporter, unless they prefer to stay anonymous.
- We ask that you give us a reasonable time to fix the issue before disclosing it publicly.
## Supported versions
Security fixes are released for the latest minor version line only. Self-hosted installations should upgrade to the latest release to receive them.
## Scope
In scope:
- The TaskView API server, web app, MCP server, and mobile app in this repository
- The hosted service at `app.taskview.tech`
Out of scope:
- Vulnerabilities in third-party dependencies that are not exploitable in TaskView (report them upstream)
- Findings that require a compromised admin account or physical access to the server
- Missing security headers, rate limiting, or best-practice recommendations without a demonstrated impact
- Denial-of-service testing against the hosted service
## Safe harbor
We will not pursue legal action against researchers who act in good faith: test only against their own self-hosted instance or their own accounts on the hosted service, avoid accessing or modifying other users' data, and report findings privately as described above.
+15
View File
@@ -19,6 +19,9 @@ ACCESS_LIFE_TIME=1d
REFRESH_LIFE_TIME=2d
JWT_ALG=HS256
# SSO: comma-separated email domains that skip DNS/HTTP ownership proof (air-gapped installs)
#SSO_TRUSTED_DOMAINS=company.com,corp.local
# SMTP Configuration
SMTP_HOST=smtp.domain.com
SMTP_PORT=465
@@ -27,6 +30,10 @@ SMTP_PASSWORD=your_smtp_password_here
SMTP_ENCRYPTION=ssl
SMTP_FROM_NAME=TaskView
SMTP_FROM_EMAIL=your_email@example.com
# Email a person when they are invited to a project (requires SMTP)
INVITE_EMAIL_ENABLED=false
# Max invite emails one user may trigger per hour (default 30)
# INVITE_EMAIL_HOURLY_LIMIT=30
# Encryption (32-byte hex key for AES-256-GCM)
# Generate a key: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
@@ -48,6 +55,14 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
# GITLAB_BASE_URL=https://gitlab.yourcompany.com
# GITLAB_API_URL=https://gitlab.yourcompany.com/api/v4
# Gitea Integration OAuth
GITEA_INTEGRATION_CLIENT_ID=
GITEA_INTEGRATION_CLIENT_SECRET=
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
# For self-hosted Gitea, override these:
# GITEA_BASE_URL=https://gitea.yourcompany.com
# GITEA_API_URL=https://gitea.yourcompany.com/api/v1
# Firebase Cloud Messaging (push notifications for mobile, optional)
# Path to Firebase service account JSON file
# FIREBASE_CREDENTIALS_PATH=./firebase-credentials.json
+30 -1
View File
@@ -1,9 +1,38 @@
// https://github.com/Gimanh/taskview-community/issues/88
// GH-88: one worker per core ('max') multiplied by the per-worker DB pool
// (DB_POOL_MAX, default 20) exhausts Postgres max_connections (default 100)
// on many-core hosts. Default to 2 workers; scale explicitly via PM2_INSTANCES.
// If you set PM2_INSTANCES to 'max', size DB_POOL_MAX yourself so that
// workers × DB_POOL_MAX stays below the Postgres max_connections limit.
const rawInstances = process.env.PM2_INSTANCES;
const instances = rawInstances === 'max'
? 'max'
: Number(rawInstances) > 0
? Number(rawInstances)
: 2;
const poolMax = Number(process.env.DB_POOL_MAX) > 0 ? Number(process.env.DB_POOL_MAX) : 20;
if (instances === 'max') {
console.warn(
'[taskview] PM2_INSTANCES=max spawns one worker per CPU core, each with its own '
+ `DB pool (${poolMax} connections). Make sure workers x DB_POOL_MAX stays below `
+ 'the Postgres max_connections limit (default 100).'
);
} else if (instances * poolMax > 80) {
console.warn(
`[taskview] DB connection budget: ${instances} worker(s) x ${poolMax} pool connections = `
+ `${instances * poolMax} potential connections. Postgres default max_connections is 100 - `
+ 'lower PM2_INSTANCES or DB_POOL_MAX if the database rejects connections.'
);
}
module.exports = {
apps: [
{
name: 'taskview-server',
script: 'taskview-server.js',
instances: 'max',
instances,
watch: true,
ignore_watch: ['logs'],
autorestart: true,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-api-server",
"version": "1.50.2",
"version": "1.53.0",
"scripts": {
"dev": "bun run --watch ./server.ts",
"start": "NODE_ENV=production node ./dist/taskview-server.js",
+30 -3
View File
@@ -1,11 +1,38 @@
// GH-88: one worker per core ('max') multiplied by the per-worker DB pool
// (DB_POOL_MAX, default 20) exhausts Postgres max_connections (default 100)
// on many-core hosts. Default to 2 workers; scale explicitly via PM2_INSTANCES.
// If you set PM2_INSTANCES to 'max', size DB_POOL_MAX yourself so that
// workers × DB_POOL_MAX stays below the Postgres max_connections limit.
const rawInstances = process.env.PM2_INSTANCES;
const instances = rawInstances === 'max'
? 'max'
: Number(rawInstances) > 0
? Number(rawInstances)
: 2;
const poolMax = Number(process.env.DB_POOL_MAX) > 0 ? Number(process.env.DB_POOL_MAX) : 20;
if (instances === 'max') {
console.warn(
'[taskview] PM2_INSTANCES=max spawns one worker per CPU core, each with its own '
+ `DB pool (${poolMax} connections). Make sure workers x DB_POOL_MAX stays below `
+ 'the Postgres max_connections limit (default 100).'
);
} else if (instances * poolMax > 80) {
console.warn(
`[taskview] DB connection budget: ${instances} worker(s) x ${poolMax} pool connections = `
+ `${instances * poolMax} potential connections. Postgres default max_connections is 100 - `
+ 'lower PM2_INSTANCES or DB_POOL_MAX if the database rejects connections.'
);
}
module.exports = {
apps: [
{
name: 'taskview-server',
script: 'taskview-server.js',
instances: 'max',
watch: true,
ignore_watch: ['logs'],
instances,
watch: false,
autorestart: true,
max_memory_restart: '1G',
env_production: {
+4
View File
@@ -6,6 +6,8 @@ import errorHandler from './middlewares/error-handler';
import routes from './routes';
import passport, { initPassportLogin } from './tv-modules/auth/strategies/passport-login';
import { LoginMethods } from './tv-modules/auth/LoginMethods';
import { InviteEmailDispatcher } from './tv-modules/collaboration/InviteEmailDispatcher';
import { PublicApiUrl } from './modules/public-url';
import cookieParser from 'cookie-parser';
import { registerAllEventHandlers, startAllWorkers } from './core/all-events';
@@ -15,6 +17,8 @@ export default class App {
constructor(port: number) {
LoginMethods.validateOnStartup();
PublicApiUrl.validateOnStartup();
InviteEmailDispatcher.validateOnStartup();
this.app = express();
this.port = port;
+2 -1
View File
@@ -1,6 +1,7 @@
import { EventEmitter } from 'node:events';
import type { RecurrenceRulesSchemaTypeForSelect, SprintsSchemaTypeForSelect, TasksSchemaTypeForSelect } from 'taskview-db-schemas';
import type { TimeEntryWithUser } from '../tv-modules/time-tracking/types';
import type { InviteEmailLocale } from '../tv-modules/collaboration/collaboration.server.types';
import { $logger } from '../modules/logget';
export interface AppEvents {
@@ -8,7 +9,7 @@ export interface AppEvents {
'task.updated': { task: TasksSchemaTypeForSelect; changes: Record<string, unknown>; initiatorId: number };
'task.assigneesChanged': { taskId: number; userIds: number[]; initiatorId: number };
'task.deleted': { taskId: number; goalId: number; initiatorId: number };
'collaboration.userAdded': { goalId: number; email: string; initiatorId: number };
'collaboration.userAdded': { goalId: number; email: string; initiatorId: number; locale: InviteEmailLocale };
'collaboration.userRemoved': { goalId: number; collaborationUserId: number; initiatorId: number };
'collaboration.rolesChanged': { goalId: number; collaborationUserId: number; initiatorId: number };
'time-entry.started': { entry: TimeEntryWithUser; taskId: number; userId: number; goalId: number };
+2
View File
@@ -7,6 +7,7 @@ import { TimeTrackingDispatcher } from '../tv-modules/time-tracking/TimeTracking
import { SprintsDispatcher } from '../tv-modules/sprints/SprintsDispatcher';
import { RecurrenceDispatcher } from '../tv-modules/recurrence/RecurrenceDispatcher';
import { MessagingDispatcher } from '../tv-modules/messaging/MessagingDispatcher';
import { InviteEmailDispatcher } from '../tv-modules/collaboration/InviteEmailDispatcher';
const dispatchers: Dispatcher[] = [
new NotificationDispatcher(),
@@ -16,6 +17,7 @@ const dispatchers: Dispatcher[] = [
new SprintsDispatcher(),
new RecurrenceDispatcher(),
new MessagingDispatcher(),
new InviteEmailDispatcher(),
];
export function registerAllEventHandlers() {
+47 -1
View File
@@ -704,5 +704,51 @@
"description": [
"Add external_team_id to messaging_identity_map so Slack identities are keyed by (provider, team, user) — prevents cross-workspace identity collision"
]
},
"55": {
"version": "1.60.0",
"name": "Recurrence schedule mode",
"releaseDate": "20260712",
"scripts": [
"/1.60.0/0.alter-recurrence-add-schedule-mode.sql"
],
"description": [
"Add schedule_mode to recurrence_rules: 'fixed' (calendar schedule) or 'after-completion' (next occurrence = completion day + interval)"
]
},
"56": {
"version": "1.61.0",
"name": "Gitea integration provider",
"releaseDate": "20260726",
"scripts": [
"/1.61.0/0.alter-integrations-provider-check-gitea.sql"
],
"description": [
"Extend integrations_provider_check constraint to allow the 'gitea' provider alongside 'github' and 'gitlab'"
]
},
"57": {
"version": "1.62.0",
"name": "Invite email rate limiting",
"releaseDate": "20260730",
"scripts": [
"/1.62.0/0.create-invite-emails.sql"
],
"description": [
"Log of sent project-invite emails (collaboration.invite_emails) backing the per-recipient cooldown and the hourly per-initiator sending cap"
]
},
"58": {
"version": "1.63.0",
"name": "SSO domain verification",
"releaseDate": "20260813",
"scripts": [
"/1.63.0/0.sso-domain-verification.sql",
"/1.63.0/1.sso-domain-verified-unique.sql"
],
"description": [
"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)."
]
}
}
}
@@ -0,0 +1,10 @@
-- 'fixed' — occurrences follow the calendar schedule (rrule anchored at dtstart);
-- 'after-completion' — the next occurrence is one FREQ/INTERVAL step after the
-- day the current instance was completed (Todoist "every!"), no calendar anchor.
ALTER TABLE tasks.recurrence_rules
ADD COLUMN IF NOT EXISTS schedule_mode VARCHAR(20) NOT NULL DEFAULT 'fixed';
ALTER TABLE tasks.recurrence_rules
DROP CONSTRAINT IF EXISTS recurrence_schedule_mode_valid;
ALTER TABLE tasks.recurrence_rules
ADD CONSTRAINT recurrence_schedule_mode_valid CHECK (schedule_mode IN ('fixed', 'after-completion'));
@@ -0,0 +1,2 @@
ALTER TABLE tasks.integrations DROP CONSTRAINT IF EXISTS integrations_provider_check;
ALTER TABLE tasks.integrations ADD CONSTRAINT integrations_provider_check CHECK (provider IN ('github', 'gitlab', 'gitea'));
@@ -0,0 +1,13 @@
-- Log of sent project-invite emails, used to rate-limit sending:
-- a 24h per-recipient cooldown and an hourly cap per initiator.
-- Rows older than 24 hours are pruned opportunistically before each insert.
CREATE TABLE IF NOT EXISTS collaboration.invite_emails (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
initiator_id INTEGER NOT NULL REFERENCES tv_auth.users(id) ON DELETE CASCADE,
email VARCHAR(255) NOT NULL,
goal_id INTEGER NOT NULL REFERENCES tasks.goals(id) ON DELETE CASCADE,
sent_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_invite_emails_initiator_sent ON collaboration.invite_emails(initiator_id, sent_at);
CREATE INDEX IF NOT EXISTS idx_invite_emails_goal_email_sent ON collaboration.invite_emails(goal_id, email, sent_at);
@@ -0,0 +1,3 @@
ALTER TABLE tv_auth.sso_configs
ADD COLUMN IF NOT EXISTS domain_verify_token VARCHAR,
ADD COLUMN IF NOT EXISTS domain_verified_at TIMESTAMP;
@@ -0,0 +1,6 @@
ALTER TABLE tv_auth.sso_configs
DROP CONSTRAINT IF EXISTS sso_configs_email_domain_restriction_key;
CREATE UNIQUE INDEX IF NOT EXISTS sso_configs_verified_domain_uniq
ON tv_auth.sso_configs (email_domain_restriction)
WHERE domain_verified_at IS NOT NULL;
+28
View File
@@ -0,0 +1,28 @@
import type { Request } from 'express';
export class PublicApiUrl {
static configured(): string | null {
const raw = process.env.API_PUBLIC_URL;
if (!raw || !raw.trim()) return null;
return raw.trim().replace(/\/+$/, '');
}
static base(req: Request): string {
return PublicApiUrl.configured() ?? `${req.protocol}://${req.get('host')}`;
}
static validateOnStartup(): void {
const raw = process.env.API_PUBLIC_URL;
if (!raw || !raw.trim()) return;
let parsed: URL;
try {
parsed = new URL(raw.trim());
} catch {
throw new Error(`API_PUBLIC_URL is not a valid URL: "${raw}"`);
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`API_PUBLIC_URL must be an http(s) URL, got: "${raw}"`);
}
}
}
@@ -5,18 +5,12 @@ import { OverdueKpi } from './kpi/OverdueKpi'
import { ThroughputSection } from './productivity/ThroughputSection'
import { PriorityMixOverTimeSection } from './productivity/PriorityMixOverTimeSection'
import { WorkloadByAssigneeSection } from './workload/WorkloadByAssigneeSection'
import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
import { StaleTasksSection } from './quality/StaleTasksSection'
import { StatusDistributionSection } from './usage/StatusDistributionSection'
import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
import { IncomeExpenseMonthSection } from './financial/IncomeExpenseMonthSection'
import { IncomeExpensePerProjectSection } from './financial/IncomeExpensePerProjectSection'
import { IncomePerProjectMonthSection } from './financial/IncomePerProjectMonthSection'
import { ExpensePerProjectMonthSection } from './financial/ExpensePerProjectMonthSection'
import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
import { AmountCoverageKpi } from './financial/AmountCoverageKpi'
import { TotalIncomeKpi } from './financial/TotalIncomeKpi'
import { TotalExpenseKpi } from './financial/TotalExpenseKpi'
@@ -59,6 +53,12 @@ import { sectionLocales } from './locales'
// - entered_at) for rows in that status. Without a transition log, this
// metric cannot be computed correctly.
// ---------------------------------------------------------------------------
// import { BlockedByDependenciesSection } from './workload/BlockedByDependenciesSection'
// import { ActiveProjectsSection } from './usage/ActiveProjectsSection'
// import { OverdueByAgeSection } from './quality/OverdueByAgeSection'
// import { TopProjectsByAmountSection } from './financial/TopProjectsByAmountSection'
// import { IncomePerTagMonthSection } from './financial/IncomePerTagMonthSection'
// import { ExpensePerTagMonthSection } from './financial/ExpensePerTagMonthSection'
// import { AgingOpenTasksSection } from './workload/AgingOpenTasksSection'
// import { TimeInKanbanStatusSection } from './workload/TimeInKanbanStatusSection'
// import { CycleTimeKpi } from './kpi/CycleTimeKpi'
@@ -82,25 +82,25 @@ const builders: SectionBuilder[] = [
new PriorityMixOverTimeSection(),
// Workload
new WorkloadByAssigneeSection(),
new BlockedByDependenciesSection(),
// new BlockedByDependenciesSection(), // disabled
// new TimeInKanbanStatusSection(), // disabled — see top-of-file comment
// new AgingOpenTasksSection(), // disabled — see top-of-file comment
// Quality
new OverdueByAgeSection(),
// new OverdueByAgeSection(), // disabled
// new CycleTimeHistogramSection(), // disabled — see top-of-file comment
new StaleTasksSection(),
// new CycleTimePerProjectSection(), // disabled — see top-of-file comment
// Usage
new StatusDistributionSection(),
new ActiveProjectsSection(),
// new ActiveProjectsSection(), // disabled
// Financial
new IncomeExpenseMonthSection(),
new IncomeExpensePerProjectSection(),
new IncomePerProjectMonthSection(),
new ExpensePerProjectMonthSection(),
new IncomePerTagMonthSection(),
new ExpensePerTagMonthSection(),
new TopProjectsByAmountSection(),
// new IncomePerTagMonthSection(), // disabled
// new ExpensePerTagMonthSection(), // disabled
// new TopProjectsByAmountSection(), // disabled
]
export class SectionRegistry {
+31
View File
@@ -153,6 +153,11 @@ export default class AuthController {
}
if (!userData) {
if (!(await this.canCreateAccount(req, email))) {
$logger.info(`[AuthController:sendLoginCode] public registration disabled, email not invited`);
return res.status(403).send({ registrationDisabled: true });
}
const password = this.makeidLogin(7),
login = this.makeidLogin(7);
@@ -227,6 +232,11 @@ export default class AuthController {
);
if (!userData) {
if (!(await this.canCreateAccount(req, user.email))) {
$logger.info(`[AuthController:loginByProvider] public registration disabled, email not invited`);
return res.redirect(`${process.env.APP_URL}/login?sso_error=registration-disabled`);
}
const password = this.makeidLogin(7);
const login = this.makeidLogin(7);
@@ -357,6 +367,16 @@ export default class AuthController {
// Invalidate code immediately to prevent replay attacks
await req.appUser.authManager.repository.updateLoginCode(null, userData.email);
if (userData.block) {
if (!userData.confirm_email_code) {
return res.status(403).send({ message: 'account_blocked' });
}
const confirmed = await req.appUser.authManager.repository.markEmailConfirmed(userData.email);
if (!confirmed) {
return res.status(500).end();
}
}
const sessionId = await req.appUser.authManager.sessionStorage.createSession(
userData.id,
req.ip,
@@ -430,6 +450,11 @@ export default class AuthController {
return res.status(400).end();
}
if (!(await this.canCreateAccount(req, email))) {
$logger.info(`[AuthController:registration] public registration disabled, email not invited`);
return res.status(403).send({ registrationDisabled: true });
}
password = hashSync(password, 10);
if (!(await this.comparePasswords(passwordRepeat, password))) {
@@ -671,9 +696,15 @@ export default class AuthController {
password: LoginMethods.isEnabled('password'),
sso: LoginMethods.isEnabled('sso'),
socialProviders: LoginMethods.availableSocialProviders(),
publicRegistration: LoginMethods.publicRegistrationAllowed(),
});
};
private canCreateAccount = async (req: Request, email: string): Promise<boolean> => {
if (LoginMethods.publicRegistrationAllowed()) return true;
return await req.appUser.authManager.repository.isEmailInvited(email);
};
private passwordChangeConfirmationMode(): PasswordChangeConfirmationMode {
return process.env.PASSWORD_CHANGE_CONFIRMATION === 'password' ? 'password' : 'email';
}
+70 -2
View File
@@ -1,8 +1,8 @@
import { eq } from 'drizzle-orm';
import { eq, sql } from 'drizzle-orm';
import { CollaborationUsersSchema, OrganizationMembersSchema, SsoIdentitiesSchema, UsersSchema } from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { $logger } from '../../modules/logget';
import type { RegisterUserInDb, UpdateUserCredentialsArgs, UpdateUserCredentialsResult, UserDbRecord } from '../../types/auth.types';
import type { RegisterUserInDb, UpdateUserCredentialsArgs, UpdateUserEmailArgs, UpdateUserCredentialsResult, UserDbRecord } from '../../types/auth.types';
export default class AuthModel {
private readonly db: Database;
@@ -69,6 +69,28 @@ export default class AuthModel {
}
}
async isEmailInvited(email: string): Promise<boolean> {
const normalized = email.toLowerCase();
try {
const orgMembers = await this.db.dbDrizzle
.select({ email: OrganizationMembersSchema.email })
.from(OrganizationMembersSchema)
.where(sql`lower(${OrganizationMembersSchema.email}) = ${normalized}`)
.limit(1);
if (orgMembers.length > 0) return true;
const collaborators = await this.db.dbDrizzle
.select({ email: CollaborationUsersSchema.email })
.from(CollaborationUsersSchema)
.where(sql`lower(${CollaborationUsersSchema.email}) = ${normalized}`)
.limit(1);
return collaborators.length > 0;
} catch (error: unknown) {
$logger.error(error, '[AuthModel:isEmailInvited] failed to check invitations');
return false;
}
}
async fetchUserById(id: number): Promise<UserDbRecord | false> {
const query = 'SELECT * FROM tv_auth.users WHERE id = $1;';
try {
@@ -97,6 +119,21 @@ export default class AuthModel {
}
}
async markEmailConfirmed(email: string): Promise<boolean> {
if (!email) return false;
try {
const result = await this.db.dbDrizzle
.update(UsersSchema)
.set({ confirmEmailCode: null, block: 0 })
.where(eq(UsersSchema.email, email));
return (result.rowCount ?? 0) > 0;
} catch (error) {
$logger.error(error, `Error marking email confirmed for ${email}`);
return false;
}
}
async confirmEmail(login: string, code: string, block: number): Promise<boolean> {
const query = `UPDATE tv_auth.users
SET confirm_email_code = NULL, block = $1
@@ -167,6 +204,37 @@ export default class AuthModel {
}
}
async updateUserEmail(args: UpdateUserEmailArgs): Promise<UpdateUserCredentialsResult> {
try {
await this.db.dbDrizzle.transaction(async (tx) => {
await tx
.update(UsersSchema)
.set({ email: args.email })
.where(eq(UsersSchema.id, args.userId));
await tx
.update(OrganizationMembersSchema)
.set({ email: args.email })
.where(eq(OrganizationMembersSchema.email, args.oldEmail));
await tx
.update(CollaborationUsersSchema)
.set({ email: args.email })
.where(eq(CollaborationUsersSchema.email, args.oldEmail));
await tx
.update(SsoIdentitiesSchema)
.set({ email: args.email })
.where(eq(SsoIdentitiesSchema.userId, args.userId));
});
return 'ok';
} catch (error) {
const pgCode = (error as { code?: string })?.code ?? (error as { cause?: { code?: string } })?.cause?.code;
if (pgCode === '23505') {
return 'conflict';
}
$logger.error(error, `Can not update email for user ${args.userId}`);
return 'error';
}
}
async updateUserPassword(password: string, userId: number): Promise<boolean> {
try {
const query = 'UPDATE tv_auth.users SET password = $1 WHERE id = $2';
+14
View File
@@ -15,7 +15,21 @@ export class LoginMethods {
return LoginMethods.enabled().has(method);
}
static publicRegistrationAllowed(): boolean {
return process.env.ALLOW_PUBLIC_REGISTRATION?.trim().toLowerCase() !== 'false';
}
static validateOnStartup(): void {
const registrationRaw = process.env.ALLOW_PUBLIC_REGISTRATION;
if (registrationRaw !== undefined && registrationRaw.trim() !== '') {
const normalized = registrationRaw.trim().toLowerCase();
if (normalized !== 'true' && normalized !== 'false') {
throw new Error(
`ALLOW_PUBLIC_REGISTRATION has unrecognized value "${registrationRaw}". Allowed: true, false`
);
}
}
const raw = process.env.AUTH_LOGIN_METHODS;
if (!raw || !raw.trim()) return;
@@ -512,4 +512,65 @@ describe('Login API', () => {
expect(te).toBe(0);
});
it('loginByCode confirms and admits a blocked-unconfirmed account', async () => {
deleteTestUserEmail = `${Date.now()}test@mail.dest`;
const email = deleteTestUserEmail;
await axios.post(`${url}/module/auth/registration`, {
email,
password: 'user1!#Q',
passwordRepeat: 'user1!#Q',
});
const userModel = new AuthModel();
const before = await userModel.getUserByLogin(email, true);
expect(before).toBeTruthy();
expect((before as any).block).toBe(1);
expect((before as any).confirm_email_code).toBeTruthy();
const code = '654321';
await userModel.updateLoginCode(`${code}:${Date.now()}`, email);
const response = await axios.post(`${url}/module/auth/login-by-code`, { email, code });
expect(response.status).toBe(200);
expect(response.data.access).toBeTruthy();
expect(response.data.refresh).toBeTruthy();
const after = await userModel.getUserByLogin(email, true);
expect((after as any).block).toBe(0);
expect((after as any).confirm_email_code).toBeNull();
});
it('loginByCode rejects a banned account (blocked, no confirm code)', async () => {
deleteTestUserEmail = `${Date.now()}test@mail.dest`;
const email = deleteTestUserEmail;
await axios.post(`${url}/module/auth/registration`, {
email,
password: 'user1!#Q',
passwordRepeat: 'user1!#Q',
});
const db = Database.getInstance();
await db.query('update tv_auth.users set block = 1, confirm_email_code = null where email = $1', [email]);
const userModel = new AuthModel();
const code = '112233';
await userModel.updateLoginCode(`${code}:${Date.now()}`, email);
let status = 0;
let message = '';
await axios.post(`${url}/module/auth/login-by-code`, { email, code }).catch((err) => {
status = err.response.status;
message = err.response.data.message;
});
expect(status).toBe(403);
expect(message).toBe('account_blocked');
const after = await userModel.getUserByLogin(email, true);
expect((after as any).block).toBe(1);
});
});
@@ -1,8 +1,12 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanFetchRolesPermissionsCollaborationRoles = async (req: Request, res: Response, next: NextFunction) => {
const goalId = req.body.goalId ? req.body.goalId : req.params.goalId;
// the only route using this guard names the goal in the path
const goalId = req.params.goalId;
if (!goalId) {
return res.status(400).end();
@@ -19,5 +23,18 @@ export const CanFetchRolesPermissionsCollaborationRoles = async (req: Request, r
return next();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(goalId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanFetchRolesPermissionsCollaborationRoles middleware');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS)) {
return next();
}
return res.status(403).end();
};
@@ -16,6 +16,7 @@ import {
} from './collaboration.types';
export class CollaborationController {
/** @deprecated */
fetchAllUsers = async (req: Request, res: Response) => {
const users = await req.appUser.collaborationManager.fetchAllUsers();
return res.tvJson(users);
@@ -82,19 +83,30 @@ export class CollaborationController {
return res.status(400).send(output.summary);
}
const user = await req.appUser.collaborationManager.addUserNew(output);
const result = await req.appUser.collaborationManager.addUserNew(output);
if (user) {
// created=false means the person was already in the goal — re-POSTing must not re-notify
if (result?.created) {
eventBus.emit('collaboration.userAdded', {
goalId: output.goalId,
email: output.email.toLowerCase(),
initiatorId: req.appUser.getUserData()!.id,
locale: this.resolveLocale(req),
});
}
return res.tvJson(user ?? null);
return res.tvJson(result?.user ?? null);
};
// The invitee has no stored locale (often no account yet), so localize by the inviter's browser language
private resolveLocale(req: Request): 'en' | 'ru' {
const acceptLanguage = req.headers['accept-language'];
if (!acceptLanguage) return 'en';
const languages = acceptLanguage.split(',').map((lang) => lang.split(';')[0].trim().toLowerCase());
return languages.some((lang) => lang === 'ru' || lang.startsWith('ru-')) ? 'ru' : 'en';
}
deleteUserNew = async (req: Request, res: Response) => {
const output = CollaborationArkTypeDeleteUser(req.body);
@@ -2,6 +2,7 @@ import type { AppUser } from '../../core/AppUser';
import { GoalPermissions } from '../../types/auth.types';
import { CollaborationRepository } from './CollaborationRepository';
import type {
CollaborationAddUserResult,
CollaborationArgAddUser,
CollaborationArgDeleteUser,
CollaborationArgToggleUserRoles,
@@ -24,6 +25,7 @@ export class CollaborationManager {
this.repository = new CollaborationRepository();
}
/** @deprecated */
async fetchAllUsers(): Promise<CollaborationUserWithRoles[] | false> {
const sharedGoals = await this.user.goalsManager.fetchSharedGoals();
@@ -69,6 +71,7 @@ export class CollaborationManager {
return Object.values(resultMap);
}
/** @deprecated */
async fetchUsersForGoal(args: FetchGoalUsersArg): Promise<CollaborationUserWithRoles[] | false> {
const users = await this.repository.fetchUsersForGoal(args.goalId);
@@ -103,6 +106,7 @@ export class CollaborationManager {
return Object.values(resultMap);
}
/** @deprecated*/
async toggleUserRoles(args: ToggleUserRolesArg): Promise<number[] | false> {
return await this.repository.updateUserRoles(args.userId, args.roles);
}
@@ -120,7 +124,7 @@ export class CollaborationManager {
return await this.repository.deleteUser(args);
}
async addUserNew(args: CollaborationArgAddUser): Promise<CollaborationUserWithRoles | null> {
async addUserNew(args: CollaborationArgAddUser): Promise<CollaborationAddUserResult | null> {
const email = args.email.toLowerCase();
const goal = await this.user.goalsManager.goalsRepository.findGoalById(args.goalId);
@@ -131,19 +135,22 @@ export class CollaborationManager {
}
}
const user = await this.repository.addUserForCollaborationNew({
const result = await this.repository.addUserForCollaborationNew({
...args,
email,
});
if (!user) return null;
if (!result) return null;
return {
...user,
goalId: args.goalId,
goal_id: args.goalId,
invitation_date: user.invitationDate,
roles: [],
goalOwner: false,
user: {
...result.user,
goalId: args.goalId,
goal_id: args.goalId,
invitation_date: result.user.invitationDate,
roles: [],
goalOwner: false,
},
created: result.created,
};
}
@@ -177,7 +184,7 @@ export class CollaborationManager {
return [];
}
const resultMap: Record<string, CollaborationUserWithRoles> = {};
users.forEach((item) => {
@@ -210,7 +217,7 @@ export class CollaborationManager {
return [];
}
const resultMap: Record<string, CollaborationUserWithRoles> = {};
users.forEach((item) => {
@@ -1,5 +1,6 @@
import { and, eq, inArray } from 'drizzle-orm';
import { and, eq, exists, inArray } from 'drizzle-orm';
import {
CollaborationRolesSchema,
CollaborationUsersSchema,
type CollaborationUsersSchemaTypeForSelect,
CollaborationUsersToGoalsSchema,
@@ -10,6 +11,7 @@ import { $logger } from '../../modules/logget';
import { logError } from '../../utils/api';
import { callWithCatch } from '../../utils/helpers';
import type {
CollaborationAddUserRepoResult,
CollaborationArgAddUser,
CollaborationArgDeleteUser,
CollaborationArgToggleUserRoles,
@@ -23,6 +25,7 @@ export class CollaborationRepository {
this.db = Database.getInstance();
}
/** @deprecated */
async fetchAllUsers(goalIds: number[]): Promise<FetchUsersForGoal[] | false> {
if (goalIds.length === 0) {
return [];
@@ -34,6 +37,10 @@ export class CollaborationRepository {
FROM collaboration.users u
left join collaboration.users_to_goals utg on u.id = utg.user_id
LEFT JOIN collaboration.users_to_roles utr ON u.id = utr.user_id
AND EXISTS (
SELECT 1 FROM collaboration.roles r
WHERE r.id = utr.role_id AND r.goal_id = utg.goal_id
)
WHERE utg.goal_id IN (${placeholders})
`;
@@ -91,7 +98,8 @@ export class CollaborationRepository {
return result.rows[0];
}
/** @deprecated */
async fetchUsersForGoal(goalId: number): Promise<FetchUsersForGoal[] | false> {
const query = `
SELECT u.*, u.invitation_date::text, utr.role_id, utg.goal_id
@@ -111,6 +119,7 @@ export class CollaborationRepository {
return result.rows;
}
/** @deprecated */
async fetchUsersForGoals(goalIds: number[]): Promise<FetchUsersForGoal[] | false> {
if (goalIds.length === 0) {
return [];
@@ -145,6 +154,7 @@ export class CollaborationRepository {
return !!(result.rowCount && result.rowCount > 0);
}
/** @deprecated */
async updateUserRoles(userId: number, roles: number[]): Promise<number[] | false> {
const deleteQuery = `DELETE FROM collaboration.users_to_roles WHERE user_id = $1`;
let i = 1;
@@ -197,8 +207,8 @@ export class CollaborationRepository {
async addUserForCollaborationNew(
args: CollaborationArgAddUser
): Promise<CollaborationUsersSchemaTypeForSelect | null> {
const user = await callWithCatch(() =>
): Promise<CollaborationAddUserRepoResult | null> {
return await callWithCatch(() =>
this.db.dbDrizzle.transaction(async (tx) => {
let userId: number;
let user: CollaborationUsersSchemaTypeForSelect;
@@ -217,18 +227,14 @@ export class CollaborationRepository {
user = userTransaction;
}
await tx.insert(CollaborationUsersToGoalsSchema).values({
const linked = await tx.insert(CollaborationUsersToGoalsSchema).values({
userId: userId,
goalId: args.goalId,
}).onConflictDoNothing();
}).onConflictDoNothing().returning();
return user;
return { user, created: linked.length > 0 };
})
);
if (!user) return null;
return user;
}
async deleteUserNew(args: CollaborationArgDeleteUser) {
@@ -250,13 +256,29 @@ export class CollaborationRepository {
async toggleUserRolesNew(args: CollaborationArgToggleUserRoles): Promise<number[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.transaction(async (tx) => {
await tx
.delete(CollaborationUsersToRolesSchema)
.where(eq(CollaborationUsersToRolesSchema.userId, args.userId));
if (args.roles.length > 0) {
const goalRoles = await tx
.select({ id: CollaborationRolesSchema.id })
.from(CollaborationRolesSchema)
.where(eq(CollaborationRolesSchema.goalId, args.goalId));
const goalRoleIds = goalRoles.map((role) => role.id);
if (goalRoleIds.length > 0) {
await tx
.delete(CollaborationUsersToRolesSchema)
.where(
and(
eq(CollaborationUsersToRolesSchema.userId, args.userId),
inArray(CollaborationUsersToRolesSchema.roleId, goalRoleIds)
)
);
}
const rolesToAssign = args.roles.filter((roleId) => goalRoleIds.includes(roleId));
if (rolesToAssign.length > 0) {
return await tx
.insert(CollaborationUsersToRolesSchema)
.values(args.roles.map((roleId) => ({ userId: args.userId, roleId })))
.values(rolesToAssign.map((roleId) => ({ userId: args.userId, roleId })))
.returning();
}
return [];
@@ -287,7 +309,20 @@ export class CollaborationRepository {
)
.leftJoin(
CollaborationUsersToRolesSchema,
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId)
and(
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId),
exists(
this.db.dbDrizzle
.select()
.from(CollaborationRolesSchema)
.where(
and(
eq(CollaborationRolesSchema.id, CollaborationUsersToRolesSchema.roleId),
eq(CollaborationRolesSchema.goalId, CollaborationUsersToGoalsSchema.goalId)
)
)
)
)
)
.where(inArray(CollaborationUsersToGoalsSchema.goalId, goalIds))
);
@@ -311,7 +346,20 @@ export class CollaborationRepository {
)
.leftJoin(
CollaborationUsersToRolesSchema,
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId)
and(
eq(CollaborationUsersSchema.id, CollaborationUsersToRolesSchema.userId),
exists(
this.db.dbDrizzle
.select()
.from(CollaborationRolesSchema)
.where(
and(
eq(CollaborationRolesSchema.id, CollaborationUsersToRolesSchema.roleId),
eq(CollaborationRolesSchema.goalId, CollaborationUsersToGoalsSchema.goalId)
)
)
)
)
)
.where(eq(CollaborationUsersToGoalsSchema.goalId, goalId))
);
@@ -5,7 +5,7 @@ import { IsOrgMemberIfProvided } from '../../middlewares/is-org-member';
import { CollaborationController } from './CollaborationController';
import { CanAddUserCollaboration } from './middlewares/CanAddUserCollaboration';
import { CanDeleteUserCollaboration } from './middlewares/CanDeleteUserCollaboration';
// import { CanFetchUsersCollaboration } from './middlewares/CanFetchUsersCollaboration';
import { CanFetchUsersCollaboration } from './middlewares/CanFetchUsersCollaboration';
import { CanToggleRolesCollaboration } from './middlewares/CanToggleRolesCollaboration';
export default class CollaborationRoutes implements Routable {
@@ -56,6 +56,10 @@ export default class CollaborationRoutes implements Routable {
/**
* Fetch users for goal for collaboration
*/
this.router.get('/:goalId', [IsLoggedIn], this.collaborationController.fetchUsersForGoalNew);
this.router.get(
'/:goalId',
[IsLoggedIn, CanFetchUsersCollaboration],
this.collaborationController.fetchUsersForGoalNew
);
}
}
@@ -0,0 +1,183 @@
import { and, count, eq, gte, lt, sql } from 'drizzle-orm';
import { GoalsSchema, InviteEmailsSchema, OrganizationsSchema, UsersSchema } from 'taskview-db-schemas';
import type { Dispatcher } from '../../core/Dispatcher';
import { Email } from '../../core/Email';
import { eventBus, type AppEvents } from '../../core/EventBus';
import { Database } from '../../modules/db';
import { $logger } from '../../modules/logget';
import { escapeHtml, parsePositiveInt } from '../../utils/helpers';
import InviteEmailTemplateEn from './mail/invite-en';
import InviteEmailTemplateRu from './mail/invite-ru';
import type { InviteEmailRateLimitArgs, InviteEmailSendArgs } from './collaboration.server.types';
const DEFAULT_HOURLY_LIMIT = 30;
export class InviteEmailDispatcher implements Dispatcher {
static enabled(): boolean {
return process.env.INVITE_EMAIL_ENABLED?.trim().toLowerCase() === 'true';
}
static hourlyLimit(): number {
return parsePositiveInt(process.env.INVITE_EMAIL_HOURLY_LIMIT) ?? DEFAULT_HOURLY_LIMIT;
}
static validateOnStartup(): void {
const enabledRaw = process.env.INVITE_EMAIL_ENABLED;
if (enabledRaw !== undefined && enabledRaw.trim() !== '') {
const normalized = enabledRaw.trim().toLowerCase();
if (normalized !== 'true' && normalized !== 'false') {
throw new Error(`INVITE_EMAIL_ENABLED has unrecognized value "${enabledRaw}". Allowed: true, false`);
}
}
const limitRaw = process.env.INVITE_EMAIL_HOURLY_LIMIT;
if (limitRaw !== undefined && limitRaw.trim() !== '' && parsePositiveInt(limitRaw) === null) {
throw new Error(
`INVITE_EMAIL_HOURLY_LIMIT has unrecognized value "${limitRaw}". Expected a positive integer`
);
}
}
register(): void {
eventBus.on('collaboration.userAdded', (data) => this.onUserAdded(data));
}
async registerWorkers(): Promise<void> {}
private async onUserAdded(data: AppEvents['collaboration.userAdded']): Promise<void> {
if (!InviteEmailDispatcher.enabled() || !process.env.SMTP_HOST) return;
const db = Database.getInstance();
const [goal] = await db.dbDrizzle
.select({ name: GoalsSchema.name, organizationId: GoalsSchema.organizationId })
.from(GoalsSchema)
.where(eq(GoalsSchema.id, data.goalId))
.limit(1);
if (!goal) return;
const [inviter] = await db.dbDrizzle
.select({ login: UsersSchema.login })
.from(UsersSchema)
.where(eq(UsersSchema.id, data.initiatorId))
.limit(1);
if (!inviter) return;
const allowed = await this.passesRateLimit({
initiatorId: data.initiatorId,
email: data.email,
goalId: data.goalId,
});
if (!allowed) return;
const link = await this.buildGoalLink(data.goalId, goal.organizationId);
if (!link) {
$logger.warn('APP_URL is not set — skipping invite email');
return;
}
await db.dbDrizzle.insert(InviteEmailsSchema).values({
initiatorId: data.initiatorId,
email: data.email,
goalId: data.goalId,
});
const fallbackName = data.locale === 'ru' ? 'Пользователь TaskView' : 'A TaskView user';
await this.sendInviteEmail({
email: data.email,
inviterName: this.truncate(inviter.login?.trim() || fallbackName),
goalName: this.truncate(goal.name || ''),
link,
locale: data.locale,
});
}
// Two rules: a 24h cooldown per (goal, recipient) — closes the delete/re-add resend loop —
// and an hourly cap per initiator against using the instance as a mail relay.
// Rows older than the cooldown window are pruned first, keeping the table tiny.
private async passesRateLimit(args: InviteEmailRateLimitArgs): Promise<boolean> {
const db = Database.getInstance();
await db.dbDrizzle
.delete(InviteEmailsSchema)
.where(lt(InviteEmailsSchema.sentAt, sql`now() - interval '24 hours'`));
const [cooldown] = await db.dbDrizzle
.select({ id: InviteEmailsSchema.id })
.from(InviteEmailsSchema)
.where(and(eq(InviteEmailsSchema.goalId, args.goalId), eq(InviteEmailsSchema.email, args.email)))
.limit(1);
if (cooldown) return false;
const [hourly] = await db.dbDrizzle
.select({ count: count() })
.from(InviteEmailsSchema)
.where(
and(
eq(InviteEmailsSchema.initiatorId, args.initiatorId),
gte(InviteEmailsSchema.sentAt, sql`now() - interval '1 hour'`)
)
);
if ((hourly?.count ?? 0) >= InviteEmailDispatcher.hourlyLimit()) {
$logger.warn(
{ initiatorId: args.initiatorId, goalId: args.goalId },
'Invite email hourly limit reached — skipping send'
);
return false;
}
return true;
}
private async sendInviteEmail(args: InviteEmailSendArgs): Promise<void> {
const template = args.locale === 'ru' ? InviteEmailTemplateRu : InviteEmailTemplateEn;
const subject =
args.locale === 'ru'
? `${args.inviterName} приглашает вас в проект «${args.goalName}» в TaskView`
: `${args.inviterName} invited you to "${args.goalName}" on TaskView`;
const text =
args.locale === 'ru'
? `${args.inviterName} приглашает вас присоединиться к проекту «${args.goalName}» в TaskView.\n\nОткрыть проект: ${args.link}`
: `${args.inviterName} has invited you to join the project "${args.goalName}" on TaskView.\n\nOpen the project: ${args.link}`;
// Single-pass replace with a function: no re-substitution of placeholders inside
// inserted values, and no special treatment of $-patterns in the replacement
const values: Record<string, string> = {
inviter: args.inviterName,
project: args.goalName,
link: args.link,
};
const html = template.replace(/\{(inviter|project|link)\}/g, (_, key: string) => escapeHtml(values[key]));
await Email.send({
text,
subject,
to: args.email,
from: process.env.SMTP_FROM_EMAIL as string,
attachment: [{ data: html, alternative: true }],
});
}
// Frontend project route is /:orgSlug/:projectId; goals without an organization fall back to the app root
private async buildGoalLink(goalId: number, organizationId: number | null): Promise<string | null> {
const appUrl = (process.env.APP_URL ?? '').replace(/\/+$/, '');
if (!appUrl) return null;
if (!organizationId) return appUrl;
const db = Database.getInstance();
const [org] = await db.dbDrizzle
.select({ slug: OrganizationsSchema.slug })
.from(OrganizationsSchema)
.where(eq(OrganizationsSchema.id, organizationId))
.limit(1);
if (!org?.slug) return appUrl;
return `${appUrl}/${encodeURIComponent(org.slug)}/${goalId}`;
}
private truncate(value: string): string {
const max = 80;
return value.length > max ? `${value.slice(0, max)}` : value;
}
}
@@ -0,0 +1,233 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Email } from '../../../core/Email';
import type { AppEvents } from '../../../core/EventBus';
import { Database } from '../../../modules/db';
import { InviteEmailDispatcher } from '../InviteEmailDispatcher';
vi.mock('../../../core/Email', () => ({
Email: {
send: vi.fn().mockResolvedValue(true),
},
}));
vi.mock('../../../modules/db', () => ({
Database: {
getInstance: vi.fn(),
},
}));
// Each select() call consumes the next result; the returned query is both awaitable
// (count query) and .limit()-able (lookups), matching the Drizzle chains in the dispatcher
function mockDb(selectResults: unknown[][]) {
const queue = [...selectResults];
const insertValues = vi.fn(async () => undefined);
const dbDrizzle = {
select: vi.fn(() => {
const rows = queue.shift() ?? [];
const query = {
limit: async () => rows,
then: (resolve: (rows: unknown[]) => void, reject: (err: unknown) => void) =>
Promise.resolve(rows).then(resolve, reject),
};
return { from: () => ({ where: () => query }) };
}),
delete: vi.fn(() => ({ where: async () => undefined })),
insert: vi.fn(() => ({ values: insertValues })),
};
vi.mocked(Database.getInstance).mockReturnValue({ dbDrizzle } as any);
return { dbDrizzle, insertValues };
}
const goalRow = { name: 'Marketing', organizationId: 3 };
const inviterRow = { login: 'Alice' };
const noCooldown: unknown[] = [];
const underLimit = [{ count: 0 }];
const orgRow = [{ slug: 'acme' }];
const inviteEvent: AppEvents['collaboration.userAdded'] = {
goalId: 42,
email: 'invitee@example.com',
initiatorId: 7,
locale: 'en',
};
describe('InviteEmailDispatcher', () => {
const dispatcher = new InviteEmailDispatcher();
const onUserAdded = (data: typeof inviteEvent) => (dispatcher as any).onUserAdded(data);
const sentHtml = () => (vi.mocked(Email.send).mock.calls[0][0] as any).attachment[0].data as string;
beforeEach(() => {
process.env.INVITE_EMAIL_ENABLED = 'true';
process.env.SMTP_HOST = 'smtp.test';
process.env.SMTP_FROM_EMAIL = 'noreply@test';
process.env.APP_URL = 'http://localhost:3000';
});
afterEach(() => {
delete process.env.INVITE_EMAIL_ENABLED;
delete process.env.INVITE_EMAIL_HOURLY_LIMIT;
vi.clearAllMocks();
});
it('does not send when the flag is off', async () => {
process.env.INVITE_EMAIL_ENABLED = 'false';
mockDb([]);
await onUserAdded(inviteEvent);
expect(Email.send).not.toHaveBeenCalled();
});
it('does not send when the flag is unset', async () => {
delete process.env.INVITE_EMAIL_ENABLED;
mockDb([]);
await onUserAdded(inviteEvent);
expect(Email.send).not.toHaveBeenCalled();
});
it('sends a localized email with a project deep link and records the send', async () => {
const { insertValues } = mockDb([[goalRow], [inviterRow], noCooldown, underLimit, orgRow]);
await onUserAdded(inviteEvent);
expect(Email.send).toHaveBeenCalledTimes(1);
const message = vi.mocked(Email.send).mock.calls[0][0] as any;
expect(message.to).toBe('invitee@example.com');
expect(message.from).toBe('noreply@test');
expect(message.subject).toBe('Alice invited you to "Marketing" on TaskView');
expect(message.text).toContain('http://localhost:3000/acme/42');
const html = sentHtml();
expect(html).toContain("You've been invited to a project");
expect(html).toContain('Alice');
expect(html).toContain('href="http://localhost:3000/acme/42"');
expect(insertValues).toHaveBeenCalledWith({
initiatorId: 7,
email: 'invitee@example.com',
goalId: 42,
});
});
it('uses the Russian template for the ru locale', async () => {
mockDb([[{ name: 'Маркетинг', organizationId: null }], [{ login: 'Алиса' }], noCooldown, underLimit]);
await onUserAdded({ ...inviteEvent, locale: 'ru' });
const message = vi.mocked(Email.send).mock.calls[0][0] as any;
expect(message.subject).toBe('Алиса приглашает вас в проект «Маркетинг» в TaskView');
expect(sentHtml()).toContain('Вас пригласили в проект');
expect(sentHtml()).toContain('href="http://localhost:3000"');
});
it('skips the send during the per-recipient cooldown', async () => {
const { insertValues } = mockDb([[goalRow], [inviterRow], [{ id: 1 }]]);
await onUserAdded(inviteEvent);
expect(Email.send).not.toHaveBeenCalled();
expect(insertValues).not.toHaveBeenCalled();
});
it('skips the send when the hourly limit is reached', async () => {
const { insertValues } = mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 30 }]]);
await onUserAdded(inviteEvent);
expect(Email.send).not.toHaveBeenCalled();
expect(insertValues).not.toHaveBeenCalled();
});
it('respects a custom INVITE_EMAIL_HOURLY_LIMIT', async () => {
process.env.INVITE_EMAIL_HOURLY_LIMIT = '2';
mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 2 }]]);
await onUserAdded(inviteEvent);
expect(Email.send).not.toHaveBeenCalled();
mockDb([[goalRow], [inviterRow], noCooldown, [{ count: 1 }], orgRow]);
await onUserAdded(inviteEvent);
expect(Email.send).toHaveBeenCalledTimes(1);
});
it('escapes user-controlled values in the html', async () => {
mockDb([
[{ name: '<img src=x onerror=alert(1)>', organizationId: null }],
[{ login: 'Bob & "Co"' }],
noCooldown,
underLimit,
]);
await onUserAdded(inviteEvent);
const html = sentHtml();
expect(html).not.toContain('<img src=x');
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
expect(html).toContain('Bob &amp; &quot;Co&quot;');
});
it('is immune to $-patterns and placeholder strings in user values', async () => {
mockDb([
[{ name: 'Project $` name', organizationId: null }],
[{ login: '{link}' }],
noCooldown,
underLimit,
]);
await onUserAdded(inviteEvent);
const html = sentHtml();
expect(html).toContain('Project $` name');
expect(html).toContain('{link}');
expect(html).toContain('href="http://localhost:3000"');
});
it('truncates overlong user values', async () => {
mockDb([
[{ name: 'p'.repeat(200), organizationId: null }],
[{ login: 'i'.repeat(200) }],
noCooldown,
underLimit,
]);
await onUserAdded(inviteEvent);
const message = vi.mocked(Email.send).mock.calls[0][0] as any;
expect(message.subject).toContain(`"${'p'.repeat(80)}…"`);
expect(message.text).toContain(`${'i'.repeat(80)}… has invited`);
});
it('does not send when the goal no longer exists', async () => {
mockDb([[]]);
await onUserAdded(inviteEvent);
expect(Email.send).not.toHaveBeenCalled();
});
it('validateOnStartup rejects unrecognized values', () => {
process.env.INVITE_EMAIL_ENABLED = 'ture';
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_ENABLED');
process.env.INVITE_EMAIL_ENABLED = 'false';
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
process.env.INVITE_EMAIL_HOURLY_LIMIT = 'abc';
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_HOURLY_LIMIT');
process.env.INVITE_EMAIL_HOURLY_LIMIT = '0';
expect(() => InviteEmailDispatcher.validateOnStartup()).toThrow('INVITE_EMAIL_HOURLY_LIMIT');
process.env.INVITE_EMAIL_HOURLY_LIMIT = '10';
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
delete process.env.INVITE_EMAIL_ENABLED;
delete process.env.INVITE_EMAIL_HOURLY_LIMIT;
expect(() => InviteEmailDispatcher.validateOnStartup()).not.toThrow();
});
});
@@ -1,4 +1,5 @@
import { type } from 'arktype';
import type { CollaborationUsersSchemaTypeForSelect } from 'taskview-db-schemas';
export const CollaborationArkTypeAddUser = type({
goalId: 'number',
@@ -97,3 +98,30 @@ export const CollaborationArkTypeToggleRolePermission = type({
});
export type CollaborationArgToggleRolePermission = typeof CollaborationArkTypeToggleRolePermission.infer;
// created=false means the person was already a collaborator of the goal — no invitation happened
export type CollaborationAddUserRepoResult = {
user: CollaborationUsersSchemaTypeForSelect;
created: boolean;
};
export type CollaborationAddUserResult = {
user: CollaborationUserWithRoles;
created: boolean;
};
export type InviteEmailLocale = 'en' | 'ru';
export type InviteEmailSendArgs = {
email: string;
inviterName: string;
goalName: string;
link: string;
locale: InviteEmailLocale;
};
export type InviteEmailRateLimitArgs = {
initiatorId: number;
email: string;
goalId: number;
};
@@ -0,0 +1,50 @@
export default `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="format-detection" content="telephone=no" />
<meta name="color-scheme" content="only" />
<title>Project invitation</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f7fa;">
<tr>
<td align="center" style="padding: 40px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);">
<tr>
<td style="padding: 40px 32px 24px; text-align: center;">
<div style="font-size: 18px; font-weight: 600; color: #000000; letter-spacing: 0.5px;">TaskView</div>
</td>
</tr>
<tr>
<td style="padding: 0 32px 16px; text-align: center;">
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #18181b;">You've been invited to a project</h1>
</td>
</tr>
<tr>
<td style="padding: 0 32px 28px; text-align: center;">
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #71717a;"><span style="font-weight: 600; color: #18181b;">{inviter}</span> has invited you to join the project<br /><span style="font-weight: 600; color: #18181b;">{project}</span></p>
</td>
</tr>
<tr>
<td align="center" style="padding: 0 32px 28px;">
<a href="{link}" style="display: inline-block; padding: 12px 32px; background-color: #16a34a; border-radius: 8px; font-size: 15px; font-weight: 600; color: #ffffff; text-decoration: none;">Open project</a>
</td>
</tr>
<tr>
<td style="padding: 0 32px 32px; text-align: center;">
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #a1a1aa;">If the button doesn't work, copy this link into your browser:<br /><a href="{link}" style="color: #16a34a; word-break: break-all;">{link}</a></p>
</td>
</tr>
<tr>
<td style="padding: 0 32px 40px; text-align: center; border-top: 1px solid #f4f4f5;">
<p style="margin: 24px 0 0; font-size: 13px; line-height: 1.5; color: #a1a1aa;">You received this email because someone invited you to a project on TaskView. If you weren't expecting it, you can safely ignore this email.</p>
</td>
</tr>
</table>
<p style="margin: 24px 0 0; font-size: 12px; color: #a1a1aa; text-align: center;">© TaskView</p>
</td>
</tr>
</table>
</body>
</html>`
@@ -0,0 +1,50 @@
export default `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="format-detection" content="telephone=no" />
<meta name="color-scheme" content="only" />
<title>Приглашение в проект</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f5f7fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color: #f5f7fa;">
<tr>
<td align="center" style="padding: 40px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; background-color: #ffffff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);">
<tr>
<td style="padding: 40px 32px 24px; text-align: center;">
<div style="font-size: 18px; font-weight: 600; color: #000000; letter-spacing: 0.5px;">TaskView</div>
</td>
</tr>
<tr>
<td style="padding: 0 32px 16px; text-align: center;">
<h1 style="margin: 0; font-size: 20px; font-weight: 600; color: #18181b;">Вас пригласили в проект</h1>
</td>
</tr>
<tr>
<td style="padding: 0 32px 28px; text-align: center;">
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #71717a;"><span style="font-weight: 600; color: #18181b;">{inviter}</span> приглашает вас присоединиться к проекту<br /><span style="font-weight: 600; color: #18181b;">{project}</span></p>
</td>
</tr>
<tr>
<td align="center" style="padding: 0 32px 28px;">
<a href="{link}" style="display: inline-block; padding: 12px 32px; background-color: #16a34a; border-radius: 8px; font-size: 15px; font-weight: 600; color: #ffffff; text-decoration: none;">Открыть проект</a>
</td>
</tr>
<tr>
<td style="padding: 0 32px 32px; text-align: center;">
<p style="margin: 0; font-size: 12px; line-height: 1.5; color: #a1a1aa;">Если кнопка не работает, скопируйте эту ссылку в браузер:<br /><a href="{link}" style="color: #16a34a; word-break: break-all;">{link}</a></p>
</td>
</tr>
<tr>
<td style="padding: 0 32px 40px; text-align: center; border-top: 1px solid #f4f4f5;">
<p style="margin: 24px 0 0; font-size: 13px; line-height: 1.5; color: #a1a1aa;">Вы получили это письмо, потому что вас пригласили в проект в TaskView. Если вы не ожидали приглашения, просто проигнорируйте это письмо.</p>
</td>
</tr>
</table>
<p style="margin: 24px 0 0; font-size: 12px; color: #a1a1aa; text-align: center;">© TaskView</p>
</td>
</tr>
</table>
</body>
</html>`
@@ -21,7 +21,8 @@ export const CanFetchUsersCollaboration = async (req: Request, res: Response, ne
if (
permissions.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS) ||
permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS)
permissions.hasPermissions(GoalPermissions.GOAL_CAN_MANAGE_USERS) ||
permissions.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS)
) {
return next();
}
+1 -1
View File
@@ -237,7 +237,7 @@ export default class GoalsManager {
await this.user.collaborationManager.repository.toggleUserRolesNew({
goalId,
userId: collabUser.id,
userId: collabUser.user.id,
roles: [role.id],
})
}
@@ -33,6 +33,15 @@ export class GraphController {
return res.tvJson(edges);
};
fetchTaskEdges = async (req: Request, res: Response) => {
const taskId = Number(req.params.taskId);
if (!Number.isFinite(taskId)) {
return res.status(400).send('Task ID is required');
}
const edges = await req.appUser.graphManager.fetchEdgesForTask(taskId);
return res.tvJson(edges);
};
deleteEdge = async (req: Request, res: Response) => {
if (!req.params.id) {
return res.status(400).send('Edge ID is required');
+4
View File
@@ -19,6 +19,10 @@ export class GraphManager {
return await this.repository.fetchAllEdges(goalId);
}
async fetchEdgesForTask(taskId: number) {
return await this.repository.fetchEdgesForTask(taskId);
}
async deleteEdge(id: number) {
return await this.repository.deleteEdge(id);
}
+11 -1
View File
@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm';
import { eq, or } from 'drizzle-orm';
import { GraphRelationsSchema } from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { callWithCatch } from '../../utils/helpers';
@@ -32,6 +32,16 @@ export class GraphRepository {
return result ?? [];
}
public async fetchEdgesForTask(taskId: number): Promise<GraphReturnRelationsType[]> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(GraphRelationsSchema)
.where(or(eq(GraphRelationsSchema.fromTaskId, taskId), eq(GraphRelationsSchema.toTaskId, taskId)))
);
return result ?? [];
}
public async deleteEdge(id: number): Promise<boolean> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.delete(GraphRelationsSchema).where(eq(GraphRelationsSchema.id, id))
+1
View File
@@ -21,6 +21,7 @@ export default class GraphRoutes implements Routable {
initRoutes() {
this.router.post('', [IsLoggedIn, CanManageGraph], this.graphController.addEdge);
this.router.get('/task/:taskId', [IsLoggedIn, CanViewGraph], this.graphController.fetchTaskEdges);
this.router.get('/:goalId', [IsLoggedIn, CanViewGraph], this.graphController.fetchAllEdges);
this.router.delete('/:id', [IsLoggedIn, CanManageGraph], this.graphController.deleteEdge);
}
@@ -3,28 +3,32 @@ import { GraphRepository } from '../GraphRepository';
import { TasksRepository } from '../../tasks/TasksRepository';
/**
* Resolves goalId from graph request.
* - GET /:goalId → params.goalId
* - POST (addEdge) → resolve via fromTaskId (body.source)
* - DELETE /:id → resolve via edge id
* Resolves the single goal a graph request belongs to.
*
* The source is chosen by what the route actually carries, not by probing every
* field in turn: a route parameter always wins, and only a request with no
* parameters at all (addEdge) is resolved from the body. Reading the body first
* would let a caller point the guard at a task they own while the handler acts
* on someone else's edge.
*
* A graph lives inside one project, so an edge whose endpoints sit in different
* goals is not a permission question — it is an impossible object. It resolves
* to null and the guards reject it before any permission is considered, the same
* invariant the tasks.check_task_graph_relation_goal trigger enforces in the DB.
*/
export async function resolveGoalId(req: Request): Promise<number | null> {
// Direct goalId in params (fetchAllEdges)
// fetchAllEdges: GET /:goalId
if (req.params.goalId) {
const id = Number(req.params.goalId);
return isNaN(id) ? null : id;
const goalId = Number(req.params.goalId);
return isNaN(goalId) ? null : goalId;
}
// addEdge: resolve goalId from task
if (req.body?.source) {
const taskId = Number(req.body.source);
if (isNaN(taskId)) return null;
const tasksRepo = new TasksRepository();
const task = await tasksRepo.fetchTaskByIdNew(taskId);
return task?.goalId ?? null;
// fetchTaskEdges: GET /task/:taskId
if (req.params.taskId) {
return goalIdForTask(req.params.taskId);
}
// deleteEdge: resolve goalId from edge
// deleteEdge: DELETE /:id
if (req.params.id) {
const edgeId = Number(req.params.id);
if (isNaN(edgeId)) return null;
@@ -33,5 +37,25 @@ export async function resolveGoalId(req: Request): Promise<number | null> {
return edge?.goalId ?? null;
}
// addEdge: POST with { source, target } — both endpoints must be in one goal
if (req.body?.source) {
const sourceGoalId = await goalIdForTask(req.body.source);
if (sourceGoalId === null) return null;
const targetGoalId = await goalIdForTask(req.body.target);
if (targetGoalId !== sourceGoalId) return null;
return sourceGoalId;
}
return null;
}
async function goalIdForTask(rawTaskId: unknown): Promise<number | null> {
const taskId = Number(rawTaskId);
if (!taskId || isNaN(taskId)) return null;
const tasksRepo = new TasksRepository();
const task = await tasksRepo.fetchTaskByIdNew(taskId);
return task?.goalId ?? null;
}
@@ -1,11 +1,14 @@
import { type } from 'arktype';
import type { Request, Response } from 'express';
import { logError } from '../../utils/api';
import { $logger } from '../../modules/logget';
import { integrationsDebugLog } from './debugLog';
import { decrypt } from '../../utils/crypto';
import AuthController from '../auth/AuthController';
import { IntegrationsRepository } from './IntegrationsRepository';
import { verifyGitHubWebhookSignature, GITHUB_BASE_URL } from './providers/github.provider';
import { verifyGitLabWebhookToken, GITLAB_BASE_URL } from './providers/gitlab.provider';
import { verifyGiteaWebhookSignature, GITEA_BASE_URL } from './providers/gitea.provider';
import { IntegrationsArkTypeAdd, IntegrationsArkTypeDelete, IntegrationsArkTypeFetch, IntegrationsArkTypeSelectRepo, IntegrationsArkTypeToggle } from './types';
export default class IntegrationsController {
@@ -47,23 +50,29 @@ export default class IntegrationsController {
initiateOAuth = async (req: Request, res: Response) => {
try {
integrationsDebugLog({ step: 'initiate:start', data: { provider: req.params.provider, projectId: req.query.projectId, hasToken: !!req.query.token } });
const token = req.query.token as string;
if (!token) {
integrationsDebugLog({ step: 'initiate:reject', data: 'token is required' });
return res.status(401).send('token is required');
}
const userPayload = await AuthController.validateTokens(token);
if (!userPayload?.userData?.id) {
integrationsDebugLog({ step: 'initiate:reject', data: 'invalid token' });
return res.status(401).send('Invalid token');
}
const provider = req.params.provider;
const projectId = Number(req.query.projectId);
if (!projectId || isNaN(projectId)) {
integrationsDebugLog({ step: 'initiate:reject', data: 'projectId is required' });
return res.status(400).send('projectId is required');
}
const url = req.appUser.integrationsManager.getOAuthUrl(provider, projectId, userPayload.userData.id);
integrationsDebugLog({ step: 'initiate:redirect', data: { userId: userPayload.userData.id, url } });
return res.redirect(url);
} catch (err) {
} catch (err: any) {
integrationsDebugLog({ step: 'initiate:error', data: { message: err?.message, stack: err?.stack } });
logError(err);
return res.status(500).send('Failed to initiate OAuth');
}
@@ -74,15 +83,36 @@ export default class IntegrationsController {
const provider = req.params.provider;
const code = req.query.code as string;
const state = req.query.state as string;
integrationsDebugLog({ step: 'callback:start', data: { provider, hasCode: !!code, hasState: !!state, queryKeys: Object.keys(req.query) } });
if (!code || !state) {
integrationsDebugLog({ step: 'callback:reject', data: 'missing code or state' });
return res.redirect(`${process.env.APP_URL}?oauth=error`);
}
const { projectId, userLogin } = await req.appUser.integrationsManager.handleOAuthCallback(provider, code, state);
return res.redirect(`${process.env.APP_URL}/${userLogin}/${projectId}/integrations?oauth=success`);
} catch (err) {
logError(err);
const { projectId, orgSlug } = await req.appUser.integrationsManager.handleOAuthCallback(provider, code, state);
integrationsDebugLog({ step: 'callback:success', data: { projectId, orgSlug } });
return res.redirect(`${process.env.APP_URL}/${orgSlug}/${projectId}/integrations?oauth=success`);
} catch (err: any) {
integrationsDebugLog({
step: 'callback:error',
data: {
message: err?.message,
responseStatus: err?.response?.status,
responseData: err?.response?.data,
stack: err?.stack,
},
});
$logger.error(
{
provider: req.params.provider,
errorMessage: err?.message,
responseStatus: err?.response?.status,
responseData: err?.response?.data,
stack: err?.stack,
},
'[integrations] OAuth callback failed',
);
return res.redirect(`${process.env.APP_URL}?oauth=error`);
}
};
@@ -210,6 +240,91 @@ export default class IntegrationsController {
}
};
handleGiteaWebhook = async (req: Request, res: Response) => {
try {
const signature = req.headers['x-gitea-signature'] as string;
const event = req.headers['x-gitea-event'] as string;
if (!signature) {
return res.status(401).send('Missing signature');
}
if (event !== 'issues') {
return res.status(200).send('OK');
}
const repoFullName = req.body?.repository?.full_name;
if (!repoFullName) {
return res.status(400).send('Missing repository');
}
const repo = new IntegrationsRepository();
const integrations = await repo.fetchAllActiveByRepoFullName(repoFullName);
if (integrations.length === 0) {
return res.status(404).send('Integration not found');
}
// Verify signature with the first integration that has a webhook secret
const withSecret = integrations.find((i) => i.webhookSecretEncrypted);
if (!withSecret) {
return res.status(401).send('No webhook secret');
}
const secret = decrypt(withSecret.webhookSecretEncrypted!);
const rawBody = (req as any).rawBody as Buffer;
if (!rawBody || !verifyGiteaWebhookSignature({ rawBody, signature, secret })) {
return res.status(401).send('Invalid signature');
}
const action = req.body.action as string;
const issue = req.body.issue;
if (!issue) {
return res.status(200).send('OK');
}
const issueNumber = issue.number as number;
const issueTitle = issue.title as string;
const issueBody = (issue.body as string) || null;
for (const integration of integrations) {
const mapping = await repo.fetchMappingByIssueNumber(integration.id, issueNumber);
if (action === 'opened') {
if (!mapping) {
await repo.createTaskAndMapping(
integration.projectId,
issueTitle,
integration.id,
issueNumber,
'open',
issueBody,
false,
`${GITEA_BASE_URL}/${repoFullName}/issues/${issueNumber}`,
);
}
} else if (action === 'edited') {
if (mapping) {
await repo.updateTaskTitleAndNote(mapping.taskId, issueTitle, issueBody);
}
} else if (action === 'closed') {
if (mapping) {
await repo.updateTaskComplete(mapping.taskId, true);
await repo.updateMappingState(mapping.id, 'closed');
}
} else if (action === 'reopened') {
if (mapping) {
await repo.updateTaskComplete(mapping.taskId, false);
await repo.updateMappingState(mapping.id, 'open');
}
}
}
return res.status(200).send('OK');
} catch (err) {
logError(err);
return res.status(500).send('Webhook processing failed');
}
};
handleGitLabWebhook = async (req: Request, res: Response) => {
try {
const token = req.headers['x-gitlab-token'] as string;
@@ -8,10 +8,12 @@ import { $logger } from '../../modules/logget';
import { IntegrationsRepository } from './IntegrationsRepository';
import { TasksRepository } from '../tasks/TasksRepository';
import type { IntegrationsSchemaTypeForSelect } from 'taskview-db-schemas';
import type { IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgFetch, IntegrationsArgSelectRepo, IntegrationsArgToggle, OAuthStatePayload, RepoItemForClient } from './types';
import type { IntegrationProvider, IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgFetch, IntegrationsArgSelectRepo, IntegrationsArgToggle, OAuthStatePayload, RepoItemForClient } from './types';
import { randomBytes } from 'crypto';
import { getGitHubOAuthUrl, exchangeGitHubCode, fetchGitHubRepos, fetchGitHubIssues, createGitHubWebhook, updateGitHubIssueState, GITHUB_BASE_URL } from './providers/github.provider';
import { getGitLabOAuthUrl, exchangeGitLabCode, fetchGitLabRepos, fetchGitLabIssues, createGitLabWebhook, updateGitLabIssueState, refreshGitLabToken, GITLAB_BASE_URL } from './providers/gitlab.provider';
import { getGiteaOAuthUrl, exchangeGiteaCode, fetchGiteaRepos, fetchGiteaIssues, createGiteaWebhook, updateGiteaIssueState, refreshGiteaToken, verifyGiteaToken, GITEA_BASE_URL } from './providers/gitea.provider';
import { integrationsDebugLog } from './debugLog';
export class IntegrationsManager {
public readonly repository: IntegrationsRepository;
@@ -55,13 +57,16 @@ export class IntegrationsManager {
return getGitHubOAuthUrl(state);
} else if (provider === 'gitlab') {
return getGitLabOAuthUrl(state);
} else if (provider === 'gitea') {
return getGiteaOAuthUrl(state);
}
throw new Error(`Unknown provider: ${provider}`);
}
async handleOAuthCallback(provider: string, code: string, state: string): Promise<{ projectId: number; userLogin: string }> {
async handleOAuthCallback(provider: string, code: string, state: string): Promise<{ projectId: number; orgSlug: string }> {
$logger.debug({ provider }, '[integrations] handleOAuthCallback start');
const payload = jwt.verify(state, process.env.JWT_SIGN as string) as OAuthStatePayload;
integrationsDebugLog({ step: 'callback:state-verified', data: { userId: payload.userId, projectId: payload.projectId, provider: payload.provider } });
if (payload.provider !== provider) {
$logger.error({ provider, payloadProvider: payload.provider }, '[integrations] provider mismatch in state');
@@ -69,6 +74,7 @@ export class IntegrationsManager {
}
const userLogin = await this.repository.fetchUserLogin(payload.userId);
integrationsDebugLog({ step: 'callback:user-fetched', data: { userLogin } });
if (!userLogin) {
$logger.error({ userId: payload.userId }, '[integrations] user not found during OAuth callback');
throw new Error('User not found');
@@ -84,19 +90,35 @@ export class IntegrationsManager {
const tokens = await exchangeGitLabCode(code);
accessTokenEncrypted = encrypt(tokens.accessToken);
refreshTokenEncrypted = encrypt(tokens.refreshToken);
} else if (provider === 'gitea') {
const tokens = await exchangeGiteaCode(code);
integrationsDebugLog({ step: 'callback:token-exchanged', data: { hasAccessToken: !!tokens.accessToken, hasRefreshToken: !!tokens.refreshToken } });
accessTokenEncrypted = encrypt(tokens.accessToken);
refreshTokenEncrypted = tokens.refreshToken ? encrypt(tokens.refreshToken) : null;
} else {
throw new Error(`Unknown provider: ${provider}`);
}
integrationsDebugLog({ step: 'callback:tokens-encrypted' });
await this.repository.createWithToken(
provider as 'github' | 'gitlab',
const created = await this.repository.createWithToken(
provider as IntegrationProvider,
payload.projectId,
accessTokenEncrypted,
refreshTokenEncrypted,
);
if (!created) {
integrationsDebugLog({ step: 'callback:db-insert-failed' });
throw new Error('Failed to store integration record');
}
integrationsDebugLog({ step: 'callback:integration-created', data: { integrationId: created.id } });
$logger.debug({ provider, projectId: payload.projectId, userLogin }, '[integrations] OAuth callback completed');
return { projectId: payload.projectId, userLogin };
// The app routes are /:orgSlug/:projectId/... — redirect must use the slug
// of the project's organization, falling back to the user login for legacy
// projects without an organization.
const orgSlug = await this.repository.fetchProjectOrgSlug(payload.projectId) ?? userLogin;
$logger.debug({ provider, projectId: payload.projectId, orgSlug }, '[integrations] OAuth callback completed');
return { projectId: payload.projectId, orgSlug };
}
async fetchRepos(integrationId: number): Promise<RepoItemForClient[]> {
@@ -126,6 +148,16 @@ export class IntegrationsManager {
description: r.description,
url: r.web_url,
}));
} else if (integration.provider === 'gitea') {
const repos = await fetchGiteaRepos(accessToken);
return repos.map((r) => ({
id: r.id,
fullName: r.full_name,
name: r.name,
isPrivate: r.private,
description: r.description,
url: r.html_url,
}));
}
return [];
@@ -174,6 +206,14 @@ export class IntegrationsManager {
} else if (integration.provider === 'gitlab' && integration.repoExternalId) {
const result = await createGitLabWebhook(accessToken, Number(integration.repoExternalId), webhookUrl, webhookSecret);
webhookId = String(result.id);
} else if (integration.provider === 'gitea') {
const result = await createGiteaWebhook({
accessToken,
repoFullName: integration.repoFullName,
webhookUrl,
secret: webhookSecret,
});
webhookId = String(result.id);
} else {
return;
}
@@ -197,12 +237,11 @@ export class IntegrationsManager {
const existingMappings = await this.repository.fetchMappingsByIntegrationId(integrationId);
const mappingsByIssueNumber = new Map(existingMappings.map((m) => [m.issueNumber, m]));
const issueUrlPrefix = this.getIssueUrlPrefix(integration);
// Backfill sourceUrl for existing tasks that don't have it yet
if (existingMappings.length > 0) {
const baseUrl = integration.provider === 'github' ? GITHUB_BASE_URL : GITLAB_BASE_URL;
const issuePath = integration.provider === 'gitlab' ? '/-/issues/' : '/issues/';
const prefix = `${baseUrl}/${integration.repoFullName}${issuePath}`;
await this.repository.backfillSourceUrls(integrationId, prefix).catch(logError);
await this.repository.backfillSourceUrls(integrationId, issueUrlPrefix).catch(logError);
}
type NewIssueItem = { goalId: number; description: string; integrationId: number; issueNumber: number; issueState: string; note: string | null; complete: boolean; kanbanOrder: number; sourceUrl: string | null };
@@ -263,6 +302,34 @@ export class IntegrationsManager {
sourceUrl: `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/${issue.iid}`,
});
}
} else if (integration.provider === 'gitea') {
const issues = await fetchGiteaIssues({ accessToken, repoFullName: integration.repoFullName, since });
for (const issue of issues) {
const existing = mappingsByIssueNumber.get(issue.number);
if (existing) {
const isClosed = issue.state === 'closed';
const targetState = isClosed ? 'closed' : 'open';
await this.repository.updateTaskComplete(existing.taskId, isClosed).catch(logError);
if (existing.issueState !== targetState) {
await this.repository.updateMappingState(existing.id, targetState).catch(logError);
}
await this.repository.updateTaskTitleAndNote(existing.taskId, issue.title, issue.body ?? null).catch(logError);
await this.repository.updateTaskSourceUrl(existing.taskId, `${issueUrlPrefix}${issue.number}`).catch(logError);
continue;
}
const isClosed = issue.state === 'closed';
newItems.push({
goalId: integration.projectId,
description: issue.title,
integrationId,
issueNumber: issue.number,
issueState: isClosed ? 'closed' : 'open',
note: issue.body ?? null,
complete: isClosed,
kanbanOrder: 0,
sourceUrl: `${issueUrlPrefix}${issue.number}`,
});
}
}
// Issues come newest-first from API.
@@ -319,6 +386,13 @@ export class IntegrationsManager {
mapping.issueNumber,
complete ? 'close' : 'reopen',
);
} else if (integration.provider === 'gitea') {
await updateGiteaIssueState({
accessToken,
repoFullName: integration.repoFullName,
issueNumber: mapping.issueNumber,
state: targetState,
});
}
await this.repository.updateMappingState(mapping.id, targetState);
@@ -326,41 +400,56 @@ export class IntegrationsManager {
return true;
}
private getIssueUrlPrefix(integration: IntegrationsSchemaTypeForSelect): string {
if (integration.provider === 'gitlab') {
return `${GITLAB_BASE_URL}/${integration.repoFullName}/-/issues/`;
}
const baseUrl = integration.provider === 'gitea' ? GITEA_BASE_URL : GITHUB_BASE_URL;
return `${baseUrl}/${integration.repoFullName}/issues/`;
}
private async getAccessToken(integration: IntegrationsSchemaTypeForSelect): Promise<string | null> {
if (!integration.accessTokenEncrypted) return null;
const accessToken = decrypt(integration.accessTokenEncrypted);
if (integration.provider !== 'gitlab' || !integration.refreshTokenEncrypted) {
const hasExpiringToken = integration.provider === 'gitlab' || integration.provider === 'gitea';
if (!hasExpiringToken || !integration.refreshTokenEncrypted) {
return accessToken;
}
// Try the current token, refresh on 401
try {
const axios = (await import('axios')).default;
const gitlabApiUrl = process.env.GITLAB_API_URL || 'https://gitlab.com/api/v4';
await axios.get(`${gitlabApiUrl}/user`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (integration.provider === 'gitea') {
await verifyGiteaToken(accessToken);
} else {
const axios = (await import('axios')).default;
const gitlabApiUrl = process.env.GITLAB_API_URL || 'https://gitlab.com/api/v4';
await axios.get(`${gitlabApiUrl}/user`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
}
return accessToken;
} catch (err: any) {
if (err?.response?.status !== 401) return accessToken;
$logger.debug({ integrationId: integration.id }, '[integrations] GitLab token expired (401), refreshing');
$logger.debug({ integrationId: integration.id, provider: integration.provider }, '[integrations] token expired (401), refreshing');
}
// Token expired, refresh it
try {
const refreshToken = decrypt(integration.refreshTokenEncrypted);
const tokens = await refreshGitLabToken(refreshToken);
const tokens = integration.provider === 'gitea'
? await refreshGiteaToken(refreshToken)
: await refreshGitLabToken(refreshToken);
await this.repository.updateTokens(
integration.id,
encrypt(tokens.accessToken),
encrypt(tokens.refreshToken),
);
$logger.debug({ integrationId: integration.id }, '[integrations] GitLab token refreshed successfully');
$logger.debug({ integrationId: integration.id, provider: integration.provider }, '[integrations] token refreshed successfully');
return tokens.accessToken;
} catch (err) {
$logger.error({ integrationId: integration.id, err }, '[integrations] GitLab token refresh failed');
$logger.error({ integrationId: integration.id, provider: integration.provider, err }, '[integrations] token refresh failed');
return null;
}
}
@@ -1,8 +1,8 @@
import { and, eq, ne, isNull, sql } from 'drizzle-orm';
import { IntegrationsSchema, IntegrationTaskMapSchema, TasksSchema, UsersSchema, type IntegrationsSchemaTypeForSelect, type IntegrationTaskMapSchemaTypeForSelect } from 'taskview-db-schemas';
import { GoalsSchema, IntegrationsSchema, IntegrationTaskMapSchema, OrganizationsSchema, TasksSchema, UsersSchema, type IntegrationsSchemaTypeForSelect, type IntegrationTaskMapSchemaTypeForSelect } from 'taskview-db-schemas';
import { Database } from '../../modules/db';
import { callWithCatch } from '../../utils/helpers';
import type { IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgSelectRepo, IntegrationsArgToggle } from './types';
import type { IntegrationProvider, IntegrationsArgAdd, IntegrationsArgDelete, IntegrationsArgSelectRepo, IntegrationsArgToggle } from './types';
import { TasksRepository } from '../tasks/TasksRepository';
export class IntegrationsRepository {
@@ -62,7 +62,7 @@ export class IntegrationsRepository {
}
async createWithToken(
provider: 'github' | 'gitlab',
provider: IntegrationProvider,
projectId: number,
accessTokenEncrypted: string,
refreshTokenEncrypted?: string | null,
@@ -322,6 +322,17 @@ export class IntegrationsRepository {
return !!result;
}
async fetchProjectOrgSlug(projectId: number): Promise<string | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select({ slug: OrganizationsSchema.slug })
.from(GoalsSchema)
.innerJoin(OrganizationsSchema, eq(GoalsSchema.organizationId, OrganizationsSchema.id))
.where(eq(GoalsSchema.id, projectId))
);
if (!result || result.length === 0) return null;
return result[0].slug;
}
async fetchUserLogin(userId: number): Promise<string | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle.select({ login: UsersSchema.login }).from(UsersSchema)
@@ -31,5 +31,6 @@ export default class IntegrationsRoutes implements Routable {
this.router.get('/oauth/:provider/callback', this.controller.handleOAuthCallback);
this.router.post('/webhook/github', this.controller.handleGitHubWebhook);
this.router.post('/webhook/gitlab', this.controller.handleGitLabWebhook);
this.router.post('/webhook/gitea', this.controller.handleGiteaWebhook);
}
}
@@ -0,0 +1,15 @@
import { appendFileSync } from 'fs';
import type { IntegrationsDebugLogEntry } from './types';
// TEMPORARY debug instrumentation for the integrations OAuth flow.
// Remove this file and all integrationsDebugLog() calls once the Gitea
// connect issue is resolved.
const LOG_PATH = '/private/tmp/claude-501/-Users-nikolaygiman-Programming-HandScreamInc-taskview/1d568266-6fbf-457c-83c2-5c5ca619edf1/scratchpad/integrations-debug.log';
export function integrationsDebugLog(entry: IntegrationsDebugLogEntry): void {
try {
appendFileSync(LOG_PATH, `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`);
} catch {
// debug logging must never break the flow
}
}
@@ -2,22 +2,26 @@ import type { Request } from 'express';
import { IntegrationsRepository } from '../IntegrationsRepository';
/**
* Resolves projectId from request.
* Checks body (projectId, integrationId, id) and query (projectId, integrationId).
* Resolves the project to authorize the request against.
*
* When the request names an integration, the project is derived from that
* integration and a projectId supplied by the caller is ignored: every handler
* that takes an integration id acts on the integration, so authorizing a
* caller-supplied project would guard a different object than the one touched.
*
* Only create and fetch carry no integration id — there the project itself is
* the object being acted on, so it is read from the request.
*/
export async function resolveProjectId(req: Request): Promise<number | null> {
// Direct projectId in body or query
const directId = req.body?.projectId ?? req.query?.projectId;
if (directId) {
const id = Number(directId);
return isNaN(id) ? null : id;
const integrationId = Number(req.body?.integrationId || req.query?.integrationId || req.body?.id);
if (integrationId && !isNaN(integrationId)) {
const repo = new IntegrationsRepository();
const integration = await repo.fetchById(integrationId);
return integration?.projectId ?? null;
}
// integrationId from body or query, or id from body
const integrationId = Number(req.body?.integrationId || req.query?.integrationId || req.body?.id);
if (!integrationId || isNaN(integrationId)) return null;
const projectId = Number(req.body?.projectId || req.query?.projectId);
if (!projectId || isNaN(projectId)) return null;
const repo = new IntegrationsRepository();
const integration = await repo.fetchById(integrationId);
return integration?.projectId ?? null;
return projectId;
}
@@ -0,0 +1,182 @@
import axios from 'axios';
import { createHmac, timingSafeEqual } from 'crypto';
import type { GiteaCreateWebhookArgs, GiteaFetchIssuesArgs, GiteaUpdateIssueStateArgs, GiteaVerifyWebhookSignatureArgs } from '../types';
export const GITEA_BASE_URL = (process.env.GITEA_BASE_URL || 'https://gitea.com').replace(/\/+$/, '');
const GITEA_API_URL = process.env.GITEA_API_URL || `${GITEA_BASE_URL}/api/v1`;
export type GiteaRepo = {
id: number;
full_name: string;
name: string;
private: boolean;
description: string | null;
html_url: string;
};
export type GiteaIssue = {
number: number;
title: string;
body: string | null;
state: 'open' | 'closed';
html_url: string;
};
export function getGiteaOAuthUrl(state: string): string {
const clientId = process.env.GITEA_INTEGRATION_CLIENT_ID;
const redirectUri = process.env.GITEA_INTEGRATION_CALLBACK_URL;
if (!clientId || !redirectUri) {
throw new Error('Gitea integration OAuth is not configured');
}
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
state,
});
return `${GITEA_BASE_URL}/login/oauth/authorize?${params.toString()}`;
}
export async function exchangeGiteaCode(code: string): Promise<{ accessToken: string; refreshToken: string | null }> {
const res = await axios.post<{ access_token: string; refresh_token?: string; token_type: string }>(
`${GITEA_BASE_URL}/login/oauth/access_token`,
{
client_id: process.env.GITEA_INTEGRATION_CLIENT_ID,
client_secret: process.env.GITEA_INTEGRATION_CLIENT_SECRET,
code,
grant_type: 'authorization_code',
redirect_uri: process.env.GITEA_INTEGRATION_CALLBACK_URL,
},
{
headers: { Accept: 'application/json' },
},
);
if (!res.data.access_token) {
throw new Error('Failed to exchange Gitea code for token');
}
return {
accessToken: res.data.access_token,
refreshToken: res.data.refresh_token ?? null,
};
}
export async function refreshGiteaToken(refreshToken: string): Promise<{ accessToken: string; refreshToken: string }> {
const res = await axios.post<{ access_token: string; refresh_token: string; token_type: string }>(
`${GITEA_BASE_URL}/login/oauth/access_token`,
{
client_id: process.env.GITEA_INTEGRATION_CLIENT_ID,
client_secret: process.env.GITEA_INTEGRATION_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: 'refresh_token',
},
{
headers: { Accept: 'application/json' },
},
);
if (!res.data.access_token) {
throw new Error('Failed to refresh Gitea token');
}
return {
accessToken: res.data.access_token,
refreshToken: res.data.refresh_token,
};
}
export async function verifyGiteaToken(accessToken: string): Promise<void> {
await axios.get(`${GITEA_API_URL}/user`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
}
export async function fetchGiteaRepos(accessToken: string): Promise<GiteaRepo[]> {
const repos: GiteaRepo[] = [];
let page = 1;
const perPage = 50;
while (true) {
const res = await axios.get<GiteaRepo[]>(`${GITEA_API_URL}/user/repos`, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
params: {
limit: perPage,
page,
},
});
repos.push(...res.data);
if (res.data.length < perPage) break;
page++;
}
return repos;
}
export async function fetchGiteaIssues(args: GiteaFetchIssuesArgs): Promise<GiteaIssue[]> {
const issues: GiteaIssue[] = [];
let page = 1;
const perPage = 50;
while (true) {
const res = await axios.get<GiteaIssue[]>(`${GITEA_API_URL}/repos/${args.repoFullName}/issues`, {
headers: {
Authorization: `Bearer ${args.accessToken}`,
},
params: {
state: 'all',
// Gitea returns pull requests from the issues endpoint too — this excludes them
type: 'issues',
limit: perPage,
page,
...(args.since ? { since: args.since } : {}),
},
});
issues.push(...res.data);
if (res.data.length < perPage) break;
page++;
}
return issues;
}
export async function createGiteaWebhook(args: GiteaCreateWebhookArgs): Promise<{ id: number }> {
const res = await axios.post<{ id: number }>(
`${GITEA_API_URL}/repos/${args.repoFullName}/hooks`,
{
type: 'gitea',
active: true,
events: ['issues'],
config: {
url: args.webhookUrl,
content_type: 'json',
secret: args.secret,
},
},
{
headers: {
Authorization: `Bearer ${args.accessToken}`,
},
},
);
return { id: res.data.id };
}
export function verifyGiteaWebhookSignature(args: GiteaVerifyWebhookSignatureArgs): boolean {
const expected = createHmac('sha256', args.secret).update(args.rawBody).digest('hex');
try {
return timingSafeEqual(Buffer.from(args.signature), Buffer.from(expected));
} catch {
return false;
}
}
export async function updateGiteaIssueState(args: GiteaUpdateIssueStateArgs): Promise<void> {
await axios.patch(
`${GITEA_API_URL}/repos/${args.repoFullName}/issues/${args.issueNumber}`,
{ state: args.state },
{
headers: {
Authorization: `Bearer ${args.accessToken}`,
},
},
);
}
+35 -2
View File
@@ -1,7 +1,7 @@
import { type } from 'arktype';
export const IntegrationsArkTypeAdd = type({
provider: "'github' | 'gitlab'",
provider: "'github' | 'gitlab' | 'gitea'",
repoFullName: 'string',
projectId: 'number',
});
@@ -30,10 +30,43 @@ export const IntegrationsArkTypeSelectRepo = type({
});
export type IntegrationsArgSelectRepo = typeof IntegrationsArkTypeSelectRepo.infer;
export type IntegrationProvider = 'github' | 'gitlab' | 'gitea';
export type OAuthStatePayload = {
userId: number;
projectId: number;
provider: 'github' | 'gitlab';
provider: IntegrationProvider;
};
export type GiteaFetchIssuesArgs = {
accessToken: string;
repoFullName: string;
since?: string;
};
export type GiteaCreateWebhookArgs = {
accessToken: string;
repoFullName: string;
webhookUrl: string;
secret: string;
};
export type GiteaVerifyWebhookSignatureArgs = {
rawBody: Buffer;
signature: string;
secret: string;
};
export type GiteaUpdateIssueStateArgs = {
accessToken: string;
repoFullName: string;
issueNumber: number;
state: 'open' | 'closed';
};
export type IntegrationsDebugLogEntry = {
step: string;
data?: unknown;
};
export type RepoItemForClient = {
+84 -11
View File
@@ -1,10 +1,11 @@
import { Router } from 'express';
import type { Routable } from '../../types/routable.type';
import { GoalPermissions } from '../../types/auth.types';
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
import { KanbanController } from './KanbanController';
import { CanManageKanban } from './middlewares/CanManageKanban';
import { CanViewKanban } from './middlewares/CanViewKanban';
import { CanFetchTasks } from './middlewares/CanFetchTasks';
import { goalIdFromBody, goalIdFromParam, goalIdFromStatusBody } from './middlewares/goal-id-resolvers';
import { requireKanbanPermission } from './middlewares/require-kanban-permission';
export default class KanbanRoutes implements Routable {
private readonly router: ReturnType<typeof Router>;
private readonly kanbanController: KanbanController;
@@ -20,17 +21,89 @@ export default class KanbanRoutes implements Routable {
}
initRoutes() {
this.router.post('/fetch-statuses', [IsLoggedIn, CanViewKanban], this.kanbanController.fetchAllColumns);
this.router.post('/add-status', [IsLoggedIn, CanManageKanban], this.kanbanController.addStatus);
this.router.post('/delete-status', [IsLoggedIn, CanManageKanban], this.kanbanController.deleteStatus);
this.router.post('/update-status', [IsLoggedIn, CanManageKanban], this.kanbanController.updateStatus);
this.router.post(
'/fetch-statuses',
[
IsLoggedIn,
requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
resolveGoalId: goalIdFromBody,
}),
],
this.kanbanController.fetchAllColumns
);
// this.router.get('columns/:goalId', [IsLoggedIn], this.kanbanController.fetchAllColumns);
this.router.get('/tasks/:goalId/:columnId/:cursor', [IsLoggedIn, CanViewKanban, CanFetchTasks], this.kanbanController.fetchTasksForColumn);
this.router.post(
'/add-status',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromBody
})
],
this.kanbanController.addStatus
);
this.router.post(
'/delete-status',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromStatusBody
})
],
this.kanbanController.deleteStatus
);
this.router.post(
'/update-status',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromStatusBody
})
],
this.kanbanController.updateStatus
);
this.router.get(
'/tasks/:goalId/:columnId/:cursor',
[
IsLoggedIn,
requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
resolveGoalId: goalIdFromParam,
}),
requireKanbanPermission({
anyOf: [GoalPermissions.COMPONENT_CAN_WATCH_CONTENT],
resolveGoalId: goalIdFromParam,
}),
],
this.kanbanController.fetchTasksForColumn
);
//we do not use this route in the client (no logic for this route on the client side)!!!
this.router.get('/tasks-order/:goalId/:columnId/:cursor', [IsLoggedIn, CanManageKanban], this.kanbanController.getTasksOrderForColumnAndCursor);
this.router.get(
'/tasks-order/:goalId/:columnId/:cursor',
[
IsLoggedIn, requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_VIEW],
resolveGoalId: goalIdFromParam
})
],
this.kanbanController.getTasksOrderForColumnAndCursor
);
this.router.patch('/update-tasks-order-and-column', [IsLoggedIn, CanManageKanban], this.kanbanController.updateTasksOrderAndColumn);
this.router.patch(
'/update-tasks-order-and-column',
[
IsLoggedIn,
requireKanbanPermission({
anyOf: [GoalPermissions.KANBAN_CAN_MANAGE],
resolveGoalId: goalIdFromBody
})
],
this.kanbanController.updateTasksOrderAndColumn
);
}
}
@@ -1,33 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
import { KanbanArkTypeCanManageKanban } from '../types';
import { ArkErrors } from 'arktype';
export const CanFetchTasks = async (req: Request, res: Response, next: NextFunction) => {
const props = req.body.goalId ? req.body : req.params;
const data = KanbanArkTypeCanManageKanban(props);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(data.goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.COMPONENT_CAN_WATCH_CONTENT)) {
return next();
}
return res.status(403).end();
};
@@ -1,54 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { ALL_TASKS_LIST_ID, DEFAULT_ID } from '../../../types/tasks.types';
import { logError } from '../../../utils/api';
import { KanbanArkTypeCanManageKanban } from '../types';
import { ArkErrors } from 'arktype';
export const CanManageKanban = async (req: Request, res: Response, next: NextFunction) => {
let props = req.body.goalId ? req.body : req.params;
switch (req.url) {
case '/update-status':
const result = await req.appUser.kanbanManager.repository.fetchStatus(req.body.id);
props = {
goalId: result?.goal_id,
};
break;
case '/delete-status':
const result2 = await req.appUser.kanbanManager.repository.fetchStatus(req.body.id);
props = {
goalId: result2?.goal_id,
};
break;
default:
break;
}
const data = KanbanArkTypeCanManageKanban(props);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(data.goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (
permissions.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS) ||
permissions.hasPermissions(GoalPermissions.TASKS_CAN_ADD_SUBTASKS)
) {
return next();
}
return res.status(403).end();
};
@@ -1,33 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
import { KanbanArkTypeCanManageKanban } from '../types';
import { ArkErrors } from 'arktype';
export const CanViewKanban = async (req: Request, res: Response, next: NextFunction) => {
const props = req.body.goalId ? req.body : req.params;
const data = KanbanArkTypeCanManageKanban(props);
if (data instanceof ArkErrors) {
return res.status(400).send(data.summary);
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(data.goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.KANBAN_CAN_VIEW)) {
return next();
}
return res.status(403).end();
};
@@ -0,0 +1,19 @@
import type { Request } from 'express';
export function goalIdFromParam(req: Request): number | null {
const goalId = Number(req.params.goalId);
return goalId && !isNaN(goalId) ? goalId : null;
}
export function goalIdFromBody(req: Request): number | null {
const goalId = Number(req.body?.goalId);
return goalId && !isNaN(goalId) ? goalId : null;
}
export async function goalIdFromStatusBody(req: Request): Promise<number | null> {
const statusId = Number(req.body?.id);
if (!statusId || isNaN(statusId)) return null;
const status = await req.appUser.kanbanManager.repository.fetchStatus(statusId);
return status?.goal_id ?? null;
}
@@ -0,0 +1,24 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { logError } from '../../../utils/api';
import type { RequireKanbanPermissionArgs } from '../types';
export function requireKanbanPermission({ anyOf, resolveGoalId }: RequireKanbanPermissionArgs) {
return async (req: Request, res: Response, next: NextFunction) => {
const goalId = await resolveGoalId(req);
if (!goalId) return res.status(400).end();
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(goalId, GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
if (!permissions) {
$logger.error('Can not resolve kanban permissions');
return res.status(500).end();
}
if (anyOf.some((permission) => permissions.hasPermissions(permission))) return next();
return res.status(403).end();
};
}
+8 -4
View File
@@ -1,6 +1,8 @@
import { type } from 'arktype';
import type { Request } from 'express';
import { z } from 'zod';
import { StringToNumber } from '../../types/app.types';
import type { GoalPermissionType } from '../../types/auth.types';
// ============ Arktype schemas ============
@@ -102,11 +104,13 @@ export const KanbanArkTypeUpdateTasksOrder = type({
export type KanbanArgUpdateTasksOrder = typeof KanbanArkTypeUpdateTasksOrder.infer;
export const KanbanArkTypeCanManageKanban = type({
goalId: NumberFromString,
});
export type KanbanGoalIdResolver = (req: Request) => Promise<number | null> | number | null;
export type KanbanArgCanManageKanban = typeof KanbanArkTypeCanManageKanban.infer;
export type RequireKanbanPermissionArgs = {
/** the caller must hold at least ONE of these */
anyOf: GoalPermissionType[];
resolveGoalId: KanbanGoalIdResolver;
};
// ============ Deprecated Zod schemas ============
+1 -10
View File
@@ -21,16 +21,7 @@ export function isSafeUrl(url: string): boolean {
}
}
// Escapes HTML text and attribute contexts (the quotes matter inside href="...")
// so a user-controlled value can't break out of a Telegram HTML message.
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export { escapeHtml } from '../../utils/helpers';
// Slack mrkdwn requires escaping these three in text (incl. link labels).
export function escapeSlackText(text: string): string {
@@ -84,12 +84,20 @@ export class RecurrenceGenerator {
// Completed late → next from today, not a pile of overdue copies (Todoist behavior).
const today = RecurrenceParser.todayInTimezone(rule.timezone);
const afterDate = rule.lastInstanceDate > today ? rule.lastInstanceDate : today;
const nextDate = RecurrenceParser.nextOccurrenceDate({
rrule: rule.rrule,
dtstart: rule.dtstart,
afterDate,
skipDates,
});
// Fixed series follow the calendar schedule; after-completion series
// take one interval step from the completion day. Stepping from
// max(lastInstanceDate, today) keeps instance dates strictly
// increasing, so the (rule_id, instance_date) unique index can
// never collide with an earlier instance of the series.
const nextDate =
rule.scheduleMode === 'after-completion'
? RecurrenceParser.nextDateAfterCompletion({ rrule: rule.rrule, afterDate })
: RecurrenceParser.nextOccurrenceDate({
rrule: rule.rrule,
dtstart: rule.dtstart,
afterDate,
skipDates,
});
if (!nextDate) {
await tx
.update(RecurrenceRulesSchema)
@@ -61,10 +61,12 @@ export class RecurrenceManager {
return fail('invalid_rule', 'timezone must be a valid IANA name');
}
const scheduleMode = args.scheduleMode ?? 'fixed';
let dtstart: Date;
let hasTime: boolean;
try {
RecurrenceParser.validateRuleString(args.rrule);
if (scheduleMode === 'after-completion') RecurrenceParser.validateForAfterCompletion(args.rrule);
({ date: dtstart, hasTime } = RecurrenceParser.parseDtstart(args.dtstart));
} catch (err) {
return fail('invalid_rule', (err as Error).message);
@@ -99,6 +101,7 @@ export class RecurrenceManager {
dtstart,
hasTime,
timezone: args.timezone,
scheduleMode,
lastInstanceDate: originInstanceDate,
notifyOnOccurrence: args.notifyOnOccurrence ?? false,
creatorId: this.initiatorId,
@@ -196,13 +199,26 @@ export class RecurrenceManager {
}
patch.timezone = args.timezone;
}
if (patch.rrule !== undefined || patch.dtstart !== undefined) {
const nextDate = RecurrenceParser.nextOccurrenceDate({
rrule: patch.rrule ?? rule.rrule,
dtstart: patch.dtstart ?? rule.dtstart,
afterDate: RecurrenceParser.todayInTimezone(patch.timezone ?? rule.timezone),
skipDates: new Set<string>(),
});
if (args.scheduleMode !== undefined) patch.scheduleMode = args.scheduleMode;
const effectiveMode = patch.scheduleMode ?? rule.scheduleMode;
if (effectiveMode === 'after-completion') {
try {
RecurrenceParser.validateForAfterCompletion(patch.rrule ?? rule.rrule);
} catch (err) {
return fail('invalid_rule', (err as Error).message);
}
}
if (patch.rrule !== undefined || patch.dtstart !== undefined || patch.scheduleMode !== undefined) {
const afterDate = RecurrenceParser.todayInTimezone(patch.timezone ?? rule.timezone);
const nextDate =
effectiveMode === 'after-completion'
? RecurrenceParser.nextDateAfterCompletion({ rrule: patch.rrule ?? rule.rrule, afterDate })
: RecurrenceParser.nextOccurrenceDate({
rrule: patch.rrule ?? rule.rrule,
dtstart: patch.dtstart ?? rule.dtstart,
afterDate,
skipDates: new Set<string>(),
});
if (!nextDate) return fail('invalid_rule', 'rule produces no occurrences');
}
if (args.notifyOnOccurrence !== undefined) patch.notifyOnOccurrence = args.notifyOnOccurrence;
@@ -1,10 +1,17 @@
import { DateTime } from 'luxon';
import { RRule } from 'rrule';
import type { InstanceWindow, InstanceWindowArgs, NextOccurrenceArgs, ParseRuleArgs } from './types';
import type { InstanceWindow, InstanceWindowArgs, NextDateAfterCompletionArgs, NextOccurrenceArgs, ParseRuleArgs } from './types';
const ALLOWED_FREQUENCIES = new Set<number>([RRule.YEARLY, RRule.MONTHLY, RRule.WEEKLY, RRule.DAILY]);
const MAX_COUNT = 10000;
const FREQ_TO_STEP_UNIT: Record<number, 'years' | 'months' | 'weeks' | 'days'> = {
[RRule.YEARLY]: 'years',
[RRule.MONTHLY]: 'months',
[RRule.WEEKLY]: 'weeks',
[RRule.DAILY]: 'days',
};
/**
* All recurrence math happens in a single floating wall-clock frame:
* `dtstart` is a Date whose UTC components equal the wall-clock components of
@@ -41,6 +48,40 @@ export class RecurrenceParser {
return RRule.parseString(rruleString).count ?? null;
}
/**
* After-completion series step from the completion day, so calendar anchors
* (BYDAY, BYMONTHDAY) have no defined meaning for them — reject instead of
* silently ignoring what the client asked for.
*/
static validateForAfterCompletion(rruleString: string): void {
const options = RRule.parseString(rruleString);
if (options.byweekday !== undefined && options.byweekday !== null) {
throw new Error('BYDAY is not supported for after-completion series');
}
if (options.bymonthday !== undefined && options.bymonthday !== null) {
throw new Error('BYMONTHDAY is not supported for after-completion series');
}
}
/**
* Next date of an after-completion series: one FREQ/INTERVAL step after
* `afterDate` (the completion day), no calendar anchor. Month/year steps
* clamp to the last valid day (Jan 31 + 1 month → Feb 28). COUNT is
* enforced by the caller via instances_created (same as fixed series);
* returns null when the step lands past UNTIL — the series is over.
*/
static nextDateAfterCompletion(args: NextDateAfterCompletionArgs): string | null {
const options = RRule.parseString(args.rrule);
const unit = options.freq !== undefined ? FREQ_TO_STEP_UNIT[options.freq] : undefined;
if (!unit) return null;
const nextDate = DateTime.fromISO(args.afterDate, { zone: 'utc' })
.plus({ [unit]: options.interval ?? 1 })
.toISODate();
if (!nextDate) return null;
if (options.until && nextDate > RecurrenceParser.toIsoDate(options.until)) return null;
return nextDate;
}
/**
* First occurrence date strictly after `afterDate`, skipping explicit skip
* dates. COUNT is intentionally stripped: the cap is "N materialized
+9
View File
@@ -2,6 +2,7 @@ import { type } from 'arktype';
import type {
RecurrenceRulesSchemaTypeForInsert,
RecurrenceRulesSchemaTypeForSelect,
RecurrenceScheduleMode,
TasksSchemaTypeForSelect,
} from 'taskview-db-schemas';
@@ -12,6 +13,7 @@ export const RecurrenceArkTypeCreate = type({
rrule: 'string > 0',
dtstart: 'string', // 'YYYY-MM-DDTHH:mm:ss' floating wall-clock, no TZ suffix
timezone: 'string > 0', // IANA name, e.g. 'Europe/Moscow'
'scheduleMode?': '"fixed" | "after-completion"',
'notifyOnOccurrence?': 'boolean',
});
@@ -20,6 +22,7 @@ export const RecurrenceArkTypeUpdate = type({
'rrule?': 'string > 0',
'dtstart?': 'string',
'timezone?': 'string > 0',
'scheduleMode?': '"fixed" | "after-completion"',
'notifyOnOccurrence?': 'boolean',
'templateOverrides?': type({
'description?': 'string',
@@ -63,6 +66,11 @@ export type NextOccurrenceArgs = {
afterDate: string;
skipDates: Set<string>;
};
export type NextDateAfterCompletionArgs = {
rrule: string;
/** 'YYYY-MM-DD' — the completion day; the next date is one FREQ/INTERVAL step after it. */
afterDate: string;
};
export type InstanceWindowArgs = {
/** 'YYYY-MM-DD' wall-clock occurrence date in the rule's timezone. */
occurrenceDate: string;
@@ -93,6 +101,7 @@ export type RecurrenceRulePatchArgs = {
dtstart: Date;
hasTime: boolean;
timezone: string;
scheduleMode: RecurrenceScheduleMode;
state: 'active' | 'paused' | 'ended';
lastInstanceDate: string;
instancesCreated: number;
+207 -42
View File
@@ -3,16 +3,26 @@ import { type } from 'arktype'
import { hashSync } from 'bcryptjs'
import type { Request, Response } from 'express'
import { $logger } from '../../modules/logget'
import { PublicApiUrl } from '../../modules/public-url'
import { logError } from '../../utils/api'
import { generateString, isEmail } from '../../utils/helpers'
import { generateLetters, generateString } from '../../utils/helpers'
import AuthModel from '../auth/AuthModel'
import { GoalsRepository } from '../goals/GoalsRepository'
import { OrganizationRepository } from '../organizations/OrganizationRepository'
import { createSsoProvider } from './providers/provider-factory'
import { SsoRepository } from './SsoRepository'
import { parseSamlMetadata } from './saml-metadata-parser'
import { generateLoginCode, stripSecrets, validateMetadataUrl } from './sso.utils'
import { SsoConfigArkTypeCreate, SsoConfigArkTypeUpdate } from './types'
import { generateLoginCode, isSsoDomainVerified, stripSecrets, validateMetadataUrl } from './sso.utils'
import {
SsoConfigArkTypeCreate,
SsoConfigArkTypeUpdate,
SsoDomainNotVerifiedError,
type ApplySsoIdpEmailArgs,
type ResolveSsoUserArgs,
type ResolveSsoUserResult,
type SsoCallbackError,
} from './types'
import type { UserDbRecord } from '../../types/auth.types'
export class SsoController {
private readonly ssoRepo = new SsoRepository()
@@ -20,6 +30,103 @@ export class SsoController {
private readonly orgRepo = new OrganizationRepository()
private readonly goalsRepo = new GoalsRepository()
private async resolveLogin(preferredUsername?: string): Promise<string> {
const base = preferredUsername?.trim().slice(0, 50)
if (!base) return generateString(7)
if (!(await this.authModel.getUserByLogin(base))) return base
for (let attempt = 0; attempt < 10; attempt++) {
const suffix = `.${generateLetters(3)}`
const candidate = `${base.slice(0, 50 - suffix.length)}${suffix}`
if (!(await this.authModel.getUserByLogin(candidate))) return candidate
}
return generateString(7)
}
private redirectSsoError(res: Response, error: SsoCallbackError) {
return res.redirect(`${process.env.APP_URL}/login?sso_error=${error}`)
}
private async createSsoUser(args: ResolveSsoUserArgs): Promise<UserDbRecord | false> {
const password = generateString(16)
const login = await this.resolveLogin(args.preferredUsername)
const id = await this.authModel.registerUserInDb({
login,
email: args.email,
password: hashSync(password, 10),
block: 0,
confirmEmailCode: '',
})
if (!id) {
$logger.error('Failed to create user during SSO login')
return false
}
const personalOrgSlug = `org-${crypto.randomUUID().slice(0, 8)}`
const personalOrg = await this.orgRepo.create({ name: `${login}'s workspace`, slug: personalOrgSlug }, id, true)
if (personalOrg) {
await this.orgRepo.addMember(personalOrg.id, args.email, 'owner')
await this.goalsRepo.createInboxGoal({ ownerId: id, organizationId: personalOrg.id })
}
return await this.authModel.fetchUserById(id)
}
private async applyIdpEmail(args: ApplySsoIdpEmailArgs): Promise<'ok' | 'email_in_use' | 'error'> {
if (args.user.email.toLowerCase() === args.email) return 'ok'
const taken = await this.authModel.getUserByLogin(args.email, true)
if (taken && taken.id !== args.user.id) return 'email_in_use'
const result = await this.authModel.updateUserEmail({
userId: args.user.id,
oldEmail: args.user.email,
email: args.email,
})
if (result === 'conflict') return 'email_in_use'
if (result !== 'ok') return 'error'
return 'ok'
}
private async resolveSsoUser(args: ResolveSsoUserArgs): Promise<ResolveSsoUserResult> {
const identity = await this.ssoRepo.findIdentity({
ssoConfigId: args.ssoConfigId,
externalId: args.externalId,
})
if (identity) {
const user = await this.authModel.fetchUserById(identity.userId)
if (!user) return { ok: false, error: 'authentication_failed' }
const emailResult = await this.applyIdpEmail({ user, email: args.email })
if (emailResult === 'email_in_use') return { ok: false, error: 'email_in_use' }
if (emailResult !== 'ok') return { ok: false, error: 'authentication_failed' }
const refreshed = await this.authModel.fetchUserById(user.id)
if (!refreshed) return { ok: false, error: 'authentication_failed' }
return { ok: true, user: refreshed }
}
const existing = await this.authModel.getUserByLogin(args.email, true)
if (existing) {
const linked = await this.ssoRepo.findIdentityByUser({
ssoConfigId: args.ssoConfigId,
userId: existing.id,
})
if (linked && linked.externalId !== args.externalId) {
return { ok: false, error: 'email_in_use' }
}
return { ok: true, user: existing }
}
const created = await this.createSsoUser(args)
if (!created) return { ok: false, error: 'authentication_failed' }
return { ok: true, user: created }
}
initiateLogin = async (req: Request, res: Response) => {
const configId = Number(req.params.configId)
if (!configId) return res.status(400).tvJson({ message: 'Invalid config ID' })
@@ -27,6 +134,10 @@ export class SsoController {
const config = await this.ssoRepo.findEnabledById(configId)
if (!config) return res.status(404).tvJson({ message: 'SSO provider not found' })
if (!isSsoDomainVerified(config)) {
return res.redirect(`${process.env.APP_URL}/login?sso_error=domain_unverified`)
}
try {
const provider = createSsoProvider(config)
const relayState = JSON.stringify({ platform: req.query.platform || '' })
@@ -44,50 +155,40 @@ export class SsoController {
const config = await this.ssoRepo.findEnabledById(configId)
if (!config) return res.status(404).tvJson({ message: 'SSO provider not found' })
if (!isSsoDomainVerified(config)) {
return res.redirect(`${process.env.APP_URL}/login?sso_error=domain_unverified`)
}
try {
const provider = createSsoProvider(config)
const ssoResult = await provider.handleCallback(req)
if (config.emailDomainRestriction) {
const domain = ssoResult.email.split('@')[1]
if (domain !== config.emailDomainRestriction) {
return res.status(403).tvJson({ message: 'Email domain not allowed for this SSO provider' })
}
if (!config.emailDomainRestriction) {
return this.redirectSsoError(res, 'authentication_failed')
}
let userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email))
if (!userData) {
const password = generateString(16)
const login = generateString(7)
const id = await this.authModel.registerUserInDb({
login,
email: ssoResult.email,
password: hashSync(password, 10),
block: 0,
confirmEmailCode: '',
})
if (!id) {
$logger.error('Failed to create user during SSO login')
return res.status(500).tvJson({ message: 'Failed to create user' })
}
const personalOrgSlug = `org-${crypto.randomUUID().slice(0, 8)}`
const personalOrg = await this.orgRepo.create({ name: `${login}'s workspace`, slug: personalOrgSlug }, id, true)
if (personalOrg) {
await this.orgRepo.addMember(personalOrg.id, ssoResult.email, 'owner')
await this.goalsRepo.createInboxGoal({ ownerId: id, organizationId: personalOrg.id })
}
userData = await this.authModel.getUserByLogin(ssoResult.email, isEmail(ssoResult.email))
const domain = ssoResult.email.split('@')[1]
if (domain !== config.emailDomainRestriction) {
return res.status(403).tvJson({ message: 'Email domain not allowed for this SSO provider' })
}
if (!userData) {
return res.status(500).tvJson({ message: 'Failed to resolve user after SSO login' })
const resolved = await this.resolveSsoUser({
ssoConfigId: config.id,
email: ssoResult.email,
externalId: ssoResult.externalId,
preferredUsername: ssoResult.preferredUsername,
})
if (!resolved.ok) {
return this.redirectSsoError(res, resolved.error)
}
await this.orgRepo.addMember(config.organizationId, ssoResult.email, config.defaultOrgRole)
const userData = resolved.user
if (userData.block && !userData.confirm_email_code) {
return this.redirectSsoError(res, 'account_blocked')
}
await this.orgRepo.addMember(config.organizationId, userData.email, config.defaultOrgRole)
await this.ssoRepo.upsertIdentity({
userId: userData.id,
@@ -131,7 +232,7 @@ export class SsoController {
if (!domain) return res.tvJson(null)
const config = await this.ssoRepo.findEnabledByDomain(domain)
if (!config) return res.tvJson(null)
if (!config || !isSsoDomainVerified(config)) return res.tvJson(null)
return res.tvJson({
id: config.id,
@@ -140,6 +241,16 @@ export class SsoController {
})
}
getPublicUrls = async (req: Request, res: Response) => {
const base = PublicApiUrl.base(req)
return res.tvJson({
apiBaseUrl: base,
callbackUrlTemplate: `${base}/module/sso/callback/{id}`,
scimEndpointUrl: `${base}/scim/v2`,
apiPublicUrlConfigured: PublicApiUrl.configured() !== null,
})
}
listConfigs = async (req: Request, res: Response) => {
const orgId = Number(req.query.organizationId)
if (!orgId) return res.status(400).tvJson({ message: 'organizationId is required' })
@@ -154,11 +265,18 @@ export class SsoController {
return res.status(400).send(out.summary)
}
const existing = await this.ssoRepo.findEnabledByDomain(out.emailDomainRestriction)
if (existing) {
const domain = out.emailDomainRestriction.toLowerCase()
const sameOrg = await this.ssoRepo.findByDomainAndOrg({ domain, organizationId: out.organizationId })
if (sameOrg) {
return res.status(409).tvJson({ message: 'SSO config for this domain already exists' })
}
const verified = await this.ssoRepo.findVerifiedByDomain(domain)
if (verified) {
return res.status(409).tvJson({ message: 'This domain is already verified by another organization' })
}
const config = await req.appUser.ssoManager.createConfig(out).catch(logError)
if (!config) {
return res.status(500).tvJson({ message: 'Failed to create SSO config' })
@@ -175,8 +293,33 @@ export class SsoController {
return res.status(400).send(out.summary)
}
const config = await req.appUser.ssoManager.updateConfig(configId, out).catch(logError)
return res.tvJson(config ? stripSecrets(config) : null)
if (out.emailDomainRestriction) {
const domain = out.emailDomainRestriction.toLowerCase()
const verified = await this.ssoRepo.findVerifiedByDomain(domain)
if (verified && verified.id !== configId) {
return res.status(409).tvJson({ message: 'This domain is already verified by another organization' })
}
const current = await this.ssoRepo.findById(configId)
if (current) {
const sameOrg = await this.ssoRepo.findByDomainAndOrg({ domain, organizationId: current.organizationId })
if (sameOrg && sameOrg.id !== configId) {
return res.status(409).tvJson({ message: 'SSO config for this domain already exists' })
}
}
}
try {
const config = await req.appUser.ssoManager.updateConfig(configId, out)
return res.tvJson(config ? stripSecrets(config) : null)
} catch (error) {
if (error instanceof SsoDomainNotVerifiedError) {
return res.status(403).tvJson({ message: 'Domain is not verified' })
}
logError(error)
return res.tvJson(null)
}
}
parseMetadata = async (req: Request, res: Response) => {
@@ -202,6 +345,28 @@ export class SsoController {
}
}
startDomainVerification = async (req: Request, res: Response) => {
const configId = Number(req.params.configId)
if (!configId) return res.status(400).end()
const result = await req.appUser.ssoManager.startDomainVerification(configId).catch(logError)
if (!result) {
return res.status(404).tvJson({ message: 'SSO config not found' })
}
return res.tvJson(result)
}
checkDomainVerification = async (req: Request, res: Response) => {
const configId = Number(req.params.configId)
if (!configId) return res.status(400).end()
const result = await req.appUser.ssoManager.checkDomainVerification(configId).catch(logError)
if (!result) {
return res.status(404).tvJson({ message: 'SSO config not found' })
}
return res.tvJson(result)
}
generateScimToken = async (req: Request, res: Response) => {
const configId = Number(req.params.configId)
if (!configId) return res.status(400).end()
+110 -5
View File
@@ -1,8 +1,22 @@
import type { AppUser } from '../../core/AppUser'
import { encrypt, encryptField } from '../../utils/crypto'
import { SsoRepository } from './SsoRepository'
import { SSO_SECRET_FIELDS } from './sso.utils'
import type { SsoConfigArgCreate, SsoConfigArgUpdate } from './types'
import {
SSO_SECRET_FIELDS,
generateDomainVerifyToken,
isSsoDomainVerified,
isTrustedSsoDomain,
proveSsoDomainOwnership,
ssoDomainVerifyDnsRecord,
ssoDomainVerifyHttpUrl,
} from './sso.utils'
import {
SsoDomainNotVerifiedError,
type CheckDomainVerificationResult,
type SsoConfigArgCreate,
type SsoConfigArgUpdate,
type StartDomainVerificationResult,
} from './types'
export class SsoManager {
public readonly repository: SsoRepository
@@ -18,11 +32,14 @@ export class SsoManager {
}
async createConfig(data: SsoConfigArgCreate) {
const domain = data.emailDomainRestriction.toLowerCase()
const trusted = isTrustedSsoDomain(domain)
return await this.repository.create({
organizationId: data.organizationId,
protocol: data.protocol,
displayName: data.displayName,
enabled: data.enabled ?? 1,
enabled: trusted ? (data.enabled ?? 1) : 0,
samlEntryPoint: data.samlEntryPoint ?? null,
samlIssuer: data.samlIssuer ?? null,
samlCert: encryptField(data.samlCert),
@@ -36,12 +53,22 @@ export class SsoManager {
oidcCallbackUrl: data.oidcCallbackUrl ?? null,
oidcScope: data.oidcScope ?? null,
defaultOrgRole: data.defaultOrgRole ?? 'member',
emailDomainRestriction: data.emailDomainRestriction.toLowerCase(),
emailDomainRestriction: domain,
domainVerifyToken: generateDomainVerifyToken(),
domainVerifiedAt: trusted ? new Date() : null,
})
}
async updateConfig(configId: number, data: SsoConfigArgUpdate) {
const encrypted: Partial<SsoConfigArgUpdate> = { ...data }
const current = await this.repository.findById(configId)
if (!current) return null
const encrypted: Partial<SsoConfigArgUpdate> & {
domainVerifyToken?: string
domainVerifiedAt?: Date | null
enabled?: number
} = { ...data }
for (const field of SSO_SECRET_FIELDS) {
if (field in encrypted) {
if (encrypted[field]) {
@@ -51,9 +78,87 @@ export class SsoManager {
}
}
}
if (data.emailDomainRestriction) {
const domain = data.emailDomainRestriction.toLowerCase()
encrypted.emailDomainRestriction = domain
if (domain !== current.emailDomainRestriction) {
const trusted = isTrustedSsoDomain(domain)
encrypted.domainVerifyToken = generateDomainVerifyToken()
encrypted.domainVerifiedAt = trusted ? new Date() : null
if (!trusted) encrypted.enabled = 0
await this.repository.deleteIdentitiesByConfig(configId)
}
}
const nextDomain = encrypted.emailDomainRestriction ?? current.emailDomainRestriction
const nextVerifiedAt = 'domainVerifiedAt' in encrypted
? encrypted.domainVerifiedAt
: current.domainVerifiedAt
const wouldBeVerified = isTrustedSsoDomain(nextDomain) || !!nextVerifiedAt
if (data.enabled === 1 && !wouldBeVerified) {
throw new SsoDomainNotVerifiedError()
}
return await this.repository.update(configId, encrypted)
}
async startDomainVerification(configId: number): Promise<StartDomainVerificationResult | null> {
const config = await this.repository.findById(configId)
if (!config) return null
let token = config.domainVerifyToken
if (!token) {
token = generateDomainVerifyToken()
const updated = await this.repository.update(configId, { domainVerifyToken: token })
if (!updated) return null
}
return {
token,
dnsRecord: ssoDomainVerifyDnsRecord(token),
httpUrl: ssoDomainVerifyHttpUrl(config.emailDomainRestriction),
isDomainVerified: isSsoDomainVerified({ ...config, domainVerifyToken: token }),
isDomainTrusted: isTrustedSsoDomain(config.emailDomainRestriction),
}
}
async checkDomainVerification(configId: number): Promise<CheckDomainVerificationResult | null> {
const config = await this.repository.findById(configId)
if (!config) return null
if (!config.domainVerifyToken) {
return {
verified: isSsoDomainVerified(config),
method: isTrustedSsoDomain(config.emailDomainRestriction) ? 'trusted' : null
}
}
const method = await proveSsoDomainOwnership({
domain: config.emailDomainRestriction,
token: config.domainVerifyToken,
})
if (!method) {
return { verified: isSsoDomainVerified(config), method: null }
}
if (!config.domainVerifiedAt || method === 'trusted') {
const updated = await this.repository.update(configId, {
domainVerifiedAt: new Date(),
enabled: 1,
})
// The partial unique index rejects a second verified config for the same
// domain another organization proved ownership first.
if (!updated) {
return { verified: false, method: null }
}
}
return { verified: true, method }
}
async deleteConfig(configId: number) {
return await this.repository.delete(configId)
}
+74 -7
View File
@@ -1,4 +1,4 @@
import { and, eq } from 'drizzle-orm'
import { and, eq, isNotNull } from 'drizzle-orm'
import {
SsoConfigsSchema,
SsoIdentitiesSchema,
@@ -8,6 +8,12 @@ import {
} from 'taskview-db-schemas'
import { Database } from '../../modules/db'
import { callWithCatch } from '../../utils/helpers'
import type {
FindSsoConfigByDomainAndOrgArgs,
FindSsoIdentityArgs,
FindSsoIdentityByUserArgs,
UpsertSsoIdentityArgs,
} from './types'
export class SsoRepository {
private readonly db: Database
@@ -16,6 +22,38 @@ export class SsoRepository {
this.db = Database.getInstance()
}
async findVerifiedByDomain(domain: string): Promise<SsoConfigsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(SsoConfigsSchema)
.where(
and(
eq(SsoConfigsSchema.emailDomainRestriction, domain.toLowerCase()),
isNotNull(SsoConfigsSchema.domainVerifiedAt),
)
)
)
if (!result || result.length === 0) return null
return result[0]
}
async findByDomainAndOrg(args: FindSsoConfigByDomainAndOrgArgs): Promise<SsoConfigsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
.select()
.from(SsoConfigsSchema)
.where(
and(
eq(SsoConfigsSchema.emailDomainRestriction, args.domain.toLowerCase()),
eq(SsoConfigsSchema.organizationId, args.organizationId),
)
)
)
if (!result || result.length === 0) return null
return result[0]
}
async findEnabledByDomain(domain: string): Promise<SsoConfigsSchemaTypeForSelect | null> {
const result = await callWithCatch(() =>
this.db.dbDrizzle
@@ -146,12 +184,41 @@ export class SsoRepository {
return !!(result?.rowCount && result.rowCount > 0)
}
async upsertIdentity(data: {
userId: number
ssoConfigId: number
externalId: string
email: string
}): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
async findIdentity(args: FindSsoIdentityArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
const result = await this.db.dbDrizzle
.select()
.from(SsoIdentitiesSchema)
.where(
and(
eq(SsoIdentitiesSchema.ssoConfigId, args.ssoConfigId),
eq(SsoIdentitiesSchema.externalId, args.externalId),
)
)
if (result.length === 0) return null
return result[0]
}
async findIdentityByUser(args: FindSsoIdentityByUserArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
const result = await this.db.dbDrizzle
.select()
.from(SsoIdentitiesSchema)
.where(
and(
eq(SsoIdentitiesSchema.ssoConfigId, args.ssoConfigId),
eq(SsoIdentitiesSchema.userId, args.userId),
)
)
if (result.length === 0) return null
return result[0]
}
async deleteIdentitiesByConfig(ssoConfigId: number): Promise<void> {
await this.db.dbDrizzle
.delete(SsoIdentitiesSchema)
.where(eq(SsoIdentitiesSchema.ssoConfigId, ssoConfigId))
}
async upsertIdentity(data: UpsertSsoIdentityArgs): Promise<SsoIdentitiesSchemaTypeForSelect | null> {
const existing = await callWithCatch(() =>
this.db.dbDrizzle
.select()
+3
View File
@@ -26,11 +26,14 @@ 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)
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)
}
@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest'
import { deriveSamlEmail } from '../sso.utils'
const EMAIL_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
const PERSISTENT_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'
const EMAIL_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
describe('deriveSamlEmail', () => {
it('takes the email attribute and lowercases it', () => {
expect(deriveSamlEmail({ email: 'User@Company.com', nameID: 'abc' })).toBe('user@company.com')
})
it('falls back to the xmlsoap emailaddress claim', () => {
expect(deriveSamlEmail({ [EMAIL_CLAIM]: 'a@b.com', nameID: 'abc' })).toBe('a@b.com')
})
it('uses nameID only when the NameID Format is emailAddress', () => {
expect(deriveSamlEmail({
nameID: 'user@company.com',
nameIDFormat: EMAIL_NAMEID_FORMAT,
})).toBe('user@company.com')
})
it('does not use nameID for a non-email NameID Format', () => {
expect(deriveSamlEmail({
nameID: 'user@company.com',
nameIDFormat: PERSISTENT_NAMEID_FORMAT,
})).toBeNull()
})
it('does not use nameID when no format is provided', () => {
expect(deriveSamlEmail({ nameID: 'user@company.com' })).toBeNull()
})
it('prefers the email attribute over an emailAddress-format nameID', () => {
expect(deriveSamlEmail({
email: 'attr@company.com',
nameID: 'name@company.com',
nameIDFormat: EMAIL_NAMEID_FORMAT,
})).toBe('attr@company.com')
})
it('returns null for a blank or non-string email attribute', () => {
expect(deriveSamlEmail({ email: ' ', nameID: 'abc' })).toBeNull()
expect(deriveSamlEmail({ email: 123, nameID: 'abc' })).toBeNull()
expect(deriveSamlEmail({ nameID: 'abc' })).toBeNull()
expect(deriveSamlEmail({})).toBeNull()
})
})
@@ -2,6 +2,7 @@ import { randomBytes } from 'crypto'
import * as client from 'openid-client'
import type { Request, Response } from 'express'
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
import { PublicApiUrl } from '../../../modules/public-url'
import type { SsoProvider, SsoAuthResult } from './sso-provider.interface'
export class OidcProvider implements SsoProvider {
@@ -28,7 +29,12 @@ export class OidcProvider implements SsoProvider {
return this.oidcConfig
}
async initiateLogin(_req: Request, res: Response, relayState?: string): Promise<void> {
private resolveCallbackUrl(req: Request): string {
return this.config.oidcCallbackUrl?.trim()
|| `${PublicApiUrl.base(req)}/module/sso/callback/${this.config.id}`
}
async initiateLogin(req: Request, res: Response, relayState?: string): Promise<void> {
const config = await this.getOidcConfig()
const scope = this.config.oidcScope ?? 'openid email profile'
const codeVerifier = client.randomPKCECodeVerifier()
@@ -63,7 +69,7 @@ export class OidcProvider implements SsoProvider {
})
const params = new URLSearchParams({
redirect_uri: this.config.oidcCallbackUrl!,
redirect_uri: this.resolveCallbackUrl(req),
scope,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
@@ -110,7 +116,7 @@ export class OidcProvider implements SsoProvider {
throw new Error('CSRF state mismatch — possible CSRF attack')
}
const callbackOrigin = new URL(this.config.oidcCallbackUrl!).origin
const callbackOrigin = new URL(this.resolveCallbackUrl(req)).origin
const currentUrl = new URL(req.originalUrl, callbackOrigin)
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: codeVerifier,
@@ -128,6 +134,7 @@ export class OidcProvider implements SsoProvider {
email: (claims.email as string).toLowerCase(),
externalId: claims.sub,
displayName: claims.name as string | undefined,
preferredUsername: claims.preferred_username as string | undefined,
provider: `oidc-${this.config.id}`,
}
}
@@ -1,6 +1,9 @@
import { SAML, ValidateInResponseTo } from '@node-saml/node-saml'
import type { Request, Response } from 'express'
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
import { PublicApiUrl } from '../../../modules/public-url'
import { deriveSamlEmail } from '../sso.utils'
import type { SamlOptionsArgs } from '../types'
import type { SsoProvider, SsoAuthResult } from './sso-provider.interface'
import { SamlDbCacheProvider } from './saml-cache-provider'
@@ -11,12 +14,12 @@ function normalizeCert(cert: string): string {
.replace(/[\s\r\n]/g, '')
}
function buildSamlOptions(config: SsoConfigsSchemaTypeForSelect, mode: 'assertion' | 'response') {
function buildSamlOptions({ config, mode, callbackUrl }: SamlOptionsArgs) {
return {
entryPoint: config.samlEntryPoint!,
issuer: config.samlIssuer!,
idpCert: normalizeCert(config.samlCert!),
callbackUrl: config.samlCallbackUrl!,
callbackUrl,
wantAssertionsSigned: mode === 'assertion',
wantAuthnResponseSigned: mode === 'response',
validateInResponseTo: ValidateInResponseTo.always,
@@ -31,29 +34,38 @@ function buildSamlOptions(config: SsoConfigsSchemaTypeForSelect, mode: 'assertio
}
export class SamlProvider implements SsoProvider {
private readonly samlAssertion: SAML
private readonly samlResponse: SAML
private readonly config: SsoConfigsSchemaTypeForSelect
constructor(config: SsoConfigsSchemaTypeForSelect) {
this.config = config
this.samlAssertion = new SAML(buildSamlOptions(config, 'assertion'))
this.samlResponse = new SAML(buildSamlOptions(config, 'response'))
}
private resolveCallbackUrl(req: Request): string {
return this.config.samlCallbackUrl?.trim()
|| `${PublicApiUrl.base(req)}/module/sso/callback/${this.config.id}`
}
async initiateLogin(req: Request, res: Response, relayState?: string): Promise<void> {
const loginUrl = await this.samlAssertion.getAuthorizeUrlAsync(relayState ?? '', req.hostname, {})
const saml = new SAML(buildSamlOptions({
config: this.config,
mode: 'assertion',
callbackUrl: this.resolveCallbackUrl(req),
}))
const loginUrl = await saml.getAuthorizeUrlAsync(relayState ?? '', req.hostname, {})
res.redirect(loginUrl)
}
async handleCallback(req: Request): Promise<SsoAuthResult> {
const callbackUrl = this.resolveCallbackUrl(req)
let profile
try {
const result = await this.samlAssertion.validatePostResponseAsync(req.body)
const saml = new SAML(buildSamlOptions({ config: this.config, mode: 'assertion', callbackUrl }))
const result = await saml.validatePostResponseAsync(req.body)
profile = result.profile
} catch {
const result = await this.samlResponse.validatePostResponseAsync(req.body)
const saml = new SAML(buildSamlOptions({ config: this.config, mode: 'response', callbackUrl }))
const result = await saml.validatePostResponseAsync(req.body)
profile = result.profile
}
@@ -61,14 +73,13 @@ export class SamlProvider implements SsoProvider {
throw new Error('SAML response missing nameID')
}
const email = (
profile.email
?? profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']
?? profile.nameID
) as string
const email = deriveSamlEmail(profile as Record<string, unknown>)
if (!email) {
throw new Error('SAML response missing email attribute')
}
return {
email: email.toLowerCase(),
email,
externalId: profile.nameID,
displayName: (profile.displayName
?? profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name']) as string | undefined,
@@ -4,6 +4,7 @@ export type SsoAuthResult = {
email: string
externalId: string
displayName?: string
preferredUsername?: string
provider: string
}
+110 -1
View File
@@ -1,11 +1,64 @@
import { randomBytes } from 'crypto'
import { resolveTxt } from 'node:dns/promises'
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
import { decryptField } from '../../utils/crypto'
import { generateString } from '../../utils/helpers'
import type { CheckSsoDomainProofArgs, SsoDomainVerificationMethod } from './types'
export const SSO_SECRET_FIELDS = ['samlCert', 'samlSigningKey', 'samlSigningCert', 'oidcClientSecret'] as const
export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
export const SSO_DOMAIN_TXT_PREFIX = 'taskview-sso-verify='
export const SSO_DOMAIN_WELL_KNOWN_PATH = '/.well-known/taskview-sso-verify.txt'
export function generateDomainVerifyToken(): string {
return `tvdom_${randomBytes(32).toString('hex')}`
}
const SAML_EMAIL_NAMEID_FORMAT = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
const SAML_EMAIL_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'
export function deriveSamlEmail(profile: Record<string, unknown>): string | null {
const fromAttribute = profile.email ?? profile[SAML_EMAIL_CLAIM]
if (typeof fromAttribute === 'string' && fromAttribute.trim()) {
return fromAttribute.trim().toLowerCase()
}
if (profile.nameIDFormat === SAML_EMAIL_NAMEID_FORMAT
&& typeof profile.nameID === 'string' && profile.nameID.trim()) {
return profile.nameID.trim().toLowerCase()
}
return null
}
export function trustedSsoDomains(): string[] {
const raw = process.env.SSO_TRUSTED_DOMAINS
if (!raw?.trim()) return []
return raw
.split(',')
.map((domain) => domain.trim().toLowerCase())
.filter(Boolean)
}
export function isTrustedSsoDomain(domain: string): boolean {
return trustedSsoDomains().includes(domain.trim().toLowerCase())
}
export function isSsoDomainVerified(config: SsoConfigsSchemaTypeForSelect): boolean {
if (isTrustedSsoDomain(config.emailDomainRestriction)) return true
return !!config.domainVerifiedAt
}
export function ssoDomainVerifyHttpUrl(domain: string): string {
const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'
return `${protocol}://${domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`
}
export function ssoDomainVerifyDnsRecord(token: string): string {
return `${SSO_DOMAIN_TXT_PREFIX}${token}`
}
export function toClientSsoConfig(config: SsoConfigsSchemaTypeForSelect) {
const { samlCert, samlSigningKey, samlSigningCert, oidcClientSecret, scimToken, ...safe } = config
const token = config.domainVerifyToken
return {
...safe,
hasSamlCert: !!samlCert,
@@ -13,9 +66,65 @@ export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
hasSamlSigningCert: !!samlSigningCert,
hasOidcClientSecret: !!oidcClientSecret,
hasScimToken: !!scimToken,
isDomainVerified: isSsoDomainVerified(config),
isDomainTrusted: isTrustedSsoDomain(config.emailDomainRestriction),
domainVerifyDnsRecord: token ? ssoDomainVerifyDnsRecord(token) : null,
domainVerifyHttpUrl: ssoDomainVerifyHttpUrl(config.emailDomainRestriction),
}
}
export function stripSecrets(config: SsoConfigsSchemaTypeForSelect) {
return toClientSsoConfig(config)
}
function tokenMatchesProof(body: string, token: string): boolean {
const trimmed = body.trim()
return trimmed === token || trimmed === ssoDomainVerifyDnsRecord(token)
}
export async function checkSsoDomainDnsTxt(args: CheckSsoDomainProofArgs): Promise<boolean> {
try {
const records = await resolveTxt(args.domain)
return records.some((chunks) => tokenMatchesProof(chunks.join(''), args.token))
} catch {
return false
}
}
export async function checkSsoDomainHttpFile(args: CheckSsoDomainProofArgs): Promise<boolean> {
const urls = process.env.NODE_ENV === 'production'
? [`https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`]
: [
`https://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`,
`http://${args.domain}${SSO_DOMAIN_WELL_KNOWN_PATH}`,
]
for (const url of urls) {
const urlError = validateMetadataUrl(url)
if (urlError) continue
try {
const response = await fetch(url, {
redirect: 'error',
signal: AbortSignal.timeout(5000),
})
if (!response.ok) continue
if (tokenMatchesProof(await response.text(), args.token)) return true
} catch {
continue
}
}
return false
}
export async function proveSsoDomainOwnership(args: CheckSsoDomainProofArgs): Promise<SsoDomainVerificationMethod | null> {
if (isTrustedSsoDomain(args.domain)) return 'trusted'
if (await checkSsoDomainDnsTxt(args)) return 'dns'
if (await checkSsoDomainHttpFile(args)) return 'http'
return null
}
export function decryptSsoConfig(config: SsoConfigsSchemaTypeForSelect): SsoConfigsSchemaTypeForSelect {
return {
...config,
+78 -1
View File
@@ -1,4 +1,12 @@
import { type } from 'arktype'
import type { SsoConfigsSchemaTypeForSelect } from 'taskview-db-schemas'
import type { UserDbRecord } from '../../types/auth.types'
export type SamlOptionsArgs = {
config: SsoConfigsSchemaTypeForSelect
mode: 'assertion' | 'response'
callbackUrl: string
}
export const SsoProtocols = {
SAML: 'saml',
@@ -52,7 +60,76 @@ export const SsoConfigArkTypeUpdate = type({
'oidcScope?': 'string',
'defaultOrgRole?': "'admin' | 'member'",
'emailDomainRestriction?': 'string',
'emailDomainRestriction?': 'string > 0',
})
export type SsoConfigArgUpdate = typeof SsoConfigArkTypeUpdate.infer
export type CheckSsoDomainProofArgs = {
domain: string
token: string
}
export type SsoDomainVerificationMethod = 'dns' | 'http' | 'trusted'
export type StartDomainVerificationResult = {
token: string
dnsRecord: string
httpUrl: string
isDomainVerified: boolean
isDomainTrusted: boolean
}
export type CheckDomainVerificationResult = {
verified: boolean
method: SsoDomainVerificationMethod | null
}
export class SsoDomainNotVerifiedError extends Error {
readonly code = 'domain_unverified'
constructor() {
super('SSO domain is not verified')
this.name = 'SsoDomainNotVerifiedError'
}
}
export type FindSsoConfigByDomainAndOrgArgs = {
domain: string
organizationId: number
}
export type FindSsoIdentityArgs = {
ssoConfigId: number
externalId: string
}
export type FindSsoIdentityByUserArgs = {
ssoConfigId: number
userId: number
}
export type UpsertSsoIdentityArgs = {
userId: number
ssoConfigId: number
externalId: string
email: string
}
export type ResolveSsoUserArgs = {
ssoConfigId: number
email: string
externalId: string
preferredUsername?: string
}
export type ApplySsoIdpEmailArgs = {
user: UserDbRecord
email: string
}
export type SsoCallbackError = 'authentication_failed' | 'email_in_use' | 'account_blocked'
export type ResolveSsoUserResult =
| { ok: true, user: UserDbRecord }
| { ok: false, error: SsoCallbackError }
-11
View File
@@ -2,25 +2,14 @@ import { Router } from 'express';
import type { Routable } from '../../types/routable.type';
import { IsLoggedIn } from '../auth/middlewares/is-logged-in';
import { CanAddTaskNew } from './middlewares/CanAddTaskNew';
// import { CanAddTask } from './middlewares/CanAddTask';
// import { CanUpdateTaskStatus } from './middlewares/CanUpdateTaskStatus';
import { CanDeleteTask } from './middlewares/CanDeleteTask';
// import { CanUpdateTaskAssignee } from './middlewares/CanUpdateTaskAssignee';
import { CanFetchTask } from './middlewares/CanFetchTask';
// import { CanUpdateTaskDescription } from './middlewares/CanUpdateTaskDescription';
// import { CanUpdateTaskNote } from './middlewares/CanUpdateTaskNote';
// import { CanUpdateTaskDeadline } from './middlewares/CanUpdateTaskDeadline';
// import { CanFetchSubtasks } from './middlewares/CanFetchSubtasks';
// import { CanUpdateTaskPriority } from './middlewares/CanUpdateTaskPriority';
// import { CanMoveTask } from './middlewares/CanMoveTask';
// import { CanSeeTaskAssignedUsers } from './middlewares/CanSeeTaskAssignedUsers';
import { CanFetchTaskHistory } from './middlewares/CanFetchTaskHistory';
import { CanFetchTasks } from './middlewares/CanFetchTasks';
import { CanRecoveryTaskHistory } from './middlewares/CanRecoveryTaskHistory';
import { CanUpdateTask } from './middlewares/CanUpdateTask';
import { CanUpdateTaskAssigneeNew } from './middlewares/CanUpdateTaskAssigneeNew';
import { TasksController } from './TasksController';
// import { MainCanCreateTaskAction } from './middlewares/MainCanCreateTaskAction';
export default class TasksRoutes implements Routable {
private readonly router: ReturnType<typeof Router>;
@@ -1,39 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { ALL_TASKS_LIST_ID, DEFAULT_ID } from '../../../types/tasks.types';
import { logError } from '../../../utils/api';
export const CanAddTask = async (req: Request, res: Response, next: NextFunction) => {
const listId = req.body.componentId;
if (!listId) {
return res.status(400).end();
}
let permissions;
if (Number(listId) === ALL_TASKS_LIST_ID && req.body.goalId && req.body.goalId !== DEFAULT_ID) {
permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(req.body.goalId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_GOAL)
.catch(logError);
} else {
permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(listId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASKLIST)
.catch(logError);
}
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (
permissions.hasPermissions(GoalPermissions.COMPONENT_CAN_ADD_TASKS) ||
permissions.hasPermissions(GoalPermissions.TASKS_CAN_ADD_SUBTASKS)
) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanFetchSubtasks = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.query.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanFetchSubtasks');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_SUBTASKS)) {
return next();
}
return res.status(403).end();
};
@@ -5,7 +5,7 @@ import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanFetchTask = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.query.taskId || req.params.taskId;
const taskId = req.params.taskId;
if (!taskId) {
return res.status(400).end();
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanMoveTask = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanMoveTask');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_DELETE)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanSeeTaskAssignedUsers = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanSeeTaskAssignedUsers');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_WATCH_ASSIGNED_USERS)) {
return next();
}
return res.status(403).end();
};
@@ -1,29 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
/** @deprecated */
export const CanUpdateTaskAssignee = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskDescription');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_ASSIGN_USERS)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanUpdateTaskDeadline = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskDeadline');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_DEADLINE)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanUpdateTaskDescription = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskDescription');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_DESCRIPTION)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanUpdateTaskNote = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskNote');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_NOTE)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanUpdateTaskPriority = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanUpdateTaskPriority');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_PRIORITY)) {
return next();
}
return res.status(403).end();
};
@@ -1,28 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { GoalPermissionsFetcher } from '../../../core/GoalPermissionsFetcher';
import { $logger } from '../../../modules/logget';
import { GoalPermissions } from '../../../types/auth.types';
import { logError } from '../../../utils/api';
export const CanUpdateTaskStatus = async (req: Request, res: Response, next: NextFunction) => {
const taskId = req.body.taskId;
if (!taskId) {
return res.status(400).end();
}
const permissions = await req.appUser.permissionsFetcher
.getPermissionsForType(Number(taskId), GoalPermissionsFetcher.PERMISSION_TYPE_FOR_TASK)
.catch(logError);
if (!permissions) {
$logger.error('Can not get permissions for CanAddTask middleware');
return res.status(500).end();
}
if (permissions.hasPermissions(GoalPermissions.TASKS_CAN_EDIT_STATUS)) {
return next();
}
return res.status(403).end();
};
+5
View File
@@ -23,6 +23,11 @@ export const AppEnvSchema = z.object({
SMTP_FROM_EMAIL: z.string().optional(),
APP_URL: z.string(),
// Send an email to a person when they are invited to a project (requires SMTP); default off
INVITE_EMAIL_ENABLED: z.string().optional(),
// Max invite emails one user may trigger per hour (default 30)
INVITE_EMAIL_HOURLY_LIMIT: z.string().optional(),
// How account password changes are confirmed: code sent by email (default) or current password
PASSWORD_CHANGE_CONFIRMATION: z.enum(['email', 'password']).optional(),
+6
View File
@@ -115,6 +115,12 @@ export type UpdateUserCredentialsArgs = {
passwordHash: string;
};
export type UpdateUserEmailArgs = {
userId: number;
oldEmail: string;
email: string;
};
export type UpdateUserCredentialsResult = 'ok' | 'conflict' | 'error';
export const RefreshTokenSchema = z.object({
+20
View File
@@ -2,6 +2,17 @@ import { randomInt } from 'crypto';
import { UAParser } from 'ua-parser-js';
import { $logger } from '../modules/logget';
// Escapes HTML text and attribute contexts (the quotes matter inside href="...")
// so a user-controlled value can't break out of the surrounding markup.
export function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export function isEmail(email: string): boolean {
const re =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
@@ -18,6 +29,15 @@ export function generateString(length: number) {
return result
}
export function generateLetters(length: number) {
let result = ''
const characters = 'abcdefghijklmnopqrstuvwxyz'
for (let i = 0; i < length; i++) {
result += characters.charAt(randomInt(characters.length))
}
return result
}
export function time() {
return Math.floor(Date.now() / 1000);
}
+9
View File
@@ -37,9 +37,18 @@ cd web
bash build-docker-web.sh $VERSION
cd ..
# Build CE MCP
echo "========================================="
echo "Building CE MCP Server..."
echo "========================================="
cd taskview-packages/taskview-mcp
bash build-docker-mcp.sh $VERSION gimanhead/taskview-ce-mcp
cd ../..
echo "========================================="
echo "Build complete!"
echo "Images built:"
echo " - gimanhead/taskview-ce-api-server:$VERSION"
echo " - gimanhead/taskview-ce-webapp:$VERSION"
echo " - gimanhead/taskview-ce-mcp:$VERSION"
echo "========================================="
+1 -1
View File
@@ -1,6 +1,6 @@
---
title: What is TaskView
description: TaskView is an open-source, self-hosted project and task management platform. Features Kanban boards, dependency graphs, team collaboration, RBAC, GitHub/GitLab sync, and full data ownership. Free alternative to other PM for teams who need privacy and control.
description: TaskView is a source-available, self-hosted project and task management platform. Features Kanban boards, dependency graphs, team collaboration, RBAC, GitHub/GitLab sync, and full data ownership. Free alternative to other PM for teams who need privacy and control.
navigation:
icon: i-lucide-house
---
+19
View File
@@ -40,7 +40,12 @@ DB_USER="taskview_db_user"
DB_PASSWORD="your_secure_password"
DB_NAME="taskviewdb"
DB_PORT=5432
# Postgres connections per API worker (this is the default)
DB_POOL_MAX=20
APP_PORT=1401
# API worker processes (this is the default). Accepts a number or "max" (one worker per CPU core).
# Keep PM2_INSTANCES x DB_POOL_MAX below the Postgres max_connections limit (default 100).
PM2_INSTANCES=2
JWT_ALG="HS256"
JWT_SIGN="secret"
ACCESS_LIFE_TIME="3d"
@@ -150,6 +155,18 @@ services:
TASKVIEW_API_URL: "http://localhost:1725"
ports:
- "8888:80"
# Enable to let AI assistants (Claude Code, Cursor, ...) work with your instance
# over MCP, read https://taskview.tech/docs/integrations/mcp
# taskview-mcp:
# image: gimanhead/taskview-ce-mcp:latest
# restart: unless-stopped
# environment:
# TASKVIEW_URL: "http://taskview-api-server:1401"
# ports:
# - "3100:3100"
# depends_on:
# - taskview-api-server
# networks: [backend]
# Enable for realtime notification read https://taskview.tech/docs/configuration/environment-variables#centrifugo-configuration-file
# centrifugo:
# image: centrifugo/centrifugo:v6
@@ -235,6 +252,8 @@ The migration container will automatically apply any new database changes on sta
- **Use a reverse proxy** (Nginx, Caddy, Traefik) to terminate SSL and serve everything over HTTPS
- **Update `APP_URL`** in `.env.taskview` and `TASKVIEW_API_URL` on the webapp service to match your production domains
- **Trim the login page** — set `AUTH_LOGIN_METHODS` in `.env.taskview` to offer only the sign-in methods you actually use (e.g. `AUTH_LOGIN_METHODS="password"`). Google/GitHub/Apple buttons are shown only when the provider is configured.
- **Close the instance** — set `ALLOW_PUBLIC_REGISTRATION="false"` so strangers can't create accounts: only emails invited to an organization or project (and users coming through your SSO provider) can sign in and get an account on first login. See [how it works](/docs/configuration/environment-variables#closing-an-instance-how-allow_public_registrationfalse-works).
- **Scale API workers deliberately** — the API runs `PM2_INSTANCES` worker processes (default `2`), and each worker opens its own pool of up to `DB_POOL_MAX` Postgres connections (default `20`). Before raising either value (or using `PM2_INSTANCES=max`), make sure `workers × DB_POOL_MAX` stays below your Postgres `max_connections` (default `100`) — otherwise the API fails with *"sorry, too many clients already"*. The API logs a warning on startup when the budget looks too high.
- **Back up the database** - the `pgdata` volume contains all your data
- **Set `restart: unless-stopped`** on all services so they survive server reboots
- **SMTP setup** - add SMTP variables to `.env.taskview` if you want email features (password recovery, invitations). See [Configuration](/docs/configuration/environment-variables) for details.
+19 -1
View File
@@ -50,7 +50,7 @@ Only **owners** and **admins** can manage members. The **Members** tab is not vi
2. Go to the **Members** tab
3. Enter an email address and click **Add Member**
New members are added with the **member** role by default. You can change their role to **admin** using the role dropdown next to their name. Members can only be invited by email. The person needs to have a TaskView account with that email.
New members are added with the **member** role by default. You can change their role to **admin** using the role dropdown next to their name. Members are invited by email address. The person doesn't need a TaskView account yet - membership is stored against the email, so you can add someone in advance and they join the organization as soon as they sign up with that address.
::callout{icon="i-lucide-alert-triangle" color="warning"}
When a project member with the **Manage users** permission invites someone into a project, that person is automatically added to the organization as a **member** - even though only admins and owners can add members directly. This is by design: a person can't be in a project without being in its organization. The auto-added member gets the minimum role and can't manage the organization.
@@ -82,3 +82,21 @@ Organizations and projects have separate permission systems:
Being an organization admin doesn't automatically give you permissions inside projects. You still need to be added to each project and assigned a project role. See [Roles and Permissions](/docs/collaboration/roles-and-permissions) for project-level access control.
The member list API endpoint is restricted to owners and admins. Regular members cannot fetch the list of organization members.
## Who can see which projects
| Who | Sees |
|-----|------|
| **Organization owner** | **Every project of the organization**, including projects created by other members |
| **Organization admin** | Only the projects they were added to |
| **Organization member** | Only the projects they were added to |
Projects created inside an organization belong to the organization, not to the person who created them: the organization owner is recorded as their owner. That is what keeps a project reachable when the person who created it leaves the company - nothing is lost with them. The flip side is that the owner sees every project of their organization, whoever created it.
Everyone else - admins included - gets access to a project only by being added to it and given a project role. Being an organization admin means administering the organization (members, settings, SSO), not its content.
::callout{icon="i-lucide-shield-alert" color="warning"}
**Access is granted explicitly, never inherited from a title.** There is deliberately no "admins can see all projects" switch: if a project should be visible to someone, they get invited to it. This keeps sensitive projects - finance, HR, salaries - private by default instead of silently opening them the moment someone is promoted to admin.
The one exception is the organization owner, who sees everything by design (see above). If a project must stay private from the owner too, keep it in your **personal workspace** rather than in the organization.
::
+23
View File
@@ -22,6 +22,23 @@ Users who sign in via SSO are automatically added to the organization that owns
Go to your organization's settings → **SSO** tab. You need the **admin** or **owner** role.
### Domain verification
SSO login stays off until the organization proves it owns the email domain (so another org on a shared instance cannot claim `gmail.com` or your company domain).
After you save the SSO config, TaskView shows a verification token. Use **one** of:
1. **DNS TXT** — add a TXT record on the domain:
`taskview-sso-verify=<token>`
2. **HTTP file** — serve the token (plain text) at:
`https://<domain>/.well-known/taskview-sso-verify.txt`
Then click **Check domain**. Either method is enough. After a successful check, SSO login is enabled.
**Closed-network / air-gapped installs:** you may not have public DNS. Set `SSO_TRUSTED_DOMAINS=company.com,corp.local` on the API server. Domains in that list skip the DNS/HTTP check and are treated as verified.
Existing SSO configs created before this check are not verified: logins stop until an admin completes verification or the domain is listed in `SSO_TRUSTED_DOMAINS`.
### SAML 2.0
**Required fields:**
@@ -35,6 +52,10 @@ Go to your organization's settings → **SSO** tab. You need the **admin** or **
| IdP Certificate | Your IdP's public signing certificate (base64, without BEGIN/END headers) |
| ACS URL (Callback) | The URL where your IdP sends SAML responses. Shown after creating the config - copy it to your IdP |
::callout{icon="i-lucide-network" color="warning"}
**Running behind a reverse proxy?** Set [`API_PUBLIC_URL`](/docs/configuration/environment-variables#application) to the public address of your API server. The ACS/Callback URL and the SCIM endpoint shown on this screen are built from it — without the variable they fall back to the address your browser used, which behind a proxy can be an internal host that your IdP cannot reach.
::
**Using Metadata URL (recommended):**
Instead of filling fields manually, paste your IdP's metadata URL and click **Sync**. This auto-fills the IdP SSO URL, Certificate, and Logout URL from the metadata XML.
@@ -141,6 +162,8 @@ Request IDs expire after 5 minutes.
| POST | `/module/sso/admin/configs` | Create SSO config |
| PATCH | `/module/sso/admin/configs/{configId}` | Update SSO config |
| DELETE | `/module/sso/admin/configs/{configId}` | Delete SSO config |
| POST | `/module/sso/admin/configs/{configId}/verify-domain` | Return DNS TXT and HTTP well-known proof for the domain |
| POST | `/module/sso/admin/configs/{configId}/verify-domain/check` | Check DNS TXT then HTTP file; enable SSO on success |
## Database tables
+34 -7
View File
@@ -1,11 +1,11 @@
---
title: GitHub & GitLab Setup
description: Connect GitHub and GitLab repositories to TaskView. Import and sync issues as tasks with OAuth authorization, webhook-based real-time updates, and AES-256 encrypted token storage. Supports GitHub Enterprise and self-hosted GitLab.
title: GitHub, GitLab & Gitea Setup
description: Connect GitHub, GitLab and Gitea repositories to TaskView. Import and sync issues as tasks with OAuth authorization, webhook-based real-time updates, and AES-256 encrypted token storage. Supports GitHub Enterprise, self-hosted GitLab and self-hosted Gitea.
navigation:
icon: i-lucide-git-pull-request
---
TaskView integrations allow you to connect GitHub or GitLab repositories to your projects. After connecting, issues from the repository are synced as tasks in TaskView and kept up to date via webhooks.
TaskView integrations allow you to connect GitHub, GitLab or Gitea repositories to your projects. After connecting, issues from the repository are synced as tasks in TaskView and kept up to date via webhooks.
## Prerequisites
@@ -81,7 +81,29 @@ GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
---
## 5. Full `.env.taskview` Example
## 5. Create Gitea OAuth App (optional)
1. On [gitea.com](https://gitea.com) (or your own instance) go to **Settings → Applications → Manage OAuth2 Applications**
2. Click **"Create Application"**
3. Fill in:
- **Application Name**: `TaskView Integrations`
- **Redirect URIs**: `http://localhost:1401/module/integrations/oauth/gitea/callback`
4. Click **"Create Application"**
5. Copy **Client ID** and **Client Secret**
Add to `.env.taskview`:
```
GITEA_INTEGRATION_CLIENT_ID=<your-client-id>
GITEA_INTEGRATION_CLIENT_SECRET=<your-client-secret>
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
```
> **Note**: For self-hosted Gitea, also set `GITEA_BASE_URL=https://gitea.yourcompany.com` (defaults to `https://gitea.com`). The API URL is derived as `{GITEA_BASE_URL}/api/v1`; override with `GITEA_API_URL` only if it's served from a different address.
---
## 6. Full `.env.taskview` Example
```env
# ... existing vars ...
@@ -98,16 +120,21 @@ GITHUB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/
GITLAB_INTEGRATION_CLIENT_ID=app_id_123
GITLAB_INTEGRATION_CLIENT_SECRET=secret_123
GITLAB_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitlab/callback
# Gitea Integration OAuth (optional)
GITEA_INTEGRATION_CLIENT_ID=client_id_123
GITEA_INTEGRATION_CLIENT_SECRET=secret_123
GITEA_INTEGRATION_CALLBACK_URL=http://localhost:1401/module/integrations/oauth/gitea/callback
```
---
## 6. Usage
## 7. Usage
1. Open a project in TaskView
2. Right-click the project in the sidebar → **"Integrations"**
3. Click **"Add Integration"**
4. Choose **GitHub** or **GitLab** - you'll be redirected to authorize
4. Choose **GitHub**, **GitLab** or **Gitea** - you'll be redirected to authorize
5. After authorization, select a repository from the list
6. Done - the integration is active
@@ -119,7 +146,7 @@ You can toggle integrations on/off or delete them from the integrations page.
- **Callback URLs**: Update to your production domain (e.g., `https://api.yourdomain.com/module/integrations/oauth/github/callback`)
- **ENCRYPTION_KEY**: Store securely, never commit to git. If changed, existing encrypted tokens become unreadable
- **Separate OAuth Apps**: Create new GitHub/GitLab OAuth Apps for production with production callback URLs
- **Separate OAuth Apps**: Create new GitHub/GitLab/Gitea OAuth Apps for production with production callback URLs
- **CORS**: Ensure your production frontend domain is in `CORS_ALLOWED_ORIGINS`
---
+53 -1
View File
@@ -7,10 +7,16 @@ navigation:
TaskView ships an MCP (Model Context Protocol) server that lets AI assistants such as Claude Code and Claude Desktop work with your projects and tasks through the TaskView API.
It runs in two modes:
```
AI client ──stdio──▶ taskview-mcp ──HTTPS──▶ TaskView API
Local (stdio): AI client ──stdio──▶ taskview-mcp (npx) ──HTTPS──▶ TaskView API
Shared (HTTP): AI client ──HTTPS──▶ taskview-mcp container ──HTTP───▶ TaskView API
```
- **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.
## Prerequisites
- Node.js >= 24
@@ -40,6 +46,52 @@ No installation is required — the server runs via `npx`. Add it to your MCP cl
Set `TASKVIEW_URL` to your own instance when self-hosting.
## Shared HTTP server (self-hosted)
The `gimanhead/taskview-ce-mcp` image serves MCP over HTTP (Streamable HTTP transport), so users don't need Node.js or `npx` — they just point their client at a URL. Add it to your `docker-compose.yml` next to the API (see the commented block in the [installation guide](/docs/getting-started/installation)):
```yaml
taskview-mcp:
image: gimanhead/taskview-ce-mcp:latest
restart: unless-stopped
environment:
# Where this MCP server forwards requests; the docker-network address
# of your API service works best
TASKVIEW_URL: "http://taskview-api-server:1401"
ports:
- "3100:3100"
networks: [backend]
```
The MCP endpoint is `/mcp` (port `3100`, configurable via `MCP_HTTP_PORT`), health check at `/health`. Every request must carry the caller's own token in the `Authorization` header — requests without it get 401. The server is stateless and keeps no data: each request is forwarded with exactly the token it came with.
Connect from Claude Code:
```bash
claude mcp add --transport http taskview https://mcp.your-domain.com/mcp \
--header "Authorization: Bearer tvk_your_token_here"
```
or in `.mcp.json` (Claude Code, Cursor, VS Code):
```json
{
"mcpServers": {
"taskview": {
"type": "http",
"url": "https://mcp.your-domain.com/mcp",
"headers": { "Authorization": "Bearer tvk_your_token_here" }
}
}
}
```
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.
::
## 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.
@@ -18,13 +18,18 @@ These must match your PostgreSQL setup.
| `DB_PASSWORD` | Yes | - | Database password |
| `DB_NAME` | Yes | - | Database name |
| `DB_PORT` | No | `5432` | Database port |
| `DB_POOL_MAX` | No | `20` | Maximum Postgres connections **per API worker** (each worker opens its own pool) |
**Connection budget:** the total number of Postgres connections is roughly `PM2_INSTANCES × DB_POOL_MAX`. Keep it below the `max_connections` of your PostgreSQL (default `100`), leaving ~10 connections of headroom for migrations and maintenance. The API logs a warning at startup when the estimate exceeds 80.
## Application
| Variable | Required | Default | Description |
|---|---|---|---|
| `APP_PORT` | No | `1401` | Port the API server listens on |
| `PM2_INSTANCES` | No | `2` | Number of API worker processes (PM2 cluster mode). Accepts a number or `max` (one worker per CPU core). When using `max`, set `DB_POOL_MAX` yourself so the connection budget above still fits. |
| `APP_URL` | Yes | https://app.taskview.tech | Full URL of the web app (e.g. `https://tasks.company.com`). Used for OAuth redirects and email links. |
| `API_PUBLIC_URL` | No | - | Public URL of the **API server** as external systems see it (e.g. `https://api.company.com`). Used to build the SSO callback/ACS URL and the SCIM endpoint shown in organization settings. Set it when the API runs behind a reverse proxy — otherwise those URLs are derived from the browser's address and may show an internal host that your IdP cannot reach. The server refuses to start if the value is not a valid http(s) URL. |
| `TRUST_PROXY` | No | `false` | Set when running behind a reverse proxy so `X-Forwarded-Proto`/`X-Forwarded-For` are honoured (correct `https` URLs, real client IP). Use the number of proxies in front of the app (`1` for a single Caddy/nginx), or an IP/subnet list (`10.0.0.0/8`, `uniquelocal`). Leave unset for direct access. Avoid `true` (trusts any hop, allows header spoofing). |
## Web app
@@ -45,11 +50,32 @@ Unlike everything else on this page, this variable is set on the **web app conta
| `JWT_ALG` | No | `HS256` | JWT signing algorithm |
| `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. |
| `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"}
Generate a strong JWT secret: `node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"`
::
### Closing an instance: how `ALLOW_PUBLIC_REGISTRATION=false` works
By default anyone who can reach your instance can create an account — through the registration endpoint, or simply by entering an email on the login page (magic-link and social sign-in create the account on first login). Set `ALLOW_PUBLIC_REGISTRATION=false` to close the instance: from that moment accounts are created **by invitation only**.
**Who can still get an account on a closed instance.** TaskView invitations are stored by email, before any account exists. An email is considered invited — and its owner can sign in and get an account created on first login — if it appears in any of these places:
- **Organization members** — added via organization settings (or provisioned through SCIM)
- **Project collaborators** — invited to a project by an existing user
Everyone else is rejected: the registration endpoint returns `403`, magic-link refuses to send a code to an unknown email, and social sign-in redirects back to the login page with an error. Existing accounts are not affected in any way — the flag only controls the *creation* of new ones.
**SSO is not blocked by this flag.** Signing in through a SAML/OIDC provider still provisions accounts, because an identity provider is configured by the administrator and is itself a controlled channel — your IdP decides who gets in.
**The value must be `true` or `false`.** Any other value (a typo like `Flase`, `0`, `no`) stops the server at startup with a clear error instead of silently leaving the instance open.
::callout{icon="i-lucide-users" color="info"}
Note that *any* existing user can invite a collaborator to their project, and an invited email becomes eligible for an account. If your policy is stricter — "only administrators approve new accounts" — restrict who you give accounts to, since every user holds an invitation key to the instance.
::
## SMTP (Email)
Required for password recovery, email confirmation, and invitation notifications. Without SMTP, these features won't work, but everything else functions normally.
@@ -63,6 +89,8 @@ Required for password recovery, email confirmation, and invitation notifications
| `SMTP_ENCRYPTION` | No | `ssl` | `ssl` or `tls` |
| `SMTP_FROM_NAME` | No | `TaskView` | Sender name in emails |
| `SMTP_FROM_EMAIL` | No | - | Sender email address |
| `INVITE_EMAIL_ENABLED` | No | `false` | Set to `true` to email a person when they are invited to a project. The email is localized (English/Russian) by the inviter's browser language and links to the project. Requires SMTP; the value must be `true` or `false` — anything else stops the server at startup. |
| `INVITE_EMAIL_HOURLY_LIMIT` | No | `30` | Maximum invite emails one user may trigger per hour. On top of this cap, the same address is never emailed about the same project more than once per 24 hours. Must be a positive integer. |
## Encryption
@@ -87,7 +115,7 @@ If you change or lose the encryption key, all stored SSO configurations and inte
## GitHub Integration
For connecting GitHub repositories. See [GitHub & GitLab Setup](/docs/integrations/setup) for a step-by-step guide.
For connecting GitHub repositories. See [GitHub, GitLab & Gitea Setup](/docs/integrations/setup) for a step-by-step guide.
| Variable | Required | Default | Description |
|---|---|---|---|
@@ -109,6 +137,18 @@ For connecting GitLab repositories.
| `GITLAB_BASE_URL` | No | `https://gitlab.com` | Override for self-hosted GitLab |
| `GITLAB_API_URL` | No | `https://gitlab.com/api/v4` | Override for self-hosted GitLab API |
## Gitea Integration
For connecting Gitea repositories.
| Variable | Required | Default | Description |
|---|---|---|---|
| `GITEA_INTEGRATION_CLIENT_ID` | No | - | OAuth2 application client ID |
| `GITEA_INTEGRATION_CLIENT_SECRET` | No | - | OAuth2 application client secret |
| `GITEA_INTEGRATION_CALLBACK_URL` | No | - | OAuth callback URL |
| `GITEA_BASE_URL` | No | `https://gitea.com` | Override for self-hosted Gitea |
| `GITEA_API_URL` | No | `{GITEA_BASE_URL}/api/v1` | Override for self-hosted Gitea API |
## Messaging Integrations (Telegram / Slack)
For delivering task notifications to messengers. See [Telegram & Slack Setup](/docs/integrations/messaging) for a step-by-step guide.
@@ -213,7 +253,12 @@ DB_USER="taskview_db_user"
DB_PASSWORD="password"
DB_NAME="taskview"
DB_PORT=5432
# Postgres connections per API worker (this is the default)
DB_POOL_MAX=20
APP_PORT=1401
# API worker processes (this is the default). Accepts a number or "max" (one worker per CPU core).
# Keep PM2_INSTANCES x DB_POOL_MAX below the Postgres max_connections limit (default 100).
PM2_INSTANCES=2
JWT_ALG="HS256"
JWT_SIGN="secret"
ACCESS_LIFE_TIME="3d"
@@ -223,6 +268,8 @@ REFRESH_LIFE_TIME="9d"
#AUTH_LOGIN_METHODS="magic-link,password,sso,social"
# Password change confirmation: "email" (code by email, needs SMTP) or "password" (no SMTP needed)
#PASSWORD_CHANGE_CONFIRMATION="email"
# Air-gapped SSO: skip DNS/HTTP domain proof for these email domains
#SSO_TRUSTED_DOMAINS="company.com,corp.local"
SMTP_HOST=smtp
SMTP_PORT=587
+2 -2
View File
@@ -13,9 +13,9 @@ TaskView is built for teams. You can invite people to your projects, assign them
2. Enter the person's email address in the input field
3. Click **Add**
The person needs to have a TaskView account with that email. If they don't have one yet, they'll need to register first (using the same email you invited them with).
The person doesn't need a TaskView account yet - the invite is stored against the email address. If they already have an account, the project appears in their sidebar right away. If they don't, they simply register with that same email and find the project waiting for them.
Once added, they'll see the project in their sidebar and can start working immediately.
Inviting someone into a project also adds them to the project's organization as a **member**, since a person can't be in a project without being in its organization.
## Removing members
+2 -2
View File
@@ -1,6 +1,6 @@
---
title: Frequently Asked Questions
description: Common questions about TaskView - self-hosted open-source task and project management. Installation, features, security, Docker deployment, team collaboration, and more.
description: Common questions about TaskView - self-hosted source-available task and project management. Installation, features, security, Docker deployment, team collaboration, and more.
navigation:
icon: i-lucide-circle-help
---
@@ -77,7 +77,7 @@ Yes. You can attach a monetary amount to any task and mark it as income or expen
### How do I invite team members?
Open a project, go to the Collaboration tab, and enter the person's email address. They need to have a TaskView account with that email. See [Team Members](/docs/collaboration/members).
Open a project, go to the Collaboration tab, and enter the person's email address. They don't need a TaskView account yet - if they register later with that same email, the project is already there. See [Team Members](/docs/collaboration/members).
### Does TaskView have role-based access control?
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "taskview-ce-monorepo",
"version": "1.50.2",
"version": "1.53.0",
"private": true,
"description": "TaskView CE monorepo containing web, API, and packages",
"workspaces": [
@@ -0,0 +1,300 @@
import { TvApi } from '@/tv'
import { TvPermissions } from '@/api/permissions'
import axios from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, initApi } from './init-api'
/**
* GET /module/collaboration/:goalId must be an object-level protected route:
* only a member of the goal holding task_can_assign_users or goal_can_manage_users
* may read its collaborator list (emails, invitation dates, roles, goalOwner flag).
*/
describe('Collaboration goal member list access control', () => {
let ownerApi: TvApi
let outsiderApi: TvApi
let outsiderEmail: string
let deleteAllGoals: () => Promise<void>
let manageUsersPermissionId: number
const permissionIdByName = new Map<string, number>()
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
outsiderApi = init.$tvApiForSecondUser
outsiderEmail = init.user2Email
deleteAllGoals = init.deleteAllGoals
const allPermissions = await ownerApi.collaboration.fetchAllPermissions()
for (const permission of allPermissions) {
permissionIdByName.set(permission.name, permission.id)
}
const found = permissionIdByName.get(TvPermissions.GOAL_CAN_MANAGE_USERS)
if (!found) throw new Error('Permission "goal_can_manage_users" is not in DB')
manageUsersPermissionId = found
})
afterAll(async () => {
await deleteAllGoals()
})
async function expectHttpStatus<T>(promise: Promise<T>, status: number): Promise<void> {
try {
await promise
throw new Error(`Expected HTTP ${status} but request succeeded`)
} catch (e: any) {
if (typeof e.message === 'string' && e.message.startsWith('Expected HTTP')) throw e
expect(e.response?.status ?? e.status, `Expected ${status}, got ${e.response?.status ?? e.status}`).toBe(status)
}
}
// A goal owned by user1 that user2 is NOT a member of, holding a third-party email
async function createPrivateGoal(organizationId?: number) {
const goal = await ownerApi.goals.createGoal({
name: `Private goal ${Date.now()}`,
...(organizationId ? { organizationId } : {}),
})
if (!goal) throw new Error('Failed to create goal')
const invitedEmail = `outside-party-${Date.now()}@test.com`
const invited = await ownerApi.collaboration.inviteUserToGoal({ email: invitedEmail, goalId: goal.id })
if (!invited) throw new Error('Failed to invite third-party email')
return { goal, invitedEmail }
}
// Invite user2 into user1's goal and grant a role carrying goal_can_manage_users
async function shareGoalWithOutsider() {
const goal = await ownerApi.goals.createGoal({ name: `Shared goal ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
const role = await ownerApi.collaboration.createRoleForGoal({
goalId: goal.id,
roleName: `Manager ${Date.now()}`,
})
if (!role) throw new Error('Failed to create role')
const toggled = await ownerApi.collaboration.toggleRolePermission({
roleId: role.id,
permissionId: manageUsersPermissionId,
})
if (!toggled || toggled.add !== true) {
throw new Error(`Expected goal_can_manage_users to be added, got ${JSON.stringify(toggled)}`)
}
await ownerApi.collaboration.toggleUserRoles({
goalId: goal.id,
userId: collab.id,
roles: [role.id],
})
return { goal, role, collab }
}
describe('JWT session of a non-member', () => {
it('cannot read the collaborator list of someone else goal', async () => {
const { goal } = await createPrivateGoal()
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
})
it('cannot enumerate collaborator emails by walking goal ids', async () => {
const { goal, invitedEmail } = await createPrivateGoal()
let leaked: Awaited<ReturnType<typeof outsiderApi.collaboration.fetchUsersForGoal>> | null = null
try {
leaked = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
} catch {
return
}
expect(
leaked ?? [],
`Leaked collaborator list of goal ${goal.id}: ${JSON.stringify(leaked)}`,
).toEqual([])
expect((leaked ?? []).some(u => u.email === invitedEmail)).toBe(false)
expect((leaked ?? []).some(u => u.goalOwner)).toBe(false)
})
it('gets the same rejection for a goal id that does not exist', async () => {
const nonExistentGoalId = 999999999
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(nonExistentGoalId), 403)
})
})
describe('Organization boundary', () => {
it('a member of the same organization who is not a member of the goal is still rejected', async () => {
const org = await ownerApi.organizations.create({ name: `Access org ${Date.now()}` })
if (!org) throw new Error('Failed to create organization')
const added = await ownerApi.organizations.addMember({
organizationId: org.id,
email: outsiderEmail,
role: 'member',
})
if (!added) throw new Error('Failed to add user2 to the organization')
// The goal lives in the shared org, but user2 was never invited into the goal itself
const { goal } = await createPrivateGoal(org.id)
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
})
})
describe('Unauthenticated access', () => {
it('is rejected with 401 rather than served', async () => {
const { goal } = await createPrivateGoal()
const response = await axios.get(`${API_URL}/module/collaboration/${goal.id}`, {
validateStatus: () => true,
})
expect(
response.status,
`Anonymous request returned ${response.status}: ${JSON.stringify(response.data)}`,
).toBe(401)
})
})
describe('API token of a non-member', () => {
it('cannot read the collaborator list of someone else goal', async () => {
const created = await outsiderApi.apiTokens.create({ name: `Access probe ${Date.now()}` })
if (!created) throw new Error('Failed to create API token for user2')
const tokenApi = new TvApi(axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${created.token}` },
}))
const { goal } = await createPrivateGoal()
try {
await expectHttpStatus(tokenApi.collaboration.fetchUsersForGoal(goal.id), 403)
} finally {
await outsiderApi.apiTokens.delete(created.item.id)
}
})
it('cannot read a goal that is outside the token allowedGoalIds scope', async () => {
const ownGoal = await outsiderApi.goals.createGoal({ name: `User2 goal ${Date.now()}` })
if (!ownGoal) throw new Error('Failed to create user2 goal')
// Token is explicitly scoped to user2's own goal only
const created = await outsiderApi.apiTokens.create({
name: `Scoped probe ${Date.now()}`,
allowedGoalIds: [ownGoal.id],
})
if (!created) throw new Error('Failed to create scoped API token for user2')
const tokenApi = new TvApi(axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${created.token}` },
}))
const { goal } = await createPrivateGoal()
try {
await expectHttpStatus(tokenApi.collaboration.fetchUsersForGoal(goal.id), 403)
} finally {
await outsiderApi.apiTokens.delete(created.item.id)
}
})
})
describe('Legitimate access is preserved', () => {
it('the goal owner can read the collaborator list', async () => {
const { goal, invitedEmail } = await createPrivateGoal()
const users = await ownerApi.collaboration.fetchUsersForGoal(goal.id)
expect(users).toBeDefined()
expect(users?.some(u => u.email === invitedEmail)).toBe(true)
})
it('a member with goal_can_manage_users can read the collaborator list', async () => {
const { goal } = await shareGoalWithOutsider()
const users = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
expect(users).toBeDefined()
expect(users?.some(u => u.email === outsiderEmail)).toBe(true)
})
/**
* A rank-and-file member must still see the project roster, otherwise the UI
* cannot render task assignees. Both default roles created by the goal trigger
* (editor and executor, migration 1.6.1/5.default-roles-for-project.sql) carry
* task_can_watch_assigned_users, so this is the common case, not an edge one.
*/
it('a member with only task_can_watch_assigned_users can read the collaborator list', async () => {
const goal = await ownerApi.goals.createGoal({ name: `Executor goal ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
const roles = await ownerApi.collaboration.fetchRolesForGoal(goal.id)
const executor = roles?.find(r => r.name === 'executor')
if (!executor) throw new Error('Default "executor" role is missing on a fresh goal')
// the role grants the watch permission and neither of the two management ones,
// so a pass here can only come from task_can_watch_assigned_users
const matrix = await ownerApi.collaboration.fetchRoleToPermissionsForGoal(goal.id)
const executorPermissionIds = (matrix ?? [])
.filter(row => row.roleId === executor.id)
.map(row => row.permissionId)
expect(executorPermissionIds).toContain(permissionIdByName.get(TvPermissions.TASK_CAN_WATCH_ASSIGNED_USERS))
expect(executorPermissionIds).not.toContain(permissionIdByName.get(TvPermissions.GOAL_CAN_MANAGE_USERS))
expect(executorPermissionIds).not.toContain(permissionIdByName.get(TvPermissions.TASK_CAN_ASSIGN_USERS))
await ownerApi.collaboration.toggleUserRoles({
goalId: goal.id,
userId: collab.id,
roles: [executor.id],
})
const users = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
expect(users).toBeDefined()
expect(users?.some(u => u.email === outsiderEmail)).toBe(true)
})
it('a member whose role carries none of the three permissions is rejected', async () => {
const goal = await ownerApi.goals.createGoal({ name: `Bare role goal ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await ownerApi.collaboration.inviteUserToGoal({ email: outsiderEmail, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
// a freshly created custom role carries no permissions at all
const bareRole = await ownerApi.collaboration.createRoleForGoal({
goalId: goal.id,
roleName: `Bare ${Date.now()}`,
})
if (!bareRole) throw new Error('Failed to create role')
await ownerApi.collaboration.toggleUserRoles({
goalId: goal.id,
userId: collab.id,
roles: [bareRole.id],
})
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
})
})
describe('Revoked access', () => {
it('a removed collaborator loses access to the collaborator list', async () => {
const { goal, collab } = await shareGoalWithOutsider()
// sanity: access is real before removal
const before = await outsiderApi.collaboration.fetchUsersForGoal(goal.id)
expect(before?.some(u => u.email === outsiderEmail)).toBe(true)
const removed = await ownerApi.collaboration.deleteUserFromGoal({ goalId: goal.id, id: collab.id })
expect(removed).toBeTruthy()
await expectHttpStatus(outsiderApi.collaboration.fetchUsersForGoal(goal.id), 403)
})
})
})
@@ -0,0 +1,109 @@
import { TvApi } from '@/tv'
import { TvPermissions } from '@/api/permissions'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { initApi } from './init-api'
describe('Collaboration roles access control', () => {
let user1Api: TvApi
let user2Api: TvApi
let user2Email: string
let deleteAllGoals: () => Promise<void>
let manageUsersPermissionId: number
beforeAll(async () => {
const init = await initApi()
user1Api = init.$tvApi
user2Api = init.$tvApiForSecondUser
user2Email = init.user2Email
deleteAllGoals = init.deleteAllGoals
const allPermissions = await user1Api.collaboration.fetchAllPermissions()
const found = allPermissions.find(p => p.name === TvPermissions.GOAL_CAN_MANAGE_USERS)
if (!found) throw new Error('Permission "goal_can_manage_users" is not in DB')
manageUsersPermissionId = found.id
})
afterAll(async () => {
await deleteAllGoals()
})
async function expectHttpStatus<T>(promise: Promise<T>, status: number): Promise<void> {
try {
await promise
throw new Error(`Expected HTTP ${status} but request succeeded`)
} catch (e: any) {
if (typeof e.message === 'string' && e.message.startsWith('Expected HTTP')) throw e
expect(e.response?.status, `Expected ${status}, got ${e.response?.status}`).toBe(status)
}
}
async function createGoalWithUser2(grantManageUsers: boolean) {
const goal = await user1Api.goals.createGoal({ name: `Roles access ${Date.now()}` })
if (!goal) throw new Error('Failed to create goal')
const collab = await user1Api.collaboration.inviteUserToGoal({ email: user2Email, goalId: goal.id })
if (!collab) throw new Error('Failed to invite user2')
const role = await user1Api.collaboration.createRoleForGoal({
goalId: goal.id,
roleName: `Manager ${Date.now()}`,
})
if (!role) throw new Error('Failed to create role')
if (grantManageUsers) {
const toggled = await user1Api.collaboration.toggleRolePermission({
roleId: role.id,
permissionId: manageUsersPermissionId,
})
if (!toggled || toggled.add !== true) {
throw new Error(`Expected goal_can_manage_users to be added, got ${JSON.stringify(toggled)}`)
}
}
await user1Api.collaboration.toggleUserRoles({
goalId: goal.id,
userId: collab.id,
roles: [role.id],
})
return { goal, role }
}
it('collaborator with goal_can_manage_users can read the role-to-permission matrix', async () => {
const { goal, role } = await createGoalWithUser2(true)
const matrix = await user2Api.collaboration.fetchRoleToPermissionsForGoal(goal.id)
expect(matrix).toBeDefined()
// the granted permission is visible in the matrix of the role user2 holds
expect(matrix?.some(row => row.roleId === role.id && row.permissionId === manageUsersPermissionId)).toBe(true)
})
it('collaborator without goal_can_manage_users cannot read the matrix', async () => {
const { goal } = await createGoalWithUser2(false)
await expectHttpStatus(user2Api.collaboration.fetchRoleToPermissionsForGoal(goal.id), 403)
})
it('changing role permissions stays owner-only', async () => {
const { goal, role } = await createGoalWithUser2(true)
// reading is allowed...
const matrix = await user2Api.collaboration.fetchRoleToPermissionsForGoal(goal.id)
expect(matrix).toBeDefined()
// ...but editing the matrix is not
await expectHttpStatus(
user2Api.collaboration.toggleRolePermission({ roleId: role.id, permissionId: manageUsersPermissionId }),
403,
)
// owner still can edit
const ownerToggle = await user1Api.collaboration.toggleRolePermission({
roleId: role.id,
permissionId: manageUsersPermissionId,
})
expect(ownerToggle?.add).toBe(false)
await expect(user1Api.goals.deleteGoal(goal.id)).resolves.toBeTruthy()
})
})
@@ -308,6 +308,127 @@ describe('Collaboration', () => {
});
// Regression for #98, the reporter's exact path: an org admin who is a collaborator
// in several projects created a new project in that org and lost every role he had.
// The trigger is creating a goal in an organization owned by SOMEONE ELSE - only then
// does the API add the creator as a collaborator, which used to wipe his other roles.
it('an org member creating a project in that org keeps his roles in other projects', async () => {
const { $tvApiForSecondUser, user2Email } = await initApi();
const org = await $api.organizations.create({ name: `Roles org ${Date.now()}` });
expect(org?.id).toBeDefined();
// the reporter's case: the second user is an ADMIN of the organization
await $api.organizations.addMember({
organizationId: org.id,
email: user2Email,
role: 'admin',
});
// owner creates two projects in the org and gives the admin roles in both
const projectA = await $api.goals.createGoal({ name: 'Org project A', organizationId: org.id });
const projectB = await $api.goals.createGoal({ name: 'Org project B', organizationId: org.id });
expect(projectA?.id).toBeDefined();
expect(projectB?.id).toBeDefined();
const collabA = await $api.collaboration.inviteUserToGoal({ goalId: projectA!.id, email: user2Email });
const collabB = await $api.collaboration.inviteUserToGoal({ goalId: projectB!.id, email: user2Email });
expect(collabA?.id).toBeDefined();
expect(collabB?.id).toEqual(collabA?.id);
const editorA = (await $api.collaboration.fetchRolesForGoal(projectA!.id))?.find((r) => r.name === 'editor');
const editorB = (await $api.collaboration.fetchRolesForGoal(projectB!.id))?.find((r) => r.name === 'editor');
expect(editorA?.id).toBeDefined();
expect(editorB?.id).toBeDefined();
await $api.collaboration.toggleUserRoles({
goalId: projectA!.id,
userId: collabA!.id,
roles: [editorA!.id],
});
await $api.collaboration.toggleUserRoles({
goalId: projectB!.id,
userId: collabA!.id,
roles: [editorB!.id],
});
// ...the admin now creates his own project INSIDE the owner's organization
const ownProject = await $tvApiForSecondUser.goals.createGoal({
name: 'Project created by the org admin',
organizationId: org.id,
});
expect(ownProject?.id).toBeDefined();
// roles in the pre-existing projects must survive
const usersA = await $api.collaboration.fetchUsersForGoal(projectA!.id);
const usersB = await $api.collaboration.fetchUsersForGoal(projectB!.id);
expect(usersA?.find((u) => u.email === user2Email)?.roles).toEqual([editorA!.id]);
expect(usersB?.find((u) => u.email === user2Email)?.roles).toEqual([editorB!.id]);
// and the new project is still usable by its creator
const ownGoals = await $tvApiForSecondUser.goals.fetchGoals(org.id);
expect(ownGoals?.some((g) => g.id === ownProject!.id)).toBe(true);
await $api.organizations.delete(org.id).catch(() => { });
});
// Regression for #98: toggling roles in one goal wiped the user's roles in every other goal
it('toggling roles in one goal must not touch the same user roles in another goal', async () => {
const email = `multi-goal-${Date.now()}@fff.com`;
const goalA = await $api.goals.createGoal({ name: 'Roles isolation goal A' });
const goalB = await $api.goals.createGoal({ name: 'Roles isolation goal B' });
expect(goalA).toBeTruthy();
expect(goalB).toBeTruthy();
const userInA = await $api.collaboration.inviteUserToGoal({ goalId: goalA!.id, email });
const userInB = await $api.collaboration.inviteUserToGoal({ goalId: goalB!.id, email });
expect(userInA?.id).toBeDefined();
// the same collaboration user is shared between goals
expect(userInB?.id).toEqual(userInA?.id);
const rolesA = await $api.collaboration.fetchRolesForGoal(goalA!.id);
const rolesB = await $api.collaboration.fetchRolesForGoal(goalB!.id);
const editorA = rolesA?.find((r) => r.name === 'editor');
const editorB = rolesB?.find((r) => r.name === 'editor');
expect(editorA?.id).toBeDefined();
expect(editorB?.id).toBeDefined();
// assign a role in goal B first
const toggledB = await $api.collaboration.toggleUserRoles({
goalId: goalB!.id,
userId: userInA?.id!,
roles: [editorB?.id!],
});
expect(toggledB).toEqual([editorB?.id!]);
// toggling roles in goal A must not clear the role in goal B
const toggledA = await $api.collaboration.toggleUserRoles({
goalId: goalA!.id,
userId: userInA?.id!,
roles: [editorA?.id!],
});
expect(toggledA).toEqual([editorA?.id!]);
const usersInB = await $api.collaboration.fetchUsersForGoal(goalB!.id);
expect(usersInB?.find((u) => u.email === email)?.roles).toEqual([editorB?.id!]);
// each goal's collaborator list shows only that goal's roles
const usersInA = await $api.collaboration.fetchUsersForGoal(goalA!.id);
expect(usersInA?.find((u) => u.email === email)?.roles).toEqual([editorA?.id!]);
// a role id belonging to another goal must not be assignable through this goal
const toggledForeign = await $api.collaboration.toggleUserRoles({
goalId: goalA!.id,
userId: userInA?.id!,
roles: [editorB?.id!],
}).catch(() => null);
expect(toggledForeign ?? []).toEqual([]);
const usersInB2 = await $api.collaboration.fetchUsersForGoal(goalB!.id);
expect(usersInB2?.find((u) => u.email === email)?.roles).toEqual([editorB?.id!]);
});
it('should handle inviting already existing collaborator', async () => {
const addResult1 = await $api.collaboration.inviteUserToGoal({
goalId: collaborationGoal?.id!,
@@ -32,6 +32,17 @@ services:
condition: service_completed_successfully
env_file:
- .env.taskview
environment:
# The test suite runs against a closed instance (see registration-flag.test.ts);
# no other test creates accounts through public registration paths.
ALLOW_PUBLIC_REGISTRATION: "false"
# IdP-facing URLs are built from this base (see sso-public-urls.test.ts)
API_PUBLIC_URL: "https://api.public.example"
# Domains that skip DNS/HTTP ownership proof — used by sso.test.ts to
# deterministically produce a *verified* config (method 'trusted').
SSO_TRUSTED_DOMAINS: "owned-sso.example"
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD-SHELL", "curl -so /dev/null http://localhost:1401/ || exit 1"]
interval: 3s
@@ -0,0 +1,161 @@
import { TvApi } from '@/tv'
import axios, { type AxiosInstance } from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
/**
* resolveGoalId() for the graph module inspects req.body.source before falling
* back to req.params.id, while deleteEdge acts on req.params.id. A caller must
* not be able to point the guard at a task they own while the handler operates
* on an edge belonging to someone else.
*/
describe('Graph object-level access control', () => {
let ownerApi: TvApi
let outsiderApi: TvApi
let deleteAllGoals: () => Promise<void>
let attackerAxios: AxiosInstance
let victimGoalId: number
let attackerTaskId: number
let victimTaskId: number
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
outsiderApi = init.$tvApiForSecondUser
deleteAllGoals = init.deleteAllGoals
const auth = await axios.post(`${API_URL}/module/auth/login`, {
login: DEFAULT_USER_2,
password: DEFAULT_PASSWORD,
})
attackerAxios = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${auth.data.access}` },
validateStatus: () => true,
})
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim graph ${Date.now()}` })
if (!victimGoal) throw new Error('Failed to create victim goal')
victimGoalId = victimGoal.id
const victimTask = await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `victim-task-${Date.now()}`,
})
if (!victimTask) throw new Error('Failed to create victim task')
victimTaskId = victimTask.id
const attackerGoal = await outsiderApi.goals.createGoal({ name: `Attacker graph ${Date.now()}` })
if (!attackerGoal) throw new Error('Failed to create attacker goal')
const attackerTask = await outsiderApi.tasks.createTask({
goalId: attackerGoal.id,
description: `attacker-task-${Date.now()}`,
})
if (!attackerTask) throw new Error('Failed to create attacker task')
attackerTaskId = attackerTask.id
})
afterAll(async () => {
await deleteAllGoals()
})
async function expectHttpStatus<T>(promise: Promise<T>, status: number): Promise<void> {
try {
await promise
throw new Error(`Expected HTTP ${status} but request succeeded`)
} catch (e: any) {
if (typeof e.message === 'string' && e.message.startsWith('Expected HTTP')) throw e
expect(e.response?.status ?? e.status, `Expected ${status}, got ${e.response?.status ?? e.status}`).toBe(status)
}
}
async function createVictimEdge(): Promise<number> {
const from = await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `victim-edge-from-${Date.now()}`,
})
const to = await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `victim-edge-to-${Date.now()}`,
})
if (!from || !to) throw new Error('Failed to create victim tasks')
const edge = await ownerApi.graph.addEdge({ source: from.id, target: to.id })
if (!edge) throw new Error('Failed to create victim edge')
return edge.id
}
async function victimEdgeExists(edgeId: number): Promise<boolean> {
const edges = await ownerApi.graph.fetchAllEdges(victimGoalId)
return (edges ?? []).some(e => e.id === edgeId)
}
it('rejects deleting another user edge even when a self-owned source task is supplied', async () => {
const edgeId = await createVictimEdge()
const response = await attackerAxios.delete(`/module/graph/${edgeId}`, {
data: { source: attackerTaskId },
})
expect(
await victimEdgeExists(edgeId),
`victim edge ${edgeId} was destroyed by a non-member`,
).toBe(true)
expect(response.status).toBe(403)
})
// A graph lives inside one project, so an edge across two of them is not a
// permission question but an impossible object: it is refused before any
// permission is looked at. Driven through the SDK on purpose — this needs no
// crafted request at all, an ordinary client using the public API reaches it.
it('rejects creating an edge whose endpoints live in different projects', async () => {
await expectHttpStatus(
outsiderApi.graph.addEdge({ source: attackerTaskId, target: victimTaskId }),
400,
)
})
// the mirror of the case above: a foreign source with an own target. This one
// fails closed even without the endpoint comparison (the goal would resolve to
// the victim project and the permission check would deny it), which is exactly
// why it needs pinning — a regression here would be silent
it('rejects creating an edge from a foreign task into a project the caller owns', async () => {
await expectHttpStatus(
outsiderApi.graph.addEdge({ source: victimTaskId, target: attackerTaskId }),
400,
)
})
// both endpoints inside the victim project: the goal resolves cleanly, so this
// is decided purely by the permission check on that goal
it('rejects creating an edge between two tasks of a project the caller is not a member of', async () => {
const second = await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `victim-second-${Date.now()}`,
})
if (!second) throw new Error('Failed to create second victim task')
await expectHttpStatus(
outsiderApi.graph.addEdge({ source: victimTaskId, target: second.id }),
403,
)
})
it('control: without the injected source the guard already rejects the delete', async () => {
const edgeId = await createVictimEdge()
const response = await attackerAxios.delete(`/module/graph/${edgeId}`)
expect(response.status).toBe(403)
expect(await victimEdgeExists(edgeId)).toBe(true)
})
it('control: the owner can still delete their own edge', async () => {
const edgeId = await createVictimEdge()
const deleted = await ownerApi.graph.deleteEdge(edgeId)
expect(deleted).toBeTruthy()
expect(await victimEdgeExists(edgeId)).toBe(false)
})
})
@@ -0,0 +1,113 @@
import { TvApi } from '@/tv'
import axios, { type AxiosInstance } from 'axios'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { API_URL, DEFAULT_PASSWORD, DEFAULT_USER_2, initApi } from './init-api'
/**
* Several guards pick the goal to authorize with `req.body.goalId ? req.body : req.params`,
* while their handlers read `req.params`. Supplying a body that names a goal the caller owns
* must not authorize a request whose path points at someone else's goal.
*/
describe('Guard/handler parameter confusion', () => {
let ownerApi: TvApi
let deleteAllGoals: () => Promise<void>
let attackerAxios: AxiosInstance
let victimGoalId: number
let attackerGoalId: number
let victimColumnId: number
beforeAll(async () => {
const init = await initApi()
ownerApi = init.$tvApi
deleteAllGoals = init.deleteAllGoals
const auth = await axios.post(`${API_URL}/module/auth/login`, {
login: DEFAULT_USER_2,
password: DEFAULT_PASSWORD,
})
attackerAxios = axios.create({
baseURL: API_URL,
headers: { Authorization: `Bearer ${auth.data.access}` },
validateStatus: () => true,
})
const victimGoal = await ownerApi.goals.createGoal({ name: `Victim confusion ${Date.now()}` })
if (!victimGoal) throw new Error('Failed to create victim goal')
victimGoalId = victimGoal.id
const attackerGoal = await axios.post(
`${API_URL}/module/goals`,
{ name: `Attacker confusion ${Date.now()}` },
{ headers: { Authorization: `Bearer ${auth.data.access}` } },
)
attackerGoalId = attackerGoal.data.response.id
await ownerApi.tasks.createTask({
goalId: victimGoalId,
description: `secret-task-${Date.now()}`,
})
const columns = await ownerApi.kanban.fetchAllColumns(victimGoalId)
if (!columns?.length) throw new Error('Victim goal has no kanban columns')
victimColumnId = columns[0].id
})
afterAll(async () => {
await deleteAllGoals()
})
it('rejects reading another goal kanban tasks when a self-owned goalId is put in the body', async () => {
const response = await attackerAxios.request({
method: 'get',
url: `/module/kanban/tasks/${victimGoalId}/${victimColumnId}/0`,
data: { goalId: attackerGoalId, columnId: victimColumnId },
})
expect(
response.status,
`Leaked kanban tasks of goal ${victimGoalId}: ${JSON.stringify(response.data)}`,
).toBe(403)
})
it('rejects reading another goal task order when a self-owned goalId is put in the body', async () => {
const response = await attackerAxios.request({
method: 'get',
url: `/module/kanban/tasks-order/${victimGoalId}/${victimColumnId}/0`,
data: { goalId: attackerGoalId, columnId: victimColumnId },
})
expect(response.status).toBe(403)
})
it('rejects reading another goal role-to-permission matrix when a self-owned goalId is put in the body', async () => {
const response = await attackerAxios.request({
method: 'get',
url: `/module/collaborationroles/role-to-permissions/${victimGoalId}`,
data: { goalId: attackerGoalId },
})
expect(
response.status,
`Leaked role matrix of goal ${victimGoalId}: ${JSON.stringify(response.data)}`,
).toBe(403)
})
it('control: the same requests without a body are already rejected', async () => {
const kanban = await attackerAxios.get(`/module/kanban/tasks/${victimGoalId}/${victimColumnId}/0`)
expect(kanban.status).toBe(403)
const roles = await attackerAxios.get(`/module/collaborationroles/role-to-permissions/${victimGoalId}`)
expect(roles.status).toBe(403)
})
it('control: the owner still reads their own kanban tasks and role matrix', async () => {
const tasks = await ownerApi.kanban
.fetchTasksForColumn(victimGoalId, victimColumnId, 0)
.catch((e: any) => { throw new Error(`kanban read failed: ${e.response?.status}`) })
expect(tasks).toBeDefined()
const matrix = await ownerApi.collaboration.fetchRoleToPermissionsForGoal(victimGoalId)
.catch((e: any) => { throw new Error(`role matrix read failed: ${e.response?.status}`) })
expect(matrix).toBeDefined()
})
})

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