Merge remote-tracking branch 'origin/release/v1.22.0' into custom

# Conflicts:
#	bun.lock
#	packages/ui/src/components/chat/message/normalizeUserDisplayParts.ts
#	packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx
#	packages/ui/src/components/layout/ContextPanelRail.tsx
#	packages/ui/src/hooks/useKeyboardShortcuts.ts
#	packages/ui/src/lib/i18n/messages/de.ts
#	packages/ui/src/lib/surfaces/DOCUMENTATION.md
#	packages/web/server/lib/fs/routes.test.js
This commit is contained in:
2026-09-03 06:10:05 -04:00
237 changed files with 17813 additions and 1117 deletions
@@ -11,12 +11,25 @@ live transcript costs O(n^2) work for a result the final decode replaces. The
composer shows no text while recording and inserts the full transcript on
stop.
Local TTS (Kokoro via sherpa-onnx OfflineTts) runs in the same worker process
and is exposed as `POST /api/dictation/tts/speak` (JSON `{text, speakerId?,
speed?, model?}` → WAV bytes; 503 with `reasonCode` while the model is
downloading). TTS models live in the same catalog/downloader as STT models
(`local/model-catalog.js` `LOCAL_TTS_MODEL_CATALOG`) and are managed by the
same status/download/delete routes.
Local TTS (Kokoro and Piper/VITS via sherpa-onnx OfflineTts) runs in the same
worker process and is exposed as `POST /api/dictation/tts/speak` (JSON
`{text, speakerId?, speed?, model?, language?, languageSample?}` → WAV bytes; 503 with
`reasonCode` while the model is downloading). TTS models live in the same
catalog/downloader as STT models (`local/model-catalog.js`
`LOCAL_TTS_MODEL_CATALOG`) and are managed by the same status/download/delete
routes.
Each TTS catalog entry declares the `languages` it speaks. With
`language: 'auto'` the service detects the language of `languageSample` — the
whole message the chunk belongs to, sent by the client with every chunk — or
of `text` when no sample is given
(`../tts/language-detect.js`, script plus function-word scoring, no
dependencies) and keeps the caller's model when it speaks that language;
otherwise it switches to the catalog model for the language, downloading it on
first use like any other model, and starts from that model's default speaker
(`defaultSpeakerByLanguage`) instead of the caller's speaker id. A language no
catalog model covers keeps the caller's model, so text is always spoken. The
response carries `X-Speech-Model` and `X-Speech-Language`.
## Ownership
@@ -68,9 +68,19 @@ export const LOCAL_STT_MODEL_CATALOG = {
* Local text-to-speech models (sherpa-onnx OfflineTts). Downloaded and
* managed through the same pipeline as the STT models.
*/
/**
* Local text-to-speech models (sherpa-onnx OfflineTts). Downloaded and
* managed through the same pipeline as the STT models.
*
* `languages` lists the languages a model speaks well; the speech service
* uses it to pick a model for the language a text is written in. Kokoro
* models carry speaker ids (`voices`); a Piper model is one voice for one
* language. `lexicon` entries are joined with commas for sherpa-onnx.
*/
export const LOCAL_TTS_MODEL_CATALOG = {
'kokoro-en-v0_19': {
type: 'kokoro',
languages: ['en'],
archiveUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-en-v0_19.tar.bz2',
extractedDir: 'kokoro-en-v0_19',
@@ -82,6 +92,188 @@ export const LOCAL_TTS_MODEL_CATALOG = {
},
description: 'Kokoro TTS (English, natural voices)',
},
'kokoro-multi-lang-v1_1': {
type: 'kokoro',
languages: ['zh', 'en'],
// sherpa-onnx wires this Kokoro build for Chinese and English only;
// speakers 0-2 are English, 3-102 Chinese.
defaultSpeakerByLanguage: { en: 0, zh: 3 },
archiveUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/kokoro-multi-lang-v1_1.tar.bz2',
extractedDir: 'kokoro-multi-lang-v1_1',
files: {
model: 'model.onnx',
voices: 'voices.bin',
tokens: 'tokens.txt',
espeakData: 'espeak-ng-data',
lexiconEnglish: 'lexicon-us-en.txt',
lexiconChinese: 'lexicon-zh.txt',
},
lexicon: ['lexiconEnglish', 'lexiconChinese'],
description: 'Kokoro TTS (Chinese and English, 103 voices)',
},
// The larger `ukrainian_tts-medium` build is a character-level model
// (`phoneme_type: text`); sherpa-onnx phonemizes every Piper model through
// espeak-ng, which turns that one into noise. `vits-coqui-uk-mai` sounds
// better but reads Cyrillic only and drops every Latin word (file names,
// product names), which is unusable in a coding chat. Lada is an espeak
// model: small, but it reads mixed text.
'piper-uk_UA-lada-x_low': {
type: 'vits',
languages: ['uk'],
archiveUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-uk_UA-lada-x_low.tar.bz2',
extractedDir: 'vits-piper-uk_UA-lada-x_low',
files: {
model: 'uk_UA-lada-x_low.onnx',
tokens: 'tokens.txt',
espeakData: 'espeak-ng-data',
},
description: 'Piper TTS (Ukrainian)',
},
'piper-de_DE-thorsten-medium': {
type: 'vits',
languages: ['de'],
archiveUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-de_DE-thorsten-medium.tar.bz2',
extractedDir: 'vits-piper-de_DE-thorsten-medium',
files: {
model: 'de_DE-thorsten-medium.onnx',
tokens: 'tokens.txt',
espeakData: 'espeak-ng-data',
},
description: 'Piper TTS (German)',
},
'piper-fr_FR-siwis-medium': {
type: 'vits',
languages: ['fr'],
archiveUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-fr_FR-siwis-medium.tar.bz2',
extractedDir: 'vits-piper-fr_FR-siwis-medium',
files: {
model: 'fr_FR-siwis-medium.onnx',
tokens: 'tokens.txt',
espeakData: 'espeak-ng-data',
},
description: 'Piper TTS (French)',
},
'piper-es_ES-davefx-medium': {
type: 'vits',
languages: ['es'],
archiveUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-es_ES-davefx-medium.tar.bz2',
extractedDir: 'vits-piper-es_ES-davefx-medium',
files: {
model: 'es_ES-davefx-medium.onnx',
tokens: 'tokens.txt',
espeakData: 'espeak-ng-data',
},
description: 'Piper TTS (Spanish)',
},
'piper-it_IT-paola-medium': {
type: 'vits',
languages: ['it'],
archiveUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-it_IT-paola-medium.tar.bz2',
extractedDir: 'vits-piper-it_IT-paola-medium',
files: {
model: 'it_IT-paola-medium.onnx',
tokens: 'tokens.txt',
espeakData: 'espeak-ng-data',
},
description: 'Piper TTS (Italian)',
},
'piper-pt_BR-faber-medium': {
type: 'vits',
languages: ['pt'],
archiveUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-pt_BR-faber-medium.tar.bz2',
extractedDir: 'vits-piper-pt_BR-faber-medium',
files: {
model: 'pt_BR-faber-medium.onnx',
tokens: 'tokens.txt',
espeakData: 'espeak-ng-data',
},
description: 'Piper TTS (Portuguese (Brazil))',
},
'piper-pl_PL-gosia-medium': {
type: 'vits',
languages: ['pl'],
archiveUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-pl_PL-gosia-medium.tar.bz2',
extractedDir: 'vits-piper-pl_PL-gosia-medium',
files: {
model: 'pl_PL-gosia-medium.onnx',
tokens: 'tokens.txt',
espeakData: 'espeak-ng-data',
},
description: 'Piper TTS (Polish)',
},
'piper-ru_RU-irina-medium': {
type: 'vits',
languages: ['ru'],
archiveUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-ru_RU-irina-medium.tar.bz2',
extractedDir: 'vits-piper-ru_RU-irina-medium',
files: {
model: 'ru_RU-irina-medium.onnx',
tokens: 'tokens.txt',
espeakData: 'espeak-ng-data',
},
description: 'Piper TTS (Russian)',
},
'piper-nl_NL-pim-medium': {
type: 'vits',
languages: ['nl'],
archiveUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-nl_NL-pim-medium.tar.bz2',
extractedDir: 'vits-piper-nl_NL-pim-medium',
files: {
model: 'nl_NL-pim-medium.onnx',
tokens: 'tokens.txt',
espeakData: 'espeak-ng-data',
},
description: 'Piper TTS (Dutch)',
},
'piper-cs_CZ-jirka-medium': {
type: 'vits',
languages: ['cs'],
archiveUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-cs_CZ-jirka-medium.tar.bz2',
extractedDir: 'vits-piper-cs_CZ-jirka-medium',
files: {
model: 'cs_CZ-jirka-medium.onnx',
tokens: 'tokens.txt',
espeakData: 'espeak-ng-data',
},
description: 'Piper TTS (Czech)',
},
'piper-tr_TR-dfki-medium': {
type: 'vits',
languages: ['tr'],
archiveUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-tr_TR-dfki-medium.tar.bz2',
extractedDir: 'vits-piper-tr_TR-dfki-medium',
files: {
model: 'tr_TR-dfki-medium.onnx',
tokens: 'tokens.txt',
espeakData: 'espeak-ng-data',
},
description: 'Piper TTS (Turkish)',
},
'piper-sv_SE-nst-medium': {
type: 'vits',
languages: ['sv'],
archiveUrl:
'https://github.com/k2-fsa/sherpa-onnx/releases/download/tts-models/vits-piper-sv_SE-nst-medium.tar.bz2',
extractedDir: 'vits-piper-sv_SE-nst-medium',
files: {
model: 'sv_SE-nst-medium.onnx',
tokens: 'tokens.txt',
espeakData: 'espeak-ng-data',
},
description: 'Piper TTS (Swedish)',
},
};
export const DEFAULT_LOCAL_STT_MODEL = 'parakeet-tdt-0.6b-v2-int8';
@@ -131,6 +323,34 @@ export function getLocalSttModelSpec(modelId) {
};
}
/**
* The local TTS model to use for a language, preferring the model the user
* selected when it speaks that language. Returns null when no catalog model
* covers the language, in which case callers keep the selected model.
* @param {string} language BCP-47 primary subtag (`uk`, `zh`...)
* @param {string} [preferredModelId]
* @returns {string | null}
*/
export function resolveLocalTtsModelForLanguage(language, preferredModelId) {
const speaks = (modelId) => LOCAL_TTS_MODEL_CATALOG[modelId]?.languages?.includes(language) === true;
if (preferredModelId && speaks(preferredModelId)) return preferredModelId;
const candidate = LOCAL_TTS_MODEL_IDS.find(speaks);
return candidate ?? null;
}
/**
* The speaker id a model should use for a language when the caller's
* speaker was chosen for another language. `undefined` keeps the caller's
* speaker.
* @param {string} modelId
* @param {string} language
* @returns {number | undefined}
*/
export function getLocalTtsDefaultSpeaker(modelId, language) {
const speaker = LOCAL_TTS_MODEL_CATALOG[modelId]?.defaultSpeakerByLanguage?.[language];
return Number.isInteger(speaker) ? speaker : undefined;
}
/**
* @param {string} modelsDir
* @param {string} modelId
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_LOCAL_TTS_MODEL,
LOCAL_TTS_MODEL_CATALOG,
getLocalSttModelSpec,
getLocalTtsDefaultSpeaker,
resolveLocalTtsModelForLanguage,
} from './model-catalog.js';
describe('local TTS catalog', () => {
it('keeps the selected model when it speaks the language', () => {
expect(resolveLocalTtsModelForLanguage('en', DEFAULT_LOCAL_TTS_MODEL)).toBe(DEFAULT_LOCAL_TTS_MODEL);
expect(resolveLocalTtsModelForLanguage('zh', 'kokoro-multi-lang-v1_1')).toBe('kokoro-multi-lang-v1_1');
});
it('picks a catalog model for a language the selected model lacks', () => {
expect(resolveLocalTtsModelForLanguage('uk', DEFAULT_LOCAL_TTS_MODEL)).toBe('piper-uk_UA-lada-x_low');
expect(resolveLocalTtsModelForLanguage('zh', DEFAULT_LOCAL_TTS_MODEL)).toBe('kokoro-multi-lang-v1_1');
});
it('returns null for a language no model covers', () => {
expect(resolveLocalTtsModelForLanguage('xx', DEFAULT_LOCAL_TTS_MODEL)).toBeNull();
});
it('gives Chinese a Chinese speaker on the multi-language Kokoro', () => {
expect(getLocalTtsDefaultSpeaker('kokoro-multi-lang-v1_1', 'zh')).toBe(3);
expect(getLocalTtsDefaultSpeaker('kokoro-multi-lang-v1_1', 'en')).toBe(0);
expect(getLocalTtsDefaultSpeaker('piper-uk_UA-lada-x_low', 'uk')).toBeUndefined();
});
it('every TTS entry declares its languages and installable files', () => {
for (const [id, spec] of Object.entries(LOCAL_TTS_MODEL_CATALOG)) {
expect(spec.languages.length, id).toBeGreaterThan(0);
expect(spec.archiveUrl, id).toMatch(/^https:\/\/github\.com\/k2-fsa\/sherpa-onnx\/releases\/download\/tts-models\//);
const resolved = getLocalSttModelSpec(id);
expect(resolved.requiredFiles, id).toContain(spec.files.model);
for (const key of spec.lexicon ?? []) {
expect(spec.files[key], `${id} lexicon ${key}`).toBeTruthy();
}
}
});
});
@@ -1,5 +1,5 @@
/**
* Sherpa-onnx offline TTS (Kokoro). Runs inside the dictation worker process
* Sherpa-onnx offline TTS (Kokoro and Piper/VITS). Runs inside the dictation worker process
* only — never load the native addon in the main server process.
*/
@@ -23,20 +23,49 @@ function float32ToPcm16le(samples) {
return Buffer.from(out.buffer, out.byteOffset, out.byteLength);
}
/**
* sherpa-onnx model config for one catalog entry. Kokoro carries a voices
* bank (speaker ids) and optional lexicons; a Piper/VITS model is a single
* voice with espeak-ng phonemization.
* @param {{ modelDir: string, type?: string, files: Record<string, string>, lexicon?: string[] }} config
*/
function buildModelConfig(config) {
const file = (key, label) => {
const filePath = path.join(config.modelDir, config.files[key]);
assertFileExists(filePath, label);
return filePath;
};
const modelPath = file('model', 'TTS model');
const tokensPath = file('tokens', 'TTS tokens');
if (config.type === 'vits') {
// Piper models phonemize through espeak-ng (`espeakData`); character
// models (Coqui) read the text directly and carry no espeak data.
const dataDir = config.files.espeakData ? file('espeakData', 'TTS espeak-ng dataDir') : '';
return { vits: { model: modelPath, tokens: tokensPath, ...(dataDir ? { dataDir } : {}), lengthScale: 1.0 } };
}
const dataDir = file('espeakData', 'TTS espeak-ng dataDir');
const voicesPath = file('voices', 'TTS voices');
const lexicon = (config.lexicon ?? []).map((key) => file(key, 'TTS lexicon')).join(',');
return {
kokoro: {
model: modelPath,
voices: voicesPath,
tokens: tokensPath,
dataDir,
lengthScale: 1.0,
...(lexicon ? { lexicon } : {}),
},
};
}
export class SherpaTtsEngine {
/**
* @param {{ modelDir: string, files: { model: string, voices: string, tokens: string, espeakData: string }, numThreads?: number }} config
* @param {{ modelDir: string, type?: string, files: Record<string, string>, lexicon?: string[], numThreads?: number }} config
*/
constructor(config) {
const modelPath = path.join(config.modelDir, config.files.model);
const voicesPath = path.join(config.modelDir, config.files.voices);
const tokensPath = path.join(config.modelDir, config.files.tokens);
const dataDir = path.join(config.modelDir, config.files.espeakData);
assertFileExists(modelPath, 'TTS model');
assertFileExists(voicesPath, 'TTS voices');
assertFileExists(tokensPath, 'TTS tokens');
assertFileExists(dataDir, 'TTS espeak-ng dataDir');
const model = buildModelConfig(config);
const sherpa = loadSherpaOnnxNode();
if (typeof sherpa.OfflineTts !== 'function') {
@@ -44,15 +73,7 @@ export class SherpaTtsEngine {
}
this.tts = new sherpa.OfflineTts({
model: {
kokoro: {
model: modelPath,
voices: voicesPath,
tokens: tokensPath,
dataDir,
lengthScale: 1.0,
},
},
model,
numThreads: config.numThreads ?? 2,
provider: 'cpu',
maxNumSentences: 1,
@@ -102,7 +102,9 @@ function getTtsEngine(modelsDir, modelId) {
const spec = getLocalSttModelSpec(modelId);
const created = new SherpaTtsEngine({
modelDir: getLocalSttModelDir(modelsDir, modelId),
type: spec.type,
files: spec.files,
lexicon: spec.lexicon,
numThreads: 2,
});
ttsEngines.set(key, created);
@@ -63,6 +63,8 @@ export function createDictationRuntime({
model: typeof req.body?.model === 'string' ? req.body.model : undefined,
speakerId: Number.isInteger(req.body?.speakerId) ? req.body.speakerId : undefined,
speed: typeof req.body?.speed === 'number' ? req.body.speed : undefined,
language: req.body?.language === 'auto' ? 'auto' : undefined,
languageSample: typeof req.body?.languageSample === 'string' ? req.body.languageSample.slice(0, 4000) : undefined,
});
if (result.error) {
res.status(503).json({
@@ -73,6 +75,8 @@ export function createDictationRuntime({
return;
}
res.setHeader('Content-Type', result.format || 'audio/wav');
res.setHeader('X-Speech-Model', result.modelId);
if (result.language) res.setHeader('X-Speech-Language', result.language);
res.send(result.audio);
} catch (error) {
res.status(500).json({ error: error?.message || 'Failed to synthesize speech' });
+27 -4
View File
@@ -1,3 +1,4 @@
import { detectTextLanguage } from '../tts/language-detect.js';
/**
* Dictation service: resolves STT providers, tracks local model download
* state, and exposes a readiness snapshot for the status route.
@@ -16,6 +17,8 @@ import { OpenAICompatibleTranscriptionSession } from './openai-compatible-sessio
import {
DEFAULT_LOCAL_STT_MODEL,
DEFAULT_LOCAL_TTS_MODEL,
getLocalTtsDefaultSpeaker,
resolveLocalTtsModelForLanguage,
LOCAL_STT_MODEL_CATALOG,
LOCAL_STT_MODEL_IDS,
LOCAL_TTS_MODEL_CATALOG,
@@ -220,10 +223,30 @@ export function createDictationService({ modelsDir }) {
/**
* Synthesize speech with the local TTS model. Returns WAV bytes, or a
* readiness error while the model is missing/downloading.
* @param {{ text: string, model?: string, speakerId?: number, speed?: number }} options
*
* With `language: 'auto'` the text's language decides the model: the
* caller's model when it speaks that language, otherwise the catalog
* model for it (downloaded on first use, reported as in-progress until it
* lands). The caller's speaker id is kept only on the caller's model; a
* substitute model starts from its own default speaker for the language.
* A language no catalog model covers keeps the caller's model, so text is
* never silently dropped.
* `languageSample` is the whole message the chunk belongs to (or a prefix
* of it): the language is judged on that, never on a short chunk alone.
* @param {{ text: string, model?: string, speakerId?: number, speed?: number, language?: string, languageSample?: string }} options
*/
const synthesizeSpeech = async ({ text, model, speakerId, speed }) => {
const modelId = isLocalTtsModelId(model) ? model : DEFAULT_LOCAL_TTS_MODEL;
const synthesizeSpeech = async ({ text, model, speakerId, speed, language, languageSample }) => {
const requestedModelId = isLocalTtsModelId(model) ? model : DEFAULT_LOCAL_TTS_MODEL;
let modelId = requestedModelId;
let resolvedLanguage = null;
if (language === 'auto') {
resolvedLanguage = detectTextLanguage(languageSample || text).language;
const forLanguage = resolveLocalTtsModelForLanguage(resolvedLanguage, requestedModelId);
if (forLanguage && forLanguage !== requestedModelId) {
modelId = forLanguage;
speakerId = getLocalTtsDefaultSpeaker(modelId, resolvedLanguage);
}
}
const installed = await isLocalSttModelInstalled(modelsDir, modelId);
if (!installed) {
const state = downloadStates.get(modelId);
@@ -251,7 +274,7 @@ export function createDictationService({ modelsDir }) {
speakerId,
speed,
});
return { audio: result.audio, format: result.format };
return { audio: result.audio, format: result.format, modelId, language: resolvedLanguage };
};
/**
@@ -23,6 +23,10 @@ Own filesystem API behavior for the web server runtime, including workspace-boun
- `POST /api/fs/exec`
- `GET /api/fs/exec/:jobId`
- `GET /api/fs/list`
- `GET /api/fs/git-dirs` — shallow nested git repository discovery for the
Git tab (depth- and visit-capped readdir walk; `.git` directory, file, or
symlink marks a repository boundary; junk directories and symlinks are
never descended into)
- Owns exec job queue state (`execJobs`) and lifecycle/TTL pruning.
- Enforces workspace boundary checks with active project + worktree fallback support.
- The active project directory is validated with `fs.realpath`, so when the project root is itself a symlink the workspace base no longer matches the paths the client sends. Workspace resolution therefore retries against the raw directory the client requested (`requestedDirectory` from `resolveProjectDirectory`) before falling back to worktree roots. Symlinks are still resolved afterwards, and write/exec routes keep their canonical containment check against the resolved base.
+129
View File
@@ -297,6 +297,79 @@ const resolveWorkspacePathFromContext = async ({ req, targetPath, resolveProject
});
};
// Nested repository discovery bounds: only shallow walks are useful for the
// Git tab's "pick a repository" picker, and deep/monorepo trees can explode
// otherwise. Directories deeper than maxDepth or beyond the visit cap are
// silently not searched.
const GIT_DIRS_MAX_DEPTH = 3;
const GIT_DIRS_MAX_DIRS = 100;
const GIT_DIRS_SKIP_LIST = new Set(['node_modules', 'dist', 'build', '.venv', 'target', '.next']);
// Walks rootPath and returns every nested git repository path (a directory
// containing a `.git` entry — a directory, a worktree pointer file, or a
// symlink). A repository boundary stops descent: nested repos inside repos
// are not reported. The root itself, when it is a repo, yields no results.
const findGitDirectories = async ({ rootPath, fsPromises, path: pathModule, maxDepth, maxDirs }) => {
const results = [];
let visited = 0;
const walk = async (dir, depth) => {
if (visited >= maxDirs) {
return;
}
let dirents;
try {
dirents = await fsPromises.readdir(dir, { withFileTypes: true });
} catch (error) {
// Unreadable subtree — skip it unless it is the root itself, which the
// route maps to 403/404/500 through the shared error handling.
if (dir === rootPath) {
throw error;
}
return;
}
visited += 1;
let isRepoBoundary = false;
const subdirectories = [];
for (const dirent of dirents) {
if (dirent.name === '.git') {
isRepoBoundary = true;
continue;
}
if (!dirent.isDirectory() || dirent.isSymbolicLink()) {
continue;
}
if (GIT_DIRS_SKIP_LIST.has(dirent.name)) {
continue;
}
if (depth >= maxDepth) {
continue;
}
subdirectories.push(dirent.name);
}
if (isRepoBoundary) {
if (dir !== rootPath) {
results.push(dir);
}
return;
}
subdirectories.sort();
for (const name of subdirectories) {
if (visited >= maxDirs) {
break;
}
await walk(pathModule.join(dir, name), depth + 1);
}
};
await walk(rootPath, 0);
return results;
};
const deriveCloneDirectoryName = (remoteUrl) => {
const remote = typeof remoteUrl === 'string' ? remoteUrl.trim() : '';
if (!remote) return '';
@@ -1607,4 +1680,60 @@ export const registerFsRoutes = (app, dependencies) => {
return res.status(500).json({ error: (error && error.message) || 'Failed to list directory' });
}
});
app.get('/api/fs/git-dirs', async (req, res) => {
const rawPath = typeof req.query.path === 'string' && req.query.path.trim().length > 0
? req.query.path.trim()
: '';
if (!rawPath) {
return res.status(400).json({ error: 'Path is required' });
}
try {
const resolved = await resolveWorkspacePathFromContext({
req,
targetPath: rawPath,
resolveProjectDirectory,
path,
os,
normalizeDirectoryPath,
openchamberUserConfigRoot,
});
if (!resolved.ok) {
return res.status(400).json({ error: resolved.error });
}
const stats = await fsPromises.stat(resolved.resolved);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' });
}
const repositories = await findGitDirectories({
rootPath: resolved.resolved,
fsPromises,
path,
maxDepth: GIT_DIRS_MAX_DEPTH,
maxDirs: GIT_DIRS_MAX_DIRS,
});
return res.json({
path: resolved.resolved,
repositories: repositories.map((repoPath) => ({
path: repoPath,
name: path.basename(repoPath),
})),
});
} catch (error) {
const err = error;
const code = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
if (code === 'ENOENT') {
return res.status(404).json({ error: 'Directory not found', reason: 'not-found' });
}
if (isOsPermissionError(err)) {
return sendOsPermissionDenied(res, 'Access to directory denied');
}
console.error('Failed to find git directories:', error);
return res.status(500).json({ error: (error && error.message) || 'Failed to find git directories' });
}
});
};
+221
View File
@@ -796,6 +796,8 @@ describe('fs read', () => {
warn.mockRestore();
});
});
describe('fs stat', () => {
it('returns exists:false for an outside-workspace path when optional', async () => {
const fsPromises = {
@@ -1202,6 +1204,225 @@ describe('fs list symlink path space (issue 2627)', () => {
}
});
describe('fs git-dirs', () => {
const createDirent = (name, type) => ({
name,
isDirectory: () => type === 'dir',
isFile: () => type === 'file',
isSymbolicLink: () => type === 'symlink',
});
// tree maps directory path -> [[name, type], ...]
const registerGitDirs = (tree, { stat, readdir: readdirOverride } = {}) => {
const { app, getRoute } = createRouteRegistry();
const readdir = readdirOverride ?? vi.fn(async (dirPath) => (tree[dirPath] ?? []).map(([name, type]) => createDirent(name, type)));
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
stat: stat ?? vi.fn(async (targetPath) => ({ isDirectory: () => Boolean(tree[targetPath]) })),
readdir,
},
spawn: vi.fn(),
crypto: { randomUUID: () => 'job-0' },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: async () => ({ directory: '/workspace' }),
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
});
return { handler: getRoute('GET', '/api/fs/git-dirs'), readdir };
};
const callGitDirs = async (handler, query) => {
const res = createMockResponse();
await handler({ query: query ?? {} }, res);
return res;
};
it('returns an empty list when the root itself is a repository', async () => {
const { handler, readdir } = registerGitDirs({
'/workspace': [['.git', 'dir'], ['proj-a', 'dir']],
'/workspace/proj-a': [['.git', 'dir']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ path: '/workspace', repositories: [] });
expect(readdir).toHaveBeenCalledTimes(1);
});
it('finds nested repositories with a .git directory', async () => {
const { handler } = registerGitDirs({
'/workspace': [['proj-a', 'dir'], ['proj-b', 'dir']],
'/workspace/proj-a': [['.git', 'dir'], ['src', 'dir']],
'/workspace/proj-a/src': [['index.ts', 'file']],
'/workspace/proj-b': [['.git', 'dir']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body.repositories).toEqual([
{ path: '/workspace/proj-a', name: 'proj-a' },
{ path: '/workspace/proj-b', name: 'proj-b' },
]);
});
it('treats a .git file (linked worktree) as a repository boundary', async () => {
const { handler } = registerGitDirs({
'/workspace': [['worktree', 'dir']],
'/workspace/worktree': [['.git', 'file']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body.repositories).toEqual([{ path: '/workspace/worktree', name: 'worktree' }]);
});
it('stops descending at repository boundaries', async () => {
const { handler, readdir } = registerGitDirs({
'/workspace': [['outer', 'dir']],
'/workspace/outer': [['.git', 'dir'], ['inner', 'dir']],
'/workspace/outer/inner': [['.git', 'dir']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body.repositories).toEqual([{ path: '/workspace/outer', name: 'outer' }]);
expect(readdir).not.toHaveBeenCalledWith('/workspace/outer/inner', { withFileTypes: true });
});
it('does not descend past the depth cap', async () => {
const { handler } = registerGitDirs({
'/workspace': [['a', 'dir']],
'/workspace/a': [['b', 'dir']],
'/workspace/a/b': [['c', 'dir']],
'/workspace/a/b/c': [['.git', 'dir'], ['d', 'dir']],
'/workspace/a/b/c/d': [['.git', 'dir']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body.repositories).toEqual([{ path: '/workspace/a/b/c', name: 'c' }]);
});
it('skips junk directories', async () => {
const { handler, readdir } = registerGitDirs({
'/workspace': [['node_modules', 'dir'], ['dist', 'dir'], ['real', 'dir']],
'/workspace/node_modules': [['dep', 'dir']],
'/workspace/node_modules/dep': [['.git', 'dir']],
'/workspace/dist': [['.git', 'dir']],
'/workspace/real': [['.git', 'dir']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body.repositories).toEqual([{ path: '/workspace/real', name: 'real' }]);
expect(readdir).not.toHaveBeenCalledWith('/workspace/node_modules', { withFileTypes: true });
});
it('never descends into symbolic links', async () => {
const { handler } = registerGitDirs({
'/workspace': [['link', 'symlink'], ['real', 'dir']],
'/workspace/real': [['.git', 'dir']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body.repositories).toEqual([{ path: '/workspace/real', name: 'real' }]);
});
it('returns repositories in deterministic order', async () => {
const { handler } = registerGitDirs({
'/workspace': [['zebra', 'dir'], ['alpha', 'dir']],
'/workspace/zebra': [['.git', 'dir']],
'/workspace/alpha': [['.git', 'dir']],
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.body.repositories.map((repo) => repo.name)).toEqual(['alpha', 'zebra']);
});
it('returns 400 when path is missing', async () => {
const { handler } = registerGitDirs({});
const res = await callGitDirs(handler, {});
expect(res.statusCode).toBe(400);
expect(res.body.error).toBe('Path is required');
});
it('returns 400 when the path is not a directory', async () => {
const { handler } = registerGitDirs({
'/workspace': [['file.txt', 'file']],
}, {
stat: vi.fn(async (targetPath) => ({ isDirectory: () => targetPath !== '/workspace/file.txt' })),
});
const res = await callGitDirs(handler, { path: '/workspace/file.txt' });
expect(res.statusCode).toBe(400);
expect(res.body).toEqual({ error: 'Specified path is not a directory', reason: 'not-directory' });
});
it('returns 404 when the directory does not exist', async () => {
const error = Object.assign(new Error('missing'), { code: 'ENOENT' });
const { handler } = registerGitDirs({}, {
stat: vi.fn(async () => { throw error; }),
});
const res = await callGitDirs(handler, { path: '/workspace/missing' });
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'Directory not found', reason: 'not-found' });
});
for (const code of ['EACCES', 'EPERM']) {
it(`maps root ${code} to the os-permission contract`, async () => {
const error = Object.assign(new Error('denied'), { code });
const { handler } = registerGitDirs({}, {
stat: vi.fn(async () => ({ isDirectory: () => true })),
readdir: vi.fn(async () => { throw error; }),
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(403);
expect(res.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' });
});
}
it('skips unreadable subtrees without failing the scan', async () => {
const tree = {
'/workspace': [['blocked', 'dir'], ['open', 'dir']],
'/workspace/open': [['.git', 'dir']],
};
const blockedError = Object.assign(new Error('denied'), { code: 'EACCES' });
const { handler } = registerGitDirs(tree, {
readdir: vi.fn(async (dirPath) => {
if (dirPath === '/workspace/blocked') {
throw blockedError;
}
return (tree[dirPath] ?? []).map(([name, type]) => createDirent(name, type));
}),
});
const res = await callGitDirs(handler, { path: '/workspace' });
expect(res.statusCode).toBe(200);
expect(res.body.repositories).toEqual([{ path: '/workspace/open', name: 'open' }]);
});
});
describe('fs stat directory scope (issue 3019)', () => {
// Wires the real project-directory runtime so the stat route resolves the
// workspace exactly as the server does: explicit x-opencode-directory header
@@ -0,0 +1,97 @@
# Linear Module Documentation
## Purpose
This module owns Linear OAuth, issue lookup, Linear-team-to-project mapping, issue status updates, and session status comments on Linear issues. Credentials live on the OpenChamber server, so web, desktop, and a phone paired to that host share them. You can store more than one Linear workspace; exactly one is current. Issue list, mapping, and new OAuth default to the current workspace. Session status comments use the workspace that started the session. The right-hand context panel lists issues for the current workspace, can switch workspace, filters the list, shows a read-only card, changes status or closes the issue, and starts a session or worktree. Start session stays visible in a footer while the issue card scrolls. The chat picker lists issues and attaches them to a message. New Worktree can also start from a Linear issue in the currently active project. A session started from a Linear issue can post started/completed/failure comments, each with an OpenChamber session link. Those comments are opt-in and only appear when this server has a publicly reachable address.
VS Code omits Linear (`RuntimeAPIs.linear` is optional). Hide Linear UI when the API is missing.
## Entrypoints and structure
- `packages/web/server/lib/linear/index.js`: public server entrypoint. `routes.js` loads it lazily with `await import('./index.js')`.
- `packages/web/server/lib/linear/routes.js`: Express registration for the public callback, `/api/linear/auth/*`, `/api/linear/issues/*`, `/api/linear/mapping`, and `/api/linear/session-status`.
- `packages/web/server/lib/linear/auth.js`: auth file, client id, scopes, redirect URI.
- `packages/web/server/lib/linear/oauth.js`: authorization-code + PKCE S256, public callback broker handoff, refresh, revoke.
- `packages/web/server/lib/linear/client.js`: GraphQL helper, viewer/organization lookup, and access-token refresh. GraphQL errors prefer `extensions.userPresentableMessage` / validation constraints over the generic `Argument Validation Error` label. User-facing Linear errors set `LinearApiError.userError`. Requests send `public-file-urls-expire-in: 3600` so file URLs in issue descriptions and comments are temporarily readable in the panel.
- `packages/web/server/lib/linear/issues.js`: list/search/get issues, team workflow states, `issueUpdate`, and `commentCreate`. Parses identifiers and Linear URLs. `issueUpdate` resolves identifiers to UUIDs first because Linear's mutation does not accept `ENG-12`. List/get include `state.id`, `priority` (04), and labels (`id`, `name`, sanitized hex `color`) so the panel can show them and update status.
- `packages/web/server/lib/linear/teams.js`: list Linear teams for mapping UI.
- `packages/web/server/lib/linear/mapping.js`: persist default and per-team OpenChamber project paths. Separate from the auth file so disconnect does not wipe maps.
- `packages/web/server/lib/linear/status.js`: persist per-session started/completed/failure flags and post the matching Linear comment with an open-session URL. Posts nothing unless the user opted in and the session origin is public; `isPublicSessionOrigin` rejects loopback, private LAN, carrier-grade NAT, link-local and single-label hosts. The dedupe file keeps the newest 500 sessions.
- `packages/web/server/lib/linear/status-runtime.js`: on the OpenCode event hub, first `session.status` idle after started posts completed once; `session.error` (except abort) posts failure once.
- `packages/web/src/api/linear.ts`: web client wrapper. Electron and hosted/Capacitor mobile reuse it. VS Code omits `linear`.
## Public routes
- `GET /linear/oauth/callback`: public fallback for an explicitly configured direct redirect URI. The built-in flow uses the stable callback broker instead, because desktop and self-hosted instances may have private or dynamic addresses.
- `GET /api/linear/auth/status`: connected flag, current user/organization/scope, and `workspaces` (id, name, current, user, authorizedAt). Never returns tokens. A 401 on the current workspace drops that workspace only; if another remains, status returns that one instead of disconnected. Identity refresh does not bump `authorizedAt`.
- `POST /api/linear/auth/start`: returns `{ authorizationUrl, expiresIn, scope }`. Body may include `origin: "desktop"` so the callback page can raise the desktop window. The authorize URL uses `prompt=consent` so Add workspace can pick a different Linear org. Completing OAuth stores or replaces that org and makes it current.
- `POST /api/linear/auth/activate`: body `{ organizationId }`. Makes that stored workspace current. 400 if the id is missing, 404 if it is not stored.
- `DELETE /api/linear/auth`: revokes the current workspace refresh token when present, then drops that workspace only. Other stored workspaces stay. Mapping is kept.
- `GET /api/linear/issues/list?query=&cursor=&status=&assignee=&teamId=&priority=`: issues from the current workspace. Omitted `status` is incomplete states (same as the chat picker). The panel sends `all`, `backlog`, `todo` (Linear `unstarted`), `started` (In Progress, excluding the In Review name), `inReview` (state name In Review), `completed` (Done), `canceled` (excluding the Duplicate name), or `duplicate` (state type or name Duplicate). `assignee` is `any` (default) or `me`. `teamId` limits the list to that Linear team. `priority` is `all` (default), `none`, `urgent`, `high`, `medium`, or `low`. An identifier or Linear URL returns that issue even if it is completed and ignores the other filters. Each issue includes `state.id` when Linear sends it, plus `priority` (0 none through 4 low) and `labels`. Never returns tokens.
- `GET /api/linear/issues/get?id=`: one issue by UUID or identifier, including description, comments, team, `state.id`, priority, and labels.
- `GET /api/linear/issues/states?teamId=`: workflow states for that Linear team (`id`, `name`, `type`, `position`), ordered like Linear's workflow: type (backlog, unstarted, started, completed, canceled) then position. Missing `teamId` is 400. Linear not-found or validation errors are 400 with Linear's presentable message. Disconnected is `{ connected: false }` with HTTP 200.
- `POST /api/linear/issues/update`: body `{ id, stateId }`. `id` may be a UUID, identifier, or Linear URL; identifiers are resolved before `issueUpdate` because Linear's mutation requires a UUID. Returns the updated issue. Closing an issue is this same call with the team's first `type: completed` state. Missing `id` or `stateId` is 400. Linear validation (for example a non-UUID `stateId`) is 400 with Linear's presentable message. A GraphQL 401 clears that workspace only. Disconnected is `{ connected: false }` with HTTP 200.
- `GET /api/linear/mapping`: stored default project plus live Linear teams with their mapped paths. Missing file is empty mapping. Malformed file is 500, not empty success. Disconnected is `{ connected: false }` with HTTP 200.
- `PUT /api/linear/mapping`: replace default project and per-team paths. Body `{ defaultProjectPath, teamProjectPaths }`. Failed write does not touch tokens. Disconnected is `{ connected: false }` and does not save.
- `GET /api/linear/preferences`: `{ sessionComments }`. `PUT /api/linear/preferences` with body `{ sessionComments: boolean }` replaces it and returns the stored value. A non-boolean body is 400. The preference is server-side because the event hub posts completed/failure without going through the interface.
- `POST /api/linear/session-status`: post a started/completed/failure comment on the linked Linear issue. Body `{ kind, sessionId, issueIdentifier?, sessionOrigin? }`. `started` requires `issueIdentifier`. `completed` and `failure` reuse the stored issue and open URL from `started`. Each kind posts at most once per session. Answers in this order: disconnected is `{ connected: false }` with HTTP 200; comments turned off is `skipped: 'disabled'`; a `sessionOrigin` nobody else can reach is `skipped: 'origin-not-public'`. `sessionOrigin` must be `http` or `https` with no path, and must resolve to a public host — loopback, private LAN and desktop deep links post no comment at all rather than a link only its author can open. Comment bodies are one markdown link: `[OpenChamber session started](url)` so Linear keeps the `?session=` query. The comment carries no issue or session title: it already sits on the issue, and titles routinely contain brackets that would break the link. Invalid body is 400.
`POST /api/linear/auth/start`, `PUT /api/linear/mapping`, `POST /api/linear/issues/update`, and `POST /api/linear/session-status` parse JSON on the route (`16kb`). They are not on the `/api` 50mb allowlist.
Disconnected list/get/states/update/mapping/session-status return `{ connected: false }` with HTTP 200 so the picker and panel can show an empty state. Missing `id` on get is 400. Missing `teamId` on states is 400.
## Auth storage and config
- Auth storage: `~/.config/openchamber/linear-auth.json` (or `$OPENCHAMBER_DATA_DIR/linear-auth.json`). Shape is `{ workspaces: [ { accessToken, refreshToken, user, organization, workspaceId, current, authorizedAt, ... } ] }`. `workspaceId` is the Linear organization id, or `user:<id>` when there is no org, or `legacy` for a migrated token with neither. A legacy single-object file is rewritten to this list on read. Reconnecting the same org replaces that slot.
- Mapping storage: `~/.config/openchamber/linear-mapping.json` (same data dir). Shape is `{ workspaces: { [workspaceId]: { defaultProjectPath, teamProjectPaths } } }`. Reads and writes use the current workspace slice. A legacy flat file is wrapped under the current workspace id on read. Disconnect does not wipe maps. Writes are atomic and file mode is `0o600`.
- Session status storage: `~/.config/openchamber/linear-session-status.json` (same data dir). Writes are atomic and file mode is `0o600`. Dedupes started/completed/failure per OpenChamber session id.
- Writes are atomic and file mode is `0o600`.
- Client ID: `OPENCHAMBER_LINEAR_CLIENT_ID` -> `settings.json` `linearClientId` -> baked-in public default.
- Client secret: `OPENCHAMBER_LINEAR_CLIENT_SECRET` -> `settings.json` `linearClientSecret`. Optional with PKCE. Do not commit a secret.
- Scopes: `OPENCHAMBER_LINEAR_SCOPES` -> `settings.json` `linearScopes` -> `read,write,comments:create`.
- Session comments: `settings.json` `linearSessionComments`, boolean, absent means off. Written only through `PUT /api/linear/preferences`.
- Broker URL: `OPENCHAMBER_LINEAR_BROKER_URL` -> `settings.json` `linearBrokerUrl` -> `https://api.openchamber.dev/v1/oauth/linear`.
- Redirect URI: `OPENCHAMBER_LINEAR_REDIRECT_URI` -> `settings.json` `linearRedirectUri` -> `<broker-url>/callback`. Setting an explicit redirect URI bypasses the broker for custom/self-hosted OAuth applications.
Linear requires an exact callback match. The built-in application registers `https://api.openchamber.dev/v1/oauth/linear/callback`; the broker holds only the short-lived authorization code. The local OpenChamber server keeps the claim secret and PKCE verifier, exchanges the code for tokens locally, then acknowledges the handoff. Custom brokers must expose `/start`, `/callback`, `/poll`, and `/complete` with the same contract.
## OAuth contract
- Authorization code + PKCE S256. Linear has no device flow.
- The broker stores hashes of OAuth state and a separate claim secret for ten minutes. It never receives the PKCE verifier or Linear tokens. The local status polling path claims a completed broker result and persists tokens on the OpenChamber server.
- Access tokens expire in 24 hours. Refresh tokens rotate; persist the new refresh token from every successful refresh. Concurrent refreshes share one in-flight promise per workspace.
- `invalid_grant` / 401 on refresh clears that workspace only so a dead token cannot loop. If it was the last workspace, status becomes disconnected.
- A GraphQL 401 after a valid-looking token also clears that workspace. A network failure while a token is stored does not: status stays connected with the last known user.
## Project mapping
OpenChamber has projects (directories), not accounts or organizations. Mapping is how create-session (picker and the right-hand panel) picks a directory:
1. If the issue's Linear team has a project path, use that.
2. Otherwise use the default project path.
3. If neither is set, the UI tells the user to map the team in Settings → Integrations. It does not fall back to the currently active project.
A worktree started from the panel or picker is created in that mapped project. New Worktree from Git is different: it stays in the currently active project.
## Shared UI
- `RuntimeAPIs.linear` is optional. Hide Linear settings, the chat picker, and the panel when it is missing (VS Code).
- Store: `packages/ui/src/stores/useLinearAuthStore.ts`. App start refreshes it from `App.tsx` and `MobileApp.tsx`, not `VSCodeApp`.
- Settings: first-party section on the Integrations page. Connect opens the authorization URL and polls status until the workspace list or current `authorizedAt` changes, so Add workspace is not treated as done just because a workspace was already connected. When connected, map a default project and optional per-team projects for the current workspace. Other stored workspaces appear in a list with Switch to. Disconnect removes the current workspace only. The panel can also switch the current workspace when more than one is stored.
- Context panel: desktop/web right-hand rail surface `linear` (`packages/ui/src/components/views/LinearIssuesView.tsx`). Singleton like git/pr. The rail icon is hidden until a Linear workspace is connected; disconnecting while the panel is open closes it. List/search defaults to all issues; the status filter is All, Backlog, To Do, In Progress, In Review, Done, Canceled, and Duplicate, matching the card status order. Identifier/URL still finds completed. Status, assignee, team, and priority filters persist in `useUIStore` so they survive rail switches. Non-default list filters and search tint the filter icon `text-primary`, same as the context rail; one control clears them, not the workspace switch. Changing those filters keeps the previous list until the next page arrives and does not disable the filter row. On a narrow panel search and the filters other than status drop to icons; status keeps its label. The card shows priority and labels. Comments render as an avatar timeline matching the pull request panel, so both context surfaces read alike; comment authors carry `avatarUrl`. The card is read-only except status (`issueUpdate`) and Close (first completed workflow state). Start session stays in a footer while the description and comments scroll. Start session / worktree share `startLinearIssueSession` with the picker. No create-issue, no writing comments, no polling. VS Code and the mobile workspace drawer omit this rail.
- Chat: composer attach menu "Link Linear Issue" attaches body and comments as `linear-issue` context on the next send. Exclusive with a linked GitHub issue or PR. The attached issue is stored on session metadata (`kind: 'linear'`) so work status can show it. Clicking that work-status row opens the Linear rail when Linear is connected on desktop/web; otherwise the Linear URL. Managed Chats do not offer start-from-issue; those sessions have no project directory.
- Worktree: New Worktree can start from a Linear issue. It uses the currently active project and does not consult team-to-project mapping. GitHub issue/PR and Linear issue are exclusive on that form.
- Status comments: off until the user turns them on in Settings -> Integrations -> Linear (`LinearSessionComments.tsx`). When on, create-session and worktree-from-Linear post `started` after the session exists. The event hub posts `completed` on the first idle after that, and `failure` on `session.error` except `MessageAbortedError`. Failed comments must not fail session create. Comment bodies are English (they live on Linear) and are one markdown link named `OpenChamber session started` (or completed/failed). Web uses `/?session=<id>` on the current origin; desktop reports the loopback origin its own server listens on, not `openchamber-ui://`. A Linear comment is read by the whole team, so the server posts nothing when that origin is not publicly reachable rather than publishing a link only its author could open. Opening `/?session=` selects that session after the global session list can resolve its directory.
- Magic prompts: `linear.issue.review.visible` / `.instructions`. Do not reuse the GitHub issue-review templates for Linear.
## Notes for contributors
The implementation and deployment hand-off for the stable callback broker is
in [`OAUTH-BROKER-HANDOFF.md`](./OAUTH-BROKER-HANDOFF.md). It records the exact
Linear redirect URI that must be registered and why the original loopback
callback could not support packaged desktop or arbitrary self-hosted servers.
- Do not log tokens, codes, verifiers, or the client secret.
- Do not add Linear under Git or as a third-party plugin row.
- Actor is `user`. Do not enable Linear client-credentials tokens for this flow.
- One OAuth grant is still one Linear organization. The server stores many grants and keeps one current. Webhooks and inbound Linear issue actions are out of scope until a later change.
@@ -0,0 +1,91 @@
# Linear OAuth broker hand-off
## Required Linear application change
Register this exact redirect URI in the Linear OAuth application used by the
baked-in client ID:
`https://api.openchamber.dev/v1/oauth/linear/callback`
Linear compares the full redirect URI, including scheme, host, path, and port.
Deploy the API broker and apply its D1 migration before testing this branch.
## Why the original callback failed
The first implementation redirected Linear back to the OpenChamber server:
`http://127.0.0.1:<listen-port>/linear/oauth/callback`
That address is not stable across OpenChamber runtimes:
- packaged desktop prefers its stored local port and can select another free
port when needed;
- local development and the CLI use different ports;
- self-hosted servers may sit behind a reverse proxy or have no public inbound
address at all.
Linear requires an exact pre-registered callback. Registering every possible
desktop or self-hosted address is impossible, and forcing desktop onto one port
would make startup fail whenever another process owns that port.
## New flow
The built-in Linear client now uses the stable callback broker in
`openchamber-website/apps/api`:
1. The OpenChamber server generates OAuth state, a PKCE verifier, and a separate
claim secret.
2. The broker stores only hashes of state and the claim secret for ten minutes.
3. Linear sends its authorization code to the stable public callback.
4. The OpenChamber server polls the broker with state and the claim secret.
5. The OpenChamber server exchanges the code using the PKCE verifier and stores
the Linear tokens locally.
6. After persistence succeeds, OpenChamber acknowledges the hand-off and the
broker marks it consumed.
The broker never receives the PKCE verifier, access token, or refresh token.
Private Relay is not involved; the local server only needs outbound HTTPS.
## Compatibility and configuration
- `OPENCHAMBER_LINEAR_BROKER_URL` or `settings.json` `linearBrokerUrl` selects a
self-hosted broker. The default is
`https://api.openchamber.dev/v1/oauth/linear`.
- `OPENCHAMBER_LINEAR_REDIRECT_URI` or `settings.json` `linearRedirectUri`
bypasses the broker and preserves the direct callback flow for a custom
Linear OAuth application.
## Owning files
OpenChamber:
- `auth.js`: broker and redirect configuration.
- `oauth.js`: PKCE, broker registration/poll/acknowledgement, token exchange.
- `routes.js`: starts authorization and completes broker results during status
polling.
Hosted API, in the `openchamber-website` repository:
- `apps/api/src/routes/linear-oauth.ts`
- `apps/api/migrations/0010_linear_oauth_transactions.sql`
- `apps/api/LINEAR-OAUTH.md`
## Validation
OpenChamber focused tests:
```sh
bunx vitest run \
packages/web/server/lib/linear/oauth.test.js \
packages/web/server/lib/linear/auth.test.js \
packages/web/server/lib/linear/routes.test.js
```
Hosted API checks:
```sh
cd apps/api
bun test src/routes/linear-oauth.test.ts
bun run check
bun run build
```
+436
View File
@@ -0,0 +1,436 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { isPlainObject, readEnv, readFiniteNumber, readTrimmedString } from './parse.js';
const DEFAULT_LINEAR_CLIENT_ID = '91bbe26a69a2c8568d3683f1e01e776c';
const DEFAULT_LINEAR_SCOPES = 'read,write,comments:create';
const DEFAULT_LINEAR_BROKER_URL = 'https://api.openchamber.dev/v1/oauth/linear';
const ACCESS_TOKEN_REFRESH_SKEW_MS = 2 * 60_000;
const LEGACY_WORKSPACE_ID = 'legacy';
const SESSION_COMMENTS_SETTING_KEY = 'linearSessionComments';
function resolveDataDir() {
const fromEnv = readEnv('OPENCHAMBER_DATA_DIR');
if (fromEnv) {
return path.resolve(fromEnv);
}
return path.join(os.homedir(), '.config', 'openchamber');
}
function storageFile() {
return path.join(resolveDataDir(), 'linear-auth.json');
}
function settingsFile() {
return path.join(resolveDataDir(), 'settings.json');
}
function ensureStorageDir() {
const dir = resolveDataDir();
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
function readJsonFile(filePath) {
if (!fs.existsSync(filePath)) {
return null;
}
try {
const raw = fs.readFileSync(filePath, 'utf8');
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
const parsed = JSON.parse(trimmed);
if (!isPlainObject(parsed)) {
return null;
}
return parsed;
} catch (error) {
console.error('Failed to read Linear auth file:', error);
return null;
}
}
function writeJsonFile(filePath, payload) {
ensureStorageDir();
const tmpFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8');
try {
fs.chmodSync(tmpFile, 0o600);
} catch {
// best-effort
}
fs.renameSync(tmpFile, filePath);
try {
fs.chmodSync(filePath, 0o600);
} catch {
// best-effort
}
}
function normalizeUser(user) {
if (!isPlainObject(user)) {
return null;
}
const id = readTrimmedString(user.id);
if (!id) {
return null;
}
return {
id,
name: readTrimmedString(user.name) || null,
displayName: readTrimmedString(user.displayName) || null,
email: readTrimmedString(user.email) || null,
avatarUrl: readTrimmedString(user.avatarUrl) || null,
};
}
function normalizeOrganization(organization) {
if (!isPlainObject(organization)) {
return null;
}
const id = readTrimmedString(organization.id);
const name = readTrimmedString(organization.name);
if (!id || !name) {
return null;
}
return {
id,
name,
urlKey: readTrimmedString(organization.urlKey) || null,
};
}
function resolveLinearWorkspaceId({ organization, user, workspaceId } = {}) {
const explicit = readTrimmedString(workspaceId);
if (explicit) return explicit;
const organizationId = organization ? readTrimmedString(organization.id) : '';
if (organizationId) return organizationId;
const userId = user ? readTrimmedString(user.id) : '';
if (userId) return `user:${userId}`;
return LEGACY_WORKSPACE_ID;
}
function normalizeAuthEntry(raw) {
if (!isPlainObject(raw)) {
return null;
}
const accessToken = readTrimmedString(raw.accessToken);
if (!accessToken) {
return null;
}
const user = normalizeUser(raw.user);
const organization = normalizeOrganization(raw.organization);
return {
accessToken,
refreshToken: readTrimmedString(raw.refreshToken) || null,
tokenType: readTrimmedString(raw.tokenType) || 'bearer',
expiresAt: readFiniteNumber(raw.expiresAt),
scope: readTrimmedString(raw.scope),
createdAt: readFiniteNumber(raw.createdAt),
authorizedAt: readFiniteNumber(raw.authorizedAt) || readFiniteNumber(raw.createdAt),
user,
organization,
current: Boolean(raw.current),
workspaceId: resolveLinearWorkspaceId({
organization,
user,
workspaceId: raw.workspaceId,
}),
};
}
function normalizeAuthList(raw) {
const source = Array.isArray(raw?.workspaces)
? raw.workspaces
: (raw?.accessToken ? [raw] : []);
const list = source.map((entry) => normalizeAuthEntry(entry)).filter(Boolean);
if (!list.length) {
return { list: [], changed: Boolean(raw && (raw.accessToken || Array.isArray(raw.workspaces))) };
}
let changed = Array.isArray(raw?.workspaces) === false && Boolean(raw?.accessToken);
const seen = new Set();
const deduped = [];
for (const entry of list) {
if (seen.has(entry.workspaceId)) {
changed = true;
continue;
}
seen.add(entry.workspaceId);
deduped.push(entry);
}
let currentFound = false;
deduped.forEach((entry) => {
if (entry.current && !currentFound) {
currentFound = true;
} else if (entry.current && currentFound) {
entry.current = false;
changed = true;
}
});
if (!currentFound && deduped[0]) {
deduped[0].current = true;
changed = true;
}
return { list: deduped, changed };
}
function readAuthList() {
const data = readJsonFile(storageFile());
if (!data) {
return [];
}
const { list, changed } = normalizeAuthList(data);
if (changed) {
writeAuthList(list);
}
return list;
}
function writeAuthList(list) {
if (!list.length) {
const filePath = storageFile();
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
return;
}
writeJsonFile(storageFile(), { workspaces: list });
}
function readSettings() {
return readJsonFile(settingsFile()) || {};
}
function writeSettings(settings) {
writeJsonFile(settingsFile(), settings);
}
function readSettingString(key) {
const stored = readSettings()[key];
return readTrimmedString(stored);
}
export function getLinearAuth() {
const list = readAuthList();
if (!list.length) {
return null;
}
return list.find((entry) => entry.current) || list[0];
}
export function getLinearAuthByWorkspaceId(workspaceId) {
const id = readTrimmedString(workspaceId);
if (!id) {
return getLinearAuth();
}
return readAuthList().find((entry) => entry.workspaceId === id) || null;
}
export function getLinearAuthWorkspaces() {
return readAuthList().map((entry) => ({
id: entry.workspaceId,
name: entry.organization?.name || null,
urlKey: entry.organization?.urlKey || null,
current: Boolean(entry.current),
user: entry.user || null,
authorizedAt: entry.authorizedAt || entry.createdAt || null,
}));
}
export function setLinearAuth(input, options = {}) {
const accessToken = readTrimmedString(input?.accessToken);
if (!accessToken) {
throw new Error('accessToken is required');
}
const activate = options.activate !== false;
const list = readAuthList();
const current = list.find((entry) => entry.current) || list[0] || null;
const nextUser = Object.prototype.hasOwnProperty.call(input, 'user')
? normalizeUser(input.user)
: current?.user || null;
const nextOrganization = Object.prototype.hasOwnProperty.call(input, 'organization')
? normalizeOrganization(input.organization)
: current?.organization || null;
const workspaceId = resolveLinearWorkspaceId({
organization: nextOrganization,
user: nextUser,
workspaceId: input?.workspaceId || (nextOrganization || nextUser ? '' : current?.workspaceId),
});
const existingIndex = list.findIndex((entry) => entry.workspaceId === workspaceId);
const previous = existingIndex >= 0 ? list[existingIndex] : (
nextOrganization || nextUser ? null : current
);
const targetIndex = existingIndex >= 0
? existingIndex
: (previous && !nextOrganization && !nextUser ? list.indexOf(previous) : -1);
const wasCurrent = previous?.current === true;
const next = {
accessToken,
refreshToken: Object.prototype.hasOwnProperty.call(input, 'refreshToken')
? (readTrimmedString(input.refreshToken) || null)
: previous?.refreshToken || null,
tokenType: readTrimmedString(input?.tokenType) || previous?.tokenType || 'bearer',
expiresAt: readFiniteNumber(input?.expiresAt) ?? previous?.expiresAt ?? null,
scope: readTrimmedString(input?.scope) || previous?.scope || '',
createdAt: previous?.createdAt || Date.now(),
authorizedAt: Object.prototype.hasOwnProperty.call(input, 'authorizedAt')
? (readFiniteNumber(input.authorizedAt) || Date.now())
: (activate ? Date.now() : (previous?.authorizedAt || previous?.createdAt || Date.now())),
user: nextUser,
organization: nextOrganization,
current: false,
workspaceId,
};
if (targetIndex >= 0) {
list[targetIndex] = next;
} else {
list.push(next);
}
const writtenIndex = targetIndex >= 0 ? targetIndex : list.length - 1;
if (activate || !list.some((entry) => entry.current)) {
list.forEach((entry, index) => {
entry.current = index === writtenIndex;
});
} else {
list[writtenIndex].current = wasCurrent;
}
writeAuthList(list);
return list[writtenIndex];
}
export function activateLinearAuth(workspaceId) {
const id = readTrimmedString(workspaceId);
if (!id) {
return false;
}
const list = readAuthList();
const index = list.findIndex((entry) => entry.workspaceId === id);
if (index === -1) {
return false;
}
list.forEach((entry, idx) => {
entry.current = idx === index;
});
writeAuthList(list);
return true;
}
export function clearLinearAuth(workspaceId) {
try {
const list = readAuthList();
if (!list.length) {
return true;
}
const id = readTrimmedString(workspaceId);
const remaining = id
? list.filter((entry) => entry.workspaceId !== id)
: list.filter((entry) => !entry.current);
if (!remaining.length) {
writeAuthList([]);
return true;
}
if (!remaining.some((entry) => entry.current)) {
remaining[0].current = true;
}
writeAuthList(remaining);
return true;
} catch (error) {
console.error('Failed to clear Linear auth file:', error);
return false;
}
}
export function isLinearAccessTokenStale(expiresAt, now = Date.now()) {
const expiry = readFiniteNumber(expiresAt);
if (expiry == null) {
return true;
}
return expiry - ACCESS_TOKEN_REFRESH_SKEW_MS <= now;
}
export function toLinearPublicStatus(auth, workspaces = getLinearAuthWorkspaces()) {
if (!auth?.accessToken) {
return { connected: false };
}
return {
connected: true,
user: auth.user || null,
organization: auth.organization || null,
scope: auth.scope || undefined,
workspaces,
};
}
export function getLinearClientId() {
const fromEnv = readEnv('OPENCHAMBER_LINEAR_CLIENT_ID');
if (fromEnv) return fromEnv;
const stored = readSettingString('linearClientId');
if (stored) return stored;
return DEFAULT_LINEAR_CLIENT_ID;
}
export function getLinearClientSecret() {
const fromEnv = readEnv('OPENCHAMBER_LINEAR_CLIENT_SECRET');
if (fromEnv) return fromEnv;
return readSettingString('linearClientSecret');
}
export function getLinearScopes() {
const fromEnv = readEnv('OPENCHAMBER_LINEAR_SCOPES');
if (fromEnv) return fromEnv;
const stored = readSettingString('linearScopes');
if (stored) return stored;
return DEFAULT_LINEAR_SCOPES;
}
export function getLinearBrokerUrl() {
const fromEnv = readEnv('OPENCHAMBER_LINEAR_BROKER_URL');
if (fromEnv) return fromEnv.replace(/\/+$/, '');
const stored = readSettingString('linearBrokerUrl');
if (stored) return stored.replace(/\/+$/, '');
return DEFAULT_LINEAR_BROKER_URL;
}
export function getLinearRedirectUri() {
const fromEnv = readEnv('OPENCHAMBER_LINEAR_REDIRECT_URI');
if (fromEnv) return fromEnv;
const stored = readSettingString('linearRedirectUri');
if (stored) return stored;
return `${getLinearBrokerUrl()}/callback`;
}
/**
* Status comments are opt-in: they are written into a Linear workspace other
* people read, so nothing is posted until the user turns them on.
*/
export function getLinearSessionCommentsEnabled() {
return readSettings()[SESSION_COMMENTS_SETTING_KEY] === true;
}
export function setLinearSessionCommentsEnabled(enabled) {
const next = enabled === true;
const settings = readSettings();
settings[SESSION_COMMENTS_SETTING_KEY] = next;
writeSettings(settings);
return next;
}
export function getLinearAuthFilePath() {
return storageFile();
}
export const DEFAULT_LINEAR_CLIENT_ID_VALUE = DEFAULT_LINEAR_CLIENT_ID;
+242
View File
@@ -0,0 +1,242 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
getLinearAuth,
getLinearAuthWorkspaces,
setLinearAuth,
activateLinearAuth,
clearLinearAuth,
toLinearPublicStatus,
getLinearClientId,
getLinearRedirectUri,
isLinearAccessTokenStale,
getLinearAuthFilePath,
DEFAULT_LINEAR_CLIENT_ID_VALUE,
} from './auth.js';
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-auth-'));
describe('Linear auth storage', () => {
let dataDir;
let previousDataDir;
let previousPort;
let previousClientId;
let previousRedirect;
beforeEach(() => {
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
previousPort = process.env.OPENCHAMBER_PORT;
previousClientId = process.env.OPENCHAMBER_LINEAR_CLIENT_ID;
previousRedirect = process.env.OPENCHAMBER_LINEAR_REDIRECT_URI;
dataDir = makeTempDir();
process.env.OPENCHAMBER_DATA_DIR = dataDir;
delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID;
delete process.env.OPENCHAMBER_LINEAR_SCOPES;
delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI;
delete process.env.OPENCHAMBER_PORT;
});
afterEach(() => {
restoreEnv('OPENCHAMBER_DATA_DIR', previousDataDir);
restoreEnv('OPENCHAMBER_PORT', previousPort);
restoreEnv('OPENCHAMBER_LINEAR_CLIENT_ID', previousClientId);
restoreEnv('OPENCHAMBER_LINEAR_REDIRECT_URI', previousRedirect);
fs.rmSync(dataDir, { recursive: true, force: true });
});
it('returns disconnected when no auth file exists', () => {
expect(getLinearAuth()).toBeNull();
expect(toLinearPublicStatus(null)).toEqual({ connected: false });
});
it('persists tokens without exposing them on the public status', () => {
setLinearAuth({
accessToken: 'lin_oauth_access',
refreshToken: 'lin_oauth_refresh',
expiresAt: Date.now() + 60_000,
scope: 'read,write',
user: { id: 'user-1', name: 'Ada', displayName: 'Ada Lovelace', email: 'ada@example.com', avatarUrl: 'https://example.com/a.png' },
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
});
const stored = getLinearAuth();
expect(stored.accessToken).toBe('lin_oauth_access');
expect(stored.refreshToken).toBe('lin_oauth_refresh');
expect(stored.workspaceId).toBe('org-1');
const publicStatus = toLinearPublicStatus(stored);
expect(publicStatus).toEqual({
connected: true,
user: {
id: 'user-1',
name: 'Ada',
displayName: 'Ada Lovelace',
email: 'ada@example.com',
avatarUrl: 'https://example.com/a.png',
},
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
scope: 'read,write',
workspaces: [{
id: 'org-1',
name: 'OpenChamber',
urlKey: 'openchamber',
current: true,
user: {
id: 'user-1',
name: 'Ada',
displayName: 'Ada Lovelace',
email: 'ada@example.com',
avatarUrl: 'https://example.com/a.png',
},
authorizedAt: stored.authorizedAt,
}],
});
expect(JSON.stringify(publicStatus)).not.toContain('lin_oauth');
const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8'));
expect(file.accessToken).toBeUndefined();
expect(file.workspaces).toHaveLength(1);
expect(file.workspaces[0].accessToken).toBe('lin_oauth_access');
});
it('keeps the previous refresh token when a later write omits it', () => {
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
expiresAt: 1,
});
setLinearAuth({
accessToken: 'access-2',
expiresAt: 2,
});
expect(getLinearAuth().refreshToken).toBe('refresh-1');
expect(getLinearAuth().accessToken).toBe('access-2');
});
it('rotates the refresh token when a new one is provided', () => {
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
});
setLinearAuth({
accessToken: 'access-2',
refreshToken: 'refresh-2',
});
expect(getLinearAuth().refreshToken).toBe('refresh-2');
});
it('rejects a write without an access token', () => {
expect(() => setLinearAuth({ refreshToken: 'refresh-1' })).toThrow('accessToken is required');
});
it('treats a missing or past expiry as stale', () => {
expect(isLinearAccessTokenStale(null)).toBe(true);
expect(isLinearAccessTokenStale(Date.now() - 1)).toBe(true);
expect(isLinearAccessTokenStale(Date.now() + 10 * 60_000)).toBe(false);
});
it('uses the baked-in client id unless env or settings override it', () => {
expect(getLinearClientId()).toBe(DEFAULT_LINEAR_CLIENT_ID_VALUE);
process.env.OPENCHAMBER_LINEAR_CLIENT_ID = 'env-client';
expect(getLinearClientId()).toBe('env-client');
});
it('uses the stable public broker callback by default', () => {
process.env.OPENCHAMBER_PORT = '3001';
expect(getLinearRedirectUri()).toBe('https://api.openchamber.dev/v1/oauth/linear/callback');
process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://localhost:3000/linear/oauth/callback';
expect(getLinearRedirectUri()).toBe('http://localhost:3000/linear/oauth/callback');
});
it('deletes the auth file on clear', () => {
setLinearAuth({ accessToken: 'access-1', refreshToken: 'refresh-1' });
expect(fs.existsSync(getLinearAuthFilePath())).toBe(true);
expect(clearLinearAuth()).toBe(true);
expect(fs.existsSync(getLinearAuthFilePath())).toBe(false);
expect(getLinearAuth()).toBeNull();
});
it('migrates a legacy single-workspace file', () => {
fs.writeFileSync(getLinearAuthFilePath(), JSON.stringify({
accessToken: 'legacy-access',
refreshToken: 'legacy-refresh',
user: { id: 'user-1', name: 'Ada' },
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
}), 'utf8');
const stored = getLinearAuth();
expect(stored.accessToken).toBe('legacy-access');
expect(stored.workspaceId).toBe('org-1');
expect(stored.current).toBe(true);
const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8'));
expect(file.workspaces).toHaveLength(1);
expect(file.accessToken).toBeUndefined();
});
it('stores a second workspace and activates it without dropping the first', () => {
setLinearAuth({
accessToken: 'access-a',
refreshToken: 'refresh-a',
user: { id: 'user-a', name: 'Ada' },
organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' },
});
setLinearAuth({
accessToken: 'access-b',
refreshToken: 'refresh-b',
user: { id: 'user-b', name: 'Ben' },
organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' },
});
expect(getLinearAuth().workspaceId).toBe('org-b');
expect(getLinearAuthWorkspaces().map((entry) => entry.id).sort()).toEqual(['org-a', 'org-b']);
expect(activateLinearAuth('org-a')).toBe(true);
expect(getLinearAuth().workspaceId).toBe('org-a');
expect(getLinearAuth().accessToken).toBe('access-a');
expect(getLinearAuthWorkspaces().find((entry) => entry.id === 'org-b').current).toBe(false);
});
it('drops only the current workspace on unscoped clear', () => {
setLinearAuth({
accessToken: 'access-a',
organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' },
user: { id: 'user-a', name: 'Ada' },
});
setLinearAuth({
accessToken: 'access-b',
organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' },
user: { id: 'user-b', name: 'Ben' },
});
expect(clearLinearAuth()).toBe(true);
expect(getLinearAuth().workspaceId).toBe('org-a');
expect(getLinearAuth().accessToken).toBe('access-a');
expect(getLinearAuthWorkspaces()).toHaveLength(1);
});
it('does not bump authorizedAt when a later write opts out of activate', () => {
setLinearAuth({
accessToken: 'access-1',
user: { id: 'user-1', name: 'Ada' },
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
});
const file = JSON.parse(fs.readFileSync(getLinearAuthFilePath(), 'utf8'));
file.workspaces[0].authorizedAt = 111;
fs.writeFileSync(getLinearAuthFilePath(), JSON.stringify(file, null, 2), 'utf8');
setLinearAuth({
accessToken: 'access-1',
user: { id: 'user-1', name: 'Ada' },
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
workspaceId: 'org-1',
}, { activate: false });
expect(getLinearAuth().authorizedAt).toBe(111);
expect(getLinearAuth().current).toBe(true);
});
});
function restoreEnv(name, previous) {
if (previous === undefined) {
delete process.env[name];
return;
}
process.env[name] = previous;
}
+191
View File
@@ -0,0 +1,191 @@
import {
getLinearAuth,
getLinearAuthByWorkspaceId,
setLinearAuth,
clearLinearAuth,
isLinearAccessTokenStale,
} from './auth.js';
import { refreshAccessToken } from './oauth.js';
import { isPlainObject, readTrimmedString } from './parse.js';
const LINEAR_GRAPHQL_URL = 'https://api.linear.app/graphql';
const VIEWER_QUERY = '{ viewer { id name displayName email avatarUrl } organization { id name urlKey } }';
// Linear file URLs in GraphQL need this header or the browser cannot load
// uploads.linear.app images (comment screenshots, description images).
const LINEAR_PUBLIC_FILE_URL_TTL_SECONDS = '3600';
export class LinearApiError extends Error {
constructor(message, status, options = {}) {
super(message);
this.name = 'LinearApiError';
this.status = status;
this.userError = options.userError === true;
}
}
function readGraphqlError(payload) {
const errors = Array.isArray(payload.errors) ? payload.errors : [];
const first = errors.length > 0 && isPlainObject(errors[0]) ? errors[0] : null;
if (!first) {
return { message: '', userError: false, status: 502 };
}
const extensions = isPlainObject(first.extensions) ? first.extensions : null;
const presentable = extensions ? readTrimmedString(extensions.userPresentableMessage) : '';
let constraint = '';
const validationErrors = extensions && Array.isArray(extensions.validationErrors)
? extensions.validationErrors
: [];
for (const entry of validationErrors) {
if (!isPlainObject(entry) || !isPlainObject(entry.constraints)) continue;
for (const value of Object.values(entry.constraints)) {
const text = readTrimmedString(value);
if (text) {
constraint = text;
break;
}
}
if (constraint) break;
}
const message = presentable || constraint || readTrimmedString(first.message);
const code = extensions ? readTrimmedString(extensions.code) : '';
const userError = extensions?.userError === true
|| code === 'INVALID_INPUT'
|| code === 'INPUT_ERROR'
|| /^entity not found/i.test(message)
|| /^argument validation/i.test(message);
return {
message,
userError,
status: userError ? 400 : 502,
};
}
function readIdentity(payload) {
const data = isPlainObject(payload) ? payload.data : null;
const viewer = isPlainObject(data) ? data.viewer : null;
if (!isPlainObject(viewer) || !readTrimmedString(viewer.id)) {
return null;
}
const organization = isPlainObject(data) ? data.organization : null;
const organizationId = isPlainObject(organization) ? readTrimmedString(organization.id) : '';
const organizationName = isPlainObject(organization) ? readTrimmedString(organization.name) : '';
return {
user: {
id: viewer.id.trim(),
name: readTrimmedString(viewer.name) || null,
displayName: readTrimmedString(viewer.displayName) || null,
email: readTrimmedString(viewer.email) || null,
avatarUrl: readTrimmedString(viewer.avatarUrl) || null,
},
organization: organizationId && organizationName
? {
id: organizationId,
name: organizationName,
urlKey: readTrimmedString(organization.urlKey) || null,
}
: null,
};
}
export async function fetchLinearGraphql(accessToken, query, variables) {
const token = readTrimmedString(accessToken);
if (!token) {
throw new LinearApiError('Linear is not connected', 401);
}
const body = { query };
if (isPlainObject(variables)) {
body.variables = variables;
}
const response = await fetch(LINEAR_GRAPHQL_URL, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'public-file-urls-expire-in': LINEAR_PUBLIC_FILE_URL_TTL_SECONDS,
},
body: JSON.stringify(body),
});
const payload = await response.json().catch(() => null);
if (response.status === 401) {
throw new LinearApiError('Linear token expired or revoked', 401);
}
if (!response.ok) {
throw new LinearApiError(`Linear GraphQL request failed (${response.status})`, response.status);
}
if (!isPlainObject(payload)) {
throw new LinearApiError('Linear GraphQL response was not JSON', 502);
}
const data = isPlainObject(payload.data) ? payload.data : null;
if (!data) {
const graphqlError = readGraphqlError(payload);
throw new LinearApiError(
graphqlError.message || 'Linear GraphQL response did not include data',
graphqlError.status,
{ userError: graphqlError.userError },
);
}
return data;
}
export async function fetchLinearIdentity(accessToken) {
const data = await fetchLinearGraphql(accessToken, VIEWER_QUERY);
const identity = readIdentity({ data });
if (!identity) {
throw new LinearApiError('Linear GraphQL response did not include a viewer', 502);
}
return identity;
}
const inFlightRefreshByWorkspace = new Map();
async function refreshWorkspaceAuth(auth) {
const tokens = await refreshAccessToken(auth.refreshToken);
const next = setLinearAuth({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken || auth.refreshToken,
tokenType: tokens.tokenType,
expiresAt: tokens.expiresAt,
scope: tokens.scope || auth.scope,
user: auth.user,
organization: auth.organization,
workspaceId: auth.workspaceId,
}, { activate: false });
return next.accessToken;
}
export async function getValidLinearAccessToken(workspaceId) {
const auth = workspaceId
? getLinearAuthByWorkspaceId(workspaceId)
: getLinearAuth();
if (!auth?.accessToken) {
return null;
}
if (!isLinearAccessTokenStale(auth.expiresAt)) {
return auth.accessToken;
}
if (!auth.refreshToken) {
clearLinearAuth(auth.workspaceId);
return null;
}
const key = auth.workspaceId;
const pending = inFlightRefreshByWorkspace.get(key);
if (pending) {
return pending;
}
const promise = refreshWorkspaceAuth(auth)
.catch((error) => {
if (error?.code === 'INVALID_GRANT' || error?.status === 400 || error?.status === 401) {
clearLinearAuth(auth.workspaceId);
return null;
}
throw error;
})
.finally(() => {
inFlightRefreshByWorkspace.delete(key);
});
inFlightRefreshByWorkspace.set(key, promise);
return promise;
}
+61
View File
@@ -0,0 +1,61 @@
export {
getLinearAuth,
getLinearAuthByWorkspaceId,
getLinearAuthWorkspaces,
setLinearAuth,
activateLinearAuth,
clearLinearAuth,
toLinearPublicStatus,
getLinearClientId,
getLinearClientSecret,
getLinearScopes,
getLinearBrokerUrl,
getLinearRedirectUri,
isLinearAccessTokenStale,
getLinearAuthFilePath,
getLinearSessionCommentsEnabled,
setLinearSessionCommentsEnabled,
DEFAULT_LINEAR_CLIENT_ID_VALUE,
} from './auth.js';
export {
startAuthorization,
consumeAuthorizationCallback,
pollAuthorizationBroker,
completeAuthorizationBroker,
refreshAccessToken,
revokeToken,
LinearOAuthError,
} from './oauth.js';
export {
fetchLinearIdentity,
getValidLinearAccessToken,
LinearApiError,
} from './client.js';
export {
listLinearIssues,
getLinearIssue,
listLinearIssueStates,
updateLinearIssue,
} from './issues.js';
export {
listLinearTeams,
} from './teams.js';
export {
LinearMappingError,
getLinearMappingFilePath,
mergeLinearMappingView,
readStoredLinearMapping,
resolveMappedProjectPath,
setStoredLinearMapping,
} from './mapping.js';
export {
LinearSessionStatusError,
isPublicSessionOrigin,
postLinearSessionStatus,
} from './status.js';
+499
View File
@@ -0,0 +1,499 @@
import { clearLinearAuth, getLinearAuth, getLinearAuthByWorkspaceId } from './auth.js';
import { fetchLinearGraphql, getValidLinearAccessToken } from './client.js';
import { isPlainObject, isString, readFiniteNumber, readTrimmedString } from './parse.js';
const PAGE_SIZE = 50;
const LIST_STATUS_STATE = {
open: { type: { nin: ['completed', 'canceled', 'duplicate'] } },
backlog: { type: { eq: 'backlog' } },
todo: { type: { eq: 'unstarted' } },
started: { type: { eq: 'started' }, name: { neqIgnoreCase: 'In Review' } },
inReview: { name: { eqIgnoreCase: 'In Review' } },
completed: { type: { eq: 'completed' } },
canceled: { type: { eq: 'canceled' }, name: { neqIgnoreCase: 'Duplicate' } },
duplicate: { or: [{ type: { eq: 'duplicate' } }, { name: { eqIgnoreCase: 'Duplicate' } }] },
};
function readListStatus(value) {
const status = readTrimmedString(value);
if (status === 'all' || Object.hasOwn(LIST_STATUS_STATE, status)) {
return status;
}
return 'open';
}
function readListAssignee(value) {
const assignee = readTrimmedString(value);
if (assignee === 'me' || assignee === 'any') {
return assignee;
}
return 'any';
}
const LIST_PRIORITY_EQ = {
none: 0,
urgent: 1,
high: 2,
medium: 3,
low: 4,
};
function readListPriority(value) {
const priority = readTrimmedString(value);
if (priority === 'none' || priority === 'urgent' || priority === 'high' || priority === 'medium' || priority === 'low') {
return priority;
}
return 'all';
}
function buildIssueListFilter({ status, assignee, teamId, priority } = {}) {
const filter = {};
const resolvedStatus = readListStatus(status);
const resolvedAssignee = readListAssignee(assignee);
const resolvedPriority = readListPriority(priority);
const team = readTrimmedString(teamId);
if (resolvedStatus !== 'all') {
filter.state = LIST_STATUS_STATE[resolvedStatus];
}
if (resolvedAssignee === 'me') {
filter.assignee = { isMe: { eq: true } };
}
if (team) {
filter.team = { id: { eq: team } };
}
if (resolvedPriority !== 'all') {
filter.priority = { eq: LIST_PRIORITY_EQ[resolvedPriority] };
}
return Object.keys(filter).length > 0 ? filter : undefined;
}
const ISSUE_SUMMARY_FIELDS = `
id
identifier
title
url
priority
state { id name type }
assignee { name displayName avatarUrl }
team { id key name }
labels { nodes { id name color } }
`;
const LIST_QUERY = `
query ListLinearIssues($first: Int!, $after: String, $filter: IssueFilter) {
issues(first: $first, after: $after, filter: $filter, orderBy: updatedAt) {
nodes { ${ISSUE_SUMMARY_FIELDS} }
pageInfo { hasNextPage endCursor }
}
}
`;
const SEARCH_QUERY = `
query SearchLinearIssues($term: String!, $first: Int!, $after: String, $filter: IssueFilter) {
searchIssues(term: $term, first: $first, after: $after, filter: $filter) {
nodes { ${ISSUE_SUMMARY_FIELDS} }
pageInfo { hasNextPage endCursor }
}
}
`;
const GET_QUERY = `
query GetLinearIssue($id: String!) {
issue(id: $id) {
${ISSUE_SUMMARY_FIELDS}
description
comments(first: 50) {
nodes {
id
body
createdAt
user { name displayName avatarUrl }
}
}
}
}
`;
const COMMENT_CREATE = `
mutation CommentCreate($input: CommentCreateInput!) {
commentCreate(input: $input) {
success
comment { id }
}
}
`;
const STATES_QUERY = `
query TeamWorkflowStates($id: String!) {
team(id: $id) {
states(first: 50) {
nodes { id name type position }
}
}
}
`;
const ISSUE_UPDATE = `
mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue {
${ISSUE_SUMMARY_FIELDS}
description
comments(first: 50) {
nodes {
id
body
createdAt
user { name displayName avatarUrl }
}
}
}
}
}
`;
const IDENTIFIER_RE = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const URL_IDENTIFIER_RE = /linear\.app\/(?:[^/]+\/)?issue\/([A-Za-z][A-Za-z0-9]*-\d+)/i;
export function parseLinearIssueRef(value) {
const trimmed = readTrimmedString(value);
if (!trimmed) return null;
const urlMatch = trimmed.match(URL_IDENTIFIER_RE);
if (urlMatch) {
return { kind: 'identifier', value: urlMatch[1].toUpperCase() };
}
if (IDENTIFIER_RE.test(trimmed)) {
return { kind: 'identifier', value: trimmed.toUpperCase() };
}
if (UUID_RE.test(trimmed)) {
return { kind: 'id', value: trimmed.toLowerCase() };
}
return null;
}
function readState(value) {
if (!isPlainObject(value)) return null;
const id = readTrimmedString(value.id) || null;
const name = readTrimmedString(value.name) || null;
const type = readTrimmedString(value.type) || null;
if (!id && !name && !type) return null;
return { id, name, type };
}
const WORKFLOW_TYPE_ORDER = {
triage: 0,
backlog: 1,
unstarted: 2,
started: 3,
completed: 4,
canceled: 5,
};
function workflowTypeRank(type) {
if (type === 'triage' || type === 'backlog' || type === 'unstarted' || type === 'started' || type === 'completed' || type === 'canceled') {
return WORKFLOW_TYPE_ORDER[type];
}
return 99;
}
function compareWorkflowStates(left, right) {
const typeDelta = workflowTypeRank(left.type) - workflowTypeRank(right.type);
if (typeDelta !== 0) return typeDelta;
if (left.position !== right.position) return left.position - right.position;
return left.name.localeCompare(right.name);
}
function readWorkflowState(value) {
if (!isPlainObject(value)) return null;
const id = readTrimmedString(value.id);
const name = readTrimmedString(value.name);
if (!id || !name) return null;
const position = readFiniteNumber(value.position);
return {
id,
name,
type: readTrimmedString(value.type) || null,
position: position ?? 0,
};
}
function readAssignee(value) {
if (!isPlainObject(value)) return null;
const name = readTrimmedString(value.name) || null;
const displayName = readTrimmedString(value.displayName) || null;
const avatarUrl = readTrimmedString(value.avatarUrl) || null;
if (!name && !displayName && !avatarUrl) return null;
return { name, displayName, avatarUrl };
}
function readTeam(value) {
if (!isPlainObject(value)) return null;
const id = readTrimmedString(value.id);
const key = readTrimmedString(value.key);
const name = readTrimmedString(value.name);
if (!id || !key || !name) return null;
return { id, key, name };
}
function readPriority(value) {
if (!Number.isInteger(value) || value < 0 || value > 4) return null;
return value;
}
function readLabelColor(value) {
const raw = readTrimmedString(value);
if (!raw) return null;
const hex = raw.startsWith('#') ? raw.slice(1) : raw;
if (!/^[0-9A-Fa-f]{6}$/.test(hex)) return null;
return `#${hex.toLowerCase()}`;
}
function readLabel(value) {
if (!isPlainObject(value)) return null;
const id = readTrimmedString(value.id);
const name = readTrimmedString(value.name);
if (!id || !name) return null;
return {
id,
name,
color: readLabelColor(value.color),
};
}
function readLabels(value) {
const nodes = isPlainObject(value) && Array.isArray(value.nodes)
? value.nodes
: Array.isArray(value)
? value
: [];
return nodes.map(readLabel).filter(Boolean);
}
function readIssueSummary(node) {
if (!isPlainObject(node)) return null;
const id = readTrimmedString(node.id);
const identifier = readTrimmedString(node.identifier);
const title = readTrimmedString(node.title);
const url = readTrimmedString(node.url);
if (!id || !identifier || !title || !url) return null;
return {
id,
identifier,
title,
url,
state: readState(node.state),
assignee: readAssignee(node.assignee),
team: readTeam(node.team),
priority: readPriority(node.priority),
labels: readLabels(node.labels),
};
}
function readComment(node) {
if (!isPlainObject(node)) return null;
const id = readTrimmedString(node.id);
if (!id) return null;
const body = isString(node.body) ? node.body : '';
const user = isPlainObject(node.user)
? {
name: readTrimmedString(node.user.name) || null,
displayName: readTrimmedString(node.user.displayName) || null,
avatarUrl: readTrimmedString(node.user.avatarUrl) || null,
}
: null;
return {
id,
body,
createdAt: readTrimmedString(node.createdAt) || null,
user: user && (user.name || user.displayName) ? user : null,
};
}
function readIssue(node) {
const summary = readIssueSummary(node);
if (!summary) return null;
const commentsPayload = isPlainObject(node.comments) ? node.comments.nodes : null;
const comments = Array.isArray(commentsPayload)
? commentsPayload.map(readComment).filter(Boolean)
: [];
return {
...summary,
description: isString(node.description) ? node.description : null,
comments,
};
}
function readPageInfo(connection) {
const pageInfo = isPlainObject(connection) ? connection.pageInfo : null;
if (!isPlainObject(pageInfo)) {
return { hasMore: false, cursor: null };
}
return {
hasMore: pageInfo.hasNextPage === true,
cursor: readTrimmedString(pageInfo.endCursor) || null,
};
}
function readIssueNodes(connection) {
const nodes = isPlainObject(connection) ? connection.nodes : null;
if (!Array.isArray(nodes)) return [];
return nodes.map(readIssueSummary).filter(Boolean);
}
async function withLinearToken(run, workspaceId) {
try {
const token = await getValidLinearAccessToken(workspaceId);
if (!token) {
return { connected: false };
}
return await run(token);
} catch (error) {
if (error?.status === 401) {
const failed = workspaceId
? getLinearAuthByWorkspaceId(workspaceId)
: getLinearAuth();
clearLinearAuth(failed?.workspaceId || workspaceId);
return { connected: false };
}
throw error;
}
}
async function fetchIssueByRef(token, ref) {
const data = await fetchLinearGraphql(token, GET_QUERY, { id: ref.value });
return readIssue(data.issue);
}
export async function listLinearIssues({ query, cursor, status, assignee, teamId, priority } = {}) {
return withLinearToken(async (token) => {
const ref = parseLinearIssueRef(query);
if (ref) {
const issue = await fetchIssueByRef(token, ref);
return {
connected: true,
issues: issue ? [issue] : [],
cursor: null,
hasMore: false,
};
}
const after = readTrimmedString(cursor) || null;
const term = readTrimmedString(query);
const filter = buildIssueListFilter({ status, assignee, teamId, priority });
const variables = {
first: PAGE_SIZE,
};
if (filter) {
variables.filter = filter;
}
if (after) {
variables.after = after;
}
if (term) {
variables.term = term;
const data = await fetchLinearGraphql(token, SEARCH_QUERY, variables);
const connection = isPlainObject(data.searchIssues) ? data.searchIssues : null;
const page = readPageInfo(connection);
return {
connected: true,
issues: readIssueNodes(connection),
cursor: page.cursor,
hasMore: page.hasMore,
};
}
const data = await fetchLinearGraphql(token, LIST_QUERY, variables);
const connection = isPlainObject(data.issues) ? data.issues : null;
const page = readPageInfo(connection);
return {
connected: true,
issues: readIssueNodes(connection),
cursor: page.cursor,
hasMore: page.hasMore,
};
});
}
export async function getLinearIssue(id) {
const ref = parseLinearIssueRef(id) || (readTrimmedString(id) ? { kind: 'id', value: readTrimmedString(id) } : null);
if (!ref) {
return { connected: true, issue: null };
}
return withLinearToken(async (token) => {
const issue = await fetchIssueByRef(token, ref);
return { connected: true, issue };
});
}
export async function listLinearIssueStates(teamId) {
const id = readTrimmedString(teamId);
if (!id) {
const error = new Error('teamId is required');
error.code = 'INVALID';
throw error;
}
return withLinearToken(async (token) => {
const data = await fetchLinearGraphql(token, STATES_QUERY, { id });
const team = isPlainObject(data.team) ? data.team : null;
const connection = isPlainObject(team) ? team.states : null;
const nodes = isPlainObject(connection) && Array.isArray(connection.nodes)
? connection.nodes
: [];
const states = nodes
.map(readWorkflowState)
.filter(Boolean)
.sort(compareWorkflowStates);
return { connected: true, states };
});
}
export async function updateLinearIssue({ id, stateId } = {}) {
const issueId = readTrimmedString(id);
const nextStateId = readTrimmedString(stateId);
if (!issueId || !nextStateId) {
const error = new Error('id and stateId are required');
error.code = 'INVALID';
throw error;
}
const ref = parseLinearIssueRef(issueId) || { kind: 'id', value: issueId };
return withLinearToken(async (token) => {
const resolved = ref.kind === 'identifier'
? await fetchIssueByRef(token, ref)
: null;
const resolvedId = resolved?.id || (ref.kind === 'id' ? ref.value : '');
if (!resolvedId) {
return { connected: true, issue: null };
}
const data = await fetchLinearGraphql(token, ISSUE_UPDATE, {
id: resolvedId,
input: { stateId: nextStateId },
});
const payload = isPlainObject(data.issueUpdate) ? data.issueUpdate : null;
return {
connected: true,
issue: payload ? readIssue(payload.issue) : null,
};
});
}
export async function createLinearIssueComment({ issueId, body, organizationId } = {}) {
const text = isString(body) ? body : '';
const ref = parseLinearIssueRef(issueId)
|| (readTrimmedString(issueId) ? { kind: 'id', value: readTrimmedString(issueId) } : null);
if (!ref || !text.trim()) {
return { connected: true, comment: null };
}
return withLinearToken(async (token) => {
const issue = await fetchIssueByRef(token, ref);
if (!issue) {
return { connected: true, comment: null };
}
const data = await fetchLinearGraphql(token, COMMENT_CREATE, {
input: { issueId: issue.id, body: text },
});
const payload = isPlainObject(data.commentCreate) ? data.commentCreate : null;
const comment = isPlainObject(payload?.comment) ? payload.comment : null;
const id = comment ? readTrimmedString(comment.id) : '';
return {
connected: true,
comment: id ? { id } : null,
};
}, organizationId);
}
@@ -0,0 +1,512 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { setLinearAuth, clearLinearAuth } from './auth.js';
import { getLinearIssue, listLinearIssues, listLinearIssueStates, parseLinearIssueRef, createLinearIssueComment, updateLinearIssue } from './issues.js';
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-issues-'));
const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json' },
});
const issueNode = {
id: 'issue-uuid-1',
identifier: 'ENG-12',
title: 'Broken login',
url: 'https://linear.app/openchamber/issue/ENG-12',
priority: 1,
state: { id: 'state-started', name: 'In Progress', type: 'started' },
assignee: { name: 'Ada', displayName: 'Ada Lovelace', avatarUrl: 'https://example.com/a.png' },
team: { id: 'team-eng', key: 'ENG', name: 'Engineering' },
labels: { nodes: [{ id: 'label-bug', name: 'Bug', color: 'EB5757' }] },
};
describe('parseLinearIssueRef', () => {
it('reads identifiers, URLs, and UUIDs', () => {
expect(parseLinearIssueRef('eng-12')).toEqual({ kind: 'identifier', value: 'ENG-12' });
expect(parseLinearIssueRef('https://linear.app/openchamber/issue/ENG-12/broken-login'))
.toEqual({ kind: 'identifier', value: 'ENG-12' });
expect(parseLinearIssueRef('11111111-2222-3333-4444-555555555555'))
.toEqual({ kind: 'id', value: '11111111-2222-3333-4444-555555555555' });
expect(parseLinearIssueRef('login redirect')).toBeNull();
});
});
describe('Linear issue list/get', () => {
let dataDir;
let previousDataDir;
beforeEach(() => {
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
dataDir = makeTempDir();
process.env.OPENCHAMBER_DATA_DIR = dataDir;
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
tokenType: 'Bearer',
expiresAt: Date.now() + 86_400_000,
scope: 'read,write,comments:create',
});
});
afterEach(() => {
vi.unstubAllGlobals();
clearLinearAuth();
if (previousDataDir === undefined) {
delete process.env.OPENCHAMBER_DATA_DIR;
} else {
process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
}
fs.rmSync(dataDir, { recursive: true, force: true });
});
it('returns disconnected without calling Linear when there is no auth', async () => {
clearLinearAuth();
const graphql = vi.fn();
vi.stubGlobal('fetch', graphql);
await expect(listLinearIssues()).resolves.toEqual({ connected: false });
expect(graphql).not.toHaveBeenCalled();
});
it('lists incomplete issues and never returns the token', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.query).toContain('query ListLinearIssues');
expect(body.variables.filter.state.type.nin).toEqual(['completed', 'canceled', 'duplicate']);
expect(options.headers.Authorization).toBe('Bearer access-1');
expect(options.headers['public-file-urls-expire-in']).toBe('3600');
return jsonResponse({
data: {
issues: {
nodes: [issueNode],
pageInfo: { hasNextPage: true, endCursor: 'cursor-2' },
},
},
});
}));
const result = await listLinearIssues();
expect(result).toEqual({
connected: true,
issues: [{
id: 'issue-uuid-1',
identifier: 'ENG-12',
title: 'Broken login',
url: 'https://linear.app/openchamber/issue/ENG-12',
state: { id: 'state-started', name: 'In Progress', type: 'started' },
assignee: { name: 'Ada', displayName: 'Ada Lovelace', avatarUrl: 'https://example.com/a.png' },
team: { id: 'team-eng', key: 'ENG', name: 'Engineering' },
priority: 1,
labels: [{ id: 'label-bug', name: 'Bug', color: '#eb5757' }],
}],
cursor: 'cursor-2',
hasMore: true,
});
expect(JSON.stringify(result)).not.toContain('access-1');
});
it('includes priority and labels and drops invalid values', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
data: {
issues: {
nodes: [{
...issueNode,
priority: 9,
labels: {
nodes: [
{ id: 'label-ok', name: 'Bug', color: '#EB5757' },
{ id: 'label-bad-color', name: 'Nope', color: 'red' },
{ id: '', name: 'Missing id' },
],
},
}],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
})));
const result = await listLinearIssues();
expect(result.issues?.[0]?.priority).toBeNull();
expect(result.issues?.[0]?.labels).toEqual([
{ id: 'label-ok', name: 'Bug', color: '#eb5757' },
{ id: 'label-bad-color', name: 'Nope', color: null },
]);
});
it('searches by text and looks up an identifier directly', async () => {
const graphql = vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
if (body.query.includes('SearchLinearIssues')) {
expect(body.variables.term).toBe('login');
return jsonResponse({
data: {
searchIssues: {
nodes: [issueNode],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
}
expect(body.variables.id).toBe('ENG-12');
return jsonResponse({
data: {
issue: {
...issueNode,
description: 'Users cannot sign in.',
comments: {
nodes: [{
id: 'comment-1',
body: 'Still broken',
createdAt: '2026-08-24T10:00:00.000Z',
user: { name: 'Ada', displayName: 'Ada Lovelace' },
}],
},
},
},
});
});
vi.stubGlobal('fetch', graphql);
const search = await listLinearIssues({ query: 'login' });
expect(search.issues).toHaveLength(1);
expect(search.hasMore).toBe(false);
const byId = await listLinearIssues({ query: 'https://linear.app/openchamber/issue/ENG-12' });
expect(byId.issues?.[0]?.identifier).toBe('ENG-12');
expect(byId.hasMore).toBe(false);
});
it('applies status, assignee, team, and priority list filters', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.variables.filter).toEqual({
state: { type: { eq: 'started' }, name: { neqIgnoreCase: 'In Review' } },
assignee: { isMe: { eq: true } },
team: { id: { eq: 'team-eng' } },
priority: { eq: 1 },
});
return jsonResponse({
data: {
issues: {
nodes: [issueNode],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
}));
const result = await listLinearIssues({
status: 'started',
assignee: 'me',
teamId: 'team-eng',
priority: 'urgent',
});
expect(result.issues).toHaveLength(1);
});
it('filters each panel status to a Linear state type or name', async () => {
const filters = [];
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
filters.push(JSON.parse(options.body).variables.filter);
return jsonResponse({
data: {
issues: {
nodes: [issueNode],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
}));
await listLinearIssues({ status: 'todo' });
await listLinearIssues({ status: 'backlog' });
await listLinearIssues({ status: 'started' });
await listLinearIssues({ status: 'inReview' });
await listLinearIssues({ status: 'completed' });
await listLinearIssues({ status: 'canceled' });
await listLinearIssues({ status: 'duplicate' });
expect(filters).toEqual([
{ state: { type: { eq: 'unstarted' } } },
{ state: { type: { eq: 'backlog' } } },
{ state: { type: { eq: 'started' }, name: { neqIgnoreCase: 'In Review' } } },
{ state: { name: { eqIgnoreCase: 'In Review' } } },
{ state: { type: { eq: 'completed' } } },
{ state: { type: { eq: 'canceled' }, name: { neqIgnoreCase: 'Duplicate' } } },
{ state: { or: [{ type: { eq: 'duplicate' } }, { name: { eqIgnoreCase: 'Duplicate' } }] } },
]);
});
it('omits the state filter when listing all issues', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.variables.filter).toBeUndefined();
return jsonResponse({
data: {
issues: {
nodes: [issueNode],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
}));
await listLinearIssues({ status: 'all' });
});
it('filters no-priority issues as Linear priority 0', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.variables.filter).toEqual({
priority: { eq: 0 },
});
return jsonResponse({
data: {
issues: {
nodes: [issueNode],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
}));
await listLinearIssues({ status: 'all', priority: 'none' });
});
it('looks up an identifier without applying list filters', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.query).toContain('GetLinearIssue');
expect(body.variables.id).toBe('ENG-12');
expect(body.variables.filter).toBeUndefined();
return jsonResponse({ data: { issue: issueNode } });
}));
const result = await listLinearIssues({
query: 'ENG-12',
status: 'completed',
assignee: 'me',
teamId: 'team-eng',
priority: 'urgent',
});
expect(result.issues?.[0]?.identifier).toBe('ENG-12');
});
it('loads one issue with comments', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
data: {
issue: {
...issueNode,
description: 'Users cannot sign in.',
comments: { nodes: [{ id: 'comment-1', body: 'Still broken', createdAt: '2026-08-24T10:00:00.000Z', user: { name: 'Ada', displayName: null, avatarUrl: 'https://linear.app/avatar/ada.png' } }] },
},
},
})));
const result = await getLinearIssue('ENG-12');
expect(result.connected).toBe(true);
expect(result.issue?.description).toBe('Users cannot sign in.');
expect(result.issue?.priority).toBe(1);
expect(result.issue?.labels).toEqual([{ id: 'label-bug', name: 'Bug', color: '#eb5757' }]);
expect(result.issue?.comments).toEqual([{
id: 'comment-1',
body: 'Still broken',
createdAt: '2026-08-24T10:00:00.000Z',
user: { name: 'Ada', displayName: null, avatarUrl: 'https://linear.app/avatar/ada.png' },
}]);
});
it('creates a comment on the resolved issue UUID', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
if (body.query.includes('query GetLinearIssue')) {
expect(body.variables.id).toBe('ENG-12');
return jsonResponse({
data: {
issue: {
...issueNode,
description: null,
comments: { nodes: [] },
},
},
});
}
expect(body.query).toContain('mutation CommentCreate');
expect(body.variables.input).toEqual({
issueId: 'issue-uuid-1',
body: 'OpenChamber session started.',
});
expect(options.headers.Authorization).toBe('Bearer access-1');
return jsonResponse({
data: {
commentCreate: {
success: true,
comment: { id: 'comment-9' },
},
},
});
}));
const result = await createLinearIssueComment({
issueId: 'ENG-12',
body: 'OpenChamber session started.',
});
expect(result).toEqual({ connected: true, comment: { id: 'comment-9' } });
expect(JSON.stringify(result)).not.toContain('access-1');
});
it('clears auth and reports disconnected after a GraphQL 401', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ errors: [{ message: 'Unauthorized' }] }, 401)));
await expect(listLinearIssues()).resolves.toEqual({ connected: false });
await expect(listLinearIssues()).resolves.toEqual({ connected: false });
});
it('lists team workflow states in Linear workflow order', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.query).toContain('query TeamWorkflowStates');
expect(body.variables.id).toBe('team-eng');
expect(options.headers.Authorization).toBe('Bearer access-1');
return jsonResponse({
data: {
team: {
states: {
nodes: [
{ id: 'state-done', name: 'Done', type: 'completed', position: 0 },
{ id: 'state-review', name: 'In Review', type: 'started', position: 1 },
{ id: 'state-todo', name: 'Todo', type: 'unstarted', position: 0 },
{ id: 'state-dup', name: 'Duplicate', type: 'canceled', position: 1 },
{ id: 'state-progress', name: 'In Progress', type: 'started', position: 0 },
{ id: 'state-backlog', name: 'Backlog', type: 'backlog', position: 0 },
{ id: 'state-canceled', name: 'Canceled', type: 'canceled', position: 0 },
],
},
},
},
});
}));
const result = await listLinearIssueStates('team-eng');
expect(result.states?.map((state) => state.name)).toEqual([
'Backlog',
'Todo',
'In Progress',
'In Review',
'Done',
'Canceled',
'Duplicate',
]);
});
it('rejects workflow states without a team id', async () => {
await expect(listLinearIssueStates('')).rejects.toMatchObject({
message: 'teamId is required',
code: 'INVALID',
});
});
it('updates an issue state and returns the issue', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.query).toContain('mutation IssueUpdate');
expect(body.variables).toEqual({
id: 'issue-uuid-1',
input: { stateId: 'state-done' },
});
expect(options.headers.Authorization).toBe('Bearer access-1');
return jsonResponse({
data: {
issueUpdate: {
success: true,
issue: {
...issueNode,
state: { id: 'state-done', name: 'Done', type: 'completed' },
description: null,
comments: { nodes: [] },
},
},
},
});
}));
const result = await updateLinearIssue({ id: 'issue-uuid-1', stateId: 'state-done' });
expect(result.connected).toBe(true);
expect(result.issue?.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' });
expect(JSON.stringify(result)).not.toContain('access-1');
});
it('rejects an issue update without id or stateId', async () => {
await expect(updateLinearIssue({ id: 'issue-uuid-1' })).rejects.toMatchObject({
message: 'id and stateId are required',
code: 'INVALID',
});
});
it('resolves an issue identifier before issueUpdate', async () => {
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
if (body.query.includes('query GetLinearIssue')) {
expect(body.variables.id).toBe('ENG-12');
return jsonResponse({
data: {
issue: {
...issueNode,
description: null,
comments: { nodes: [] },
},
},
});
}
expect(body.query).toContain('mutation IssueUpdate');
expect(body.variables).toEqual({
id: 'issue-uuid-1',
input: { stateId: 'state-done' },
});
return jsonResponse({
data: {
issueUpdate: {
success: true,
issue: {
...issueNode,
state: { id: 'state-done', name: 'Done', type: 'completed' },
description: null,
comments: { nodes: [] },
},
},
},
});
}));
const result = await updateLinearIssue({ id: 'ENG-12', stateId: 'state-done' });
expect(result.connected).toBe(true);
expect(result.issue?.id).toBe('issue-uuid-1');
expect(result.issue?.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' });
});
it('surfaces Linear validation constraints from GraphQL errors', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({
data: null,
errors: [{
message: 'Argument Validation Error',
extensions: {
code: 'INVALID_INPUT',
userError: true,
userPresentableMessage: 'stateId must be a UUID.',
validationErrors: [{
property: 'stateId',
constraints: { isUuid: 'stateId must be a UUID.' },
}],
},
}],
})));
await expect(updateLinearIssue({ id: 'issue-uuid-1', stateId: 'not-a-uuid' })).rejects.toMatchObject({
name: 'LinearApiError',
message: 'stateId must be a UUID.',
status: 400,
userError: true,
});
});
});
+188
View File
@@ -0,0 +1,188 @@
import fs from 'fs';
import path from 'path';
import { getLinearAuth, getLinearAuthFilePath } from './auth.js';
import { isPlainObject, readTrimmedString } from './parse.js';
export class LinearMappingError extends Error {
constructor(message, code) {
super(message);
this.name = 'LinearMappingError';
this.code = code;
}
}
function mappingFile() {
return path.join(path.dirname(getLinearAuthFilePath()), 'linear-mapping.json');
}
const UNSCOPED_MAPPING_KEY = '__unscoped__';
function mappingOrgKey() {
const auth = getLinearAuth();
return readTrimmedString(auth?.workspaceId) || UNSCOPED_MAPPING_KEY;
}
function emptyMapping() {
return {
defaultProjectPath: null,
teamProjectPaths: {},
};
}
function readTeamProjectPaths(value) {
if (!isPlainObject(value)) {
return {};
}
const next = {};
for (const key of Object.keys(value)) {
const teamId = readTrimmedString(key);
const projectPath = readTrimmedString(value[key]);
if (teamId && projectPath) {
next[teamId] = projectPath;
}
}
return next;
}
function normalizeMappingSlice(raw) {
if (!isPlainObject(raw)) {
return emptyMapping();
}
return {
defaultProjectPath: readTrimmedString(raw.defaultProjectPath) || null,
teamProjectPaths: readTeamProjectPaths(raw.teamProjectPaths),
};
}
function readMappingDocument(raw) {
if (!isPlainObject(raw)) {
return { workspaces: {} };
}
if (isPlainObject(raw.workspaces)) {
const workspaces = {};
for (const key of Object.keys(raw.workspaces)) {
const orgKey = readTrimmedString(key);
if (!orgKey) continue;
workspaces[orgKey] = normalizeMappingSlice(raw.workspaces[key]);
}
return { workspaces };
}
return {
workspaces: {
[mappingOrgKey()]: normalizeMappingSlice(raw),
},
};
}
function writeJsonFile(filePath, payload) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const tmpFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8');
try {
fs.chmodSync(tmpFile, 0o600);
} catch {
// best-effort
}
fs.renameSync(tmpFile, filePath);
try {
fs.chmodSync(filePath, 0o600);
} catch {
// best-effort
}
}
export function getLinearMappingFilePath() {
return mappingFile();
}
export function readStoredLinearMapping() {
const filePath = mappingFile();
if (!fs.existsSync(filePath)) {
return emptyMapping();
}
let parsed;
try {
const raw = fs.readFileSync(filePath, 'utf8');
const trimmed = raw.trim();
if (!trimmed) {
return emptyMapping();
}
parsed = JSON.parse(trimmed);
} catch {
throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED');
}
if (!isPlainObject(parsed)) {
throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED');
}
const document = readMappingDocument(parsed);
return document.workspaces[mappingOrgKey()] || emptyMapping();
}
export function setStoredLinearMapping(input) {
if (!isPlainObject(input)) {
throw new LinearMappingError('Mapping body must be an object', 'INVALID');
}
const filePath = mappingFile();
let document = { workspaces: {} };
if (fs.existsSync(filePath)) {
try {
const raw = fs.readFileSync(filePath, 'utf8');
const trimmed = raw.trim();
if (trimmed) {
const parsed = JSON.parse(trimmed);
if (!isPlainObject(parsed)) {
throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED');
}
document = readMappingDocument(parsed);
}
} catch (error) {
if (error instanceof LinearMappingError) {
throw error;
}
throw new LinearMappingError('Linear mapping file is malformed', 'MALFORMED');
}
}
const next = {
defaultProjectPath: readTrimmedString(input.defaultProjectPath) || null,
teamProjectPaths: readTeamProjectPaths(input.teamProjectPaths),
};
document.workspaces[mappingOrgKey()] = next;
writeJsonFile(filePath, document);
return next;
}
export function mergeLinearMappingView(stored, teams) {
const mapping = stored || emptyMapping();
const nodes = Array.isArray(teams) ? teams : [];
return {
defaultProjectPath: mapping.defaultProjectPath,
teams: nodes.map((team) => ({
id: team.id,
key: team.key,
name: team.name,
projectPath: mapping.teamProjectPaths[team.id] || null,
})),
};
}
export function resolveMappedProjectPath(view, team) {
const teams = Array.isArray(view?.teams) ? view.teams : [];
const teamId = team ? readTrimmedString(team.id) : '';
if (teamId) {
const row = teams.find((entry) => entry.id === teamId);
if (row?.projectPath) {
return row.projectPath;
}
}
const teamKey = team ? readTrimmedString(team.key) : '';
if (teamKey) {
const row = teams.find((entry) => entry.key === teamKey);
if (row?.projectPath) {
return row.projectPath;
}
}
return view?.defaultProjectPath || null;
}
@@ -0,0 +1,147 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { activateLinearAuth, getLinearAuth, setLinearAuth } from './auth.js';
import {
getLinearMappingFilePath,
mergeLinearMappingView,
readStoredLinearMapping,
resolveMappedProjectPath,
setStoredLinearMapping,
} from './mapping.js';
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-mapping-'));
describe('Linear project mapping storage', () => {
let dataDir;
let previousDataDir;
beforeEach(() => {
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
dataDir = makeTempDir();
process.env.OPENCHAMBER_DATA_DIR = dataDir;
});
afterEach(() => {
if (previousDataDir === undefined) {
delete process.env.OPENCHAMBER_DATA_DIR;
} else {
process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
}
fs.rmSync(dataDir, { recursive: true, force: true });
});
it('treats a missing file as empty mapping, not a failure', () => {
expect(fs.existsSync(getLinearMappingFilePath())).toBe(false);
expect(readStoredLinearMapping()).toEqual({
defaultProjectPath: null,
teamProjectPaths: {},
});
});
it('round-trips a default project and per-team paths', () => {
const written = setStoredLinearMapping({
defaultProjectPath: '/Users/ada/openchamber',
teamProjectPaths: {
'team-eng': '/Users/ada/eng',
'team-empty': ' ',
},
});
expect(written).toEqual({
defaultProjectPath: '/Users/ada/openchamber',
teamProjectPaths: { 'team-eng': '/Users/ada/eng' },
});
expect(readStoredLinearMapping()).toEqual(written);
expect(fs.statSync(getLinearMappingFilePath()).mode & 0o777).toBe(0o600);
});
it('replaces the previous mapping on write', () => {
setStoredLinearMapping({
defaultProjectPath: '/old',
teamProjectPaths: { 'team-eng': '/eng' },
});
const next = setStoredLinearMapping({
defaultProjectPath: null,
teamProjectPaths: {},
});
expect(next).toEqual({ defaultProjectPath: null, teamProjectPaths: {} });
expect(readStoredLinearMapping()).toEqual(next);
});
it('keeps tokens when a mapping write is rejected', () => {
setLinearAuth({
accessToken: 'access-keep',
refreshToken: 'refresh-keep',
expiresAt: Date.now() + 60_000,
});
setStoredLinearMapping({
defaultProjectPath: '/keep',
teamProjectPaths: { 'team-eng': '/eng' },
});
expect(() => setStoredLinearMapping(null)).toThrow(/object/);
expect(readStoredLinearMapping()).toEqual({
defaultProjectPath: '/keep',
teamProjectPaths: { 'team-eng': '/eng' },
});
expect(getLinearAuth().accessToken).toBe('access-keep');
});
it('rejects a malformed mapping file instead of treating it as empty', () => {
fs.writeFileSync(getLinearMappingFilePath(), '{not-json', 'utf8');
expect(() => readStoredLinearMapping()).toThrow(/malformed/);
});
it('merges live teams onto stored paths and resolves team then default', () => {
const stored = {
defaultProjectPath: '/default',
teamProjectPaths: { 'team-eng': '/eng' },
};
const view = mergeLinearMappingView(stored, [
{ id: 'team-eng', key: 'ENG', name: 'Engineering' },
{ id: 'team-des', key: 'DES', name: 'Design' },
]);
expect(view).toEqual({
defaultProjectPath: '/default',
teams: [
{ id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: '/eng' },
{ id: 'team-des', key: 'DES', name: 'Design', projectPath: null },
],
});
expect(resolveMappedProjectPath(view, { id: 'team-eng', key: 'ENG' })).toBe('/eng');
expect(resolveMappedProjectPath(view, { id: 'team-des', key: 'DES' })).toBe('/default');
expect(resolveMappedProjectPath(view, null)).toBe('/default');
});
it('keeps mapping slices isolated per workspace', () => {
setLinearAuth({
accessToken: 'access-a',
user: { id: 'user-a', name: 'Ada' },
organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' },
});
setStoredLinearMapping({
defaultProjectPath: '/alpha',
teamProjectPaths: { 'team-a': '/alpha-eng' },
});
setLinearAuth({
accessToken: 'access-b',
user: { id: 'user-b', name: 'Ben' },
organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' },
});
setStoredLinearMapping({
defaultProjectPath: '/beta',
teamProjectPaths: {},
});
expect(readStoredLinearMapping()).toEqual({
defaultProjectPath: '/beta',
teamProjectPaths: {},
});
expect(activateLinearAuth('org-a')).toBe(true);
expect(readStoredLinearMapping()).toEqual({
defaultProjectPath: '/alpha',
teamProjectPaths: { 'team-a': '/alpha-eng' },
});
});
});
+345
View File
@@ -0,0 +1,345 @@
import crypto from 'crypto';
import {
getLinearClientId,
getLinearClientSecret,
getLinearBrokerUrl,
getLinearRedirectUri,
getLinearScopes,
} from './auth.js';
import { isPlainObject, isString, readFiniteNumber, readTrimmedString } from './parse.js';
export const LINEAR_AUTHORIZE_URL = 'https://linear.app/oauth/authorize';
export const LINEAR_TOKEN_URL = 'https://api.linear.app/oauth/token';
export const LINEAR_REVOKE_URL = 'https://api.linear.app/oauth/revoke';
export const PENDING_AUTHORIZATION_TTL_MS = 10 * 60_000;
const pendingByState = new Map();
const brokerPollsByState = new Map();
export class LinearOAuthError extends Error {
constructor(message, code = 'LINEAR_OAUTH_FAILED') {
super(message);
this.name = 'LinearOAuthError';
this.code = code;
}
}
export function createPkcePair() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
function pruneExpiredPending(now = Date.now()) {
for (const [state, entry] of pendingByState.entries()) {
if (!entry || entry.expiresAt <= now) {
pendingByState.delete(state);
}
}
}
function normalizeScope(scope) {
if (isString(scope)) {
return scope.trim();
}
if (Array.isArray(scope)) {
return scope.filter((item) => isString(item) && item.trim()).join(',');
}
return '';
}
function readExpiresAt(expiresIn, now = Date.now()) {
const seconds = readFiniteNumber(expiresIn);
if (seconds == null || seconds <= 0) {
return now + 24 * 60 * 60 * 1000;
}
return now + Math.floor(seconds) * 1000;
}
function parseTokenPayload(payload) {
if (!isPlainObject(payload)) {
throw new LinearOAuthError('Linear token response was empty');
}
if (readTrimmedString(payload.error)) {
throw new LinearOAuthError(
readTrimmedString(payload.error_description) || readTrimmedString(payload.error),
readTrimmedString(payload.error).toUpperCase(),
);
}
const accessToken = readTrimmedString(payload.access_token);
if (!accessToken) {
throw new LinearOAuthError('Linear token response was missing access_token');
}
return {
accessToken,
refreshToken: readTrimmedString(payload.refresh_token) || null,
tokenType: readTrimmedString(payload.token_type) || 'bearer',
expiresAt: readExpiresAt(payload.expires_in),
scope: normalizeScope(payload.scope),
};
}
async function postForm(url, body) {
const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(body).toString(),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const description = isPlainObject(payload)
? (readTrimmedString(payload.error_description) || readTrimmedString(payload.error))
: '';
const error = new LinearOAuthError(
description || `Linear token request failed (${response.status})`,
readTrimmedString(payload?.error).toUpperCase() || 'LINEAR_OAUTH_FAILED',
);
error.status = response.status;
throw error;
}
return parseTokenPayload(payload);
}
async function readJsonResponse(response, fallbackMessage) {
const payload = await response.json().catch(() => null);
if (!response.ok) {
const message = isPlainObject(payload) && readTrimmedString(payload.error)
? readTrimmedString(payload.error)
: `${fallbackMessage} (${response.status})`;
const error = new LinearOAuthError(message, 'LINEAR_BROKER_FAILED');
error.status = response.status;
throw error;
}
if (!isPlainObject(payload)) {
throw new LinearOAuthError(`${fallbackMessage}: invalid response`, 'LINEAR_BROKER_FAILED');
}
return payload;
}
function brokerCallbackUrl(brokerUrl) {
return `${brokerUrl.replace(/\/+$/, '')}/callback`;
}
async function registerBrokerTransaction({ brokerUrl, state, claimSecret }) {
const response = await fetch(`${brokerUrl}/start`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ state, claimSecret }),
});
const payload = await readJsonResponse(response, 'Could not start Linear authorization broker');
const redirectUri = readTrimmedString(payload.redirectUri);
if (!redirectUri || redirectUri !== brokerCallbackUrl(brokerUrl)) {
throw new LinearOAuthError('Linear authorization broker returned an unexpected callback URL', 'LINEAR_BROKER_FAILED');
}
return redirectUri;
}
export async function startAuthorization({ origin } = {}) {
const clientId = getLinearClientId();
if (!clientId) {
throw new LinearOAuthError(
'Linear OAuth client not configured. Set OPENCHAMBER_LINEAR_CLIENT_ID.',
'LINEAR_CLIENT_ID_MISSING',
);
}
pruneExpiredPending();
const { verifier, challenge } = createPkcePair();
const state = crypto.randomBytes(32).toString('base64url');
const brokerUrl = getLinearBrokerUrl();
const configuredRedirectUri = getLinearRedirectUri();
const usesBroker = configuredRedirectUri === brokerCallbackUrl(brokerUrl);
const claimSecret = usesBroker ? crypto.randomBytes(32).toString('base64url') : null;
const redirectUri = usesBroker
? await registerBrokerTransaction({ brokerUrl, state, claimSecret })
: configuredRedirectUri;
const scope = getLinearScopes();
pendingByState.set(state, {
codeVerifier: verifier,
redirectUri,
origin: origin === 'desktop' ? 'desktop' : 'web',
broker: usesBroker ? { url: brokerUrl, claimSecret } : null,
expiresAt: Date.now() + PENDING_AUTHORIZATION_TTL_MS,
});
const url = new URL(LINEAR_AUTHORIZE_URL);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('scope', scope);
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('actor', 'user');
url.searchParams.set('prompt', 'consent');
return {
authorizationUrl: url.toString(),
expiresIn: Math.floor(PENDING_AUTHORIZATION_TTL_MS / 1000),
scope,
};
}
async function pollBrokerState(state, pending) {
const response = await fetch(`${pending.broker.url}/poll`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ state, claimSecret: pending.broker.claimSecret }),
});
if (response.status === 202) {
return null;
}
const payload = await readJsonResponse(response, 'Could not read Linear authorization result');
const status = readTrimmedString(payload.status);
if (status === 'complete') {
const result = await consumeAuthorizationCallback({ code: payload.code, state });
return {
...result,
brokerReceipt: { state, ...pending.broker },
};
}
if (status === 'failed') {
return consumeAuthorizationCallback({
state,
error: payload.error,
errorDescription: payload.errorDescription,
});
}
throw new LinearOAuthError('Linear authorization broker returned an unexpected result', 'LINEAR_BROKER_FAILED');
}
export async function completeAuthorizationBroker(receipt) {
if (!receipt?.url || !receipt?.state || !receipt?.claimSecret) return false;
const response = await fetch(`${receipt.url}/complete`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ state: receipt.state, claimSecret: receipt.claimSecret }),
});
if (!response.ok) {
throw new LinearOAuthError(`Could not acknowledge Linear authorization result (${response.status})`, 'LINEAR_BROKER_FAILED');
}
return true;
}
export async function pollAuthorizationBroker() {
pruneExpiredPending();
for (const [state, pending] of pendingByState.entries()) {
if (!pending?.broker) continue;
let poll = brokerPollsByState.get(state);
if (!poll) {
poll = pollBrokerState(state, pending).finally(() => brokerPollsByState.delete(state));
brokerPollsByState.set(state, poll);
}
const result = await poll;
if (result) return result;
}
return null;
}
function failAuthorization(message, code, origin) {
const error = new LinearOAuthError(message, code);
if (origin) {
error.origin = origin;
}
return error;
}
export async function consumeAuthorizationCallback({ code, state, error, errorDescription }) {
pruneExpiredPending();
const pending = readTrimmedString(state) ? pendingByState.get(state) : null;
if (readTrimmedString(error)) {
if (readTrimmedString(state)) pendingByState.delete(state);
throw failAuthorization(
readTrimmedString(errorDescription) || readTrimmedString(error),
readTrimmedString(error).toUpperCase(),
pending?.origin,
);
}
if (!readTrimmedString(code)) {
if (readTrimmedString(state)) pendingByState.delete(state);
throw failAuthorization(
'Linear did not return an authorization code.',
'MISSING_CODE',
pending?.origin,
);
}
if (!pending?.codeVerifier) {
throw failAuthorization(
'This authorization session has expired or is unknown to the running app. Return to OpenChamber and click Connect again.',
'UNKNOWN_STATE',
);
}
const body = {
grant_type: 'authorization_code',
code: code.trim(),
redirect_uri: pending.redirectUri,
client_id: getLinearClientId(),
code_verifier: pending.codeVerifier,
};
const clientSecret = getLinearClientSecret();
if (clientSecret) {
body.client_secret = clientSecret;
}
try {
const tokens = await postForm(LINEAR_TOKEN_URL, body);
pendingByState.delete(state);
return {
...tokens,
origin: pending.origin,
};
} catch (caught) {
if (caught instanceof Error) {
caught.origin = pending.origin;
}
throw caught;
}
}
export async function refreshAccessToken(refreshToken) {
const token = readTrimmedString(refreshToken);
if (!token) {
throw new LinearOAuthError('refresh_token is required', 'MISSING_REFRESH_TOKEN');
}
const body = {
grant_type: 'refresh_token',
refresh_token: token,
client_id: getLinearClientId(),
};
const clientSecret = getLinearClientSecret();
if (clientSecret) {
body.client_secret = clientSecret;
}
return postForm(LINEAR_TOKEN_URL, body);
}
export async function revokeToken(token, tokenTypeHint) {
const value = readTrimmedString(token);
if (!value) {
return false;
}
const body = { token: value };
if (tokenTypeHint === 'access_token' || tokenTypeHint === 'refresh_token') {
body.token_type_hint = tokenTypeHint;
}
try {
const response = await fetch(LINEAR_REVOKE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(body).toString(),
});
return response.status === 200;
} catch {
return false;
}
}
export function clearPendingAuthorizationsForTests() {
pendingByState.clear();
brokerPollsByState.clear();
}
@@ -0,0 +1,166 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
startAuthorization,
consumeAuthorizationCallback,
pollAuthorizationBroker,
completeAuthorizationBroker,
refreshAccessToken,
clearPendingAuthorizationsForTests,
} from './oauth.js';
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-oauth-'));
describe('Linear OAuth PKCE', () => {
let dataDir;
let previousDataDir;
let previousPort;
let previousRedirect;
beforeEach(() => {
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
previousPort = process.env.OPENCHAMBER_PORT;
previousRedirect = process.env.OPENCHAMBER_LINEAR_REDIRECT_URI;
dataDir = makeTempDir();
process.env.OPENCHAMBER_DATA_DIR = dataDir;
process.env.OPENCHAMBER_PORT = '3001';
delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID;
process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://127.0.0.1:3001/linear/oauth/callback';
clearPendingAuthorizationsForTests();
});
afterEach(() => {
vi.unstubAllGlobals();
clearPendingAuthorizationsForTests();
restoreEnv('OPENCHAMBER_DATA_DIR', previousDataDir);
restoreEnv('OPENCHAMBER_PORT', previousPort);
restoreEnv('OPENCHAMBER_LINEAR_REDIRECT_URI', previousRedirect);
fs.rmSync(dataDir, { recursive: true, force: true });
});
it('creates an S256 authorize URL and stores a pending verifier', async () => {
const started = await startAuthorization({ origin: 'desktop' });
const url = new URL(started.authorizationUrl);
expect(url.origin + url.pathname).toBe('https://linear.app/oauth/authorize');
expect(url.searchParams.get('client_id')).toBe('91bbe26a69a2c8568d3683f1e01e776c');
expect(url.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:3001/linear/oauth/callback');
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
expect(url.searchParams.get('code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(url.searchParams.get('actor')).toBe('user');
expect(url.searchParams.get('prompt')).toBe('consent');
expect(started.scope).toBe('read,write,comments:create');
expect(started.expiresIn).toBe(600);
});
it('refuses a callback whose state was never started', async () => {
const tokenFetch = vi.fn();
vi.stubGlobal('fetch', tokenFetch);
await expect(consumeAuthorizationCallback({
code: 'attacker-code',
state: 'forged',
})).rejects.toMatchObject({ code: 'UNKNOWN_STATE' });
expect(tokenFetch).not.toHaveBeenCalled();
});
it('exchanges a matching code with the original PKCE verifier', async () => {
const started = await startAuthorization({ origin: 'web' });
const state = new URL(started.authorizationUrl).searchParams.get('state');
const tokenFetch = vi.fn(async () => new Response(JSON.stringify({
access_token: 'access-1',
refresh_token: 'refresh-1',
token_type: 'Bearer',
expires_in: 86399,
scope: 'read,write,comments:create',
}), { status: 200 }));
vi.stubGlobal('fetch', tokenFetch);
const result = await consumeAuthorizationCallback({ code: 'auth-code', state });
expect(result.accessToken).toBe('access-1');
expect(result.refreshToken).toBe('refresh-1');
expect(result.origin).toBe('web');
expect(tokenFetch).toHaveBeenCalledTimes(1);
const [url, init] = tokenFetch.mock.calls[0];
expect(String(url)).toBe('https://api.linear.app/oauth/token');
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
const body = new URLSearchParams(init.body);
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('code')).toBe('auth-code');
expect(body.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(body.get('client_secret')).toBeNull();
await expect(consumeAuthorizationCallback({ code: 'auth-code', state })).rejects.toMatchObject({
code: 'UNKNOWN_STATE',
});
});
it('persists a rotated refresh token from Linear', async () => {
const tokenFetch = vi.fn(async () => new Response(JSON.stringify({
access_token: 'access-2',
refresh_token: 'refresh-2',
token_type: 'Bearer',
expires_in: 86399,
}), { status: 200 }));
vi.stubGlobal('fetch', tokenFetch);
const tokens = await refreshAccessToken('refresh-1');
expect(tokens.accessToken).toBe('access-2');
expect(tokens.refreshToken).toBe('refresh-2');
const body = new URLSearchParams(tokenFetch.mock.calls[0][1].body);
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('refresh-1');
});
it('claims a broker callback and exchanges it locally with PKCE', async () => {
delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI;
const brokerAndTokenFetch = vi.fn(async (url, init) => {
const target = String(url);
if (target.endsWith('/start')) {
const body = JSON.parse(init.body);
expect(body.state).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(body.claimSecret).toMatch(/^[A-Za-z0-9_-]{43}$/);
return new Response(JSON.stringify({
redirectUri: 'https://api.openchamber.dev/v1/oauth/linear/callback',
expiresIn: 600,
}), { status: 200 });
}
if (target.endsWith('/poll')) {
return new Response(JSON.stringify({ status: 'complete', code: 'broker-code' }), { status: 200 });
}
if (target.endsWith('/complete')) {
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
if (target === 'https://api.linear.app/oauth/token') {
const body = new URLSearchParams(init.body);
expect(body.get('code')).toBe('broker-code');
expect(body.get('redirect_uri')).toBe('https://api.openchamber.dev/v1/oauth/linear/callback');
expect(body.get('code_verifier')).toMatch(/^[A-Za-z0-9_-]{43}$/);
return new Response(JSON.stringify({
access_token: 'broker-access',
refresh_token: 'broker-refresh',
expires_in: 86399,
}), { status: 200 });
}
throw new Error(`unexpected fetch: ${target}`);
});
vi.stubGlobal('fetch', brokerAndTokenFetch);
const started = await startAuthorization({ origin: 'desktop' });
const authorizationUrl = new URL(started.authorizationUrl);
expect(authorizationUrl.searchParams.get('redirect_uri')).toBe('https://api.openchamber.dev/v1/oauth/linear/callback');
const result = await pollAuthorizationBroker();
expect(result).toMatchObject({ accessToken: 'broker-access', origin: 'desktop' });
await expect(completeAuthorizationBroker(result.brokerReceipt)).resolves.toBe(true);
expect(brokerAndTokenFetch).toHaveBeenCalledTimes(4);
});
});
function restoreEnv(name, previous) {
if (previous === undefined) {
delete process.env[name];
return;
}
process.env[name] = previous;
}
+23
View File
@@ -0,0 +1,23 @@
export function isString(value) {
return Object.prototype.toString.call(value) === '[object String]';
}
export function isPlainObject(value) {
if (value == null || Array.isArray(value)) {
return false;
}
return Object.getPrototypeOf(value) === Object.prototype;
}
export function readTrimmedString(value) {
return isString(value) && value.trim() ? value.trim() : '';
}
export function readFiniteNumber(value) {
return Number.isFinite(value) ? value : null;
}
export function readEnv(name) {
const raw = process.env[name];
return raw ? raw.trim() : '';
}
+432
View File
@@ -0,0 +1,432 @@
import express from 'express';
import { readTrimmedString } from './parse.js';
const PENDING_JSON_LIMIT = '16kb';
const parseJsonBody = express.json({ limit: PENDING_JSON_LIMIT });
function queryValue(req, key) {
const raw = req.query?.[key];
const value = Array.isArray(raw) ? raw[0] : raw;
return readTrimmedString(value);
}
function isLinearUserError(error) {
return error?.code === 'INVALID' || error?.userError === true;
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderLinearOAuthCallbackPage({ title, message, desktopReturn }) {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)} — OpenChamber</title>
<style>
:root { color-scheme: light dark; }
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: Canvas; color: CanvasText; }
main { max-width: 34rem; padding: 2.5rem 2rem; text-align: center; }
h1 { font-size: 1.25rem; margin: 0 0 0.75rem; }
p { margin: 0; line-height: 1.5; opacity: 0.85; }
a.return { display: inline-block; margin-top: 1.5rem; padding: 0.5rem 1.25rem; border-radius: 0.5rem;
border: 1px solid color-mix(in srgb, CanvasText 25%, transparent); color: inherit; text-decoration: none; }
</style>
</head>
<body>
<main>
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(message)}</p>
${desktopReturn ? `<a class="return" href="openchamber://focus/linear-auth">Return to OpenChamber</a>
<script>window.location.href = 'openchamber://focus/linear-auth';</script>` : ''}
</main>
</body>
</html>`;
}
async function storeAuthorizationResult(libraries, result) {
const { setLinearAuth, fetchLinearIdentity } = libraries;
let user = null;
let organization = null;
try {
const identity = await fetchLinearIdentity(result.accessToken);
user = identity.user;
organization = identity.organization;
} catch (error) {
console.error('Failed to load Linear identity after OAuth:', error);
}
return setLinearAuth({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
tokenType: result.tokenType,
expiresAt: result.expiresAt,
scope: result.scope,
user,
organization,
});
}
export function registerLinearRoutes(app) {
let linearLibraries = null;
const getLinearLibraries = async () => {
if (!linearLibraries) {
linearLibraries = await import('./index.js');
}
return linearLibraries;
};
app.get('/linear/oauth/callback', async (req, res) => {
const finish = (status, { title, message, desktopReturn = false }) => {
res.status(status).type('html').send(renderLinearOAuthCallbackPage({ title, message, desktopReturn }));
};
try {
const libraries = await getLinearLibraries();
const { consumeAuthorizationCallback } = libraries;
const result = await consumeAuthorizationCallback({
code: queryValue(req, 'code'),
state: queryValue(req, 'state'),
error: queryValue(req, 'error'),
errorDescription: queryValue(req, 'error_description'),
});
await storeAuthorizationResult(libraries, result);
return finish(200, {
title: 'Authorization Complete',
message: 'You can close this tab and return to OpenChamber.',
desktopReturn: result.origin === 'desktop',
});
} catch (error) {
const code = error instanceof Error ? error.code : '';
const status = code === 'UNKNOWN_STATE' || code === 'MISSING_CODE' || code === 'ACCESS_DENIED'
? 400
: 502;
return finish(status, {
title: 'Authorization Failed',
message: error instanceof Error ? error.message : 'Linear authorization failed. Return to OpenChamber and click Connect again.',
desktopReturn: error?.origin === 'desktop',
});
}
});
app.get('/api/linear/auth/status', async (_req, res) => {
try {
const libraries = await getLinearLibraries();
const {
getLinearAuth,
getLinearAuthWorkspaces,
getValidLinearAccessToken,
fetchLinearIdentity,
setLinearAuth,
clearLinearAuth,
toLinearPublicStatus,
pollAuthorizationBroker,
completeAuthorizationBroker,
} = libraries;
try {
const result = await pollAuthorizationBroker();
if (result) {
await storeAuthorizationResult(libraries, result);
await completeAuthorizationBroker(result.brokerReceipt).catch((error) => {
console.warn('Failed to acknowledge Linear authorization broker result:', error);
});
}
} catch (error) {
console.error('Failed to complete Linear authorization through broker:', error);
}
const accessToken = await getValidLinearAccessToken();
if (!accessToken) {
return res.json({ connected: false });
}
const auth = getLinearAuth();
try {
const identity = await fetchLinearIdentity(accessToken);
const next = setLinearAuth({
accessToken,
refreshToken: auth?.refreshToken,
tokenType: auth?.tokenType,
expiresAt: auth?.expiresAt,
scope: auth?.scope,
user: identity.user,
organization: identity.organization,
workspaceId: auth?.workspaceId,
}, { activate: false });
return res.json(toLinearPublicStatus(next, getLinearAuthWorkspaces()));
} catch (error) {
if (error?.status === 401) {
clearLinearAuth(auth?.workspaceId);
const remaining = getLinearAuth();
if (!remaining) {
return res.json({ connected: false });
}
return res.json(toLinearPublicStatus(remaining, getLinearAuthWorkspaces()));
}
if (auth) {
return res.json(toLinearPublicStatus(auth, getLinearAuthWorkspaces()));
}
throw error;
}
} catch (error) {
console.error('Failed to get Linear auth status:', error);
return res.status(500).json({ error: error.message || 'Failed to get Linear auth status' });
}
});
app.post('/api/linear/auth/start', parseJsonBody, async (req, res) => {
try {
const { startAuthorization } = await getLinearLibraries();
const origin = req.body?.origin === 'desktop' ? 'desktop' : 'web';
const payload = await startAuthorization({ origin });
return res.json(payload);
} catch (error) {
const status = error?.code === 'LINEAR_CLIENT_ID_MISSING' ? 400 : 500;
console.error('Failed to start Linear authorization:', error);
return res.status(status).json({ error: error.message || 'Failed to start Linear authorization' });
}
});
app.get('/api/linear/issues/list', async (req, res) => {
try {
const { listLinearIssues } = await getLinearLibraries();
const result = await listLinearIssues({
query: queryValue(req, 'query'),
cursor: queryValue(req, 'cursor'),
status: queryValue(req, 'status'),
assignee: queryValue(req, 'assignee'),
teamId: queryValue(req, 'teamId'),
priority: queryValue(req, 'priority'),
});
return res.json(result);
} catch (error) {
console.error('Failed to list Linear issues:', error);
return res.status(500).json({ error: error.message || 'Failed to list Linear issues' });
}
});
app.get('/api/linear/issues/get', async (req, res) => {
try {
const id = queryValue(req, 'id');
if (!id) {
return res.status(400).json({ error: 'id is required' });
}
const { getLinearIssue } = await getLinearLibraries();
const result = await getLinearIssue(id);
return res.json(result);
} catch (error) {
console.error('Failed to load Linear issue:', error);
return res.status(500).json({ error: error.message || 'Failed to load Linear issue' });
}
});
app.get('/api/linear/issues/states', async (req, res) => {
try {
const teamId = queryValue(req, 'teamId');
if (!teamId) {
return res.status(400).json({ error: 'teamId is required' });
}
const { listLinearIssueStates } = await getLinearLibraries();
const result = await listLinearIssueStates(teamId);
return res.json(result);
} catch (error) {
if (isLinearUserError(error)) {
return res.status(400).json({ error: error.message });
}
console.error('Failed to load Linear workflow states:', error);
return res.status(500).json({ error: error.message || 'Failed to load Linear workflow states' });
}
});
app.post('/api/linear/issues/update', parseJsonBody, async (req, res) => {
try {
const { updateLinearIssue } = await getLinearLibraries();
const result = await updateLinearIssue({
id: req.body?.id,
stateId: req.body?.stateId,
});
return res.json(result);
} catch (error) {
if (isLinearUserError(error)) {
return res.status(400).json({ error: error.message });
}
console.error('Failed to update Linear issue:', error);
return res.status(500).json({ error: error.message || 'Failed to update Linear issue' });
}
});
app.get('/api/linear/mapping', async (_req, res) => {
try {
const {
listLinearTeams,
readStoredLinearMapping,
mergeLinearMappingView,
LinearMappingError,
} = await getLinearLibraries();
const teamsResult = await listLinearTeams();
if (teamsResult.connected === false) {
return res.json({ connected: false });
}
let stored;
try {
stored = readStoredLinearMapping();
} catch (error) {
if (error instanceof LinearMappingError && error.code === 'MALFORMED') {
return res.status(500).json({ error: error.message });
}
throw error;
}
return res.json({
connected: true,
...mergeLinearMappingView(stored, teamsResult.teams),
});
} catch (error) {
console.error('Failed to load Linear mapping:', error);
return res.status(500).json({ error: error.message || 'Failed to load Linear mapping' });
}
});
app.put('/api/linear/mapping', parseJsonBody, async (req, res) => {
try {
const {
getValidLinearAccessToken,
listLinearTeams,
setStoredLinearMapping,
mergeLinearMappingView,
LinearMappingError,
} = await getLinearLibraries();
const accessToken = await getValidLinearAccessToken();
if (!accessToken) {
return res.json({ connected: false });
}
let stored;
try {
stored = setStoredLinearMapping(req.body);
} catch (error) {
if (error instanceof LinearMappingError && error.code === 'INVALID') {
return res.status(400).json({ error: error.message });
}
throw error;
}
const teamsResult = await listLinearTeams();
if (teamsResult.connected === false) {
return res.json({
connected: true,
...mergeLinearMappingView(stored, []),
});
}
return res.json({
connected: true,
...mergeLinearMappingView(stored, teamsResult.teams),
});
} catch (error) {
console.error('Failed to save Linear mapping:', error);
return res.status(500).json({ error: error.message || 'Failed to save Linear mapping' });
}
});
app.post('/api/linear/session-status', parseJsonBody, async (req, res) => {
try {
const { postLinearSessionStatus, LinearSessionStatusError } = await getLinearLibraries();
try {
const result = await postLinearSessionStatus({
kind: req.body?.kind,
sessionId: req.body?.sessionId,
issueIdentifier: req.body?.issueIdentifier,
sessionOrigin: req.body?.sessionOrigin,
});
return res.json(result);
} catch (error) {
if (error instanceof LinearSessionStatusError && error.code === 'INVALID') {
return res.status(400).json({ error: error.message });
}
if (error instanceof LinearSessionStatusError && error.code === 'MALFORMED') {
return res.status(500).json({ error: error.message });
}
throw error;
}
} catch (error) {
console.error('Failed to post Linear session status:', error);
return res.status(500).json({ error: error.message || 'Failed to post Linear session status' });
}
});
app.get('/api/linear/preferences', async (_req, res) => {
try {
const { getLinearSessionCommentsEnabled } = await getLinearLibraries();
return res.json({ sessionComments: getLinearSessionCommentsEnabled() });
} catch (error) {
console.error('Failed to load Linear preferences:', error);
return res.status(500).json({ error: error.message || 'Failed to load Linear preferences' });
}
});
app.put('/api/linear/preferences', parseJsonBody, async (req, res) => {
try {
const sessionComments = req.body?.sessionComments;
if (sessionComments !== true && sessionComments !== false) {
return res.status(400).json({ error: 'sessionComments must be a boolean' });
}
const { setLinearSessionCommentsEnabled } = await getLinearLibraries();
return res.json({ sessionComments: setLinearSessionCommentsEnabled(sessionComments) });
} catch (error) {
console.error('Failed to save Linear preferences:', error);
return res.status(500).json({ error: error.message || 'Failed to save Linear preferences' });
}
});
app.post('/api/linear/auth/activate', parseJsonBody, async (req, res) => {
try {
const {
activateLinearAuth,
getLinearAuth,
getLinearAuthWorkspaces,
toLinearPublicStatus,
} = await getLinearLibraries();
const organizationId = readTrimmedString(req.body?.organizationId);
if (!organizationId) {
return res.status(400).json({ error: 'organizationId is required' });
}
const activated = activateLinearAuth(organizationId);
if (!activated) {
return res.status(404).json({ error: 'Linear workspace not found' });
}
const auth = getLinearAuth();
if (!auth) {
return res.json({ connected: false });
}
return res.json(toLinearPublicStatus(auth, getLinearAuthWorkspaces()));
} catch (error) {
console.error('Failed to switch Linear workspace:', error);
return res.status(500).json({ error: error.message || 'Failed to switch Linear workspace' });
}
});
app.delete('/api/linear/auth', async (_req, res) => {
try {
const { getLinearAuth, clearLinearAuth, revokeToken } = await getLinearLibraries();
const auth = getLinearAuth();
if (auth?.refreshToken) {
await revokeToken(auth.refreshToken, 'refresh_token');
} else if (auth?.accessToken) {
await revokeToken(auth.accessToken, 'access_token');
}
const removed = clearLinearAuth(auth?.workspaceId);
return res.json({ success: true, removed });
} catch (error) {
console.error('Failed to disconnect Linear:', error);
return res.status(500).json({ error: error.message || 'Failed to disconnect Linear' });
}
});
}
@@ -0,0 +1,661 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { registerLinearRoutes } from './routes.js';
import { setLinearAuth, setLinearSessionCommentsEnabled } from './auth.js';
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-routes-'));
const createApp = () => {
const app = express();
registerLinearRoutes(app);
return app;
};
const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json' },
});
describe('Linear auth routes', () => {
let dataDir;
let previousDataDir;
beforeEach(() => {
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
dataDir = makeTempDir();
process.env.OPENCHAMBER_DATA_DIR = dataDir;
process.env.OPENCHAMBER_PORT = '3001';
process.env.OPENCHAMBER_LINEAR_REDIRECT_URI = 'http://127.0.0.1:3001/linear/oauth/callback';
delete process.env.OPENCHAMBER_LINEAR_CLIENT_ID;
});
afterEach(() => {
vi.unstubAllGlobals();
if (previousDataDir === undefined) {
delete process.env.OPENCHAMBER_DATA_DIR;
} else {
process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
}
delete process.env.OPENCHAMBER_PORT;
delete process.env.OPENCHAMBER_LINEAR_REDIRECT_URI;
fs.rmSync(dataDir, { recursive: true, force: true });
});
it('starts authorization and completes it from the public callback', async () => {
const app = createApp();
const start = await request(app)
.post('/api/linear/auth/start')
.send({ origin: 'desktop' })
.expect(200);
expect(start.body.authorizationUrl).toContain('https://linear.app/oauth/authorize');
const state = new URL(start.body.authorizationUrl).searchParams.get('state');
vi.stubGlobal('fetch', vi.fn(async (url) => {
const target = String(url);
if (target === 'https://api.linear.app/oauth/token') {
return jsonResponse({
access_token: 'access-1',
refresh_token: 'refresh-1',
token_type: 'Bearer',
expires_in: 86399,
scope: 'read,write,comments:create',
});
}
if (target === 'https://api.linear.app/graphql') {
return jsonResponse({
data: {
viewer: {
id: 'user-1',
name: 'Ada',
displayName: 'Ada Lovelace',
email: 'ada@example.com',
avatarUrl: 'https://example.com/a.png',
},
organization: { id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' },
},
});
}
throw new Error(`unexpected fetch: ${target}`);
}));
const callback = await request(app)
.get('/linear/oauth/callback')
.query({ state, code: 'auth-code' })
.expect(200);
expect(callback.text).toContain('Authorization Complete');
expect(callback.text).toContain('openchamber://focus/linear-auth');
const status = await request(app).get('/api/linear/auth/status').expect(200);
expect(status.body.connected).toBe(true);
expect(status.body.user).toEqual({
id: 'user-1',
name: 'Ada',
displayName: 'Ada Lovelace',
email: 'ada@example.com',
avatarUrl: 'https://example.com/a.png',
});
expect(status.body.organization).toEqual({ id: 'org-1', name: 'OpenChamber', urlKey: 'openchamber' });
expect(status.body.scope).toBe('read,write,comments:create');
expect(status.body.workspaces).toEqual([{
id: 'org-1',
name: 'OpenChamber',
urlKey: 'openchamber',
current: true,
user: {
id: 'user-1',
name: 'Ada',
displayName: 'Ada Lovelace',
email: 'ada@example.com',
avatarUrl: 'https://example.com/a.png',
},
authorizedAt: expect.any(Number),
}]);
expect(JSON.stringify(status.body)).not.toContain('access-1');
expect(JSON.stringify(status.body)).not.toContain('refresh-1');
const again = await request(app).get('/api/linear/auth/status').expect(200);
expect(again.body.workspaces[0].authorizedAt).toBe(status.body.workspaces[0].authorizedAt);
});
it('never exchanges a code whose state is unknown', async () => {
const tokenFetch = vi.fn();
vi.stubGlobal('fetch', tokenFetch);
const app = createApp();
const response = await request(app)
.get('/linear/oauth/callback')
.query({ state: 'forged', code: 'attacker-code' })
.expect(400);
expect(tokenFetch).not.toHaveBeenCalled();
expect(response.text).toContain('Authorization Failed');
expect(response.text).not.toContain('openchamber://');
});
it('omits the desktop deep link for flows started outside the desktop shell', async () => {
const app = createApp();
const start = await request(app)
.post('/api/linear/auth/start')
.send({ origin: 'web' })
.expect(200);
const state = new URL(start.body.authorizationUrl).searchParams.get('state');
vi.stubGlobal('fetch', vi.fn(async (url) => {
const target = String(url);
if (target.includes('/oauth/token')) {
return jsonResponse({
access_token: 'access-1',
refresh_token: 'refresh-1',
expires_in: 86399,
});
}
return jsonResponse({
data: { viewer: { id: 'user-1', name: 'Ada' }, organization: null },
});
}));
const response = await request(app)
.get('/linear/oauth/callback')
.query({ state, code: 'auth-code' })
.expect(200);
expect(response.text).not.toContain('openchamber://');
});
it('disconnects and revokes the refresh token', async () => {
const app = createApp();
const start = await request(app).post('/api/linear/auth/start').send({}).expect(200);
const state = new URL(start.body.authorizationUrl).searchParams.get('state');
const fetchMock = vi.fn(async (url) => {
const target = String(url);
if (target.includes('/oauth/token')) {
return jsonResponse({
access_token: 'access-1',
refresh_token: 'refresh-1',
expires_in: 86399,
});
}
if (target.includes('/graphql')) {
return jsonResponse({ data: { viewer: { id: 'user-1', name: 'Ada' } } });
}
if (target.includes('/oauth/revoke')) {
return new Response('', { status: 200 });
}
throw new Error(`unexpected fetch: ${target}`);
});
vi.stubGlobal('fetch', fetchMock);
await request(app).get('/linear/oauth/callback').query({ state, code: 'auth-code' }).expect(200);
await request(app).delete('/api/linear/auth').expect(200);
const revokeCall = fetchMock.mock.calls.find(([url]) => String(url).includes('/oauth/revoke'));
expect(revokeCall).toBeTruthy();
const body = new URLSearchParams(revokeCall[1].body);
expect(body.get('token')).toBe('refresh-1');
expect(body.get('token_type_hint')).toBe('refresh_token');
const status = await request(app).get('/api/linear/auth/status').expect(200);
expect(status.body).toEqual({ connected: false });
});
it('stores a second workspace, switches current, and disconnects only that one', async () => {
const app = createApp();
const startA = await request(app).post('/api/linear/auth/start').send({}).expect(200);
const stateA = new URL(startA.body.authorizationUrl).searchParams.get('state');
vi.stubGlobal('fetch', vi.fn(async (url) => {
const target = String(url);
if (target.includes('/oauth/token')) {
return jsonResponse({
access_token: 'access-a',
refresh_token: 'refresh-a',
expires_in: 86399,
scope: 'read,write,comments:create',
});
}
if (target.includes('/graphql')) {
return jsonResponse({
data: {
viewer: { id: 'user-a', name: 'Ada' },
organization: { id: 'org-a', name: 'Alpha', urlKey: 'alpha' },
},
});
}
throw new Error(`unexpected fetch: ${target}`);
}));
await request(app).get('/linear/oauth/callback').query({ state: stateA, code: 'code-a' }).expect(200);
const startB = await request(app).post('/api/linear/auth/start').send({}).expect(200);
const stateB = new URL(startB.body.authorizationUrl).searchParams.get('state');
vi.stubGlobal('fetch', vi.fn(async (url) => {
const target = String(url);
if (target.includes('/oauth/token')) {
return jsonResponse({
access_token: 'access-b',
refresh_token: 'refresh-b',
expires_in: 86399,
scope: 'read,write,comments:create',
});
}
if (target.includes('/graphql')) {
return jsonResponse({
data: {
viewer: { id: 'user-b', name: 'Ben' },
organization: { id: 'org-b', name: 'Beta', urlKey: 'beta' },
},
});
}
if (target.includes('/oauth/revoke')) {
return new Response('', { status: 200 });
}
throw new Error(`unexpected fetch: ${target}`);
}));
await request(app).get('/linear/oauth/callback').query({ state: stateB, code: 'code-b' }).expect(200);
const both = await request(app).get('/api/linear/auth/status').expect(200);
expect(both.body.organization.id).toBe('org-b');
expect(both.body.workspaces).toHaveLength(2);
await request(app).post('/api/linear/auth/activate').send({}).expect(400);
await request(app).post('/api/linear/auth/activate').send({ organizationId: 'missing' }).expect(404);
const activated = await request(app)
.post('/api/linear/auth/activate')
.send({ organizationId: 'org-a' })
.expect(200);
expect(activated.body.organization.id).toBe('org-a');
expect(activated.body.workspaces.find((entry) => entry.id === 'org-a').current).toBe(true);
expect(activated.body.workspaces.find((entry) => entry.id === 'org-b').current).toBe(false);
await request(app).delete('/api/linear/auth').expect(200);
const remaining = await request(app).get('/api/linear/auth/status').expect(200);
expect(remaining.body.connected).toBe(true);
expect(remaining.body.organization.id).toBe('org-b');
expect(remaining.body.workspaces).toHaveLength(1);
expect(remaining.body.workspaces[0].id).toBe('org-b');
});
it('lists and gets issues through authenticated routes without leaking tokens', async () => {
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
tokenType: 'Bearer',
expiresAt: Date.now() + 86_400_000,
scope: 'read',
});
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
if (body.query.includes('GetLinearIssue')) {
return jsonResponse({
data: {
issue: {
id: 'issue-1',
identifier: 'ENG-12',
title: 'Broken login',
url: 'https://linear.app/openchamber/issue/ENG-12',
state: { name: 'Todo', type: 'unstarted' },
assignee: null,
description: 'Users cannot sign in.',
comments: { nodes: [] },
},
},
});
}
return jsonResponse({
data: {
issues: {
nodes: [{
id: 'issue-1',
identifier: 'ENG-12',
title: 'Broken login',
url: 'https://linear.app/openchamber/issue/ENG-12',
state: { name: 'Todo', type: 'unstarted' },
assignee: null,
}],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
}));
const app = createApp();
const list = await request(app).get('/api/linear/issues/list').expect(200);
expect(list.body.connected).toBe(true);
expect(list.body.issues).toHaveLength(1);
expect(JSON.stringify(list.body)).not.toContain('access-1');
const missing = await request(app).get('/api/linear/issues/get').expect(400);
expect(missing.body.error).toBe('id is required');
const got = await request(app).get('/api/linear/issues/get').query({ id: 'ENG-12' }).expect(200);
expect(got.body.issue.identifier).toBe('ENG-12');
expect(got.body.issue.description).toBe('Users cannot sign in.');
expect(got.body.issue.state).toEqual({ id: null, name: 'Todo', type: 'unstarted' });
});
it('passes list filters from query params to Linear', async () => {
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
tokenType: 'Bearer',
expiresAt: Date.now() + 86_400_000,
scope: 'read',
});
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.variables.filter).toEqual({
state: { type: { eq: 'completed' } },
assignee: { isMe: { eq: true } },
team: { id: { eq: 'team-eng' } },
priority: { eq: 1 },
});
return jsonResponse({
data: {
issues: {
nodes: [],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
}));
const app = createApp();
const list = await request(app).get('/api/linear/issues/list').query({
status: 'completed',
assignee: 'me',
teamId: 'team-eng',
priority: 'urgent',
}).expect(200);
expect(list.body.connected).toBe(true);
expect(list.body.issues).toEqual([]);
});
it('lists workflow states and updates issue status without leaking tokens', async () => {
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
tokenType: 'Bearer',
expiresAt: Date.now() + 86_400_000,
scope: 'write',
});
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
if (body.query.includes('TeamWorkflowStates')) {
expect(body.variables.id).toBe('team-eng');
expect(options.headers.Authorization).toBe('Bearer access-1');
return jsonResponse({
data: {
team: {
states: {
nodes: [
{ id: 'state-todo', name: 'Todo', type: 'unstarted', position: 1 },
{ id: 'state-done', name: 'Done', type: 'completed', position: 2 },
],
},
},
},
});
}
expect(body.query).toContain('mutation IssueUpdate');
expect(body.variables).toEqual({
id: 'issue-uuid-1',
input: { stateId: 'state-done' },
});
return jsonResponse({
data: {
issueUpdate: {
success: true,
issue: {
id: 'issue-uuid-1',
identifier: 'ENG-12',
title: 'Broken login',
url: 'https://linear.app/openchamber/issue/ENG-12',
state: { id: 'state-done', name: 'Done', type: 'completed' },
assignee: null,
description: null,
comments: { nodes: [] },
},
},
},
});
}));
const app = createApp();
const missingTeam = await request(app).get('/api/linear/issues/states').expect(400);
expect(missingTeam.body.error).toBe('teamId is required');
const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'team-eng' }).expect(200);
expect(states.body.connected).toBe(true);
expect(states.body.states).toEqual([
{ id: 'state-todo', name: 'Todo', type: 'unstarted', position: 1 },
{ id: 'state-done', name: 'Done', type: 'completed', position: 2 },
]);
expect(JSON.stringify(states.body)).not.toContain('access-1');
const missingBody = await request(app).post('/api/linear/issues/update').send({}).expect(400);
expect(missingBody.body.error).toBe('id and stateId are required');
const updated = await request(app).post('/api/linear/issues/update').send({
id: 'issue-uuid-1',
stateId: 'state-done',
}).expect(200);
expect(updated.body.connected).toBe(true);
expect(updated.body.issue.identifier).toBe('ENG-12');
expect(updated.body.issue.state).toEqual({ id: 'state-done', name: 'Done', type: 'completed' });
expect(JSON.stringify(updated.body)).not.toContain('access-1');
});
it('returns 400 for Linear validation and not-found GraphQL errors', async () => {
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
tokenType: 'Bearer',
expiresAt: Date.now() + 86_400_000,
scope: 'write',
});
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
if (body.query.includes('TeamWorkflowStates')) {
return jsonResponse({
data: null,
errors: [{
message: 'Entity not found: Team',
extensions: {
code: 'INPUT_ERROR',
userError: true,
userPresentableMessage: 'Could not find referenced Team.',
},
}],
});
}
return jsonResponse({
data: null,
errors: [{
message: 'Argument Validation Error',
extensions: {
code: 'INVALID_INPUT',
userError: true,
userPresentableMessage: 'stateId must be a UUID.',
},
}],
});
}));
const app = createApp();
const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'missing-team' }).expect(400);
expect(states.body.error).toBe('Could not find referenced Team.');
const updated = await request(app).post('/api/linear/issues/update').send({
id: 'issue-uuid-1',
stateId: 'not-a-uuid',
}).expect(400);
expect(updated.body.error).toBe('stateId must be a UUID.');
});
it('returns disconnected for issue routes when Linear is not connected', async () => {
const app = createApp();
const list = await request(app).get('/api/linear/issues/list').expect(200);
expect(list.body).toEqual({ connected: false });
const got = await request(app).get('/api/linear/issues/get').query({ id: 'ENG-12' }).expect(200);
expect(got.body).toEqual({ connected: false });
const states = await request(app).get('/api/linear/issues/states').query({ teamId: 'team-eng' }).expect(200);
expect(states.body).toEqual({ connected: false });
const updated = await request(app).post('/api/linear/issues/update').send({
id: 'issue-1',
stateId: 'state-done',
}).expect(200);
expect(updated.body).toEqual({ connected: false });
});
it('returns disconnected mapping when Linear is not connected', async () => {
const app = createApp();
const mapping = await request(app).get('/api/linear/mapping').expect(200);
expect(mapping.body).toEqual({ connected: false });
const saved = await request(app).put('/api/linear/mapping').send({
defaultProjectPath: '/tmp/project',
teamProjectPaths: {},
}).expect(200);
expect(saved.body).toEqual({ connected: false });
});
it('saves and reads Linear team-to-project mapping without leaking tokens', async () => {
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
tokenType: 'Bearer',
expiresAt: Date.now() + 86_400_000,
scope: 'read',
});
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.query).toContain('query ListLinearTeams');
expect(options.headers.Authorization).toBe('Bearer access-1');
return jsonResponse({
data: {
teams: {
nodes: [
{ id: 'team-eng', key: 'ENG', name: 'Engineering' },
{ id: 'team-des', key: 'DES', name: 'Design' },
],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
}));
const app = createApp();
const empty = await request(app).get('/api/linear/mapping').expect(200);
expect(empty.body).toEqual({
connected: true,
defaultProjectPath: null,
teams: [
{ id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: null },
{ id: 'team-des', key: 'DES', name: 'Design', projectPath: null },
],
});
expect(JSON.stringify(empty.body)).not.toContain('access-1');
const saved = await request(app).put('/api/linear/mapping').send({
defaultProjectPath: '/Users/ada/openchamber',
teamProjectPaths: { 'team-eng': '/Users/ada/eng' },
}).expect(200);
expect(saved.body).toEqual({
connected: true,
defaultProjectPath: '/Users/ada/openchamber',
teams: [
{ id: 'team-eng', key: 'ENG', name: 'Engineering', projectPath: '/Users/ada/eng' },
{ id: 'team-des', key: 'DES', name: 'Design', projectPath: null },
],
});
expect(JSON.stringify(saved.body)).not.toContain('access-1');
const reread = await request(app).get('/api/linear/mapping').expect(200);
expect(reread.body.defaultProjectPath).toBe('/Users/ada/openchamber');
expect(reread.body.teams[0].projectPath).toBe('/Users/ada/eng');
});
it('posts a session status comment and never leaks the token', async () => {
setLinearSessionCommentsEnabled(true);
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
tokenType: 'Bearer',
expiresAt: Date.now() + 86_400_000,
scope: 'read,write,comments:create',
});
vi.stubGlobal('fetch', vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
if (body.query.includes('query GetLinearIssue')) {
return jsonResponse({
data: {
issue: {
id: 'issue-1',
identifier: 'ENG-12',
title: 'Broken login',
url: 'https://linear.app/openchamber/issue/ENG-12',
state: { name: 'Todo', type: 'unstarted' },
assignee: null,
description: null,
comments: { nodes: [] },
},
},
});
}
expect(body.query).toContain('mutation CommentCreate');
return jsonResponse({
data: {
commentCreate: {
success: true,
comment: { id: 'comment-1' },
},
},
});
}));
const app = createApp();
const missing = await request(app).post('/api/linear/session-status').send({
kind: 'started',
}).expect(400);
expect(missing.body.error).toBe('kind and sessionId are required');
const posted = await request(app).post('/api/linear/session-status').send({
kind: 'started',
sessionId: 'ses_1',
issueIdentifier: 'ENG-12',
sessionOrigin: 'https://app.example.com',
}).expect(200);
expect(posted.body).toEqual({
connected: true,
posted: true,
commentId: 'comment-1',
});
expect(JSON.stringify(posted.body)).not.toContain('access-1');
});
it('reads and writes the session-comment preference', async () => {
const app = createApp();
const initial = await request(app).get('/api/linear/preferences').expect(200);
expect(initial.body).toEqual({ sessionComments: false });
const invalid = await request(app).put('/api/linear/preferences').send({ sessionComments: 'yes' }).expect(400);
expect(invalid.body.error).toBe('sessionComments must be a boolean');
const enabled = await request(app).put('/api/linear/preferences').send({ sessionComments: true }).expect(200);
expect(enabled.body).toEqual({ sessionComments: true });
const reread = await request(app).get('/api/linear/preferences').expect(200);
expect(reread.body).toEqual({ sessionComments: true });
});
it('returns disconnected session-status when Linear is not connected', async () => {
const app = createApp();
const response = await request(app).post('/api/linear/session-status').send({
kind: 'started',
sessionId: 'ses_1',
issueIdentifier: 'ENG-12',
}).expect(200);
expect(response.body).toEqual({ connected: false });
});
});
@@ -0,0 +1,64 @@
import { isPlainObject, readTrimmedString } from './parse.js';
import { postLinearSessionStatus } from './status.js';
function readProperties(payload) {
if (!isPlainObject(payload)) return {};
return isPlainObject(payload.properties) ? payload.properties : {};
}
function readNested(properties, key) {
return isPlainObject(properties[key]) ? properties[key] : {};
}
function extractSessionId(payload) {
const properties = readProperties(payload);
const info = readNested(properties, 'info');
return readTrimmedString(info.sessionID)
|| readTrimmedString(info.sessionId)
|| readTrimmedString(properties.sessionID)
|| readTrimmedString(properties.sessionId)
|| readTrimmedString(properties.session);
}
function extractStatusType(payload) {
if (!isPlainObject(payload) || payload.type !== 'session.status') return '';
const properties = readProperties(payload);
const status = readNested(properties, 'status');
const info = readNested(properties, 'info');
return readTrimmedString(status.type) || readTrimmedString(info.type);
}
function extractErrorName(payload) {
if (!isPlainObject(payload) || payload.type !== 'session.error') return '';
const properties = readProperties(payload);
return readTrimmedString(readNested(properties, 'error').name);
}
export function createLinearSessionStatusRuntime() {
let stopped = false;
const processPayload = (payload) => {
if (stopped) return;
const sessionId = extractSessionId(payload);
if (!sessionId) return;
if (isPlainObject(payload) && payload.type === 'session.error') {
if (extractErrorName(payload) === 'MessageAbortedError') return;
void postLinearSessionStatus({ kind: 'failure', sessionId }).catch((error) => {
console.warn('[linear] failed to post session failure comment:', error?.message || error);
});
return;
}
if (extractStatusType(payload) !== 'idle') return;
void postLinearSessionStatus({ kind: 'completed', sessionId }).catch((error) => {
console.warn('[linear] failed to post session completed comment:', error?.message || error);
});
};
const stop = () => {
stopped = true;
};
return { processPayload, stop };
}
@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { setLinearAuth, clearLinearAuth, setLinearSessionCommentsEnabled } from './auth.js';
import { createLinearSessionStatusRuntime } from './status-runtime.js';
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-status-runtime-'));
const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json' },
});
const issueNode = {
id: 'issue-uuid-1',
identifier: 'ENG-12',
title: 'Broken login',
url: 'https://linear.app/openchamber/issue/ENG-12',
state: { name: 'In Progress', type: 'started' },
assignee: null,
team: { id: 'team-eng', key: 'ENG', name: 'Engineering' },
description: null,
comments: { nodes: [] },
};
function stubLinearGraphql({ commentId = 'comment-1' } = {}) {
return vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
if (body.query.includes('query GetLinearIssue')) {
return jsonResponse({ data: { issue: issueNode } });
}
if (body.query.includes('mutation CommentCreate')) {
return jsonResponse({
data: {
commentCreate: {
success: true,
comment: { id: commentId },
},
},
});
}
throw new Error(`unexpected query: ${body.query}`);
});
}
describe('Linear session status runtime', () => {
let dataDir;
let previousDataDir;
let previousPort;
beforeEach(() => {
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
previousPort = process.env.OPENCHAMBER_PORT;
dataDir = makeTempDir();
process.env.OPENCHAMBER_DATA_DIR = dataDir;
process.env.OPENCHAMBER_PORT = '3001';
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
tokenType: 'Bearer',
expiresAt: Date.now() + 86_400_000,
scope: 'read,write,comments:create',
});
setLinearSessionCommentsEnabled(true);
});
afterEach(() => {
vi.unstubAllGlobals();
clearLinearAuth();
if (previousDataDir === undefined) {
delete process.env.OPENCHAMBER_DATA_DIR;
} else {
process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
}
if (previousPort === undefined) {
delete process.env.OPENCHAMBER_PORT;
} else {
process.env.OPENCHAMBER_PORT = previousPort;
}
fs.rmSync(dataDir, { recursive: true, force: true });
});
it('posts completed on the first idle after started, then ignores later idles', async () => {
const { postLinearSessionStatus } = await import('./status.js');
vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'started' }));
await postLinearSessionStatus({
kind: 'started',
sessionId: 'ses_1',
issueIdentifier: 'ENG-12',
sessionOrigin: 'https://app.example.com',
});
const graphql = stubLinearGraphql({ commentId: 'done' });
vi.stubGlobal('fetch', graphql);
const runtime = createLinearSessionStatusRuntime();
runtime.processPayload({
type: 'session.status',
properties: { sessionID: 'ses_1', status: { type: 'idle' } },
});
runtime.processPayload({
type: 'session.status',
properties: { sessionID: 'ses_1', status: { type: 'idle' } },
});
await vi.waitFor(() => {
const commentCalls = graphql.mock.calls.filter(([, options]) => {
return JSON.parse(options.body).query.includes('mutation CommentCreate');
});
expect(commentCalls).toHaveLength(1);
});
runtime.stop();
});
it('posts failure on session.error and skips user abort', async () => {
const { postLinearSessionStatus } = await import('./status.js');
vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'started' }));
await postLinearSessionStatus({
kind: 'started',
sessionId: 'ses_1',
issueIdentifier: 'ENG-12',
sessionOrigin: 'https://app.example.com',
});
const graphql = stubLinearGraphql({ commentId: 'fail' });
vi.stubGlobal('fetch', graphql);
const runtime = createLinearSessionStatusRuntime();
runtime.processPayload({
type: 'session.error',
properties: {
sessionID: 'ses_1',
error: { name: 'MessageAbortedError', message: 'stopped' },
},
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(graphql).not.toHaveBeenCalled();
runtime.processPayload({
type: 'session.error',
properties: {
sessionID: 'ses_1',
error: { name: 'ProviderError', message: 'boom' },
},
});
await vi.waitFor(() => {
const commentCalls = graphql.mock.calls.filter(([, options]) => {
return JSON.parse(options.body).query.includes('mutation CommentCreate');
});
expect(commentCalls).toHaveLength(1);
const body = JSON.parse(commentCalls[0][1].body).variables.input.body;
expect(body).toContain('OpenChamber session failed');
});
runtime.stop();
});
it('does not treat busy as completed', async () => {
const graphql = stubLinearGraphql();
vi.stubGlobal('fetch', graphql);
const runtime = createLinearSessionStatusRuntime();
runtime.processPayload({
type: 'session.status',
properties: { sessionID: 'ses_1', status: { type: 'busy' } },
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(graphql).not.toHaveBeenCalled();
runtime.stop();
});
});
+280
View File
@@ -0,0 +1,280 @@
import fs from 'fs';
import path from 'path';
import { getLinearAuth, getLinearAuthFilePath, getLinearSessionCommentsEnabled } from './auth.js';
import { createLinearIssueComment } from './issues.js';
import { isPlainObject, readTrimmedString } from './parse.js';
const LINEAR_SESSION_STATUS_KINDS = ['started', 'completed', 'failure'];
const MAX_SESSION_STATUS_RECORDS = 500;
export class LinearSessionStatusError extends Error {
constructor(message, code) {
super(message);
this.name = 'LinearSessionStatusError';
this.code = code;
}
}
const inflight = new Map();
function statusFile() {
return path.join(path.dirname(getLinearAuthFilePath()), 'linear-session-status.json');
}
function writeJsonFile(filePath, payload) {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const tmpFile = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tmpFile, JSON.stringify(payload, null, 2), 'utf8');
try {
fs.chmodSync(tmpFile, 0o600);
} catch {
// best-effort
}
fs.renameSync(tmpFile, filePath);
try {
fs.chmodSync(filePath, 0o600);
} catch {
// best-effort
}
}
const PRIVATE_HOST_SUFFIXES = ['.local', '.localhost', '.internal', '.lan', '.home.arpa'];
function isPrivateIpv4(hostname) {
const parts = hostname.split('.');
if (parts.length !== 4) return false;
const octets = parts.map((part) => (/^\d{1,3}$/.test(part) ? Number(part) : -1));
if (octets.some((octet) => octet < 0 || octet > 255)) return false;
const [a, b] = octets;
if (a === 0 || a === 10 || a === 127) return true;
if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
// 100.64.0.0/10 is carrier-grade NAT, which Tailscale and similar overlays use.
if (a === 100 && b >= 64 && b <= 127) return true;
return false;
}
function isPrivateIpv6(hostname) {
const address = hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
if (address === '::1' || address === '::') return true;
// fc00::/7 (unique local) and fe80::/10 (link local).
return /^f[cd]/.test(address) || /^fe[89ab]/.test(address);
}
/**
* A session link is only worth writing into Linear when somebody other than the
* person who started the session can open it. Loopback, private LAN and
* overlay-network addresses reach nobody else, so they do not qualify.
*/
export function isPublicSessionOrigin(value) {
const origin = readSessionOrigin(value);
if (!origin) return false;
let hostname;
try {
hostname = new URL(origin).hostname.toLowerCase();
} catch {
return false;
}
if (!hostname || hostname === 'localhost') return false;
if (PRIVATE_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix))) return false;
if (hostname.includes(':') || hostname.startsWith('[')) return !isPrivateIpv6(hostname);
if (/^[\d.]+$/.test(hostname)) return !isPrivateIpv4(hostname);
// A bare single-label host is a LAN machine name, not a routable address.
return hostname.includes('.');
}
export function readSessionOrigin(value) {
const trimmed = readTrimmedString(value);
if (!trimmed) return '';
try {
const url = new URL(trimmed);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
if (url.username || url.password) return '';
if (url.search || url.hash) return '';
if (url.pathname && url.pathname !== '/') return '';
return url.origin;
} catch {
return '';
}
}
export function buildLinearSessionOpenUrl(sessionId, sessionOrigin) {
const id = readTrimmedString(sessionId);
const origin = readSessionOrigin(sessionOrigin);
if (!origin) return '';
return `${origin}/?session=${encodeURIComponent(id)}`;
}
function statusWord(kind) {
if (kind === 'started') return 'started';
if (kind === 'completed') return 'completed';
return 'failed';
}
export function buildLinearSessionStatusComment({ kind, sessionUrl }) {
const url = readTrimmedString(sessionUrl);
const label = `OpenChamber session ${statusWord(kind)}`;
if (!url) return label;
// The comment already lives on the issue, so it says only what happened and
// links to the session. Issue titles routinely contain brackets ("[Bug] …"),
// which would break this markdown link if they were repeated in the label.
return `[${label}](${url})`;
}
function readBooleanFlag(value) {
return value === true;
}
function readRecord(value) {
if (!isPlainObject(value)) return null;
const issueIdentifier = readTrimmedString(value.issueIdentifier);
if (!issueIdentifier) return null;
return {
issueIdentifier,
sessionOrigin: readSessionOrigin(value.sessionOrigin) || null,
organizationId: readTrimmedString(value.organizationId) || null,
started: readBooleanFlag(value.started),
completed: readBooleanFlag(value.completed),
failure: readBooleanFlag(value.failure),
};
}
function readRecords() {
const filePath = statusFile();
if (!fs.existsSync(filePath)) {
return {};
}
let parsed;
try {
const raw = fs.readFileSync(filePath, 'utf8');
const trimmed = raw.trim();
if (!trimmed) {
return {};
}
parsed = JSON.parse(trimmed);
} catch {
throw new LinearSessionStatusError('Linear session status file is malformed', 'MALFORMED');
}
if (!isPlainObject(parsed)) {
throw new LinearSessionStatusError('Linear session status file is malformed', 'MALFORMED');
}
const next = {};
for (const key of Object.keys(parsed)) {
const sessionId = readTrimmedString(key);
const record = readRecord(parsed[key]);
if (sessionId && record) {
next[sessionId] = record;
}
}
return next;
}
/**
* The file only exists to dedupe comments, so it does not need to remember
* every session ever started. Keep the newest entries and drop the tail.
*/
export function pruneSessionStatusRecords(records, limit = MAX_SESSION_STATUS_RECORDS) {
const keys = Object.keys(records);
if (keys.length <= limit) {
return records;
}
const kept = {};
for (const key of keys.slice(keys.length - limit)) {
kept[key] = records[key];
}
return kept;
}
function writeRecords(records) {
writeJsonFile(statusFile(), pruneSessionStatusRecords(records));
}
async function postOnce(input) {
const kind = readTrimmedString(input?.kind);
const sessionId = readTrimmedString(input?.sessionId);
if (!LINEAR_SESSION_STATUS_KINDS.includes(kind) || !sessionId) {
throw new LinearSessionStatusError('kind and sessionId are required', 'INVALID');
}
// Disconnected answers first so the picker and panel keep showing their
// "connect Linear" state whatever the comment preference says.
if (!getLinearAuth()) {
return { connected: false };
}
if (!getLinearSessionCommentsEnabled()) {
return { connected: true, posted: false, skipped: 'disabled' };
}
const records = readRecords();
const existing = records[sessionId] || null;
if (existing?.[kind] === true) {
return { connected: true, posted: false, skipped: 'already-posted' };
}
if (kind !== 'started' && existing?.started !== true) {
return { connected: true, posted: false, skipped: 'not-started' };
}
const issueIdentifier = readTrimmedString(input?.issueIdentifier)
|| readTrimmedString(existing?.issueIdentifier);
if (!issueIdentifier) {
throw new LinearSessionStatusError('issueIdentifier is required', 'INVALID');
}
const sessionOrigin = readSessionOrigin(input?.sessionOrigin)
|| readTrimmedString(existing?.sessionOrigin);
// Without an origin other people can reach, the comment would carry a link
// only its author could open. Say nothing rather than publish a dead link.
if (!isPublicSessionOrigin(sessionOrigin)) {
return { connected: true, posted: false, skipped: 'origin-not-public' };
}
const sessionUrl = buildLinearSessionOpenUrl(sessionId, sessionOrigin);
const organizationId = readTrimmedString(input?.organizationId)
|| readTrimmedString(existing?.organizationId)
|| readTrimmedString(getLinearAuth()?.workspaceId);
const body = buildLinearSessionStatusComment({ kind, sessionUrl });
const commentResult = await createLinearIssueComment({
issueId: issueIdentifier,
body,
organizationId,
});
if (commentResult.connected === false) {
return { connected: false };
}
if (!commentResult.comment) {
return { connected: true, posted: false, skipped: 'issue-not-found' };
}
records[sessionId] = {
issueIdentifier,
sessionOrigin: sessionOrigin || null,
organizationId: organizationId || null,
started: existing?.started === true || kind === 'started',
completed: existing?.completed === true || kind === 'completed',
failure: existing?.failure === true || kind === 'failure',
};
writeRecords(records);
return {
connected: true,
posted: true,
commentId: commentResult.comment.id,
};
}
export async function postLinearSessionStatus(input) {
const kind = readTrimmedString(input?.kind);
const sessionId = readTrimmedString(input?.sessionId);
const key = `${sessionId}:${kind}`;
const pending = inflight.get(key);
if (pending) {
return pending;
}
const promise = postOnce(input).finally(() => {
inflight.delete(key);
});
inflight.set(key, promise);
return promise;
}
@@ -0,0 +1,271 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { setLinearAuth, clearLinearAuth, setLinearSessionCommentsEnabled } from './auth.js';
import {
buildLinearSessionOpenUrl,
buildLinearSessionStatusComment,
isPublicSessionOrigin,
postLinearSessionStatus,
pruneSessionStatusRecords,
readSessionOrigin,
} from './status.js';
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-status-'));
const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json' },
});
const issueNode = {
id: 'issue-uuid-1',
identifier: 'ENG-12',
title: 'Broken login',
url: 'https://linear.app/openchamber/issue/ENG-12',
state: { name: 'In Progress', type: 'started' },
assignee: null,
team: { id: 'team-eng', key: 'ENG', name: 'Engineering' },
description: null,
comments: { nodes: [] },
};
function stubLinearGraphql({ commentId = 'comment-1' } = {}) {
return vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
if (body.query.includes('query GetLinearIssue')) {
return jsonResponse({ data: { issue: issueNode } });
}
if (body.query.includes('mutation CommentCreate')) {
expect(body.variables.input.issueId).toBe('issue-uuid-1');
expect(body.variables.input.body).toContain('/?session=ses_1');
return jsonResponse({
data: {
commentCreate: {
success: true,
comment: { id: commentId },
},
},
});
}
throw new Error(`unexpected query: ${body.query}`);
});
}
describe('Linear session status comments', () => {
let dataDir;
let previousDataDir;
let previousPort;
beforeEach(() => {
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
previousPort = process.env.OPENCHAMBER_PORT;
dataDir = makeTempDir();
process.env.OPENCHAMBER_DATA_DIR = dataDir;
process.env.OPENCHAMBER_PORT = '3001';
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
tokenType: 'Bearer',
expiresAt: Date.now() + 86_400_000,
scope: 'read,write,comments:create',
});
setLinearSessionCommentsEnabled(true);
});
afterEach(() => {
vi.unstubAllGlobals();
clearLinearAuth();
if (previousDataDir === undefined) {
delete process.env.OPENCHAMBER_DATA_DIR;
} else {
process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
}
if (previousPort === undefined) {
delete process.env.OPENCHAMBER_PORT;
} else {
process.env.OPENCHAMBER_PORT = previousPort;
}
fs.rmSync(dataDir, { recursive: true, force: true });
});
it('reads http(s) origins and rejects other URLs', () => {
expect(readSessionOrigin('https://app.example.com')).toBe('https://app.example.com');
expect(readSessionOrigin('http://127.0.0.1:3001/')).toBe('http://127.0.0.1:3001');
expect(readSessionOrigin('javascript:alert(1)')).toBe('');
expect(readSessionOrigin('https://app.example.com/secret')).toBe('');
expect(readSessionOrigin('openchamber:')).toBe('');
expect(buildLinearSessionOpenUrl('ses_1', 'https://app.example.com'))
.toBe('https://app.example.com/?session=ses_1');
expect(buildLinearSessionOpenUrl('ses_1', '')).toBe('');
});
it('treats only externally reachable origins as public', () => {
expect(isPublicSessionOrigin('https://chamber.example.com')).toBe(true);
expect(isPublicSessionOrigin('http://chamber.example.com:8080')).toBe(true);
expect(isPublicSessionOrigin('https://203.0.113.10')).toBe(true);
expect(isPublicSessionOrigin('http://localhost:3001')).toBe(false);
expect(isPublicSessionOrigin('http://127.0.0.1:3001')).toBe(false);
expect(isPublicSessionOrigin('http://[::1]:3001')).toBe(false);
expect(isPublicSessionOrigin('http://192.168.1.20:3001')).toBe(false);
expect(isPublicSessionOrigin('http://10.0.0.5:3001')).toBe(false);
expect(isPublicSessionOrigin('http://172.20.1.4:3001')).toBe(false);
expect(isPublicSessionOrigin('http://169.254.10.1:3001')).toBe(false);
expect(isPublicSessionOrigin('http://100.101.102.103:3001')).toBe(false);
expect(isPublicSessionOrigin('http://macbook.local:3001')).toBe(false);
expect(isPublicSessionOrigin('http://macbook:3001')).toBe(false);
expect(isPublicSessionOrigin('http://[fd00::1]:3001')).toBe(false);
expect(isPublicSessionOrigin('openchamber:')).toBe(false);
expect(isPublicSessionOrigin('')).toBe(false);
});
it('posts nothing while session comments are turned off', async () => {
setLinearSessionCommentsEnabled(false);
const graphql = vi.fn();
vi.stubGlobal('fetch', graphql);
await expect(postLinearSessionStatus({
kind: 'started',
sessionId: 'ses_1',
issueIdentifier: 'ENG-12',
sessionOrigin: 'https://app.example.com',
})).resolves.toEqual({ connected: true, posted: false, skipped: 'disabled' });
expect(graphql).not.toHaveBeenCalled();
});
it('posts nothing when the session origin only the author can reach', async () => {
const graphql = vi.fn();
vi.stubGlobal('fetch', graphql);
await expect(postLinearSessionStatus({
kind: 'started',
sessionId: 'ses_1',
issueIdentifier: 'ENG-12',
sessionOrigin: 'http://127.0.0.1:3001',
})).resolves.toEqual({ connected: true, posted: false, skipped: 'origin-not-public' });
await expect(postLinearSessionStatus({
kind: 'started',
sessionId: 'ses_2',
issueIdentifier: 'ENG-12',
})).resolves.toEqual({ connected: true, posted: false, skipped: 'origin-not-public' });
expect(graphql).not.toHaveBeenCalled();
});
it('keeps the newest dedupe records and drops the oldest', () => {
const records = {};
for (let index = 0; index < 5; index += 1) {
records[`ses_${index}`] = { issueIdentifier: 'ENG-12', started: true };
}
expect(Object.keys(pruneSessionStatusRecords(records, 3))).toEqual(['ses_2', 'ses_3', 'ses_4']);
expect(Object.keys(pruneSessionStatusRecords(records, 10))).toHaveLength(5);
});
it('makes the whole status line one link and carries no title', () => {
expect(buildLinearSessionStatusComment({
kind: 'started',
sessionUrl: 'https://app.example.com/?session=ses_1',
})).toBe('[OpenChamber session started](https://app.example.com/?session=ses_1)');
expect(buildLinearSessionStatusComment({
kind: 'completed',
sessionUrl: 'https://app.example.com/?session=ses_1',
})).toBe('[OpenChamber session completed](https://app.example.com/?session=ses_1)');
expect(buildLinearSessionStatusComment({
kind: 'failure',
sessionUrl: 'https://app.example.com/?session=ses_1',
})).toBe('[OpenChamber session failed](https://app.example.com/?session=ses_1)');
});
it('cannot be broken by brackets in the issue title', async () => {
const graphql = stubLinearGraphql();
vi.stubGlobal('fetch', graphql);
await postLinearSessionStatus({
kind: 'started',
sessionId: 'ses_1',
issueIdentifier: 'ENG-12',
sessionOrigin: 'https://app.example.com',
});
const commentCalls = graphql.mock.calls.filter(([, options]) => {
return JSON.parse(options.body).query.includes('mutation CommentCreate');
});
const body = JSON.parse(commentCalls[0][1].body).variables.input.body;
// One balanced pair of brackets, so a title like "[Bug] …" can never leak in
// and split the link across the renderer.
expect(body.match(/\[/g)).toHaveLength(1);
expect(body.match(/\]/g)).toHaveLength(1);
});
it('returns disconnected without calling Linear when there is no auth', async () => {
clearLinearAuth();
const graphql = vi.fn();
vi.stubGlobal('fetch', graphql);
await expect(postLinearSessionStatus({
kind: 'started',
sessionId: 'ses_1',
issueIdentifier: 'ENG-12',
})).resolves.toEqual({ connected: false });
expect(graphql).not.toHaveBeenCalled();
});
it('posts a started comment once and skips repeats', async () => {
const graphql = stubLinearGraphql();
vi.stubGlobal('fetch', graphql);
const first = await postLinearSessionStatus({
kind: 'started',
sessionId: 'ses_1',
issueIdentifier: 'ENG-12',
sessionOrigin: 'https://app.example.com',
});
expect(first).toEqual({ connected: true, posted: true, commentId: 'comment-1' });
const second = await postLinearSessionStatus({
kind: 'started',
sessionId: 'ses_1',
issueIdentifier: 'ENG-12',
sessionOrigin: 'https://app.example.com',
});
expect(second).toEqual({ connected: true, posted: false, skipped: 'already-posted' });
const commentCalls = graphql.mock.calls.filter(([, options]) => {
return JSON.parse(options.body).query.includes('mutation CommentCreate');
});
expect(commentCalls).toHaveLength(1);
const body = JSON.parse(commentCalls[0][1].body).variables.input.body;
expect(body).toBe('[OpenChamber session started](https://app.example.com/?session=ses_1)');
expect(JSON.stringify(first)).not.toContain('access-1');
});
it('skips completed until started has been posted', async () => {
const graphql = stubLinearGraphql();
vi.stubGlobal('fetch', graphql);
await expect(postLinearSessionStatus({
kind: 'completed',
sessionId: 'ses_1',
})).resolves.toEqual({ connected: true, posted: false, skipped: 'not-started' });
expect(graphql).not.toHaveBeenCalled();
});
it('posts completed once after started, reusing the stored open URL', async () => {
vi.stubGlobal('fetch', stubLinearGraphql({ commentId: 'comment-started' }));
await postLinearSessionStatus({
kind: 'started',
sessionId: 'ses_1',
issueIdentifier: 'ENG-12',
sessionOrigin: 'https://app.example.com',
});
const graphql = stubLinearGraphql({ commentId: 'comment-done' });
vi.stubGlobal('fetch', graphql);
const first = await postLinearSessionStatus({ kind: 'completed', sessionId: 'ses_1' });
expect(first).toEqual({ connected: true, posted: true, commentId: 'comment-done' });
const second = await postLinearSessionStatus({ kind: 'completed', sessionId: 'ses_1' });
expect(second).toEqual({ connected: true, posted: false, skipped: 'already-posted' });
const commentCalls = graphql.mock.calls.filter(([, options]) => {
return JSON.parse(options.body).query.includes('mutation CommentCreate');
});
expect(commentCalls).toHaveLength(1);
const body = JSON.parse(commentCalls[0][1].body).variables.input.body;
expect(body).toBe('[OpenChamber session completed](https://app.example.com/?session=ses_1)');
});
});
+72
View File
@@ -0,0 +1,72 @@
import { clearLinearAuth, getLinearAuth } from './auth.js';
import { fetchLinearGraphql, getValidLinearAccessToken } from './client.js';
import { isPlainObject, readTrimmedString } from './parse.js';
const TEAMS_QUERY = `
query ListLinearTeams($first: Int!, $after: String) {
teams(first: $first, after: $after) {
nodes { id key name }
pageInfo { hasNextPage endCursor }
}
}
`;
const PAGE_SIZE = 50;
const MAX_PAGES = 20;
function readTeam(node) {
if (!isPlainObject(node)) {
return null;
}
const id = readTrimmedString(node.id);
const key = readTrimmedString(node.key);
const name = readTrimmedString(node.name);
if (!id || !key || !name) {
return null;
}
return { id, key, name };
}
export async function listLinearTeams() {
try {
const token = await getValidLinearAccessToken();
if (!token) {
return { connected: false };
}
const teams = [];
let after = null;
for (let page = 0; page < MAX_PAGES; page += 1) {
const variables = { first: PAGE_SIZE };
if (after) {
variables.after = after;
}
const data = await fetchLinearGraphql(token, TEAMS_QUERY, variables);
const connection = isPlainObject(data.teams) ? data.teams : null;
const nodes = isPlainObject(connection) && Array.isArray(connection.nodes)
? connection.nodes
: [];
for (const node of nodes) {
const team = readTeam(node);
if (team) {
teams.push(team);
}
}
const pageInfo = isPlainObject(connection) ? connection.pageInfo : null;
if (!isPlainObject(pageInfo) || pageInfo.hasNextPage !== true) {
break;
}
after = readTrimmedString(pageInfo.endCursor);
if (!after) {
break;
}
}
return { connected: true, teams };
} catch (error) {
if (error?.status === 401) {
clearLinearAuth(getLinearAuth()?.workspaceId);
return { connected: false };
}
throw error;
}
}
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { clearLinearAuth, setLinearAuth } from './auth.js';
import { listLinearTeams } from './teams.js';
const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-linear-teams-'));
const jsonResponse = (payload, status = 200) => new Response(JSON.stringify(payload), {
status,
headers: { 'Content-Type': 'application/json' },
});
describe('Linear teams list', () => {
let dataDir;
let previousDataDir;
beforeEach(() => {
previousDataDir = process.env.OPENCHAMBER_DATA_DIR;
dataDir = makeTempDir();
process.env.OPENCHAMBER_DATA_DIR = dataDir;
setLinearAuth({
accessToken: 'access-1',
refreshToken: 'refresh-1',
tokenType: 'Bearer',
expiresAt: Date.now() + 86_400_000,
scope: 'read,write,comments:create',
});
});
afterEach(() => {
vi.unstubAllGlobals();
clearLinearAuth();
if (previousDataDir === undefined) {
delete process.env.OPENCHAMBER_DATA_DIR;
} else {
process.env.OPENCHAMBER_DATA_DIR = previousDataDir;
}
fs.rmSync(dataDir, { recursive: true, force: true });
});
it('returns disconnected without calling Linear when there is no auth', async () => {
clearLinearAuth();
const graphql = vi.fn();
vi.stubGlobal('fetch', graphql);
await expect(listLinearTeams()).resolves.toEqual({ connected: false });
expect(graphql).not.toHaveBeenCalled();
});
it('lists teams across pages and never returns the token', async () => {
const graphql = vi.fn(async (_url, options) => {
const body = JSON.parse(options.body);
expect(body.query).toContain('query ListLinearTeams');
expect(options.headers.Authorization).toBe('Bearer access-1');
if (!body.variables.after) {
return jsonResponse({
data: {
teams: {
nodes: [{ id: 'team-eng', key: 'ENG', name: 'Engineering' }],
pageInfo: { hasNextPage: true, endCursor: 'cursor-2' },
},
},
});
}
expect(body.variables.after).toBe('cursor-2');
return jsonResponse({
data: {
teams: {
nodes: [{ id: 'team-des', key: 'DES', name: 'Design' }],
pageInfo: { hasNextPage: false, endCursor: null },
},
},
});
});
vi.stubGlobal('fetch', graphql);
const result = await listLinearTeams();
expect(result).toEqual({
connected: true,
teams: [
{ id: 'team-eng', key: 'ENG', name: 'Engineering' },
{ id: 'team-des', key: 'DES', name: 'Design' },
],
});
expect(JSON.stringify(result)).not.toContain('access-1');
expect(graphql).toHaveBeenCalledTimes(2);
});
it('clears auth and reports disconnected after a GraphQL 401', async () => {
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ errors: [{ message: 'Unauthorized' }] }, 401)));
await expect(listLinearTeams()).resolves.toEqual({ connected: false });
});
});
@@ -4,6 +4,7 @@ import { registerSmallModelRoutes } from '../small-model/routes.js';
import { registerWalkthroughRoutes } from '../walkthrough/routes.js';
import { registerSessionGoalRoutes } from '../session-goal/routes.js';
import { registerGitHubRoutes } from '../github/routes.js';
import { registerLinearRoutes } from '../linear/routes.js';
import { registerGitLabRoutes } from '../gitlab/routes.js';
import { registerGiteaRoutes } from '../gitea/routes.js';
import { registerGitRoutes } from '../git/routes.js';
@@ -303,6 +304,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
registerWalkthroughRoutes(app, { getWalkthroughService });
registerSessionGoalRoutes(app);
registerGitHubRoutes(app);
registerLinearRoutes(app);
registerGitLabRoutes(app);
registerGiteaRoutes(app);
registerGitProviderRoutes(app);
@@ -47,20 +47,20 @@ export const createStaticRoutesRuntime = (dependencies) => {
normalizePwaOrientation,
});
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {
app.get(/^(?!\/api|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {
res.sendFile(path.join(distPath, 'index.html'));
});
return;
}
console.warn(`Warning: ${distPath} not found, static files will not be served`);
app.get(/^(?!\/api|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {
app.get(/^(?!\/api|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (_req, res) => {
res.status(404).send('Static files not found. Please build the application first.');
});
};
const registerApiOnlyFallbackRoutes = (app) => {
app.get(/^(?!\/api|\/auth|\/health|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
app.get(/^(?!\/api|\/auth|\/health|\/linear|.*\.(js|css|svg|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|map)).*$/, (req, res) => {
const command = 'openchamber connect-url --help';
res.status(200).format({
html: () => {
@@ -499,11 +499,20 @@ export const createProjectConfigRuntime = (deps) => {
}
};
// Normalized tasks for reading, plus the raw on-disk record of each one for
// writing back. Normalization only keeps the fields THIS build knows, so a
// write that re-serialized normalized tasks would strip every field added
// by a newer build (or a newer UI) the moment an older server touched the
// file — a goal or auto-accept setting silently lost after a task ran.
// Writers therefore persist untouched tasks from `rawTasksByID` verbatim and
// only serialize a normalized task where the task itself was deliberately
// replaced.
const readProjectConfigFromDisk = async (projectID) => {
const parsed = await readRawProjectConfigFromDisk(projectID);
const tasksRaw = Array.isArray(parsed.scheduledTasks) ? parsed.scheduledTasks : [];
const now = Date.now();
const scheduledTasks = [];
const rawTasksByID = new Map();
for (const task of tasksRaw) {
try {
const normalized = normalizeTaskForStorage(task, {
@@ -514,15 +523,31 @@ export const createProjectConfigRuntime = (deps) => {
refreshUpdatedAt: false,
});
scheduledTasks.push(normalized);
rawTasksByID.set(normalized.id, task);
} catch {
}
}
return {
version: PROJECT_CONFIG_VERSION,
scheduledTasks,
rawTasksByID,
};
};
// The list to write: tasks this write replaced go out normalized; every
// other task goes out exactly as stored, fields unknown to this build
// included. A state-only update counts as untouched — only its `state` is
// swapped onto the stored record. Callers keep working with (and returning)
// the normalized tasks; only the bytes on disk differ.
const toStoredTasks = (config, tasks, { replacedIDs = new Set(), stateUpdatedID = null } = {}) => (
tasks.map((task) => {
if (replacedIDs.has(task.id)) return task;
const stored = config.rawTasksByID.get(task.id);
if (!stored) return task;
return task.id === stateUpdatedID ? { ...stored, state: task.state } : stored;
})
);
const writeProjectConfigToDisk = async (projectID, config) => {
const filePath = resolveProjectConfigPath(projectID);
const parentDirectory = path.dirname(filePath);
@@ -607,7 +632,7 @@ export const createProjectConfigRuntime = (deps) => {
const nextConfig = {
version: PROJECT_CONFIG_VERSION,
scheduledTasks: nextTasks,
scheduledTasks: toStoredTasks(current, nextTasks, { replacedIDs: new Set([normalizedTask.id]) }),
};
await writeProjectConfigToDisk(projectID, nextConfig);
@@ -633,7 +658,7 @@ export const createProjectConfigRuntime = (deps) => {
if (deleted) {
await writeProjectConfigToDisk(projectID, {
version: PROJECT_CONFIG_VERSION,
scheduledTasks: nextTasks,
scheduledTasks: toStoredTasks(current, nextTasks),
});
}
@@ -676,7 +701,7 @@ export const createProjectConfigRuntime = (deps) => {
await writeProjectConfigToDisk(projectID, {
version: PROJECT_CONFIG_VERSION,
scheduledTasks: nextTasks,
scheduledTasks: toStoredTasks(current, nextTasks, { stateUpdatedID: nextTask.id }),
});
return {
@@ -737,7 +762,7 @@ export const createProjectConfigRuntime = (deps) => {
await writeProjectConfigToDisk(projectID, {
version: PROJECT_CONFIG_VERSION,
scheduledTasks: nextTasks,
scheduledTasks: toStoredTasks(current, nextTasks, { stateUpdatedID: nextTask.id }),
});
return {
@@ -797,6 +822,7 @@ export const createProjectConfigRuntime = (deps) => {
const consumedLoopPaths = new Set();
const nextTasks = [];
const replacedIDs = new Set();
for (const task of tasks) {
if (task.loopFile && !activeLoopFilePaths.has(task.loopFile)) {
// The driving loop file was removed (or renamed) — unschedule.
@@ -828,6 +854,7 @@ export const createProjectConfigRuntime = (deps) => {
},
);
nextTasks.push(adopted);
replacedIDs.add(adopted.id);
pendingLoops.delete(loop.definition.name);
if (task.loopFile) {
consumedLoopPaths.add(task.loopFile);
@@ -863,6 +890,7 @@ export const createProjectConfigRuntime = (deps) => {
},
);
nextTasks.push(created);
replacedIDs.add(created.id);
} catch (error) {
console.warn(`[scheduled-tasks] skipped loop ${loop.filePath}:`, error?.message ?? error);
}
@@ -870,7 +898,7 @@ export const createProjectConfigRuntime = (deps) => {
await writeProjectConfigToDisk(projectID, {
version: PROJECT_CONFIG_VERSION,
scheduledTasks: nextTasks,
scheduledTasks: toStoredTasks(current, nextTasks, { replacedIDs }),
});
return nextTasks;
@@ -14,6 +14,7 @@ const createRuntime = async () => {
});
return {
runtime,
tempRoot,
cleanup: async () => {
await rm(tempRoot, { recursive: true, force: true });
},
@@ -472,6 +473,90 @@ describe('project-config loop reconciliation', () => {
}
});
describe('fields this build does not know', () => {
// Simulates a config written by a newer build (or a newer UI): the task
// carries execution and state fields normalization here has never heard of.
const seedForeignTask = async (runtime, tempRoot) => {
const created = await runtime.upsertScheduledTask('project-test', {
name: 'Nightly digest',
enabled: true,
schedule: { kind: 'daily', time: '09:30', timezone: 'UTC' },
execution: { prompt: 'Summarize', providerID: 'openai', modelID: 'gpt-4.1', goalEnabled: true },
});
const filePath = path.join(tempRoot, 'project-test.json');
const stored = JSON.parse(await readFile(filePath, 'utf8'));
stored.scheduledTasks[0].execution.futureExecutionField = 'keep me';
stored.scheduledTasks[0].state.futureStateField = 42;
stored.scheduledTasks[0].futureTopLevelField = true;
await writeFile(filePath, JSON.stringify(stored, null, 2), 'utf8');
return { id: created.task.id, filePath };
};
const readStoredTask = async (filePath, id) => {
const stored = JSON.parse(await readFile(filePath, 'utf8'));
return stored.scheduledTasks.find((task) => task.id === id);
};
it('survive a state update after a run, and the claim update', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
const { id, filePath } = await seedForeignTask(runtime, tempRoot);
await runtime.updateScheduledTaskState('project-test', id, { lastStatus: 'success', lastRunAt: 1000 });
let stored = await readStoredTask(filePath, id);
expect(stored.execution.futureExecutionField).toBe('keep me');
expect(stored.execution.goalEnabled).toBe(true);
expect(stored.futureTopLevelField).toBe(true);
expect(stored.state.lastStatus).toBe('success');
await runtime.updateScheduledTaskStateIf('project-test', id, () => true, { lastScheduledFor: 5000 });
stored = await readStoredTask(filePath, id);
expect(stored.execution.futureExecutionField).toBe('keep me');
expect(stored.state.lastScheduledFor).toBe(5000);
} finally {
await cleanup();
}
});
it('survive writes that replace or delete a different task, and a loop sync', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
const { id, filePath } = await seedForeignTask(runtime, tempRoot);
const other = await runtime.upsertScheduledTask('project-test', {
id: 'other-task',
name: 'Other',
enabled: true,
schedule: { kind: 'daily', time: '10:00', timezone: 'UTC' },
execution: { prompt: 'Other', providerID: 'openai', modelID: 'gpt-4.1' },
});
expect((await readStoredTask(filePath, id)).execution.futureExecutionField).toBe('keep me');
await runtime.deleteScheduledTask('project-test', other.task.id);
expect((await readStoredTask(filePath, id)).execution.futureExecutionField).toBe('keep me');
await runtime.reconcileLoopTasks('project-test', []);
expect((await readStoredTask(filePath, id)).execution.futureExecutionField).toBe('keep me');
} finally {
await cleanup();
}
});
it('are dropped only when the task itself is deliberately saved', async () => {
const { runtime, tempRoot, cleanup } = await createRuntime();
try {
const { id, filePath } = await seedForeignTask(runtime, tempRoot);
const [task] = await runtime.listScheduledTasks('project-test');
await runtime.upsertScheduledTask('project-test', { ...task, name: 'Renamed' });
const stored = await readStoredTask(filePath, id);
expect(stored.name).toBe('Renamed');
expect(stored.execution.futureExecutionField).toBeUndefined();
} finally {
await cleanup();
}
});
});
it('conditionally updates state only when the predicate passes (occurrence claim)', async () => {
const { runtime, cleanup } = await createRuntime();
try {
@@ -25,6 +25,13 @@ in shared project config under the project write lock:
from the winner's persisted `nextRunAt`.
- Project config writes also take a cross-process `.json.lock` file so the
read-modify-write is serialized across processes, not only within one process.
- The sharing processes may run different OpenChamber versions. Normalization
keeps only the fields a build knows, so every writer persists tasks it did
not change verbatim from disk and swaps only `state` onto a task whose state
it updated; a task goes out normalized only when it was deliberately
replaced (upsert, loop adoption). An older server touching the file after a
run therefore cannot strip fields a newer build added, such as a task's goal
or auto-accept settings.
- Lock timeout / filesystem errors on claim, manual-start, or completion state
writes always release the in-process running slot (via `finally`) and best-effort
re-arm the **next future** occurrence; they must not leave the task permanently
@@ -11,6 +11,7 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API.
- `packages/web/server/lib/text/summarization.js`: Shared text summarization stub and sanitization utilities. It performs no external Zen calls.
- `packages/web/server/lib/tts/stt.js`: STT proxy for OpenAI-compatible transcription endpoints.
- `packages/web/server/lib/tts/base-url.js`: shared base URL validation and normalization for custom OpenAI-compatible endpoints.
- `packages/web/server/lib/tts/language-detect.js`: dependency-free language detection for voice selection (`detectTextLanguage`, `pickVoiceForLanguage`, `languageOfLocale`). Used by the macOS `say` route (`language: 'auto'` switches to an installed voice whose locale matches the text; the response carries `X-Speech-Voice` and `X-Speech-Language`) and by the dictation module's local TTS model choice.
## Public exports
@@ -0,0 +1,210 @@
/**
* Language detection for text-to-speech voice selection.
*
* Picks the language a piece of chat text is written in so a TTS provider
* can choose a matching voice or model. Deliberately small and dependency
* free: the writing system decides most cases outright, and Latin-script
* languages are told apart by function words and characteristic letters.
* The answer is a best effort for voice selection, not a linguistic claim
* an unknown language falls back to English rather than failing.
*/
const SCRIPT_RANGES = [
['hangul', /[가-힯ᄀ-ᇿ㄰-㆏]/g],
['kana', /[぀-ヿ]/g],
['han', /[一-鿿㐀-䶿]/g],
['cyrillic', /[Ѐ-ӿ]/g],
['greek', /[Ͱ-Ͽ]/g],
['arabic', /[؀-ۿ]/g],
['hebrew', /[֐-׿]/g],
['thai', /[฀-๿]/g],
['devanagari', /[ऀ-ॿ]/g],
['latin', /[A-Za-zÀ-ɏ]/g],
];
const SCRIPT_LANGUAGE = {
hangul: 'ko',
greek: 'el',
arabic: 'ar',
hebrew: 'he',
thai: 'th',
devanagari: 'hi',
};
// Letters that only (or overwhelmingly) occur in one language of a script.
const LATIN_MARKERS = {
pl: /[łęąńśźż]/i,
cs: /[řěůťďň]/i,
tr: /[ğışİ]/,
pt: /[ãõ]/i,
es: /[ñ¿¡]/,
de: /[ß]/,
fr: /[œ]/i,
sv: /[å]/i,
};
// Frequent function words per language. Scored by whole-word hits; every
// list has the same length so scores stay comparable.
const STOPWORDS = {
en: ['the', 'and', 'is', 'to', 'of', 'that', 'you', 'with', 'for', 'this', 'are', 'it', 'not', 'have', 'can', 'will', 'your', 'from', 'which', 'when'],
de: ['und', 'der', 'die', 'das', 'ist', 'nicht', 'mit', 'ein', 'eine', 'auch', 'sich', 'auf', 'für', 'wird', 'werden', 'oder', 'aber', 'wenn', 'sind', 'kann'],
fr: ['le', 'la', 'les', 'et', 'est', 'une', 'des', 'pour', 'que', 'qui', 'dans', 'pas', 'vous', 'sur', 'avec', 'sont', 'nous', 'cette', 'mais', 'plus'],
es: ['el', 'la', 'los', 'las', 'que', 'es', 'una', 'por', 'para', 'con', 'del', 'como', 'pero', 'más', 'este', 'esta', 'son', 'tiene', 'puede', 'también'],
it: ['il', 'la', 'che', 'di', 'è', 'una', 'per', 'non', 'con', 'del', 'della', 'come', 'sono', 'anche', 'questo', 'questa', 'gli', 'nel', 'più', 'essere'],
pt: ['o', 'a', 'os', 'as', 'que', 'é', 'uma', 'para', 'com', 'não', 'do', 'da', 'como', 'mas', 'também', 'este', 'esta', 'são', 'você', 'pode'],
pl: ['i', 'nie', 'jest', 'się', 'na', 'to', 'że', 'jak', 'ale', 'dla', 'oraz', 'przez', 'czy', 'tym', 'jego', 'można', 'jeśli', 'tego', 'które', 'także'],
nl: ['de', 'het', 'een', 'en', 'van', 'is', 'niet', 'dat', 'met', 'voor', 'ook', 'zijn', 'maar', 'als', 'wordt', 'deze', 'kan', 'naar', 'bij', 'dan'],
cs: ['a', 'je', 'se', 'na', 'to', 'že', 'jak', 'ale', 'pro', 'nebo', 'jsou', 'může', 'také', 'tento', 'když', 'jeho', 'které', 'být', 'aby', 'ještě'],
tr: ['ve', 'bir', 'bu', 'için', 'ile', 'de', 'da', 'ama', 'gibi', 'daha', 'var', 'olarak', 'çok', 'ne', 'her', 'kadar', 'sonra', 'değil', 'olan', 'ise'],
sv: ['och', 'att', 'det', 'är', 'en', 'som', 'för', 'inte', 'med', 'till', 'den', 'kan', 'har', 'ett', 'men', 'också', 'eller', 'från', 'när', 'vara'],
uk: ['і', 'та', 'що', 'це', 'не', 'як', 'для', 'він', 'вона', 'але', 'або', 'також', 'тільки', 'вже', 'якщо', 'його', 'цей', 'ця', 'бути', 'коли'],
ru: ['и', 'что', 'это', 'не', 'как', 'для', 'он', 'она', 'но', 'или', 'также', 'только', 'уже', 'если', 'его', 'этот', 'эта', 'быть', 'когда', 'чтобы'],
};
const LATIN_LANGUAGES = ['en', 'de', 'fr', 'es', 'it', 'pt', 'pl', 'nl', 'cs', 'tr', 'sv'];
const CYRILLIC_LANGUAGES = ['uk', 'ru'];
const countMatches = (text, pattern) => {
const matches = text.match(pattern);
return matches ? matches.length : 0;
};
const scoreStopwords = (words, languages) => {
const scores = {};
for (const language of languages) {
const list = new Set(STOPWORDS[language]);
let hits = 0;
for (const word of words) {
if (list.has(word)) hits += 1;
}
scores[language] = hits;
}
return scores;
};
const bestOf = (scores, fallback) => {
let best = fallback;
let bestScore = 0;
for (const [language, score] of Object.entries(scores)) {
if (score > bestScore) {
best = language;
bestScore = score;
}
}
return best;
};
const pickByMarkers = (text, markers) => {
for (const [language, pattern] of Object.entries(markers)) {
if (pattern.test(text)) return language;
}
return null;
};
/**
* @param {string} text
* @returns {{ language: string, script: string }} BCP-47 primary language subtag and the dominant script.
*/
export function detectTextLanguage(text) {
const source = typeof text === 'string' ? text : '';
const counts = SCRIPT_RANGES.map(([script, pattern]) => [script, countMatches(source, pattern)]);
const letters = counts.reduce((sum, [, count]) => sum + count, 0);
if (letters === 0) return { language: 'en', script: 'latin' };
// Kana settles Japanese even when Han dominates the character count.
const kana = counts.find(([script]) => script === 'kana')?.[1] ?? 0;
const han = counts.find(([script]) => script === 'han')?.[1] ?? 0;
if (kana > 0 && kana + han >= letters * 0.3) return { language: 'ja', script: 'kana' };
if (han > 0 && han >= letters * 0.3) return { language: 'zh', script: 'han' };
const [script] = counts.reduce((best, entry) => (entry[1] > best[1] ? entry : best));
if (script in SCRIPT_LANGUAGE) return { language: SCRIPT_LANGUAGE[script], script };
const words = source.toLowerCase().split(/[^\p{L}\p{M}']+/u).filter(Boolean);
if (script === 'cyrillic') {
const scores = scoreStopwords(words, CYRILLIC_LANGUAGES);
const ukMarkers = countMatches(source, /[іїєґ]/gi);
const ruMarkers = countMatches(source, /[ыэъё]/gi);
// Letters decide: the two alphabets differ in letters that occur in
// nearly every sentence. Function words only settle a text that shows
// neither set, and a text with no Russian-only letters is far more
// likely Ukrainian than the reverse, so that tie goes to Ukrainian.
if (ukMarkers !== ruMarkers) return { language: ukMarkers > ruMarkers ? 'uk' : 'ru', script };
if (scores.uk !== scores.ru) return { language: scores.uk > scores.ru ? 'uk' : 'ru', script };
return { language: ruMarkers > 0 ? 'ru' : 'uk', script };
}
const scores = scoreStopwords(words, LATIN_LANGUAGES);
const marked = pickByMarkers(source, LATIN_MARKERS);
// A characteristic letter outranks stopword counts unless another language
// clearly dominates the function words (a German text quoting "façade").
if (marked && scores[marked] * 2 >= scores[bestOf(scores, marked)]) {
return { language: marked, script };
}
return { language: bestOf(scores, 'en'), script };
}
/**
* Map a detected language onto the locales a voice list uses (`uk_UA`,
* `en_US`...). Returns the preferred locale prefixes in order.
* @param {string} language
* @returns {string[]}
*/
function localePrefixesForLanguage(language) {
const table = {
en: ['en_US', 'en_GB', 'en'],
uk: ['uk_UA', 'uk'],
ru: ['ru_RU', 'ru'],
de: ['de_DE', 'de'],
fr: ['fr_FR', 'fr_CA', 'fr'],
es: ['es_ES', 'es_MX', 'es'],
it: ['it_IT', 'it'],
pt: ['pt_BR', 'pt_PT', 'pt'],
pl: ['pl_PL', 'pl'],
nl: ['nl_NL', 'nl_BE', 'nl'],
cs: ['cs_CZ', 'cs'],
tr: ['tr_TR', 'tr'],
sv: ['sv_SE', 'sv'],
zh: ['zh_CN', 'zh_TW', 'zh_HK', 'zh'],
ja: ['ja_JP', 'ja'],
ko: ['ko_KR', 'ko'],
el: ['el_GR', 'el'],
ar: ['ar_001', 'ar_SA', 'ar'],
he: ['he_IL', 'he'],
th: ['th_TH', 'th'],
hi: ['hi_IN', 'hi'],
};
return table[language] ?? [language];
}
/**
* Choose a voice for a language from a `say`-style voice list.
* Prefers an enhanced/premium variant of a matching voice, then any voice of
* the exact locale, then any voice of the language. Returns null when the
* list has no voice for that language.
* @param {string} language
* @param {ReadonlyArray<{ name: string, locale: string }>} voices
* @returns {string | null}
*/
export function pickVoiceForLanguage(language, voices) {
const prefixes = localePrefixesForLanguage(language);
for (const prefix of prefixes) {
const matching = voices.filter((voice) => voice.locale === prefix || voice.locale.startsWith(`${prefix}_`) || (prefix === language && voice.locale.startsWith(`${language}_`)));
if (matching.length === 0) continue;
const enhanced = matching.find((voice) => /\((Enhanced|Premium)\)/i.test(voice.name));
return (enhanced ?? matching[0]).name;
}
return null;
}
/**
* Language of a voice, from its locale (`uk_UA` `uk`).
* @param {string | null | undefined} locale
* @returns {string | null}
*/
export function languageOfLocale(locale) {
if (typeof locale !== 'string' || !locale) return null;
return locale.split(/[_-]/)[0].toLowerCase();
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { detectTextLanguage, languageOfLocale, pickVoiceForLanguage } from './language-detect.js';
describe('detectTextLanguage', () => {
it.each([
['en', 'The build is green and the tests pass, so you can merge this now.'],
['uk', 'Привіт! Це тестове повідомлення, і воно написане українською мовою.'],
['ru', 'Привет! Это тестовое сообщение, и оно написано на русском языке.'],
['de', 'Die Änderung ist fertig und die Tests laufen ohne Fehler durch.'],
['fr', 'La modification est prête et les tests passent sans erreur.'],
['es', 'El cambio está listo y las pruebas pasan sin errores.'],
['it', 'La modifica è pronta e i test passano senza errori.'],
['pt', 'A alteração está pronta e os testes passam sem erros, você pode continuar.'],
['pl', 'Zmiana jest gotowa i testy przechodzą bez błędów.'],
['nl', 'De wijziging is klaar en de tests slagen zonder fouten.'],
['cs', 'Změna je hotová a testy procházejí bez chyb.'],
['tr', 'Değişiklik hazır ve testler hatasız geçiyor.'],
['sv', 'Ändringen är klar och testerna går igenom utan fel.'],
['zh', '修改已经完成,所有测试都通过了。'],
['ja', '変更が完了し、すべてのテストに合格しました。'],
['ko', '변경이 완료되었고 모든 테스트를 통과했습니다.'],
])('detects %s', (language, text) => {
expect(detectTextLanguage(text).language).toBe(language);
});
it.each([
['uk', 'Готово. Запушено.'],
['uk', 'Все ок'],
['uk', 'Добре, давай так зробимо'],
['ru', 'Хорошо, давай так и сделаем'],
['ru', 'Готово, всё запушено.'],
])('tells short %s phrases apart by letters', (language, text) => {
expect(detectTextLanguage(text).language).toBe(language);
});
it('falls back to English for text without letters', () => {
expect(detectTextLanguage('1234 ... !!!').language).toBe('en');
expect(detectTextLanguage('').language).toBe('en');
});
it('does not let a single quoted foreign word flip an English paragraph', () => {
const text = 'The façade of the building is the part that you see from the street, and it is not the same as the interior.';
expect(detectTextLanguage(text).language).toBe('en');
});
});
describe('pickVoiceForLanguage', () => {
const voices = [
{ name: 'Samantha', locale: 'en_US' },
{ name: 'Daniel', locale: 'en_GB' },
{ name: 'Lesya', locale: 'uk_UA' },
{ name: 'Lesya (Enhanced)', locale: 'uk_UA' },
{ name: 'Milena', locale: 'ru_RU' },
{ name: 'Anna', locale: 'de_DE' },
];
it('prefers the enhanced variant of a matching voice', () => {
expect(pickVoiceForLanguage('uk', voices)).toBe('Lesya (Enhanced)');
});
it('prefers the primary locale of a language', () => {
expect(pickVoiceForLanguage('en', voices)).toBe('Samantha');
});
it('returns null when no voice speaks the language', () => {
expect(pickVoiceForLanguage('ja', voices)).toBeNull();
});
});
describe('languageOfLocale', () => {
it('reads the language subtag', () => {
expect(languageOfLocale('uk_UA')).toBe('uk');
expect(languageOfLocale('en-GB')).toBe('en');
expect(languageOfLocale(null)).toBeNull();
});
});
+23 -1
View File
@@ -2,6 +2,8 @@ import express from 'express';
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
import { summarizeText, sanitizeForTTS, sanitizeForNote } from '../text/summarization.js';
import { detectTextLanguage, languageOfLocale, pickVoiceForLanguage } from './language-detect.js';
export function registerTtsRoutes(app, { sayTTSCapability }) {
let ttsModulePromise = null;
const getTtsModule = async () => {
@@ -154,7 +156,8 @@ export function registerTtsRoutes(app, { sayTTSCapability }) {
// macOS 'say' command TTS speak endpoint
app.post('/api/tts/say/speak', async (req, res) => {
try {
const { text, voice = 'Samantha', rate = 200 } = req.body || {};
const { text, rate = 200, language, languageSample } = req.body || {};
let voice = typeof req.body?.voice === 'string' && req.body.voice.trim() ? req.body.voice.trim() : 'Samantha';
if (!text || typeof text !== 'string' || !text.trim()) {
return res.status(400).json({ error: 'Text is required' });
@@ -164,6 +167,23 @@ export function registerTtsRoutes(app, { sayTTSCapability }) {
if (process.platform !== 'darwin') {
return res.status(503).json({ error: 'macOS say command not available on this platform' });
}
// `language: 'auto'`: keep the chosen voice while it speaks the text's
// language, otherwise switch to an installed voice that does. A
// language with no installed voice keeps the chosen voice — say still
// reads the text, just with an accent — rather than failing.
let resolvedLanguage = null;
if (language === 'auto') {
const capability = await sayTTSCapability;
const voices = Array.isArray(capability?.voices) ? capability.voices : [];
const sample = typeof languageSample === 'string' && languageSample.trim() ? languageSample.slice(0, 4000) : text;
resolvedLanguage = detectTextLanguage(sample).language;
const chosen = voices.find((entry) => entry.name === voice);
if (languageOfLocale(chosen?.locale) !== resolvedLanguage) {
const match = pickVoiceForLanguage(resolvedLanguage, voices);
if (match) voice = match;
}
}
const { exec } = await import('child_process');
const { promisify } = await import('util');
@@ -195,6 +215,8 @@ export function registerTtsRoutes(app, { sayTTSCapability }) {
// Send audio response
res.setHeader('Content-Type', 'audio/mp4');
res.setHeader('X-Speech-Voice', voice);
if (resolvedLanguage) res.setHeader('X-Speech-Language', resolvedLanguage);
res.setHeader('Content-Length', audioBuffer.length);
res.send(audioBuffer);
@@ -33,6 +33,32 @@ describe('tts routes', () => {
});
});
it('switches the say voice to the language of the text when asked to', async () => {
const capability = Promise.resolve({
available: true,
voices: [
{ name: 'Samantha', locale: 'en_US' },
{ name: 'Lesya', locale: 'uk_UA' },
{ name: 'Lesya (Enhanced)', locale: 'uk_UA' },
],
});
const app = createApp(capability);
const response = await request(app)
.post('/api/tts/say/speak')
.send({ text: 'Привіт! Це відповідь українською мовою, і вона досить довга.', voice: 'Samantha', language: 'auto' });
// On macOS the route synthesizes; elsewhere it refuses before running say.
// Either way the chosen voice must be the Ukrainian one when the platform
// allows the request to proceed.
if (process.platform === 'darwin') {
expect(response.status).toBe(200);
expect(response.headers['x-speech-voice']).toBe('Lesya (Enhanced)');
expect(response.headers['x-speech-language']).toBe('uk');
} else {
expect(response.status).toBe(503);
}
});
it('returns local note fallback while model summarization is retired', async () => {
const response = await request(createApp())
.post('/api/text/summarize')