From 8e7ce4b6fedd6a4dfc8940a9c13dbd7e658081aa Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:53:45 +0200 Subject: [PATCH 1/3] feat(apps): add Atlassian read-only app --- playground/demo-apps/atlassian/index.ts | 229 ++++++++++++++++++++ playground/demo-apps/atlassian/package.json | 15 ++ 2 files changed, 244 insertions(+) create mode 100644 playground/demo-apps/atlassian/index.ts create mode 100644 playground/demo-apps/atlassian/package.json diff --git a/playground/demo-apps/atlassian/index.ts b/playground/demo-apps/atlassian/index.ts new file mode 100644 index 0000000000..20b9f48286 --- /dev/null +++ b/playground/demo-apps/atlassian/index.ts @@ -0,0 +1,229 @@ +import { + array, + bearer, + boolean, + decodeJson, + defineApp, + defineProvider, + json, + number, + object, + oauth2, + query, + router, + string, + type QueryContext, +} from "apps"; + +const apiOrigin = "https://api.atlassian.com"; +const accessibleResourcesUrl = `${apiOrigin}/oauth/token/accessible-resources`; +const scopes = [ + "offline_access", + "read:jira-work", + "read:confluence-content.all", + "search:confluence", +]; + +const atlassian = defineProvider({ + name: "Atlassian", + hosts: ["api.atlassian.com"], + auth: { + oauth: oauth2({ + authorizationUrl: "https://auth.atlassian.com/authorize", + tokenUrl: "https://auth.atlassian.com/oauth/token", + tokenEndpointAuthMethod: "client_secret_post", + tokenRequestFormat: "json", + authorizationParams: { audience: "api.atlassian.com", prompt: "consent" }, + scopes, + request: ({ access_token }) => bearer(access_token), + }), + }, + async health({ account, fetch, signal }) { + const response = await fetch(accessibleResourcesUrl, { + signal, + headers: { ...account.headers(), accept: "application/json" }, + }); + const sites = await decodeJson(response, array(object({ id: string(), name: string() }))); + if (sites.length === 0) throw new Error("No Atlassian sites are accessible to this account."); + }, +}); + +const requirements = { user: { accounts: { atlassian } } }; +type Context = QueryContext; + +const apiFetch = ( + context: Context, + url: string, + options: { readonly method?: "GET" | "POST"; readonly body?: unknown } = {}, +) => { + const body = options.body === undefined ? undefined : JSON.stringify(options.body); + return context.fetch(url, { + ...(options.method === undefined ? {} : { method: options.method }), + ...(body === undefined ? {} : { body }), + signal: context.signal, + headers: { + ...context.user.accounts.atlassian.headers(), + accept: "application/json", + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + }); +}; + +const boundedNumber = (value: number | undefined, fallback: number, maximum: number) => + Math.max(1, Math.min(maximum, Math.trunc(value ?? fallback))); + +const Sites = array( + object({ + id: string(), + name: string(), + url: string(), + scopes: array(string()).optional(), + }), +); + +const listSites = query( + { + description: "List Atlassian Cloud sites accessible to the connected account.", + input: object({}), + }, + async (context: Context) => { + const response = await apiFetch(context, accessibleResourcesUrl); + return decodeJson(response, Sites); + }, +); + +const ConfluenceSearch = object({ + results: array( + object({ + id: string(), + title: string(), + type: string().optional(), + status: string().optional(), + excerpt: string().optional(), + space: object({ key: string().optional(), name: string().optional() }).optional(), + _links: object({ webui: string().optional() }).optional(), + }), + ), + size: number().optional(), +}); + +const searchConfluence = query( + { + description: "Search Confluence pages with a CQL query on one accessible site.", + input: object({ + cloudId: string({ minLength: 1 }), + cql: string({ minLength: 1 }), + limit: number().optional(), + }), + }, + async (context: Context, { cloudId, cql, limit }) => { + const params = new URLSearchParams({ + cql, + limit: String(boundedNumber(limit, 25, 100)), + expand: "space", + }); + const base = `${apiOrigin}/ex/confluence/${encodeURIComponent(cloudId)}/wiki/rest/api`; + const response = await apiFetch(context, `${base}/content/search?${params}`); + return decodeJson(response, ConfluenceSearch); + }, +); + +const ConfluencePage = object({ + id: string(), + title: string(), + type: string().optional(), + status: string().optional(), + space: object({ key: string().optional(), name: string().optional() }).optional(), + body: object({ + storage: object({ value: string(), representation: string().optional() }).optional(), + }).optional(), + _links: object({ webui: string().optional() }).optional(), +}); + +const readConfluencePage = query( + { + description: "Read a Confluence page and its storage-format body.", + input: object({ cloudId: string({ minLength: 1 }), pageId: string({ minLength: 1 }) }), + }, + async (context: Context, { cloudId, pageId }) => { + const base = `${apiOrigin}/ex/confluence/${encodeURIComponent(cloudId)}/wiki/rest/api`; + const params = new URLSearchParams({ expand: "body.storage,space,version" }); + const response = await apiFetch( + context, + `${base}/content/${encodeURIComponent(pageId)}?${params}`, + ); + return decodeJson(response, ConfluencePage); + }, +); + +const JiraIssue = object({ + id: string(), + key: string(), + self: string().optional(), + fields: object({ + summary: string(), + description: json().optional(), + status: object({ name: string() }).optional(), + issuetype: object({ name: string() }).optional(), + project: object({ key: string(), name: string().optional() }).optional(), + updated: string().optional(), + }), +}); + +const getJiraIssue = query( + { + description: "Read a Jira issue by key from one accessible site.", + input: object({ cloudId: string({ minLength: 1 }), issueKey: string({ minLength: 1 }) }), + }, + async (context: Context, { cloudId, issueKey }) => { + const base = `${apiOrigin}/ex/jira/${encodeURIComponent(cloudId)}/rest/api/3`; + const params = new URLSearchParams({ + fields: "summary,description,status,issuetype,project,updated", + }); + const response = await apiFetch( + context, + `${base}/issue/${encodeURIComponent(issueKey)}?${params}`, + ); + return decodeJson(response, JiraIssue); + }, +); + +const JiraSearch = object({ + issues: array(JiraIssue), + total: number().optional(), + isLast: boolean().optional(), + nextPageToken: string().optional(), +}); + +const searchJiraIssues = query( + { + description: "Search Jira issues with JQL on one accessible site.", + input: object({ + cloudId: string({ minLength: 1 }), + jql: string({ minLength: 1 }), + maxResults: number().optional(), + }), + }, + async (context: Context, { cloudId, jql, maxResults }) => { + const base = `${apiOrigin}/ex/jira/${encodeURIComponent(cloudId)}/rest/api/3`; + const response = await apiFetch(context, `${base}/search/jql`, { + method: "POST", + body: { + jql, + maxResults: boundedNumber(maxResults, 25, 100), + fields: ["summary", "status", "issuetype", "project", "updated"], + }, + }); + return decodeJson(response, JiraSearch); + }, +); + +export default defineApp(requirements, { + tools: router({ + listSites, + searchConfluence, + readConfluencePage, + getJiraIssue, + searchJiraIssues, + }), +}); diff --git a/playground/demo-apps/atlassian/package.json b/playground/demo-apps/atlassian/package.json new file mode 100644 index 0000000000..6d7b55f7f4 --- /dev/null +++ b/playground/demo-apps/atlassian/package.json @@ -0,0 +1,15 @@ +{ + "name": "@executor.sh/atlassian", + "private": true, + "description": "Read accessible Atlassian sites, search and read Confluence pages, and search and read Jira issues.", + "type": "module", + "exports": { + ".": "./index.ts" + }, + "dependencies": { + "apps": "0.0.1-beta.103" + }, + "executor": { + "title": "Atlassian" + } +} From 529e6295e597a006f31b44236999fb75dbcfe655 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:55:20 +0200 Subject: [PATCH 2/3] feat: offer hosted Atlassian read-only OAuth client --- .changeset/atlassian-hosted-client.md | 5 ++ .../src/implementation/first-party-oauth.ts | 22 ++++++ e2e/support/managed-server.ts | 2 + e2e/test-plan.ts | 13 +++ e2e/tests/credential-placement.spec.ts | 79 +++++++++++++++++++ 5 files changed, 121 insertions(+) create mode 100644 .changeset/atlassian-hosted-client.md diff --git a/.changeset/atlassian-hosted-client.md b/.changeset/atlassian-hosted-client.md new file mode 100644 index 0000000000..1da1fc3167 --- /dev/null +++ b/.changeset/atlassian-hosted-client.md @@ -0,0 +1,5 @@ +--- +"@executor-js/hosted-server": patch +--- + +Offer an optional hosted Atlassian OAuth 2.0 (3LO) client for personal, read-only Jira and Confluence access. It uses JSON token requests, offline access, and credential placement restricted to api.atlassian.com. diff --git a/apps/hosted/server/src/implementation/first-party-oauth.ts b/apps/hosted/server/src/implementation/first-party-oauth.ts index c7aaeddbfa..a354f96fe4 100644 --- a/apps/hosted/server/src/implementation/first-party-oauth.ts +++ b/apps/hosted/server/src/implementation/first-party-oauth.ts @@ -103,6 +103,14 @@ const slackMcpUserScopes = [ "files:write", ]; +/** Read-only Jira and Confluence access, renewed through Atlassian's rotating refresh tokens. */ +const atlassianScopes = [ + "offline_access", + "read:jira-work", + "read:confluence-content.all", + "search:confluence", +]; + /** * The clients an operator can turn on by setting their credentials. * @@ -158,6 +166,20 @@ export const firstPartyOAuthClientDefinitions: readonly FirstPartyOAuthClientDef allowedScopes: googleAllowedScopes, placement: { hosts: ["*.googleapis.com"] }, }, + { + id: "atlassian", + label: "Atlassian", + server: { + authorizationUrl: "https://auth.atlassian.com/authorize", + tokenUrl: "https://auth.atlassian.com/oauth/token", + authorizationParams: { audience: "api.atlassian.com", prompt: "consent" }, + tokenRequestFormat: "json", + }, + tokenEndpointAuthMethod: "client_secret_post", + defaultScopes: atlassianScopes, + allowedScopes: atlassianScopes, + placement: { hosts: ["api.atlassian.com"] }, + }, { id: "slack", label: "Executor for Slack", diff --git a/e2e/support/managed-server.ts b/e2e/support/managed-server.ts index e273481163..e281eaa812 100644 --- a/e2e/support/managed-server.ts +++ b/e2e/support/managed-server.ts @@ -69,6 +69,8 @@ export const OperatorSettings = Schema.Struct({ EXECUTOR_OAUTH_SYNTHETIC_MAIL_CLIENT_SECRET: Schema.optionalKey(Schema.String), EXECUTOR_OAUTH_GOOGLE_CLIENT_ID: Schema.optionalKey(Schema.String), EXECUTOR_OAUTH_GOOGLE_CLIENT_SECRET: Schema.optionalKey(Schema.String), + EXECUTOR_OAUTH_ATLASSIAN_CLIENT_ID: Schema.optionalKey(Schema.String), + EXECUTOR_OAUTH_ATLASSIAN_CLIENT_SECRET: Schema.optionalKey(Schema.String), EXECUTOR_OAUTH_SLACK_CLIENT_ID: Schema.optionalKey(Schema.String), EXECUTOR_OAUTH_SLACK_CLIENT_SECRET: Schema.optionalKey(Schema.String), }); diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index 7d50ef03f8..94adf0b6dd 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -2959,6 +2959,19 @@ export const scenarios = plan({ local: na("Local products have no operator OAuth clients."), }, }, + credentialManagedAtlassian: { + fixtures: "actors", + file: "credential-placement.spec.ts", + title: + "the hosted Atlassian client offers personal read scopes only after both credentials are configured", + targets: { + "self-host": scheduled, + cloud: na( + "Restarts a runner-owned product; Cloud composes the same reviewed client definitions.", + ), + local: na("Local products have no operator OAuth clients."), + }, + }, credentialManagedPlaced: { fixtures: "actors", file: "credential-placement.spec.ts", diff --git a/e2e/tests/credential-placement.spec.ts b/e2e/tests/credential-placement.spec.ts index 009facb479..dfd233d02e 100644 --- a/e2e/tests/credential-placement.spec.ts +++ b/e2e/tests/credential-placement.spec.ts @@ -1086,6 +1086,85 @@ layer(HostedLive, { excludeTestServices: true })("Credential placement", (it) => ), ); + it.effect(scenarios.credentialManagedAtlassian.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const { api, actors, prefix, deploy, connection } = yield* harness; + const endpoints = { + authorizationUrl: "https://auth.atlassian.com/authorize", + tokenUrl: "https://auth.atlassian.com/oauth/token", + authorizationParams: { audience: "api.atlassian.com", prompt: "consent" }, + tokenRequestFormat: "json", + }; + const scopes = [ + "offline_access", + "read:jira-work", + "read:confluence-content.all", + "search:confluence", + ]; + const signIn = (key: string, requested = scopes, server = endpoints) => + Effect.gen(function* () { + const name = `Atlassian ${key} ${randomUUID().slice(0, 8)}`; + const { path, app } = yield* deploy( + name, + endpointsApp(name, ["api.atlassian.com"], server, requested), + ); + const id = yield* connection(path, "mail"); + const started = yield* api.request( + actors.owner, + "POST", + `${prefix}/connections/${id}/oauth/start`, + { + method: "oauth", + label: "Atlassian", + }, + ); + const provider = app.requirements.accounts.mail!.provider; + const setup = yield* body( + Setup, + yield* api.request( + actors.owner, + "GET", + `${prefix}/providers/${provider}/oauth/oauth/setup`, + ), + ); + return { started, setup }; + }); + const cleared = { + EXECUTOR_OAUTH_ATLASSIAN_CLIENT_ID: "", + EXECUTOR_OAUTH_ATLASSIAN_CLIENT_SECRET: "", + }; + yield* Effect.addFinalizer(() => restartWith(cleared).pipe(Effect.orDie)); + yield* restartWith(cleared); + expect((yield* signIn("unset")).setup.firstParty).toBeUndefined(); + yield* restartWith({ + EXECUTOR_OAUTH_ATLASSIAN_CLIENT_ID: "synthetic-atlassian-client", + EXECUTOR_OAUTH_ATLASSIAN_CLIENT_SECRET: "synthetic-atlassian-secret", + }); + const allowed = yield* signIn("read"); + expect(allowed.setup).toMatchObject({ firstParty: { label: "Atlassian" }, scopes }); + expect(allowed.started.status, JSON.stringify(allowed.started.body)).toBe(200); + const sent = new URL((yield* body(Redirect, allowed.started)).authorizationUrl); + expect(`${sent.origin}${sent.pathname}`).toBe(endpoints.authorizationUrl); + expect(sent.searchParams.get("client_id")).toBe("synthetic-atlassian-client"); + expect(new Set(sent.searchParams.get("scope")?.split(" "))).toEqual(new Set(scopes)); + expect(sent.searchParams.get("audience")).toBe("api.atlassian.com"); + expect(sent.searchParams.get("prompt")).toBe("consent"); + const wider = yield* signIn("write", [...scopes, "write:jira-work"]); + expect(wider.setup.firstParty).toBeUndefined(); + expect(wider.started.body).toMatchObject({ _tag: "OAuthClientUnavailable" }); + const otherServer = yield* signIn("other-server", scopes, { + ...endpoints, + authorizationUrl: "https://example.invalid/authorize", + tokenUrl: "https://example.invalid/oauth/token", + }); + expect(otherServer.setup.firstParty).toBeUndefined(); + expect(otherServer.started.body).toMatchObject({ _tag: "OAuthClientUnavailable" }); + }), + ), + ); + /** An app on the operator client's issuer, and an account connected through its client. */ const managedAccount = (rawToken: boolean, configured = operator) => Effect.gen(function* () { From 2fa90ad2c6bc7578acc36a1cb837b311caa58a50 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Sat, 24 Oct 2026 04:57:11 +0200 Subject: [PATCH 3/3] chore: offer Atlassian app source through destination issue --- playground/demo-apps/atlassian/index.ts | 229 -------------------- playground/demo-apps/atlassian/package.json | 15 -- 2 files changed, 244 deletions(-) delete mode 100644 playground/demo-apps/atlassian/index.ts delete mode 100644 playground/demo-apps/atlassian/package.json diff --git a/playground/demo-apps/atlassian/index.ts b/playground/demo-apps/atlassian/index.ts deleted file mode 100644 index 20b9f48286..0000000000 --- a/playground/demo-apps/atlassian/index.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { - array, - bearer, - boolean, - decodeJson, - defineApp, - defineProvider, - json, - number, - object, - oauth2, - query, - router, - string, - type QueryContext, -} from "apps"; - -const apiOrigin = "https://api.atlassian.com"; -const accessibleResourcesUrl = `${apiOrigin}/oauth/token/accessible-resources`; -const scopes = [ - "offline_access", - "read:jira-work", - "read:confluence-content.all", - "search:confluence", -]; - -const atlassian = defineProvider({ - name: "Atlassian", - hosts: ["api.atlassian.com"], - auth: { - oauth: oauth2({ - authorizationUrl: "https://auth.atlassian.com/authorize", - tokenUrl: "https://auth.atlassian.com/oauth/token", - tokenEndpointAuthMethod: "client_secret_post", - tokenRequestFormat: "json", - authorizationParams: { audience: "api.atlassian.com", prompt: "consent" }, - scopes, - request: ({ access_token }) => bearer(access_token), - }), - }, - async health({ account, fetch, signal }) { - const response = await fetch(accessibleResourcesUrl, { - signal, - headers: { ...account.headers(), accept: "application/json" }, - }); - const sites = await decodeJson(response, array(object({ id: string(), name: string() }))); - if (sites.length === 0) throw new Error("No Atlassian sites are accessible to this account."); - }, -}); - -const requirements = { user: { accounts: { atlassian } } }; -type Context = QueryContext; - -const apiFetch = ( - context: Context, - url: string, - options: { readonly method?: "GET" | "POST"; readonly body?: unknown } = {}, -) => { - const body = options.body === undefined ? undefined : JSON.stringify(options.body); - return context.fetch(url, { - ...(options.method === undefined ? {} : { method: options.method }), - ...(body === undefined ? {} : { body }), - signal: context.signal, - headers: { - ...context.user.accounts.atlassian.headers(), - accept: "application/json", - ...(body === undefined ? {} : { "content-type": "application/json" }), - }, - }); -}; - -const boundedNumber = (value: number | undefined, fallback: number, maximum: number) => - Math.max(1, Math.min(maximum, Math.trunc(value ?? fallback))); - -const Sites = array( - object({ - id: string(), - name: string(), - url: string(), - scopes: array(string()).optional(), - }), -); - -const listSites = query( - { - description: "List Atlassian Cloud sites accessible to the connected account.", - input: object({}), - }, - async (context: Context) => { - const response = await apiFetch(context, accessibleResourcesUrl); - return decodeJson(response, Sites); - }, -); - -const ConfluenceSearch = object({ - results: array( - object({ - id: string(), - title: string(), - type: string().optional(), - status: string().optional(), - excerpt: string().optional(), - space: object({ key: string().optional(), name: string().optional() }).optional(), - _links: object({ webui: string().optional() }).optional(), - }), - ), - size: number().optional(), -}); - -const searchConfluence = query( - { - description: "Search Confluence pages with a CQL query on one accessible site.", - input: object({ - cloudId: string({ minLength: 1 }), - cql: string({ minLength: 1 }), - limit: number().optional(), - }), - }, - async (context: Context, { cloudId, cql, limit }) => { - const params = new URLSearchParams({ - cql, - limit: String(boundedNumber(limit, 25, 100)), - expand: "space", - }); - const base = `${apiOrigin}/ex/confluence/${encodeURIComponent(cloudId)}/wiki/rest/api`; - const response = await apiFetch(context, `${base}/content/search?${params}`); - return decodeJson(response, ConfluenceSearch); - }, -); - -const ConfluencePage = object({ - id: string(), - title: string(), - type: string().optional(), - status: string().optional(), - space: object({ key: string().optional(), name: string().optional() }).optional(), - body: object({ - storage: object({ value: string(), representation: string().optional() }).optional(), - }).optional(), - _links: object({ webui: string().optional() }).optional(), -}); - -const readConfluencePage = query( - { - description: "Read a Confluence page and its storage-format body.", - input: object({ cloudId: string({ minLength: 1 }), pageId: string({ minLength: 1 }) }), - }, - async (context: Context, { cloudId, pageId }) => { - const base = `${apiOrigin}/ex/confluence/${encodeURIComponent(cloudId)}/wiki/rest/api`; - const params = new URLSearchParams({ expand: "body.storage,space,version" }); - const response = await apiFetch( - context, - `${base}/content/${encodeURIComponent(pageId)}?${params}`, - ); - return decodeJson(response, ConfluencePage); - }, -); - -const JiraIssue = object({ - id: string(), - key: string(), - self: string().optional(), - fields: object({ - summary: string(), - description: json().optional(), - status: object({ name: string() }).optional(), - issuetype: object({ name: string() }).optional(), - project: object({ key: string(), name: string().optional() }).optional(), - updated: string().optional(), - }), -}); - -const getJiraIssue = query( - { - description: "Read a Jira issue by key from one accessible site.", - input: object({ cloudId: string({ minLength: 1 }), issueKey: string({ minLength: 1 }) }), - }, - async (context: Context, { cloudId, issueKey }) => { - const base = `${apiOrigin}/ex/jira/${encodeURIComponent(cloudId)}/rest/api/3`; - const params = new URLSearchParams({ - fields: "summary,description,status,issuetype,project,updated", - }); - const response = await apiFetch( - context, - `${base}/issue/${encodeURIComponent(issueKey)}?${params}`, - ); - return decodeJson(response, JiraIssue); - }, -); - -const JiraSearch = object({ - issues: array(JiraIssue), - total: number().optional(), - isLast: boolean().optional(), - nextPageToken: string().optional(), -}); - -const searchJiraIssues = query( - { - description: "Search Jira issues with JQL on one accessible site.", - input: object({ - cloudId: string({ minLength: 1 }), - jql: string({ minLength: 1 }), - maxResults: number().optional(), - }), - }, - async (context: Context, { cloudId, jql, maxResults }) => { - const base = `${apiOrigin}/ex/jira/${encodeURIComponent(cloudId)}/rest/api/3`; - const response = await apiFetch(context, `${base}/search/jql`, { - method: "POST", - body: { - jql, - maxResults: boundedNumber(maxResults, 25, 100), - fields: ["summary", "status", "issuetype", "project", "updated"], - }, - }); - return decodeJson(response, JiraSearch); - }, -); - -export default defineApp(requirements, { - tools: router({ - listSites, - searchConfluence, - readConfluencePage, - getJiraIssue, - searchJiraIssues, - }), -}); diff --git a/playground/demo-apps/atlassian/package.json b/playground/demo-apps/atlassian/package.json deleted file mode 100644 index 6d7b55f7f4..0000000000 --- a/playground/demo-apps/atlassian/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "@executor.sh/atlassian", - "private": true, - "description": "Read accessible Atlassian sites, search and read Confluence pages, and search and read Jira issues.", - "type": "module", - "exports": { - ".": "./index.ts" - }, - "dependencies": { - "apps": "0.0.1-beta.103" - }, - "executor": { - "title": "Atlassian" - } -}