@stina/extension-api 1.3.1 → 1.7.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 (41) hide show
  1. package/dist/{chunk-ZB7GJUPS.js → chunk-S3YP4QPF.js} +1 -1
  2. package/dist/{chunk-ZB7GJUPS.js.map → chunk-S3YP4QPF.js.map} +1 -1
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +19 -4
  5. package/dist/index.d.ts +19 -4
  6. package/dist/index.js +1 -1
  7. package/dist/runtime.cjs +53 -6
  8. package/dist/runtime.cjs.map +1 -1
  9. package/dist/runtime.d.cts +2 -2
  10. package/dist/runtime.d.ts +2 -2
  11. package/dist/runtime.js +54 -7
  12. package/dist/runtime.js.map +1 -1
  13. package/dist/schemas/index.cjs +34 -2
  14. package/dist/schemas/index.cjs.map +1 -1
  15. package/dist/schemas/index.d.cts +91 -5
  16. package/dist/schemas/index.d.ts +91 -5
  17. package/dist/schemas/index.js +30 -2
  18. package/dist/schemas/index.js.map +1 -1
  19. package/dist/{types.tools-C0GqXQlu.d.cts → types.tools-DcFBsfRV.d.cts} +223 -17
  20. package/dist/{types.tools-C0GqXQlu.d.ts → types.tools-DcFBsfRV.d.ts} +223 -17
  21. package/package.json +1 -1
  22. package/schema/extension-manifest.schema.json +40 -0
  23. package/src/background.test.ts +33 -0
  24. package/src/background.ts +9 -0
  25. package/src/index.ts +8 -0
  26. package/src/messages.ts +5 -0
  27. package/src/runtime/accountsApi.ts +26 -0
  28. package/src/runtime/executionContext.test.ts +113 -0
  29. package/src/runtime/executionContext.ts +28 -2
  30. package/src/runtime/index.ts +1 -0
  31. package/src/runtime.ts +38 -3
  32. package/src/schemas/accounts.schema.test.ts +58 -0
  33. package/src/schemas/contributions.schema.ts +48 -0
  34. package/src/schemas/index.ts +6 -0
  35. package/src/schemas/permissions.schema.ts +11 -1
  36. package/src/types.context.ts +111 -0
  37. package/src/types.contributions.ts +47 -0
  38. package/src/types.permissions.ts +15 -0
  39. package/src/types.provider.ts +51 -1
  40. package/src/types.tools.ts +29 -15
  41. package/src/types.ts +9 -0
@@ -110,6 +110,8 @@
110
110
  "user.location.read",
111
111
  "chat.history.read",
112
112
  "chat.current.read",
113
+ "attachments.read",
114
+ "accounts.use",
113
115
  "chat.message.write",
114
116
  "provider.register",
115
117
  "tools.register",
@@ -852,6 +854,44 @@
852
854
  ],
853
855
  "additionalProperties": false,
854
856
  "description": "Storage collection declarations"
857
+ },
858
+ "accounts": {
859
+ "type": "array",
860
+ "items": {
861
+ "type": "object",
862
+ "properties": {
863
+ "provider": {
864
+ "type": "string",
865
+ "enum": [
866
+ "microsoft"
867
+ ],
868
+ "description": "External account provider"
869
+ },
870
+ "scopes": {
871
+ "type": "array",
872
+ "items": {
873
+ "type": "string",
874
+ "pattern": "^[A-Za-z]+(\\.[A-Za-z]+)+$",
875
+ "errorMessage": {
876
+ "pattern": "Must be a Microsoft Graph permission name, such as \"Mail.Read\""
877
+ }
878
+ },
879
+ "minItems": 1,
880
+ "description": "Microsoft Graph delegated permissions"
881
+ },
882
+ "reason": {
883
+ "$ref": "#/definitions/ExtensionManifest/properties/contributes/properties/tools/items/properties/confirmationPrompt",
884
+ "description": "Why the extension needs them"
885
+ }
886
+ },
887
+ "required": [
888
+ "provider",
889
+ "scopes"
890
+ ],
891
+ "additionalProperties": false,
892
+ "description": "An external account the extension works through"
893
+ },
894
+ "description": "External accounts the extension works through, signed in once in Stina's settings"
855
895
  }
856
896
  },
857
897
  "additionalProperties": false,
@@ -318,6 +318,39 @@ describe('WorkerBackgroundTaskManager', () => {
318
318
  expect(receivedContext!.signal).toBeDefined()
319
319
  expect(receivedContext!.reportHealth).toBeDefined()
320
320
  expect(receivedContext!.log).toBeDefined()
321
+ // Not granted in this setup, so a task cannot reach for an account.
322
+ expect(receivedContext!.accounts).toBeUndefined()
323
+ })
324
+
325
+ it('hands a task the accounts of the user it runs for, when the extension may use them', async () => {
326
+ const accountsApi = { list: vi.fn(), getAccessToken: vi.fn() }
327
+ const createUserAccountsAPI = vi.fn(() => accountsApi)
328
+ const withAccounts = new WorkerBackgroundTaskManager({
329
+ extensionId: 'test-extension',
330
+ extensionVersion: '1.0.0',
331
+ storagePath: '/fake/path',
332
+ sendTaskRegistered,
333
+ sendTaskStatus,
334
+ sendHealthReport,
335
+ createLogAPI,
336
+ createStorageAPI,
337
+ createUserStorageAPI,
338
+ createSecretsAPI,
339
+ createUserSecretsAPI,
340
+ createUserAccountsAPI,
341
+ })
342
+
343
+ let receivedContext: BackgroundTaskContext | null = null
344
+ await withAccounts.start(
345
+ { id: 'task-2', name: 'Mail watch', userId: 'user-7', restartPolicy: { type: 'never' } },
346
+ vi.fn(async (context) => {
347
+ receivedContext = context
348
+ })
349
+ )
350
+ await withAccounts.handleStart('task-2')
351
+
352
+ expect(createUserAccountsAPI).toHaveBeenCalledWith('user-7')
353
+ expect(receivedContext!.accounts).toBe(accountsApi)
321
354
  })
322
355
  })
323
356
 
package/src/background.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  LogAPI,
15
15
  StorageAPI,
16
16
  SecretsAPI,
17
+ ConnectedAccountsAPI,
17
18
  } from './types.js'
18
19
 
19
20
  // ============================================================================
@@ -66,6 +67,11 @@ export interface WorkerBackgroundTaskManagerOptions {
66
67
  createSecretsAPI: () => SecretsAPI
67
68
  /** Create user-scoped secrets API */
68
69
  createUserSecretsAPI: (userId: string) => SecretsAPI
70
+ /**
71
+ * Create the connected accounts API for a user. Absent unless the extension
72
+ * holds `accounts.use`, which leaves `accounts` off the task's context.
73
+ */
74
+ createUserAccountsAPI?: (userId: string) => ConnectedAccountsAPI
69
75
  }
70
76
 
71
77
  // ============================================================================
@@ -235,6 +241,9 @@ export class WorkerBackgroundTaskManager {
235
241
  userStorage,
236
242
  secrets,
237
243
  userSecrets,
244
+ ...(config.userId && this.options.createUserAccountsAPI
245
+ ? { accounts: this.options.createUserAccountsAPI(config.userId) }
246
+ : {}),
238
247
  signal,
239
248
  reportHealth: (status: string) => {
240
249
  const timestamp = new Date().toISOString()
package/src/index.ts CHANGED
@@ -36,6 +36,8 @@ export type {
36
36
  ToolDefinition,
37
37
  ToolConfirmationConfig,
38
38
  CommandDefinition,
39
+ AccountProvider,
40
+ AccountContribution,
39
41
 
40
42
  // Permissions
41
43
  Permission,
@@ -63,6 +65,11 @@ export type {
63
65
  ChatAPI,
64
66
  ChatInstructionMessage,
65
67
  ConversationPresentation,
68
+ AttachmentsAPI,
69
+ AttachmentContent,
70
+ ConnectedAccountsAPI,
71
+ ConnectedAccount,
72
+ AccountAccessToken,
66
73
  LogAPI,
67
74
 
68
75
  // Background workers
@@ -87,6 +94,7 @@ export type {
87
94
  ModelCapabilities,
88
95
  ChatMessage,
89
96
  ChatImage,
97
+ ChatFile,
90
98
  ChatOptions,
91
99
  GetModelsOptions,
92
100
  StreamEvent,
package/src/messages.ts CHANGED
@@ -264,6 +264,11 @@ export type RequestMethod =
264
264
  // Tools cross-extension methods
265
265
  | 'tools.list'
266
266
  | 'tools.execute'
267
+ // Attachments
268
+ | 'attachments.read'
269
+ // Connected accounts
270
+ | 'accounts.list'
271
+ | 'accounts.getAccessToken'
267
272
 
268
273
  export interface ProviderRegisteredMessage {
269
274
  type: 'provider-registered'
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Worker-side connected accounts API.
3
+ */
4
+
5
+ import type { AccountAccessToken, ConnectedAccount, ConnectedAccountsAPI } from '../types.js'
6
+ import type { RequestMessage } from '../messages.js'
7
+
8
+ type SendRequest = <T>(method: RequestMessage['method'], payload: unknown) => Promise<T>
9
+
10
+ /**
11
+ * Build the accounts API for one user.
12
+ *
13
+ * The user id is bound here, from the request that is being served, and not
14
+ * taken from the extension's own arguments: an extension cannot reach another
15
+ * user's account by passing a different id, because there is no id to pass.
16
+ */
17
+ export function buildUserAccountsAPI(sendRequest: SendRequest, userId: string): ConnectedAccountsAPI {
18
+ return {
19
+ async list(provider: 'microsoft'): Promise<ConnectedAccount[]> {
20
+ return sendRequest<ConnectedAccount[]>('accounts.list', { provider, userId })
21
+ },
22
+ async getAccessToken(accountId: string): Promise<AccountAccessToken> {
23
+ return sendRequest<AccountAccessToken>('accounts.getAccessToken', { accountId, userId })
24
+ },
25
+ }
26
+ }
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { createExecutionContext } from './executionContext.js'
3
+ import type { ExtensionContext } from '../types.js'
4
+
5
+ const extensionContext = {
6
+ extension: { id: 'mail-reader', version: '1.0.0', storagePath: '/tmp/x' },
7
+ } as ExtensionContext
8
+
9
+ /**
10
+ * What a tool is handed when it runs.
11
+ *
12
+ * The attachments API is the part worth pinning: it is absent unless the extension
13
+ * asked for the permission *and* the request knows whose work it is, and an absent
14
+ * API is what makes a tool check before reaching for files it may not have.
15
+ */
16
+ describe('createExecutionContext', () => {
17
+ it('leaves attachments out without the permission', () => {
18
+ const context = createExecutionContext(vi.fn(), extensionContext, 'user-1', [])
19
+
20
+ expect(context.attachments).toBeUndefined()
21
+ })
22
+
23
+ it('leaves attachments out without a user, whatever the permission says', () => {
24
+ // An attachment belongs to somebody. A request that cannot say whose work it is
25
+ // doing has no business reading one.
26
+ const context = createExecutionContext(vi.fn(), extensionContext, undefined, ['attachments.read'])
27
+
28
+ expect(context.attachments).toBeUndefined()
29
+ })
30
+
31
+ it('reads an attachment for the user the request belongs to', async () => {
32
+ const sendRequest = vi.fn().mockResolvedValue({
33
+ id: 'att-7',
34
+ mime: 'application/pdf',
35
+ data: 'JVBE',
36
+ byteSize: 4,
37
+ })
38
+
39
+ const context = createExecutionContext(sendRequest, extensionContext, 'user-1', ['attachments.read'])
40
+ const content = await context.attachments!.read('att-7')
41
+
42
+ // The user id is the runtime's to supply, not the tool's: it comes from the
43
+ // request, so a tool cannot reach another user's file by passing a different one.
44
+ expect(sendRequest).toHaveBeenCalledWith('attachments.read', {
45
+ attachmentId: 'att-7',
46
+ userId: 'user-1',
47
+ })
48
+ expect(content?.mime).toBe('application/pdf')
49
+ })
50
+
51
+ it('passes a missing attachment through as nothing', async () => {
52
+ const context = createExecutionContext(
53
+ vi.fn().mockResolvedValue(null),
54
+ extensionContext,
55
+ 'user-1',
56
+ ['attachments.read']
57
+ )
58
+
59
+ expect(await context.attachments!.read('gone')).toBeNull()
60
+ })
61
+
62
+ it('still builds the rest of the context without the permission', () => {
63
+ const context = createExecutionContext(vi.fn(), extensionContext, 'user-1')
64
+
65
+ expect(context.userId).toBe('user-1')
66
+ expect(context.storage).toBeDefined()
67
+ expect(context.userStorage).toBeDefined()
68
+ expect(context.secrets).toBeDefined()
69
+ expect(context.userSecrets).toBeDefined()
70
+ })
71
+
72
+ describe('connected accounts', () => {
73
+ it('leaves them out without the permission', () => {
74
+ const context = createExecutionContext(vi.fn(), extensionContext, 'user-1', ['attachments.read'])
75
+
76
+ expect(context.accounts).toBeUndefined()
77
+ })
78
+
79
+ it('leaves them out without a user, whatever the permission says', () => {
80
+ const context = createExecutionContext(vi.fn(), extensionContext, undefined, ['accounts.use'])
81
+
82
+ expect(context.accounts).toBeUndefined()
83
+ })
84
+
85
+ it('asks for the user the request belongs to, not one the extension names', async () => {
86
+ const sendRequest = vi
87
+ .fn()
88
+ .mockResolvedValueOnce([{ id: 'ms-1', provider: 'microsoft', email: 'a@b.se' }])
89
+ .mockResolvedValueOnce({ token: 'tok', expiresAt: '2026-09-16T12:00:00.000Z' })
90
+
91
+ const context = createExecutionContext(sendRequest, extensionContext, 'user-1', ['accounts.use'])
92
+ await context.accounts!.list('microsoft')
93
+ const token = await context.accounts!.getAccessToken('ms-1')
94
+
95
+ expect(sendRequest).toHaveBeenNthCalledWith(1, 'accounts.list', {
96
+ provider: 'microsoft',
97
+ userId: 'user-1',
98
+ })
99
+ expect(sendRequest).toHaveBeenNthCalledWith(2, 'accounts.getAccessToken', {
100
+ accountId: 'ms-1',
101
+ userId: 'user-1',
102
+ })
103
+ expect(token.token).toBe('tok')
104
+ })
105
+
106
+ it('leaves attachments alone when only accounts were granted', () => {
107
+ const context = createExecutionContext(vi.fn(), extensionContext, 'user-1', ['accounts.use'])
108
+
109
+ expect(context.accounts).toBeDefined()
110
+ expect(context.attachments).toBeUndefined()
111
+ })
112
+ })
113
+ })
@@ -2,10 +2,11 @@
2
2
  * Shared execution context builder for tool, action, and scheduler operations.
3
3
  */
4
4
 
5
- import type { ExecutionContext, ExtensionContext } from '../types.js'
5
+ import type { AttachmentContent, ExecutionContext, ExtensionContext } from '../types.js'
6
6
  import type { RequestMessage } from '../messages.js'
7
7
  import { buildExtensionStorageAPI, buildUserStorageAPI } from './storageApi.js'
8
8
  import { buildExtensionSecretsAPI, buildUserSecretsAPI } from './secretsApi.js'
9
+ import { buildUserAccountsAPI } from './accountsApi.js'
9
10
 
10
11
  type SendRequest = <T>(method: RequestMessage['method'], payload: unknown) => Promise<T>
11
12
 
@@ -16,8 +17,17 @@ type SendRequest = <T>(method: RequestMessage['method'], payload: unknown) => Pr
16
17
  export function createExecutionContext(
17
18
  sendRequest: SendRequest,
18
19
  extensionContext: ExtensionContext,
19
- userId?: string
20
+ userId?: string,
21
+ /**
22
+ * What the extension was granted. The permission-gated APIs are left out here
23
+ * rather than only refused by the host, so a tool can see whether a capability
24
+ * is there at all, the way it can for storage — the host refuses them regardless.
25
+ */
26
+ granted: readonly string[] = []
20
27
  ): ExecutionContext {
28
+ const canReadAttachments = granted.includes('attachments.read')
29
+ const canUseAccounts = granted.includes('accounts.use')
30
+
21
31
  return {
22
32
  userId,
23
33
  extension: {
@@ -33,5 +43,21 @@ export function createExecutionContext(
33
43
  userSecrets: userId
34
44
  ? buildUserSecretsAPI(sendRequest, userId)
35
45
  : buildExtensionSecretsAPI(sendRequest),
46
+ // Only with a user to scope it to. An attachment belongs to somebody, and a
47
+ // request that cannot say whose work it is doing has no business reading one.
48
+ ...(canReadAttachments && userId
49
+ ? {
50
+ attachments: {
51
+ async read(attachmentId: string): Promise<AttachmentContent | null> {
52
+ return sendRequest<AttachmentContent | null>('attachments.read', {
53
+ attachmentId,
54
+ userId,
55
+ })
56
+ },
57
+ },
58
+ }
59
+ : {}),
60
+ // Scoped to a user for the same reason: a connected account is somebody's.
61
+ ...(canUseAccounts && userId ? { accounts: buildUserAccountsAPI(sendRequest, userId) } : {}),
36
62
  }
37
63
  }
@@ -6,3 +6,4 @@
6
6
  export { buildExtensionStorageAPI, buildUserStorageAPI } from './storageApi.js'
7
7
  export { buildExtensionSecretsAPI, buildUserSecretsAPI } from './secretsApi.js'
8
8
  export { createExecutionContext } from './executionContext.js'
9
+ export { buildUserAccountsAPI } from './accountsApi.js'
package/src/runtime.ts CHANGED
@@ -57,6 +57,7 @@ import {
57
57
  buildUserStorageAPI,
58
58
  buildExtensionSecretsAPI,
59
59
  buildUserSecretsAPI,
60
+ buildUserAccountsAPI,
60
61
  createExecutionContext,
61
62
  } from './runtime/index.js'
62
63
 
@@ -105,6 +106,13 @@ const messagePort = getMessagePort()
105
106
  let extensionModule: ExtensionModule | null = null
106
107
  let extensionDisposable: Disposable | null = null
107
108
  let extensionContext: ExtensionContext | null = null
109
+ /**
110
+ * What this extension was granted, kept from activation.
111
+ *
112
+ * A request-scoped context is built long after `handleActivate` has returned, and
113
+ * has to know the same things it did.
114
+ */
115
+ let grantedPermissions: string[] = []
108
116
  let backgroundTaskManager: WorkerBackgroundTaskManager | null = null
109
117
 
110
118
  const pendingRequests = new Map<string, PendingRequest>()
@@ -284,6 +292,7 @@ async function handleActivate(payload: {
284
292
  settings: Record<string, unknown>
285
293
  }): Promise<void> {
286
294
  const { extensionId, extensionVersion, storagePath, permissions } = payload
295
+ grantedPermissions = permissions
287
296
 
288
297
  // Build the context based on permissions
289
298
  extensionContext = buildContext(extensionId, extensionVersion, storagePath, permissions)
@@ -346,7 +355,12 @@ function handleSettingsChanged(key: string, value: unknown): void {
346
355
  // ExecutionContext builder is in runtime/executionContext.ts
347
356
 
348
357
  async function handleSchedulerFire(payload: SchedulerFirePayload): Promise<void> {
349
- const execContext = createExecutionContext(sendRequest, extensionContext!, payload.userId)
358
+ const execContext = createExecutionContext(
359
+ sendRequest,
360
+ extensionContext!,
361
+ payload.userId,
362
+ grantedPermissions
363
+ )
350
364
 
351
365
  // Run callbacks concurrently to avoid blocking
352
366
  const results = await Promise.allSettled(
@@ -543,7 +557,12 @@ async function handleToolExecuteRequest(
543
557
  }
544
558
 
545
559
  try {
546
- const execContext = createExecutionContext(sendRequest, extensionContext!, payload.userId)
560
+ const execContext = createExecutionContext(
561
+ sendRequest,
562
+ extensionContext!,
563
+ payload.userId,
564
+ grantedPermissions
565
+ )
547
566
 
548
567
  const result = await tool.execute(payload.params, execContext)
549
568
 
@@ -586,7 +605,12 @@ async function handleActionExecuteRequest(
586
605
  }
587
606
 
588
607
  try {
589
- const execContext = createExecutionContext(sendRequest, extensionContext!, payload.userId)
608
+ const execContext = createExecutionContext(
609
+ sendRequest,
610
+ extensionContext!,
611
+ payload.userId,
612
+ grantedPermissions
613
+ )
590
614
 
591
615
  const result = await action.execute(payload.params, execContext)
592
616
 
@@ -967,6 +991,9 @@ function buildContext(
967
991
  createUserStorageAPI: (userId) => buildUserStorageAPI(sendRequest, userId),
968
992
  createSecretsAPI: () => buildExtensionSecretsAPI(sendRequest),
969
993
  createUserSecretsAPI: (userId) => buildUserSecretsAPI(sendRequest, userId),
994
+ ...(hasPermission('accounts.use')
995
+ ? { createUserAccountsAPI: (userId: string) => buildUserAccountsAPI(sendRequest, userId) }
996
+ : {}),
970
997
  })
971
998
  }
972
999
 
@@ -1016,6 +1043,9 @@ export type {
1016
1043
  ExecutionContext,
1017
1044
  ExtensionContext,
1018
1045
  ExtensionModule,
1046
+ ConnectedAccountsAPI,
1047
+ ConnectedAccount,
1048
+ AccountAccessToken,
1019
1049
  Disposable,
1020
1050
  AIProvider,
1021
1051
  Tool,
@@ -1027,6 +1057,11 @@ export type {
1027
1057
  ActionResult,
1028
1058
  ModelInfo,
1029
1059
  ChatMessage,
1060
+ // What a message can carry. A provider extension reads these on every user turn,
1061
+ // and until now had to reach for the root entry to name them — or declare them
1062
+ // itself, which is what two of them did.
1063
+ ChatImage,
1064
+ ChatFile,
1030
1065
  ChatOptions,
1031
1066
  GetModelsOptions,
1032
1067
  ModelCapabilities,
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { AccountContributionSchema, ExtensionContributionsSchema } from './contributions.schema.js'
3
+
4
+ /**
5
+ * What an extension may ask of a connected account.
6
+ *
7
+ * Every enabled extension's scopes go into one sign-in request. A scope Microsoft
8
+ * rejects therefore breaks the sign-in for all of them, which is why the schema
9
+ * is stricter than "any string".
10
+ */
11
+ describe('AccountContributionSchema', () => {
12
+ it('accepts Graph permission names', () => {
13
+ const result = AccountContributionSchema.safeParse({
14
+ provider: 'microsoft',
15
+ scopes: ['Mail.Read', 'Mail.Send', 'Calendars.ReadWrite', 'Calendars.Read.Shared'],
16
+ reason: { en: 'Read your mail', sv: 'Läsa din mail' },
17
+ })
18
+
19
+ expect(result.success).toBe(true)
20
+ })
21
+
22
+ // Microsoft issues a token for one resource at a time. An Exchange scope next to
23
+ // Graph scopes makes the whole combined request fail.
24
+ it('refuses a scope for another resource', () => {
25
+ const result = AccountContributionSchema.safeParse({
26
+ provider: 'microsoft',
27
+ scopes: ['https://outlook.office.com/IMAP.AccessAsUser.All'],
28
+ })
29
+
30
+ expect(result.success).toBe(false)
31
+ })
32
+
33
+ it('refuses the scopes the host asks for itself', () => {
34
+ for (const scope of ['offline_access', 'User.Read', 'user.read', 'openid']) {
35
+ const result = AccountContributionSchema.safeParse({ provider: 'microsoft', scopes: [scope] })
36
+ expect(result.success, scope).toBe(false)
37
+ }
38
+ })
39
+
40
+ it('needs at least one scope', () => {
41
+ expect(AccountContributionSchema.safeParse({ provider: 'microsoft', scopes: [] }).success).toBe(false)
42
+ })
43
+
44
+ it('knows only the providers the host can sign in to', () => {
45
+ expect(
46
+ AccountContributionSchema.safeParse({ provider: 'google', scopes: ['Mail.Read'] }).success
47
+ ).toBe(false)
48
+ })
49
+
50
+ it('is part of what a manifest may contribute', () => {
51
+ const result = ExtensionContributionsSchema.safeParse({
52
+ accounts: [{ provider: 'microsoft', scopes: ['Calendars.ReadWrite'] }],
53
+ })
54
+
55
+ expect(result.success).toBe(true)
56
+ expect(result.success && result.data.accounts?.[0]?.scopes).toEqual(['Calendars.ReadWrite'])
57
+ })
58
+ })
@@ -304,6 +304,48 @@ export const StorageContributionsSchema = z
304
304
  })
305
305
  .describe('Storage contributions')
306
306
 
307
+ // =============================================================================
308
+ // Connected Accounts
309
+ // =============================================================================
310
+
311
+ /**
312
+ * A Microsoft Graph delegated permission name: `Mail.Read`, `Calendars.ReadWrite`.
313
+ *
314
+ * Bare names only. A full URI would name some other resource, and Microsoft hands
315
+ * out tokens for one resource at a time - a manifest mixing Graph with, say,
316
+ * `https://outlook.office.com/IMAP.AccessAsUser.All` would make the combined
317
+ * sign-in fail for every extension, not just the one that asked.
318
+ */
319
+ export const GRAPH_SCOPE_PATTERN = /^[A-Za-z]+(\.[A-Za-z]+)+$/
320
+
321
+ /**
322
+ * Scopes the host always asks for and therefore does not accept from a manifest:
323
+ * a declared one would be redundant at best.
324
+ */
325
+ export const HOST_ACCOUNT_SCOPES = ['offline_access', 'User.Read', 'openid', 'profile', 'email'] as const
326
+
327
+ export const AccountProviderSchema = z.enum(['microsoft']).describe('External account provider')
328
+
329
+ export const AccountContributionSchema = z
330
+ .object({
331
+ provider: AccountProviderSchema,
332
+ scopes: z
333
+ .array(
334
+ z
335
+ .string()
336
+ .regex(GRAPH_SCOPE_PATTERN, 'Must be a Microsoft Graph permission name, such as "Mail.Read"')
337
+ .refine(
338
+ (scope) =>
339
+ !HOST_ACCOUNT_SCOPES.some((hostScope) => hostScope.toLowerCase() === scope.toLowerCase()),
340
+ { message: 'Requested by the host already; leave it out' }
341
+ )
342
+ )
343
+ .min(1)
344
+ .describe('Microsoft Graph delegated permissions'),
345
+ reason: LocalizedStringSchema.optional().describe('Why the extension needs them'),
346
+ })
347
+ .describe('An external account the extension works through')
348
+
307
349
  // =============================================================================
308
350
  // Extension Contributions
309
351
  // =============================================================================
@@ -324,6 +366,10 @@ export const ExtensionContributionsSchema = z
324
366
  commands: z.array(CommandDefinitionSchema).optional().describe('Slash commands'),
325
367
  prompts: z.array(PromptContributionSchema).optional().describe('Prompt contributions'),
326
368
  storage: StorageContributionsSchema.optional().describe('Storage collection declarations'),
369
+ accounts: z
370
+ .array(AccountContributionSchema)
371
+ .optional()
372
+ .describe("External accounts the extension works through, signed in once in Stina's settings"),
327
373
  })
328
374
  .describe('What an extension can contribute to Stina')
329
375
 
@@ -353,4 +399,6 @@ export type PromptSection = z.infer<typeof PromptSectionSchema>
353
399
  export type PromptContribution = z.infer<typeof PromptContributionSchema>
354
400
  export type StorageCollectionConfig = z.infer<typeof StorageCollectionConfigSchema>
355
401
  export type StorageContributions = z.infer<typeof StorageContributionsSchema>
402
+ export type AccountProvider = z.infer<typeof AccountProviderSchema>
403
+ export type AccountContribution = z.infer<typeof AccountContributionSchema>
356
404
  export type ExtensionContributions = z.infer<typeof ExtensionContributionsSchema>
@@ -58,6 +58,12 @@ export {
58
58
  CommandDefinitionSchema,
59
59
  PromptContributionSchema,
60
60
  PromptSectionSchema,
61
+ AccountProviderSchema,
62
+ AccountContributionSchema,
63
+ GRAPH_SCOPE_PATTERN,
64
+ HOST_ACCOUNT_SCOPES,
65
+ type AccountProvider,
66
+ type AccountContribution,
61
67
  type ExtensionContributions,
62
68
  type LocalizedString,
63
69
  type ToolSettingsViewDefinition,
@@ -20,6 +20,8 @@ export const VALID_PERMISSIONS = [
20
20
  'user.location.read',
21
21
  'chat.history.read',
22
22
  'chat.current.read',
23
+ 'attachments.read',
24
+ 'accounts.use',
23
25
  'chat.message.write',
24
26
  'provider.register',
25
27
  'tools.register',
@@ -75,7 +77,15 @@ const StoragePermissionSchema = z.enum(['storage.collections', 'secrets.manage']
75
77
  * User data permission schema
76
78
  */
77
79
  const UserDataPermissionSchema = z
78
- .enum(['user.profile.read', 'user.list', 'user.location.read', 'chat.history.read', 'chat.current.read'])
80
+ .enum([
81
+ 'user.profile.read',
82
+ 'user.list',
83
+ 'user.location.read',
84
+ 'chat.history.read',
85
+ 'chat.current.read',
86
+ 'attachments.read',
87
+ 'accounts.use',
88
+ ])
79
89
  .describe('User data access permission')
80
90
 
81
91
  /**