import { BusError } from '../../bus/src/broker.mjs'; import { outcome, all } from './vikunja.mjs'; // Startup checks from addendum B sections 2 and 3, run before the first poll. Any failure refuses // the business: the adapter then answers every task verb for it with the refusal code. export const TESTED_VERSION = 'v2.7.0'; export const BUCKETS = ['todo', 'in-progress', 'in-review', 'blocked', 'done']; // The addendum used "one more than the highest task id the sync bot sees". On a shared instance // that id can belong to a project the bot can't read, which answers 403, not 404. Task ids are // int64, so the largest int32 is missing on any instance this stack will meet. export const MISSING = 2147483647; const refuse = (code) => { throw new BusError(code); }; const expect = (r, status, code) => { if (r.status !== status) refuse(r.status === 401 ? 'tracker-unauthorized' : r.status === 0 || r.status >= 500 ? 'tracker-unavailable' : code); }; export function parseVersion(v) { const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(v ?? ''); return m ? m.slice(1, 4).map(Number) : null; } export async function startup(ctx) { const info = await ctx.call(null, 'GET', '/info'); expect(info, 200, 'tracker-unexpected'); const v = parseVersion(info.json?.version); if (!v || v[0] !== 2 || v[1] < 4) refuse('tracker-version'); const max = info.json?.max_items_per_page; ctx.per = Number.isSafeInteger(max) && max > 0 ? Math.min(50, max) : 50; ctx.version = info.json.version; ctx.untested = info.json.version !== TESTED_VERSION; // expires_at, checked locally: Vikunja accepts a mint with a past expiry (probes.md, O). const mine = ctx.credentials .status() .filter((s) => s.service === 'vikunja' && s.instance.startsWith(ctx.business + '/')); if (!mine.some((s) => s.instance === ctx.business + '/@sync')) refuse('credential-unavailable'); if (mine.some((s) => s.state === 'expired')) refuse('credential-expired'); // Sync identity: the control must pass and the write probe must be refused by scope. expect(await ctx.sync('GET', `/projects/${ctx.project}`), 200, 'tracker-project'); await install(ctx); const probe = await ctx.sync('PATCH', `/tasks/${MISSING}`, { body: {} }); if (probe.status === 404 || probe.status === 403 || outcome(probe) === 'ok') refuse('scope-too-broad'); expect(probe, 401, 'tracker-unexpected'); for (const [role, r] of Object.entries(ctx.roles)) { const control = await ctx.as(role, 'GET', `/tasks/${MISSING}`); if (control.status !== 404 || control.json?.code !== 4002) { expect(control, 404, 'tracker-unexpected'); refuse('tracker-unexpected'); } const probes = [['DELETE', `/tasks/${MISSING}`, undefined]]; if (r.definition !== 'pm') probes.push(['POST', `/tasks/${MISSING}/labels`, { label_id: 1 }]); for (const [method, path, body] of probes) { const p = await ctx.as(role, method, path, { body }); if (p.status === 404 || p.status === 403 || outcome(p) === 'ok') refuse('scope-too-broad'); if (p.status !== 401) refuse(p.status === 0 || p.status >= 500 ? 'tracker-unavailable' : 'tracker-unexpected'); } } // Every configured label must be visible to the pm, or label writes would answer 403. if (ctx.labels.size) { const seen = await all(ctx.asCall(ctx.pm), null, '/labels', {}, ctx.per); if (!seen.ok) refuse(seen.r.status === 401 ? 'tracker-unauthorized' : 'tracker-unavailable'); const ids = new Set(seen.items.map((l) => l?.id)); if ([...ctx.labels.keys()].some((id) => !ids.has(id))) refuse('tracker-labels'); } } // Install checks: one manual kanban view, the five buckets once each, done and default wired. async function install(ctx) { const views = await all(ctx.syncCall, null, `/projects/${ctx.project}/views`, {}, ctx.per); if (!views.ok) refuse('tracker-unavailable'); const kanban = views.items.filter((x) => x?.view_kind === 'kanban'); if (kanban.length !== 1 || kanban[0].bucket_configuration_mode !== 'manual') refuse('tracker-install'); const k = kanban[0]; const buckets = await all(ctx.syncCall, null, `/projects/${ctx.project}/views/${k.id}/buckets`, {}, ctx.per); if (!buckets.ok) refuse('tracker-unavailable'); const ids = {}; for (const title of BUCKETS) { const hits = buckets.items.filter((b) => b?.title === title); if (hits.length !== 1 || !Number.isSafeInteger(hits[0].id) || hits[0].id < 1) refuse('tracker-install'); ids[title] = hits[0].id; } if (k.done_bucket_id !== ids.done || k.default_bucket_id !== ids.todo) refuse('tracker-install'); ctx.view = { id: k.id, done: ids.done, todo: ids.todo, ids, titles: Object.fromEntries(Object.entries(ids).map(([t, id]) => [id, t])), }; }