@danypops/tickets 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Application service — the hexagon's core orchestration layer. Implements the
3
+ * driver-facing operations (used by the CLI and the pi-tickets extension) and
4
+ * routes every call to the named backend's repository. Depends only on ports,
5
+ * never on a concrete adapter.
6
+ */
7
+ import type { Comment, CreateInput, Issue, ListFilter, UpdateInput } from "../domain/issue.js";
8
+ import { parseRef } from "../domain/issue.js";
9
+ import { hasComments, type IssueRepository } from "../ports/repository.js";
10
+
11
+ export class UnknownBackendError extends Error {
12
+ constructor(backend: string, known: string[]) {
13
+ super(`unknown backend "${backend}" (known: ${known.join(", ") || "none configured"})`);
14
+ this.name = "UnknownBackendError";
15
+ }
16
+ }
17
+
18
+ export class NotSupportedError extends Error {
19
+ constructor(backend: string, capability: string) {
20
+ super(`${backend} does not support ${capability}`);
21
+ this.name = "NotSupportedError";
22
+ }
23
+ }
24
+
25
+ export class TicketService {
26
+ constructor(private readonly repos: Record<string, IssueRepository>) {}
27
+
28
+ backends(): string[] {
29
+ return Object.keys(this.repos);
30
+ }
31
+
32
+ private repo(backend: string): IssueRepository {
33
+ const repo = this.repos[backend];
34
+ if (!repo) throw new UnknownBackendError(backend, this.backends());
35
+ return repo;
36
+ }
37
+
38
+ // Every method here is declared `async` deliberately, even where the body
39
+ // doesn't otherwise need to await anything: ref parsing and backend lookup
40
+ // can throw synchronously (UnknownBackendError, invalid ref), and callers
41
+ // uniformly treat every TicketService call as a Promise. Without `async`,
42
+ // those synchronous throws would escape before a Promise ever existed,
43
+ // breaking `await`/`.rejects` callers alike.
44
+ async list(backend: string, filter: ListFilter = {}): Promise<Issue[]> {
45
+ return this.repo(backend).list(filter);
46
+ }
47
+
48
+ async get(ref: string): Promise<Issue> {
49
+ const { backend, key } = parseRef(ref);
50
+ return this.repo(backend).get(key);
51
+ }
52
+
53
+ async create(backend: string, input: CreateInput): Promise<Issue> {
54
+ return this.repo(backend).create(input);
55
+ }
56
+
57
+ async update(ref: string, input: UpdateInput): Promise<Issue> {
58
+ const { backend, key } = parseRef(ref);
59
+ return this.repo(backend).update(key, input);
60
+ }
61
+
62
+ async search(backend: string, query: string, limit?: number): Promise<Issue[]> {
63
+ return this.repo(backend).search(query, limit);
64
+ }
65
+
66
+ async children(ref: string): Promise<Issue[]> {
67
+ const { backend, key } = parseRef(ref);
68
+ return this.repo(backend).listChildren(key);
69
+ }
70
+
71
+ async comments(ref: string): Promise<Comment[]> {
72
+ const { backend, key } = parseRef(ref);
73
+ const repo = this.repo(backend);
74
+ if (!hasComments(repo)) throw new NotSupportedError(backend, "comments");
75
+ return repo.listComments(key);
76
+ }
77
+
78
+ async addComment(ref: string, body: string): Promise<Comment> {
79
+ const { backend, key } = parseRef(ref);
80
+ const repo = this.repo(backend);
81
+ if (!hasComments(repo)) throw new NotSupportedError(backend, "comments");
82
+ return repo.addComment(key, body);
83
+ }
84
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Cross-platform "open this URL in the user's browser" helper. Deliberately
3
+ * hand-rolled rather than a dependency: it's a 3-way platform switch behind
4
+ * one array-form spawn call (no shell string interpolation, so no injection
5
+ * surface from an untrusted URL), well under the size where a mature
6
+ * dependency would remove a real bug class.
7
+ */
8
+ import { spawn } from "node:child_process";
9
+
10
+ export type Spawner = (command: string, args: string[]) => void;
11
+
12
+ const defaultSpawner: Spawner = (command, args) => {
13
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
14
+ child.unref();
15
+ };
16
+
17
+ function isHttpUrl(url: string): boolean {
18
+ try {
19
+ const parsed = new URL(url);
20
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ /** Throws on anything that isn't a well-formed http(s) URL — never hands an untrusted string to a shell. */
27
+ export function openUrl(url: string, opts: { platform?: NodeJS.Platform; spawner?: Spawner } = {}): void {
28
+ if (!isHttpUrl(url)) throw new Error(`refusing to open non-http(s) URL: ${url}`);
29
+ const platform = opts.platform ?? process.platform;
30
+ const spawner = opts.spawner ?? defaultSpawner;
31
+
32
+ if (platform === "darwin") return spawner("open", [url]);
33
+ if (platform === "win32") return spawner("cmd", ["/c", "start", '""', url]);
34
+ return spawner("xdg-open", [url]);
35
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * OAuth 2.0 Device Authorization Grant (RFC 8628), shared between GitHub and
3
+ * GitLab — both implement the same shape, confirmed against their own docs:
4
+ * GitHub: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
5
+ * POST https://github.com/login/device/code
6
+ * POST https://github.com/login/oauth/access_token (grant_type=urn:ietf:params:oauth:grant-type:device_code)
7
+ * GitLab: https://docs.gitlab.com/api/oauth2/ ("Device Authorization Grant", GA in GitLab 17.9)
8
+ * POST {baseUrl}/oauth/authorize_device
9
+ * POST {baseUrl}/oauth/token (same grant_type)
10
+ * This is the "open a link, enter a code" flow: no client secret, no redirect
11
+ * URI/local callback server needed, ideal for a headless daemon. Jira/Atlassian
12
+ * has no equivalent (see jira-oauth.ts, which uses Authorization Code instead).
13
+ */
14
+ import type { FetchLike } from "../adapters/http.js";
15
+
16
+ export const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
17
+
18
+ export class DeviceFlowError extends Error {
19
+ constructor(
20
+ public readonly provider: string,
21
+ public readonly code: string,
22
+ description?: string,
23
+ ) {
24
+ super(`${provider} device authorization failed: ${code}${description ? ` (${description})` : ""}`);
25
+ this.name = "DeviceFlowError";
26
+ }
27
+ }
28
+
29
+ export interface DeviceFlowConfig {
30
+ /** Used only in error messages, e.g. "github". */
31
+ provider: string;
32
+ deviceAuthorizationUrl: string;
33
+ tokenUrl: string;
34
+ clientId: string;
35
+ scope?: string;
36
+ fetchImpl?: FetchLike;
37
+ }
38
+
39
+ export interface DeviceAuthorization {
40
+ deviceCode: string;
41
+ userCode: string;
42
+ verificationUri: string;
43
+ verificationUriComplete?: string;
44
+ expiresInSeconds: number;
45
+ intervalSeconds: number;
46
+ }
47
+
48
+ export interface DeviceFlowToken {
49
+ accessToken: string;
50
+ tokenType: string;
51
+ scope?: string;
52
+ /** ISO timestamp; undefined when the provider issues a non-expiring token (classic GitHub OAuth Apps). */
53
+ expiresAt?: string;
54
+ refreshToken?: string;
55
+ }
56
+
57
+ async function postForm(
58
+ fetchImpl: FetchLike,
59
+ url: string,
60
+ params: Record<string, string>,
61
+ ): Promise<Record<string, unknown>> {
62
+ const res = await fetchImpl(url, {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
65
+ body: new URLSearchParams(params).toString(),
66
+ });
67
+ const text = await res.text();
68
+ return text ? (JSON.parse(text) as Record<string, unknown>) : {};
69
+ }
70
+
71
+ export async function requestDeviceAuthorization(config: DeviceFlowConfig): Promise<DeviceAuthorization> {
72
+ const fetchImpl = config.fetchImpl ?? fetch;
73
+ const params: Record<string, string> = { client_id: config.clientId };
74
+ if (config.scope) params.scope = config.scope;
75
+ const body = await postForm(fetchImpl, config.deviceAuthorizationUrl, params);
76
+ if (typeof body.device_code !== "string" || typeof body.user_code !== "string") {
77
+ throw new DeviceFlowError(config.provider, "invalid_response", JSON.stringify(body));
78
+ }
79
+ return {
80
+ deviceCode: body.device_code,
81
+ userCode: body.user_code,
82
+ verificationUri: String(body.verification_uri ?? ""),
83
+ verificationUriComplete:
84
+ typeof body.verification_uri_complete === "string" ? body.verification_uri_complete : undefined,
85
+ expiresInSeconds: Number(body.expires_in ?? 900),
86
+ intervalSeconds: Number(body.interval ?? 5),
87
+ };
88
+ }
89
+
90
+ export interface PollOptions {
91
+ /** Injectable for tests; defaults to a real timer. */
92
+ sleep?: (ms: number) => Promise<void>;
93
+ /** Safety cap independent of the server's expires_in, so a misbehaving server can't loop forever. */
94
+ maxAttempts?: number;
95
+ }
96
+
97
+ const DEFAULT_MAX_ATTEMPTS = 200;
98
+
99
+ export async function pollForToken(
100
+ config: DeviceFlowConfig,
101
+ authorization: DeviceAuthorization,
102
+ opts: PollOptions = {},
103
+ ): Promise<DeviceFlowToken> {
104
+ const fetchImpl = config.fetchImpl ?? fetch;
105
+ const sleep = opts.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
106
+ const maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
107
+ let intervalMs = authorization.intervalSeconds * 1000;
108
+ const deadline = Date.now() + authorization.expiresInSeconds * 1000;
109
+
110
+ for (let attempt = 0; attempt < maxAttempts && Date.now() < deadline; attempt++) {
111
+ await sleep(intervalMs);
112
+ const body = await postForm(fetchImpl, config.tokenUrl, {
113
+ grant_type: DEVICE_GRANT_TYPE,
114
+ device_code: authorization.deviceCode,
115
+ client_id: config.clientId,
116
+ });
117
+
118
+ if (body.error === "authorization_pending") continue;
119
+ if (body.error === "slow_down") {
120
+ intervalMs += 5_000;
121
+ continue;
122
+ }
123
+ if (typeof body.error === "string") {
124
+ throw new DeviceFlowError(config.provider, body.error, body.error_description as string | undefined);
125
+ }
126
+ if (typeof body.access_token !== "string") {
127
+ throw new DeviceFlowError(config.provider, "invalid_response", JSON.stringify(body));
128
+ }
129
+ return {
130
+ accessToken: body.access_token,
131
+ tokenType: String(body.token_type ?? "Bearer"),
132
+ scope: typeof body.scope === "string" ? body.scope : undefined,
133
+ expiresAt:
134
+ typeof body.expires_in === "number" ? new Date(Date.now() + body.expires_in * 1000).toISOString() : undefined,
135
+ refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : undefined,
136
+ };
137
+ }
138
+
139
+ throw new DeviceFlowError(config.provider, "expired_token", "device code expired before authorization completed");
140
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * GitHub delegated auth via the OAuth 2.0 Device Authorization Grant.
3
+ * Docs: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
4
+ * Requires a GitHub OAuth App (or GitHub App) with the device flow enabled in
5
+ * its settings; the client ID is public (no secret needed for this flow).
6
+ */
7
+ import type { FetchLike } from "../adapters/http.js";
8
+ import { type DeviceFlowConfig, type DeviceFlowToken, type PollOptions, pollForToken, requestDeviceAuthorization } from "./device-flow.js";
9
+
10
+ export const GITHUB_DEVICE_AUTHORIZATION_URL = "https://github.com/login/device/code";
11
+ export const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
12
+ export const GITHUB_DEFAULT_SCOPE = "repo read:org";
13
+
14
+ export interface DevicePrompt {
15
+ userCode: string;
16
+ verificationUri: string;
17
+ verificationUriComplete?: string;
18
+ }
19
+
20
+ export interface GitHubDeviceLoginOptions {
21
+ clientId: string;
22
+ scope?: string;
23
+ fetchImpl?: FetchLike;
24
+ onPrompt: (prompt: DevicePrompt) => void | Promise<void>;
25
+ poll?: PollOptions;
26
+ }
27
+
28
+ export async function loginWithGitHubDeviceFlow(opts: GitHubDeviceLoginOptions): Promise<DeviceFlowToken> {
29
+ const config: DeviceFlowConfig = {
30
+ provider: "github",
31
+ deviceAuthorizationUrl: GITHUB_DEVICE_AUTHORIZATION_URL,
32
+ tokenUrl: GITHUB_TOKEN_URL,
33
+ clientId: opts.clientId,
34
+ scope: opts.scope ?? GITHUB_DEFAULT_SCOPE,
35
+ fetchImpl: opts.fetchImpl,
36
+ };
37
+ const authorization = await requestDeviceAuthorization(config);
38
+ await opts.onPrompt({
39
+ userCode: authorization.userCode,
40
+ verificationUri: authorization.verificationUri,
41
+ verificationUriComplete: authorization.verificationUriComplete,
42
+ });
43
+ return pollForToken(config, authorization, opts.poll);
44
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * GitLab delegated auth via the OAuth 2.0 Device Authorization Grant, GA
3
+ * since GitLab 17.9 (self-managed) and available on GitLab.com.
4
+ * Docs: https://docs.gitlab.com/api/oauth2/ ("Device Authorization Grant").
5
+ * Requires a "non-confidential" GitLab OAuth application (no client secret
6
+ * for this flow, same shape as GitHub's device flow / RFC 8628).
7
+ */
8
+ import type { FetchLike } from "../adapters/http.js";
9
+ import { type DeviceFlowConfig, type DeviceFlowToken, type PollOptions, pollForToken, requestDeviceAuthorization } from "./device-flow.js";
10
+ import type { DevicePrompt } from "./github-oauth.js";
11
+
12
+ export const GITLAB_DEFAULT_SCOPE = "read_api";
13
+
14
+ export function gitlabDeviceEndpoints(baseUrl = "https://gitlab.com"): {
15
+ deviceAuthorizationUrl: string;
16
+ tokenUrl: string;
17
+ } {
18
+ const trimmed = baseUrl.replace(/\/+$/, "");
19
+ return {
20
+ deviceAuthorizationUrl: `${trimmed}/oauth/authorize_device`,
21
+ tokenUrl: `${trimmed}/oauth/token`,
22
+ };
23
+ }
24
+
25
+ export interface GitLabDeviceLoginOptions {
26
+ clientId: string;
27
+ baseUrl?: string;
28
+ scope?: string;
29
+ fetchImpl?: FetchLike;
30
+ onPrompt: (prompt: DevicePrompt) => void | Promise<void>;
31
+ poll?: PollOptions;
32
+ }
33
+
34
+ export async function loginWithGitLabDeviceFlow(opts: GitLabDeviceLoginOptions): Promise<DeviceFlowToken> {
35
+ const endpoints = gitlabDeviceEndpoints(opts.baseUrl);
36
+ const config: DeviceFlowConfig = {
37
+ provider: "gitlab",
38
+ deviceAuthorizationUrl: endpoints.deviceAuthorizationUrl,
39
+ tokenUrl: endpoints.tokenUrl,
40
+ clientId: opts.clientId,
41
+ scope: opts.scope ?? GITLAB_DEFAULT_SCOPE,
42
+ fetchImpl: opts.fetchImpl,
43
+ };
44
+ const authorization = await requestDeviceAuthorization(config);
45
+ await opts.onPrompt({
46
+ userCode: authorization.userCode,
47
+ verificationUri: authorization.verificationUri,
48
+ verificationUriComplete: authorization.verificationUriComplete,
49
+ });
50
+ return pollForToken(config, authorization, opts.poll);
51
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Jira/Atlassian delegated auth via OAuth 2.0 (3LO), Authorization Code grant.
3
+ * Docs: https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/
4
+ *
5
+ * Unlike GitHub/GitLab, Atlassian's 3LO apps are confidential clients (no
6
+ * PKCE, no device flow — confirmed absent from their docs) and require a
7
+ * client secret. That means this flow needs a local redirect: we bind an
8
+ * ephemeral loopback HTTP server, send the user to Atlassian's consent
9
+ * screen, and receive the authorization code on the callback.
10
+ *
11
+ * OAuth 2.0 (3LO) access tokens are NOT used the way Basic-auth API tokens
12
+ * are: requests go to https://api.atlassian.com/ex/jira/{cloudId}/... with
13
+ * `Authorization: Bearer <token>`, not directly to the tenant's *.atlassian.net
14
+ * domain. The cloudId is discovered via the accessible-resources endpoint
15
+ * after the token exchange — see JiraOAuthToken below.
16
+ */
17
+ import { randomBytes } from "node:crypto";
18
+ import { createServer, type Server } from "node:http";
19
+ import type { AddressInfo } from "node:net";
20
+ import type { FetchLike } from "../adapters/http.js";
21
+
22
+ export const ATLASSIAN_AUTHORIZE_URL = "https://auth.atlassian.com/authorize";
23
+ export const ATLASSIAN_TOKEN_URL = "https://auth.atlassian.com/oauth/token";
24
+ export const ATLASSIAN_ACCESSIBLE_RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources";
25
+ export const JIRA_DEFAULT_SCOPE = "read:jira-work write:jira-work read:jira-user offline_access";
26
+
27
+ export interface JiraSite {
28
+ id: string;
29
+ name: string;
30
+ url: string;
31
+ scopes: string[];
32
+ }
33
+
34
+ export interface JiraOAuthToken {
35
+ accessToken: string;
36
+ refreshToken?: string;
37
+ expiresAt?: string;
38
+ cloudId: string;
39
+ siteUrl: string;
40
+ }
41
+
42
+ export class JiraOAuthError extends Error {
43
+ constructor(message: string) {
44
+ super(`jira oauth: ${message}`);
45
+ this.name = "JiraOAuthError";
46
+ }
47
+ }
48
+
49
+ export interface CallbackResult {
50
+ code: string;
51
+ redirectUri: string;
52
+ }
53
+
54
+ export interface CallbackServer {
55
+ redirectUriFor(host?: string): string;
56
+ waitForCode(timeoutMs: number): Promise<CallbackResult>;
57
+ close(): Promise<void>;
58
+ }
59
+
60
+ /**
61
+ * Binds an ephemeral loopback HTTP server that answers exactly one
62
+ * `/callback` request, validating `state` before resolving. Exported
63
+ * separately from loginWithJiraAuthorizationCode so tests can drive it with
64
+ * a real HTTP request instead of a real browser.
65
+ */
66
+ export function startCallbackServer(expectedState: string, port = 0): CallbackServer {
67
+ let resolveCode: ((result: CallbackResult) => void) | undefined;
68
+ let rejectCode: ((error: Error) => void) | undefined;
69
+
70
+ const server: Server = createServer((req, res) => {
71
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
72
+ if (url.pathname !== "/callback") {
73
+ res.writeHead(404).end("not found");
74
+ return;
75
+ }
76
+ const error = url.searchParams.get("error");
77
+ const state = url.searchParams.get("state");
78
+ const code = url.searchParams.get("code");
79
+
80
+ if (error) {
81
+ res.writeHead(400, { "Content-Type": "text/html" }).end("<html><body>Authorization denied. You can close this tab.</body></html>");
82
+ rejectCode?.(new JiraOAuthError(`authorization denied: ${error}`));
83
+ return;
84
+ }
85
+ if (state !== expectedState) {
86
+ res.writeHead(400, { "Content-Type": "text/html" }).end("<html><body>State mismatch. Aborting.</body></html>");
87
+ rejectCode?.(new JiraOAuthError("state mismatch on OAuth callback"));
88
+ return;
89
+ }
90
+ if (!code) {
91
+ res.writeHead(400, { "Content-Type": "text/html" }).end("<html><body>Missing authorization code.</body></html>");
92
+ rejectCode?.(new JiraOAuthError("callback missing authorization code"));
93
+ return;
94
+ }
95
+ res.writeHead(200, { "Content-Type": "text/html" }).end("<html><body>Authorized. You can close this tab and return to the terminal.</body></html>");
96
+ resolveCode?.({ code, redirectUri: redirectUriFor() });
97
+ });
98
+
99
+ function redirectUriFor(host = "127.0.0.1"): string {
100
+ const address = server.address() as AddressInfo;
101
+ return `http://${host}:${address.port}/callback`;
102
+ }
103
+
104
+ server.listen(port);
105
+
106
+ return {
107
+ redirectUriFor,
108
+ waitForCode(timeoutMs: number): Promise<CallbackResult> {
109
+ return new Promise((resolve, reject) => {
110
+ resolveCode = resolve;
111
+ rejectCode = reject;
112
+ setTimeout(() => reject(new JiraOAuthError("timed out waiting for the OAuth callback")), timeoutMs);
113
+ });
114
+ },
115
+ close(): Promise<void> {
116
+ return new Promise((resolve) => server.close(() => resolve()));
117
+ },
118
+ };
119
+ }
120
+
121
+ export function buildAuthorizeUrl(opts: {
122
+ clientId: string;
123
+ redirectUri: string;
124
+ scope: string;
125
+ state: string;
126
+ }): string {
127
+ const url = new URL(ATLASSIAN_AUTHORIZE_URL);
128
+ url.searchParams.set("audience", "api.atlassian.com");
129
+ url.searchParams.set("client_id", opts.clientId);
130
+ url.searchParams.set("scope", opts.scope);
131
+ url.searchParams.set("redirect_uri", opts.redirectUri);
132
+ url.searchParams.set("state", opts.state);
133
+ url.searchParams.set("response_type", "code");
134
+ url.searchParams.set("prompt", "consent");
135
+ return url.toString();
136
+ }
137
+
138
+ interface TokenResponse {
139
+ access_token: string;
140
+ refresh_token?: string;
141
+ expires_in?: number;
142
+ scope?: string;
143
+ }
144
+
145
+ async function exchangeCode(
146
+ fetchImpl: FetchLike,
147
+ opts: { clientId: string; clientSecret: string; code: string; redirectUri: string },
148
+ ): Promise<TokenResponse> {
149
+ const res = await fetchImpl(ATLASSIAN_TOKEN_URL, {
150
+ method: "POST",
151
+ headers: { "Content-Type": "application/json" },
152
+ body: JSON.stringify({
153
+ grant_type: "authorization_code",
154
+ client_id: opts.clientId,
155
+ client_secret: opts.clientSecret,
156
+ code: opts.code,
157
+ redirect_uri: opts.redirectUri,
158
+ }),
159
+ });
160
+ const body = (await res.json()) as TokenResponse & { error?: string; error_description?: string };
161
+ if (!res.ok || !body.access_token) {
162
+ throw new JiraOAuthError(body.error_description ?? body.error ?? `token exchange failed with HTTP ${res.status}`);
163
+ }
164
+ return body;
165
+ }
166
+
167
+ export async function fetchAccessibleResources(fetchImpl: FetchLike, accessToken: string): Promise<JiraSite[]> {
168
+ const res = await fetchImpl(ATLASSIAN_ACCESSIBLE_RESOURCES_URL, {
169
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
170
+ });
171
+ if (!res.ok) throw new JiraOAuthError(`accessible-resources failed with HTTP ${res.status}`);
172
+ return (await res.json()) as JiraSite[];
173
+ }
174
+
175
+ export interface JiraOAuthLoginOptions {
176
+ clientId: string;
177
+ clientSecret: string;
178
+ scope?: string;
179
+ port?: number;
180
+ fetchImpl?: FetchLike;
181
+ onPrompt: (authorizeUrl: string) => void | Promise<void>;
182
+ timeoutMs?: number;
183
+ /** Pick a site when the token is granted access to more than one; defaults to the first. */
184
+ chooseSite?: (sites: JiraSite[]) => JiraSite;
185
+ }
186
+
187
+ const DEFAULT_CALLBACK_TIMEOUT_MS = 120_000;
188
+
189
+ export async function loginWithJiraAuthorizationCode(opts: JiraOAuthLoginOptions): Promise<JiraOAuthToken> {
190
+ const fetchImpl = opts.fetchImpl ?? fetch;
191
+ const state = randomBytes(16).toString("hex");
192
+ const server = startCallbackServer(state, opts.port ?? 0);
193
+
194
+ try {
195
+ const redirectUri = server.redirectUriFor();
196
+ const authorizeUrl = buildAuthorizeUrl({
197
+ clientId: opts.clientId,
198
+ redirectUri,
199
+ scope: opts.scope ?? JIRA_DEFAULT_SCOPE,
200
+ state,
201
+ });
202
+ const waiting = server.waitForCode(opts.timeoutMs ?? DEFAULT_CALLBACK_TIMEOUT_MS);
203
+ await opts.onPrompt(authorizeUrl);
204
+ const { code } = await waiting;
205
+
206
+ const token = await exchangeCode(fetchImpl, {
207
+ clientId: opts.clientId,
208
+ clientSecret: opts.clientSecret,
209
+ code,
210
+ redirectUri,
211
+ });
212
+ const sites = await fetchAccessibleResources(fetchImpl, token.access_token);
213
+ if (sites.length === 0) throw new JiraOAuthError("no accessible Atlassian sites granted to this token");
214
+ const site = (opts.chooseSite ?? ((all: JiraSite[]) => all[0]))(sites) as JiraSite;
215
+
216
+ return {
217
+ accessToken: token.access_token,
218
+ refreshToken: token.refresh_token,
219
+ expiresAt: token.expires_in ? new Date(Date.now() + token.expires_in * 1000).toISOString() : undefined,
220
+ cloudId: site.id,
221
+ siteUrl: site.url,
222
+ };
223
+ } finally {
224
+ await server.close();
225
+ }
226
+ }
227
+
228
+ export async function refreshJiraToken(
229
+ fetchImpl: FetchLike,
230
+ opts: { clientId: string; clientSecret: string; refreshToken: string },
231
+ ): Promise<{ accessToken: string; refreshToken?: string; expiresAt?: string }> {
232
+ const res = await fetchImpl(ATLASSIAN_TOKEN_URL, {
233
+ method: "POST",
234
+ headers: { "Content-Type": "application/json" },
235
+ body: JSON.stringify({
236
+ grant_type: "refresh_token",
237
+ client_id: opts.clientId,
238
+ client_secret: opts.clientSecret,
239
+ refresh_token: opts.refreshToken,
240
+ }),
241
+ });
242
+ const body = (await res.json()) as TokenResponse & { error?: string; error_description?: string };
243
+ if (!res.ok || !body.access_token) {
244
+ throw new JiraOAuthError(body.error_description ?? body.error ?? `refresh failed with HTTP ${res.status}`);
245
+ }
246
+ return {
247
+ accessToken: body.access_token,
248
+ refreshToken: body.refresh_token,
249
+ expiresAt: body.expires_in ? new Date(Date.now() + body.expires_in * 1000).toISOString() : undefined,
250
+ };
251
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Local, per-backend persistence for delegated OAuth tokens — separate from
3
+ * daemon-kit's own daemon-auth token (paths.token, which authenticates RPC
4
+ * callers to the daemon). This one holds what the daemon uses to authenticate
5
+ * *to* GitHub/GitLab/Jira on the user's behalf. Same security posture as
6
+ * daemon-kit's ensureAuthToken: 0700 directory, 0600 files, atomic write.
7
+ */
8
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+
12
+ export interface StoredToken {
13
+ accessToken: string;
14
+ refreshToken?: string;
15
+ /** ISO timestamp; absent means the provider issued a non-expiring token. */
16
+ expiresAt?: string;
17
+ scope?: string;
18
+ /** Backend-specific extras that don't fit the common shape, e.g. Jira's cloudId. */
19
+ extra?: Record<string, string>;
20
+ }
21
+
22
+ export interface TokenStoreEnv {
23
+ env?: Record<string, string | undefined>;
24
+ home?: string;
25
+ }
26
+
27
+ export function tokenStoreDir(opts: TokenStoreEnv = {}): string {
28
+ const env = opts.env ?? process.env;
29
+ const home = opts.home ?? homedir();
30
+ const stateHome = env.XDG_STATE_HOME || join(home, ".local", "state");
31
+ return join(stateHome, "tickets", "oauth");
32
+ }
33
+
34
+ function tokenPath(backend: string, opts: TokenStoreEnv = {}): string {
35
+ return join(tokenStoreDir(opts), `${backend}.json`);
36
+ }
37
+
38
+ export function saveToken(backend: string, token: StoredToken, opts: TokenStoreEnv = {}): void {
39
+ const path = tokenPath(backend, opts);
40
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
41
+ const temp = `${path}.${process.pid}.tmp`;
42
+ writeFileSync(temp, `${JSON.stringify(token, null, 2)}\n`, { mode: 0o600 });
43
+ renameSync(temp, path);
44
+ }
45
+
46
+ export function loadToken(backend: string, opts: TokenStoreEnv = {}): StoredToken | undefined {
47
+ const path = tokenPath(backend, opts);
48
+ if (!existsSync(path)) return undefined;
49
+ chmodSync(path, 0o600);
50
+ try {
51
+ return JSON.parse(readFileSync(path, "utf8")) as StoredToken;
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ }
56
+
57
+ export function deleteToken(backend: string, opts: TokenStoreEnv = {}): void {
58
+ rmSync(tokenPath(backend, opts), { force: true });
59
+ }
60
+
61
+ export function listStoredBackends(opts: TokenStoreEnv = {}): string[] {
62
+ const dir = tokenStoreDir(opts);
63
+ if (!existsSync(dir)) return [];
64
+ return readdirSync(dir)
65
+ .filter((f) => f.endsWith(".json"))
66
+ .map((f) => f.slice(0, -".json".length));
67
+ }
68
+
69
+ /** A token is usable if it has no expiry, or expires more than `skewMs` from now. */
70
+ export function isTokenFresh(token: StoredToken, skewMs = 60_000): boolean {
71
+ if (!token.expiresAt) return true;
72
+ return new Date(token.expiresAt).getTime() - Date.now() > skewMs;
73
+ }