@shipfox/api-integration-linear 2.0.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.
Files changed (151) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/.turbo/turbo-type$colon$emit.log +1 -0
  3. package/.turbo/turbo-type.log +1 -0
  4. package/CHANGELOG.md +89 -0
  5. package/LICENSE +21 -0
  6. package/README.md +88 -0
  7. package/dist/api/client.d.ts +29 -0
  8. package/dist/api/client.d.ts.map +1 -0
  9. package/dist/api/client.js +189 -0
  10. package/dist/api/client.js.map +1 -0
  11. package/dist/config.d.ts +8 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +22 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/core/agent-tools-provider.d.ts +32 -0
  16. package/dist/core/agent-tools-provider.d.ts.map +1 -0
  17. package/dist/core/agent-tools-provider.js +63 -0
  18. package/dist/core/agent-tools-provider.js.map +1 -0
  19. package/dist/core/agent-tools.d.ts +10 -0
  20. package/dist/core/agent-tools.d.ts.map +1 -0
  21. package/dist/core/agent-tools.js +920 -0
  22. package/dist/core/agent-tools.js.map +1 -0
  23. package/dist/core/disconnect.d.ts +18 -0
  24. package/dist/core/disconnect.d.ts.map +1 -0
  25. package/dist/core/disconnect.js +23 -0
  26. package/dist/core/disconnect.js.map +1 -0
  27. package/dist/core/errors.d.ts +35 -0
  28. package/dist/core/errors.d.ts.map +1 -0
  29. package/dist/core/errors.js +59 -0
  30. package/dist/core/errors.js.map +1 -0
  31. package/dist/core/install.d.ts +47 -0
  32. package/dist/core/install.d.ts.map +1 -0
  33. package/dist/core/install.js +102 -0
  34. package/dist/core/install.js.map +1 -0
  35. package/dist/core/scopes.d.ts +4 -0
  36. package/dist/core/scopes.d.ts.map +1 -0
  37. package/dist/core/scopes.js +17 -0
  38. package/dist/core/scopes.js.map +1 -0
  39. package/dist/core/state.d.ts +12 -0
  40. package/dist/core/state.d.ts.map +1 -0
  41. package/dist/core/state.js +54 -0
  42. package/dist/core/state.js.map +1 -0
  43. package/dist/core/tokens.d.ts +39 -0
  44. package/dist/core/tokens.d.ts.map +1 -0
  45. package/dist/core/tokens.js +117 -0
  46. package/dist/core/tokens.js.map +1 -0
  47. package/dist/core/webhook.d.ts +16 -0
  48. package/dist/core/webhook.d.ts.map +1 -0
  49. package/dist/core/webhook.js +105 -0
  50. package/dist/core/webhook.js.map +1 -0
  51. package/dist/db/db.d.ts +369 -0
  52. package/dist/db/db.d.ts.map +1 -0
  53. package/dist/db/db.js +18 -0
  54. package/dist/db/db.js.map +1 -0
  55. package/dist/db/installations.d.ts +53 -0
  56. package/dist/db/installations.d.ts.map +1 -0
  57. package/dist/db/installations.js +125 -0
  58. package/dist/db/installations.js.map +1 -0
  59. package/dist/db/migrations.d.ts +2 -0
  60. package/dist/db/migrations.d.ts.map +1 -0
  61. package/dist/db/migrations.js +5 -0
  62. package/dist/db/migrations.js.map +1 -0
  63. package/dist/db/schema/common.d.ts +2 -0
  64. package/dist/db/schema/common.d.ts.map +1 -0
  65. package/dist/db/schema/common.js +4 -0
  66. package/dist/db/schema/common.js.map +1 -0
  67. package/dist/db/schema/installations.d.ts +186 -0
  68. package/dist/db/schema/installations.d.ts.map +1 -0
  69. package/dist/db/schema/installations.js +40 -0
  70. package/dist/db/schema/installations.js.map +1 -0
  71. package/dist/index.d.ts +53 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +56 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/presentation/dto/integrations.d.ts +17 -0
  76. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  77. package/dist/presentation/dto/integrations.js +6 -0
  78. package/dist/presentation/dto/integrations.js.map +1 -0
  79. package/dist/presentation/e2eRoutes/create-connection.d.ts +16 -0
  80. package/dist/presentation/e2eRoutes/create-connection.d.ts.map +1 -0
  81. package/dist/presentation/e2eRoutes/create-connection.js +64 -0
  82. package/dist/presentation/e2eRoutes/create-connection.js.map +1 -0
  83. package/dist/presentation/e2eRoutes/index.d.ts +5 -0
  84. package/dist/presentation/e2eRoutes/index.d.ts.map +1 -0
  85. package/dist/presentation/e2eRoutes/index.js +11 -0
  86. package/dist/presentation/e2eRoutes/index.js.map +1 -0
  87. package/dist/presentation/routes/errors.d.ts +2 -0
  88. package/dist/presentation/routes/errors.d.ts.map +1 -0
  89. package/dist/presentation/routes/errors.js +65 -0
  90. package/dist/presentation/routes/errors.js.map +1 -0
  91. package/dist/presentation/routes/install.d.ts +19 -0
  92. package/dist/presentation/routes/install.d.ts.map +1 -0
  93. package/dist/presentation/routes/install.js +100 -0
  94. package/dist/presentation/routes/install.js.map +1 -0
  95. package/dist/presentation/routes/webhooks.d.ts +11 -0
  96. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  97. package/dist/presentation/routes/webhooks.js +136 -0
  98. package/dist/presentation/routes/webhooks.js.map +1 -0
  99. package/dist/tsconfig.test.tsbuildinfo +1 -0
  100. package/drizzle/0000_initial.sql +15 -0
  101. package/drizzle/meta/0000_snapshot.json +126 -0
  102. package/drizzle/meta/_journal.json +13 -0
  103. package/drizzle.config.ts +7 -0
  104. package/package.json +72 -0
  105. package/src/api/client.test.ts +366 -0
  106. package/src/api/client.ts +281 -0
  107. package/src/config.test.ts +34 -0
  108. package/src/config.ts +22 -0
  109. package/src/connection-external-url.test.ts +64 -0
  110. package/src/core/agent-tools-provider.test.ts +245 -0
  111. package/src/core/agent-tools-provider.ts +106 -0
  112. package/src/core/agent-tools.ts +918 -0
  113. package/src/core/disconnect.test.ts +45 -0
  114. package/src/core/disconnect.ts +27 -0
  115. package/src/core/errors.ts +71 -0
  116. package/src/core/install.ts +162 -0
  117. package/src/core/scopes.ts +18 -0
  118. package/src/core/state.test.ts +51 -0
  119. package/src/core/state.ts +85 -0
  120. package/src/core/tokens.test.ts +281 -0
  121. package/src/core/tokens.ts +219 -0
  122. package/src/core/webhook.test.ts +318 -0
  123. package/src/core/webhook.ts +146 -0
  124. package/src/db/db.ts +18 -0
  125. package/src/db/installations.test.ts +182 -0
  126. package/src/db/installations.ts +215 -0
  127. package/src/db/migrations.ts +4 -0
  128. package/src/db/schema/common.ts +3 -0
  129. package/src/db/schema/installations.ts +42 -0
  130. package/src/index.test.ts +20 -0
  131. package/src/index.ts +153 -0
  132. package/src/presentation/dto/integrations.ts +9 -0
  133. package/src/presentation/e2eRoutes/create-connection.ts +85 -0
  134. package/src/presentation/e2eRoutes/index.test.ts +193 -0
  135. package/src/presentation/e2eRoutes/index.ts +14 -0
  136. package/src/presentation/routes/errors.ts +60 -0
  137. package/src/presentation/routes/install.test.ts +460 -0
  138. package/src/presentation/routes/install.ts +132 -0
  139. package/src/presentation/routes/webhooks.test.ts +407 -0
  140. package/src/presentation/routes/webhooks.ts +154 -0
  141. package/test/api-secrets.d.ts +28 -0
  142. package/test/env.ts +13 -0
  143. package/test/factories/index.ts +1 -0
  144. package/test/globalSetup.ts +23 -0
  145. package/test/index.ts +1 -0
  146. package/test/setup.ts +17 -0
  147. package/tsconfig.build.json +9 -0
  148. package/tsconfig.build.tsbuildinfo +1 -0
  149. package/tsconfig.json +3 -0
  150. package/tsconfig.test.json +9 -0
  151. package/vitest.config.ts +12 -0
@@ -0,0 +1,15 @@
1
+ CREATE TABLE "integrations_linear_installations" (
2
+ "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
3
+ "connection_id" uuid NOT NULL,
4
+ "organization_id" text NOT NULL,
5
+ "organization_url_key" text NOT NULL,
6
+ "app_user_id" text NOT NULL,
7
+ "scopes" jsonb NOT NULL,
8
+ "token_expires_at" timestamp with time zone,
9
+ "status" text NOT NULL,
10
+ "created_at" timestamp with time zone DEFAULT now() NOT NULL,
11
+ "updated_at" timestamp with time zone DEFAULT now() NOT NULL
12
+ );
13
+ --> statement-breakpoint
14
+ CREATE UNIQUE INDEX "integrations_linear_installations_connection_unique" ON "integrations_linear_installations" USING btree ("connection_id");--> statement-breakpoint
15
+ CREATE UNIQUE INDEX "integrations_linear_installations_organization_unique" ON "integrations_linear_installations" USING btree ("organization_id");
@@ -0,0 +1,126 @@
1
+ {
2
+ "id": "f694c6ed-8526-4b68-bbe2-1893057dba4e",
3
+ "prevId": "00000000-0000-0000-0000-000000000000",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.integrations_linear_installations": {
8
+ "name": "integrations_linear_installations",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "uuid",
14
+ "primaryKey": true,
15
+ "notNull": true,
16
+ "default": "uuidv7()"
17
+ },
18
+ "connection_id": {
19
+ "name": "connection_id",
20
+ "type": "uuid",
21
+ "primaryKey": false,
22
+ "notNull": true
23
+ },
24
+ "organization_id": {
25
+ "name": "organization_id",
26
+ "type": "text",
27
+ "primaryKey": false,
28
+ "notNull": true
29
+ },
30
+ "organization_url_key": {
31
+ "name": "organization_url_key",
32
+ "type": "text",
33
+ "primaryKey": false,
34
+ "notNull": true
35
+ },
36
+ "app_user_id": {
37
+ "name": "app_user_id",
38
+ "type": "text",
39
+ "primaryKey": false,
40
+ "notNull": true
41
+ },
42
+ "scopes": {
43
+ "name": "scopes",
44
+ "type": "jsonb",
45
+ "primaryKey": false,
46
+ "notNull": true
47
+ },
48
+ "token_expires_at": {
49
+ "name": "token_expires_at",
50
+ "type": "timestamp with time zone",
51
+ "primaryKey": false,
52
+ "notNull": false
53
+ },
54
+ "status": {
55
+ "name": "status",
56
+ "type": "text",
57
+ "primaryKey": false,
58
+ "notNull": true
59
+ },
60
+ "created_at": {
61
+ "name": "created_at",
62
+ "type": "timestamp with time zone",
63
+ "primaryKey": false,
64
+ "notNull": true,
65
+ "default": "now()"
66
+ },
67
+ "updated_at": {
68
+ "name": "updated_at",
69
+ "type": "timestamp with time zone",
70
+ "primaryKey": false,
71
+ "notNull": true,
72
+ "default": "now()"
73
+ }
74
+ },
75
+ "indexes": {
76
+ "integrations_linear_installations_connection_unique": {
77
+ "name": "integrations_linear_installations_connection_unique",
78
+ "columns": [
79
+ {
80
+ "expression": "connection_id",
81
+ "isExpression": false,
82
+ "asc": true,
83
+ "nulls": "last"
84
+ }
85
+ ],
86
+ "isUnique": true,
87
+ "concurrently": false,
88
+ "method": "btree",
89
+ "with": {}
90
+ },
91
+ "integrations_linear_installations_organization_unique": {
92
+ "name": "integrations_linear_installations_organization_unique",
93
+ "columns": [
94
+ {
95
+ "expression": "organization_id",
96
+ "isExpression": false,
97
+ "asc": true,
98
+ "nulls": "last"
99
+ }
100
+ ],
101
+ "isUnique": true,
102
+ "concurrently": false,
103
+ "method": "btree",
104
+ "with": {}
105
+ }
106
+ },
107
+ "foreignKeys": {},
108
+ "compositePrimaryKeys": {},
109
+ "uniqueConstraints": {},
110
+ "policies": {},
111
+ "checkConstraints": {},
112
+ "isRLSEnabled": false
113
+ }
114
+ },
115
+ "enums": {},
116
+ "schemas": {},
117
+ "sequences": {},
118
+ "roles": {},
119
+ "policies": {},
120
+ "views": {},
121
+ "_meta": {
122
+ "columns": {},
123
+ "schemas": {},
124
+ "tables": {}
125
+ }
126
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1783419172861,
9
+ "tag": "0000_initial",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,7 @@
1
+ import {defineConfig} from 'drizzle-kit';
2
+
3
+ export default defineConfig({
4
+ schema: './src/db/schema/*.ts',
5
+ out: './drizzle',
6
+ dialect: 'postgresql',
7
+ });
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@shipfox/api-integration-linear",
3
+ "license": "MIT",
4
+ "version": "2.0.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
8
+ "directory": "libs/api/integration/linear"
9
+ },
10
+ "private": false,
11
+ "type": "module",
12
+ "main": "dist/index.js",
13
+ "types": "dist/index.d.ts",
14
+ "imports": {
15
+ "#test/*": "./test/*",
16
+ "#*": {
17
+ "workspace-source": "./src/*",
18
+ "development": "./src/*",
19
+ "default": "./dist/*"
20
+ }
21
+ },
22
+ "exports": {
23
+ ".": {
24
+ "development": {
25
+ "types": "./src/index.ts",
26
+ "default": "./src/index.ts"
27
+ },
28
+ "default": {
29
+ "types": "./dist/index.d.ts",
30
+ "default": "./dist/index.js"
31
+ }
32
+ }
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/sdk": "1.29.0",
36
+ "drizzle-orm": "^0.45.2",
37
+ "ky": "^2.0.0",
38
+ "zod": "^4.4.3",
39
+ "@shipfox/api-auth-context": "2.0.0",
40
+ "@shipfox/config": "1.2.1",
41
+ "@shipfox/api-integration-core-dto": "2.0.0",
42
+ "@shipfox/api-integration-linear-dto": "2.0.0",
43
+ "@shipfox/api-workspaces": "2.0.0",
44
+ "@shipfox/node-drizzle": "0.2.1",
45
+ "@shipfox/node-fastify": "0.2.1",
46
+ "@shipfox/node-postgres": "0.4.1",
47
+ "@shipfox/node-opentelemetry": "0.5.0"
48
+ },
49
+ "peerDependencies": {
50
+ "@shipfox/api-secrets": "2.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "@types/pg": "^8.15.5",
54
+ "drizzle-kit": "^0.31.10",
55
+ "fastify": "^5.3.3",
56
+ "fishery": "^2.2.2",
57
+ "@shipfox/biome": "1.8.1",
58
+ "@shipfox/swc": "1.2.5",
59
+ "@shipfox/ts-config": "1.3.8",
60
+ "@shipfox/typescript": "1.1.6",
61
+ "@shipfox/vitest": "1.2.2"
62
+ },
63
+ "scripts": {
64
+ "build": "shipfox-swc",
65
+ "check": "shipfox-biome-check",
66
+ "check:fix": "shipfox-biome-check --write",
67
+ "test": "shipfox-vitest-run",
68
+ "test:watch": "shipfox-vitest-watch",
69
+ "type": "shipfox-tsc-check",
70
+ "type:emit": "shipfox-tsc-emit"
71
+ }
72
+ }
@@ -0,0 +1,366 @@
1
+ import {HTTPError, TimeoutError} from 'ky';
2
+ import {LinearIntegrationProviderError} from '#core/errors.js';
3
+ import {createLinearApiClient} from './client.js';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ post: vi.fn(),
7
+ warn: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('@shipfox/node-opentelemetry', () => ({
11
+ logger: () => ({
12
+ warn: mocks.warn,
13
+ }),
14
+ }));
15
+
16
+ vi.mock('ky', () => {
17
+ class HTTPError extends Error {
18
+ constructor(public response: {status: number; statusText?: string; headers: Headers}) {
19
+ super('http');
20
+ this.name = 'HTTPError';
21
+ }
22
+ }
23
+ class TimeoutError extends Error {
24
+ constructor() {
25
+ super('timeout');
26
+ this.name = 'TimeoutError';
27
+ }
28
+ }
29
+ return {default: {post: mocks.post}, HTTPError, TimeoutError};
30
+ });
31
+
32
+ function resolves(data: unknown) {
33
+ return {json: () => Promise.resolve(data)};
34
+ }
35
+
36
+ function rejects(error: unknown) {
37
+ return {json: () => Promise.reject(error)};
38
+ }
39
+
40
+ function httpError(
41
+ status: number,
42
+ headers: Record<string, string> = {},
43
+ statusText = 'Rejected',
44
+ ): HTTPError {
45
+ return new HTTPError(
46
+ {status, statusText, headers: new Headers(headers)} as never,
47
+ {} as never,
48
+ {} as never,
49
+ );
50
+ }
51
+
52
+ describe('createLinearApiClient.exchangeAuthorizationCode', () => {
53
+ beforeEach(() => {
54
+ vi.useRealTimers();
55
+ mocks.post.mockReset();
56
+ mocks.warn.mockReset();
57
+ });
58
+
59
+ it('posts a form-encoded OAuth exchange and parses tokens, expiry, and scopes', async () => {
60
+ vi.useFakeTimers({now: new Date('2026-07-07T12:00:00.000Z')});
61
+ mocks.post.mockReturnValue(
62
+ resolves({
63
+ access_token: 'access-token',
64
+ refresh_token: 'refresh-token',
65
+ expires_in: 3600,
66
+ scope: 'read write app:assignable',
67
+ }),
68
+ );
69
+ const client = createLinearApiClient();
70
+
71
+ const result = await client.exchangeAuthorizationCode({code: 'oauth-code'});
72
+
73
+ const firstCall = mocks.post.mock.calls[0];
74
+ expect(firstCall).toBeDefined();
75
+ const [, options] = firstCall as [string, {body: URLSearchParams}];
76
+ const body = options.body as URLSearchParams;
77
+ expect(body.get('grant_type')).toBe('authorization_code');
78
+ expect(body.get('client_id')).toBe('test-client-id');
79
+ expect(body.get('client_secret')).toBe('test-client-secret');
80
+ expect(body.get('code')).toBe('oauth-code');
81
+ expect(body.get('redirect_uri')).toBe(
82
+ 'https://shipfox.example.com/integrations/linear/callback',
83
+ );
84
+ expect(result).toEqual({
85
+ accessToken: 'access-token',
86
+ refreshToken: 'refresh-token',
87
+ expiresAt: new Date('2026-07-07T13:00:00.000Z'),
88
+ scopes: ['read', 'write', 'app:assignable'],
89
+ });
90
+ });
91
+
92
+ it('parses a long-lived token response without refresh or expiry', async () => {
93
+ mocks.post.mockReturnValue(resolves({access_token: 'access-token', scope: ['read']}));
94
+ const client = createLinearApiClient();
95
+
96
+ const result = await client.exchangeAuthorizationCode({code: 'oauth-code'});
97
+
98
+ expect(result).toEqual({
99
+ accessToken: 'access-token',
100
+ refreshToken: undefined,
101
+ expiresAt: undefined,
102
+ scopes: ['read'],
103
+ });
104
+ });
105
+
106
+ it('maps a 429 to rate-limited with retry-after seconds', async () => {
107
+ mocks.post.mockReturnValue(rejects(httpError(429, {'retry-after': '30'})));
108
+ const client = createLinearApiClient();
109
+
110
+ const result = client.exchangeAuthorizationCode({code: 'oauth-code'});
111
+
112
+ await expect(result).rejects.toMatchObject({reason: 'rate-limited', retryAfterSeconds: 30});
113
+ await expect(result).rejects.toBeInstanceOf(LinearIntegrationProviderError);
114
+ });
115
+
116
+ it('maps a 5xx to provider-unavailable', async () => {
117
+ mocks.post.mockReturnValue(rejects(httpError(503)));
118
+ const client = createLinearApiClient();
119
+
120
+ const result = client.exchangeAuthorizationCode({code: 'oauth-code'});
121
+
122
+ await expect(result).rejects.toMatchObject({reason: 'provider-unavailable'});
123
+ });
124
+
125
+ it('maps a 4xx to access-denied', async () => {
126
+ mocks.post.mockReturnValue(rejects(httpError(403)));
127
+ const client = createLinearApiClient();
128
+
129
+ const result = client.exchangeAuthorizationCode({code: 'oauth-code'});
130
+
131
+ await expect(result).rejects.toMatchObject({reason: 'access-denied'});
132
+ });
133
+
134
+ it('maps a timeout to timeout', async () => {
135
+ mocks.post.mockReturnValue(rejects(new TimeoutError({} as never)));
136
+ const client = createLinearApiClient();
137
+
138
+ const result = client.exchangeAuthorizationCode({code: 'oauth-code'});
139
+
140
+ await expect(result).rejects.toMatchObject({reason: 'timeout'});
141
+ });
142
+
143
+ it('maps a response without an access token to malformed-provider-response', async () => {
144
+ mocks.post.mockReturnValue(resolves({refresh_token: 'refresh-token', scope: 'read'}));
145
+ const client = createLinearApiClient();
146
+
147
+ const result = client.exchangeAuthorizationCode({code: 'oauth-code'});
148
+
149
+ await expect(result).rejects.toMatchObject({reason: 'malformed-provider-response'});
150
+ });
151
+
152
+ it('never logs or throws OAuth secrets from rejected requests', async () => {
153
+ mocks.post.mockReturnValue(rejects(httpError(403)));
154
+ const client = createLinearApiClient();
155
+
156
+ const error = await client
157
+ .exchangeAuthorizationCode({code: 'super-secret-code'})
158
+ .catch((thrown: unknown) => thrown);
159
+
160
+ const serialized = [
161
+ (error as Error).message,
162
+ JSON.stringify(error),
163
+ String((error as {cause?: unknown}).cause),
164
+ JSON.stringify(mocks.warn.mock.calls),
165
+ ].join(' ');
166
+ expect(serialized).not.toContain('super-secret-code');
167
+ expect(serialized).not.toContain('test-client-secret');
168
+ expect(mocks.warn.mock.calls[0]).toEqual([
169
+ {operation: 'exchange-authorization-code', status: 403, statusText: 'Rejected'},
170
+ 'Linear API request rejected',
171
+ ]);
172
+ });
173
+ });
174
+
175
+ describe('createLinearApiClient.refreshAccessToken', () => {
176
+ beforeEach(() => {
177
+ vi.useRealTimers();
178
+ mocks.post.mockReset();
179
+ mocks.warn.mockReset();
180
+ });
181
+
182
+ it('posts a refresh-token grant and parses the new tokens', async () => {
183
+ vi.useFakeTimers({now: new Date('2026-07-07T12:00:00.000Z')});
184
+ mocks.post.mockReturnValue(
185
+ resolves({
186
+ access_token: 'new-access-token',
187
+ refresh_token: 'new-refresh-token',
188
+ expires_in: 1800,
189
+ scope: 'read,write',
190
+ }),
191
+ );
192
+ const client = createLinearApiClient();
193
+
194
+ const result = await client.refreshAccessToken({refreshToken: 'old-refresh-token'});
195
+
196
+ const firstCall = mocks.post.mock.calls[0];
197
+ expect(firstCall).toBeDefined();
198
+ const [, options] = firstCall as [string, {body: URLSearchParams}];
199
+ const body = options.body as URLSearchParams;
200
+ expect(body.get('grant_type')).toBe('refresh_token');
201
+ expect(body.get('refresh_token')).toBe('old-refresh-token');
202
+ expect(result).toEqual({
203
+ accessToken: 'new-access-token',
204
+ refreshToken: 'new-refresh-token',
205
+ expiresAt: new Date('2026-07-07T12:30:00.000Z'),
206
+ scopes: ['read', 'write'],
207
+ });
208
+ });
209
+
210
+ it('maps an invalid refresh token to access-denied', async () => {
211
+ mocks.post.mockReturnValue(rejects(httpError(400)));
212
+ const client = createLinearApiClient();
213
+
214
+ const result = client.refreshAccessToken({refreshToken: 'invalid-refresh-token'});
215
+
216
+ await expect(result).rejects.toMatchObject({reason: 'access-denied'});
217
+ });
218
+ });
219
+
220
+ describe('createLinearApiClient.revokeToken', () => {
221
+ beforeEach(() => {
222
+ mocks.post.mockReset();
223
+ mocks.warn.mockReset();
224
+ });
225
+
226
+ it('posts an OAuth token revocation request', async () => {
227
+ mocks.post.mockResolvedValue(undefined);
228
+ const client = createLinearApiClient();
229
+
230
+ await client.revokeToken({token: 'access-token', tokenTypeHint: 'access_token'});
231
+
232
+ const firstCall = mocks.post.mock.calls[0];
233
+ expect(firstCall).toBeDefined();
234
+ const [url, options] = firstCall as [string, {body: URLSearchParams}];
235
+ const body = options.body as URLSearchParams;
236
+ expect(url).toBe('https://api.linear.app/oauth/revoke');
237
+ expect(body.get('client_id')).toBe('test-client-id');
238
+ expect(body.get('client_secret')).toBe('test-client-secret');
239
+ expect(body.get('token')).toBe('access-token');
240
+ expect(body.get('token_type_hint')).toBe('access_token');
241
+ });
242
+
243
+ it('maps a rejected revocation without logging the token', async () => {
244
+ mocks.post.mockRejectedValue(httpError(400));
245
+ const client = createLinearApiClient();
246
+
247
+ const error = await client
248
+ .revokeToken({token: 'secret-token', tokenTypeHint: 'refresh_token'})
249
+ .catch((thrown: unknown) => thrown);
250
+
251
+ expect(error).toMatchObject({reason: 'access-denied'});
252
+ const serialized = [
253
+ (error as Error).message,
254
+ JSON.stringify(error),
255
+ JSON.stringify(mocks.warn.mock.calls),
256
+ ].join(' ');
257
+ expect(serialized).not.toContain('secret-token');
258
+ });
259
+ });
260
+
261
+ describe('createLinearApiClient.getIdentity', () => {
262
+ beforeEach(() => {
263
+ vi.useRealTimers();
264
+ mocks.post.mockReset();
265
+ mocks.warn.mockReset();
266
+ });
267
+
268
+ it('derives the app user and organization identity', async () => {
269
+ mocks.post.mockReturnValue(
270
+ resolves({
271
+ data: {
272
+ viewer: {id: 'app-user-id'},
273
+ organization: {id: 'org-id', name: 'Acme', urlKey: 'acme'},
274
+ },
275
+ }),
276
+ );
277
+ const client = createLinearApiClient();
278
+
279
+ const result = await client.getIdentity({accessToken: 'linear-token'});
280
+
281
+ const firstCall = mocks.post.mock.calls[0];
282
+ expect(firstCall).toBeDefined();
283
+ const [, options] = firstCall as [string, {headers: Record<string, string>}];
284
+ expect(options.headers).toEqual({authorization: 'Bearer linear-token'});
285
+ expect(result).toEqual({
286
+ appUserId: 'app-user-id',
287
+ organizationId: 'org-id',
288
+ organizationName: 'Acme',
289
+ organizationUrlKey: 'acme',
290
+ });
291
+ });
292
+
293
+ it('maps missing viewer or organization data to malformed-provider-response', async () => {
294
+ mocks.post.mockReturnValue(resolves({data: {viewer: {id: 'app-user-id'}}}));
295
+ const client = createLinearApiClient();
296
+
297
+ const result = client.getIdentity({accessToken: 'linear-token'});
298
+
299
+ await expect(result).rejects.toMatchObject({reason: 'malformed-provider-response'});
300
+ });
301
+
302
+ it('maps GraphQL auth errors to access-denied without leaking the provider message', async () => {
303
+ mocks.post.mockReturnValue(
304
+ resolves({
305
+ errors: [
306
+ {
307
+ message: 'invalid token linear-token',
308
+ extensions: {type: 'authentication'},
309
+ },
310
+ ],
311
+ }),
312
+ );
313
+ const client = createLinearApiClient();
314
+
315
+ const error = await client.getIdentity({accessToken: 'linear-token'}).catch((thrown) => thrown);
316
+
317
+ expect(error).toMatchObject({reason: 'access-denied'});
318
+ const serialized = [
319
+ (error as Error).message,
320
+ JSON.stringify(error),
321
+ JSON.stringify(mocks.warn.mock.calls),
322
+ ].join(' ');
323
+ expect(serialized).not.toContain('linear-token');
324
+ expect(serialized).not.toContain('invalid token');
325
+ });
326
+
327
+ it('maps GraphQL transport auth failures to access-denied', async () => {
328
+ mocks.post.mockReturnValue(rejects(httpError(401)));
329
+ const client = createLinearApiClient();
330
+
331
+ const result = client.getIdentity({accessToken: 'linear-token'});
332
+
333
+ await expect(result).rejects.toMatchObject({reason: 'access-denied'});
334
+ });
335
+
336
+ it('maps GraphQL non-auth transport 4xx failures to malformed-provider-response', async () => {
337
+ mocks.post.mockReturnValue(rejects(httpError(400)));
338
+ const client = createLinearApiClient();
339
+
340
+ const result = client.getIdentity({accessToken: 'linear-token'});
341
+
342
+ await expect(result).rejects.toMatchObject({reason: 'malformed-provider-response'});
343
+ });
344
+
345
+ it('maps non-auth GraphQL errors to malformed-provider-response', async () => {
346
+ mocks.post.mockReturnValue(
347
+ resolves({
348
+ errors: [{message: 'validation detail', extensions: {type: 'invalid'}}],
349
+ }),
350
+ );
351
+ const client = createLinearApiClient();
352
+
353
+ const result = client.getIdentity({accessToken: 'linear-token'});
354
+
355
+ await expect(result).rejects.toMatchObject({reason: 'malformed-provider-response'});
356
+ });
357
+
358
+ it('maps GraphQL responses without data to malformed-provider-response', async () => {
359
+ mocks.post.mockReturnValue(resolves({}));
360
+ const client = createLinearApiClient();
361
+
362
+ const result = client.getIdentity({accessToken: 'linear-token'});
363
+
364
+ await expect(result).rejects.toMatchObject({reason: 'malformed-provider-response'});
365
+ });
366
+ });