@stina/extension-api 1.6.0 → 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 (39) hide show
  1. package/dist/{chunk-3Q3YXWOH.js → chunk-S3YP4QPF.js} +1 -1
  2. package/dist/{chunk-3Q3YXWOH.js.map → chunk-S3YP4QPF.js.map} +1 -1
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +12 -4
  5. package/dist/index.d.ts +12 -4
  6. package/dist/index.js +1 -1
  7. package/dist/runtime.cjs +24 -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 +25 -7
  12. package/dist/runtime.js.map +1 -1
  13. package/dist/schemas/index.cjs +26 -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 +22 -2
  18. package/dist/schemas/index.js.map +1 -1
  19. package/dist/{types.tools-R0xGhiBa.d.cts → types.tools-DcFBsfRV.d.cts} +105 -1
  20. package/dist/{types.tools-R0xGhiBa.d.ts → types.tools-DcFBsfRV.d.ts} +105 -1
  21. package/package.json +1 -1
  22. package/schema/extension-manifest.schema.json +39 -0
  23. package/src/background.test.ts +33 -0
  24. package/src/background.ts +9 -0
  25. package/src/index.ts +5 -0
  26. package/src/messages.ts +3 -0
  27. package/src/runtime/accountsApi.ts +26 -0
  28. package/src/runtime/executionContext.test.ts +46 -4
  29. package/src/runtime/executionContext.ts +10 -4
  30. package/src/runtime/index.ts +1 -0
  31. package/src/runtime.ts +15 -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 +2 -0
  36. package/src/types.context.ts +68 -0
  37. package/src/types.contributions.ts +47 -0
  38. package/src/types.permissions.ts +8 -0
  39. package/src/types.ts +6 -0
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
 
@@ -358,7 +359,7 @@ async function handleSchedulerFire(payload: SchedulerFirePayload): Promise<void>
358
359
  sendRequest,
359
360
  extensionContext!,
360
361
  payload.userId,
361
- grantedPermissions.includes('attachments.read')
362
+ grantedPermissions
362
363
  )
363
364
 
364
365
  // Run callbacks concurrently to avoid blocking
@@ -560,7 +561,7 @@ async function handleToolExecuteRequest(
560
561
  sendRequest,
561
562
  extensionContext!,
562
563
  payload.userId,
563
- grantedPermissions.includes('attachments.read')
564
+ grantedPermissions
564
565
  )
565
566
 
566
567
  const result = await tool.execute(payload.params, execContext)
@@ -608,7 +609,7 @@ async function handleActionExecuteRequest(
608
609
  sendRequest,
609
610
  extensionContext!,
610
611
  payload.userId,
611
- grantedPermissions.includes('attachments.read')
612
+ grantedPermissions
612
613
  )
613
614
 
614
615
  const result = await action.execute(payload.params, execContext)
@@ -990,6 +991,9 @@ function buildContext(
990
991
  createUserStorageAPI: (userId) => buildUserStorageAPI(sendRequest, userId),
991
992
  createSecretsAPI: () => buildExtensionSecretsAPI(sendRequest),
992
993
  createUserSecretsAPI: (userId) => buildUserSecretsAPI(sendRequest, userId),
994
+ ...(hasPermission('accounts.use')
995
+ ? { createUserAccountsAPI: (userId: string) => buildUserAccountsAPI(sendRequest, userId) }
996
+ : {}),
993
997
  })
994
998
  }
995
999
 
@@ -1039,6 +1043,9 @@ export type {
1039
1043
  ExecutionContext,
1040
1044
  ExtensionContext,
1041
1045
  ExtensionModule,
1046
+ ConnectedAccountsAPI,
1047
+ ConnectedAccount,
1048
+ AccountAccessToken,
1042
1049
  Disposable,
1043
1050
  AIProvider,
1044
1051
  Tool,
@@ -1050,6 +1057,11 @@ export type {
1050
1057
  ActionResult,
1051
1058
  ModelInfo,
1052
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,
1053
1065
  ChatOptions,
1054
1066
  GetModelsOptions,
1055
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,
@@ -21,6 +21,7 @@ export const VALID_PERMISSIONS = [
21
21
  'chat.history.read',
22
22
  'chat.current.read',
23
23
  'attachments.read',
24
+ 'accounts.use',
24
25
  'chat.message.write',
25
26
  'provider.register',
26
27
  'tools.register',
@@ -83,6 +84,7 @@ const UserDataPermissionSchema = z
83
84
  'chat.history.read',
84
85
  'chat.current.read',
85
86
  'attachments.read',
87
+ 'accounts.use',
86
88
  ])
87
89
  .describe('User data access permission')
88
90
 
@@ -94,6 +94,74 @@ export interface ExecutionContext {
94
94
  * so in its manifest and check before reaching for them.
95
95
  */
96
96
  readonly attachments?: AttachmentsAPI
97
+
98
+ /**
99
+ * The external accounts this user has connected to Stina.
100
+ *
101
+ * Present only for an extension holding `accounts.use`, and only on a request
102
+ * that knows whose it is - an account belongs to somebody, like an attachment.
103
+ */
104
+ readonly accounts?: ConnectedAccountsAPI
105
+ }
106
+
107
+ /**
108
+ * Working through an account the user signed in to in Stina's settings.
109
+ *
110
+ * The user connects a Microsoft account once, and every extension that declares
111
+ * Microsoft under `contributes.accounts` can use it. The extension never sees the
112
+ * refresh token; it asks for an access token when it needs one and gets a fresh
113
+ * one back, refreshed by the host when the old one has run out.
114
+ *
115
+ * One thing the host cannot narrow: Microsoft issues a Graph token carrying every
116
+ * permission the user has granted Stina, not only the ones this extension
117
+ * declared. The declaration decides whether an extension gets a token at all,
118
+ * and what the user is asked to approve - not what the token can do.
119
+ */
120
+ export interface ConnectedAccountsAPI {
121
+ /**
122
+ * The accounts this user has connected for a provider, newest first.
123
+ *
124
+ * An empty list is the normal answer for a user who has not connected one yet:
125
+ * the extension should point them at Stina's settings rather than start a
126
+ * sign-in of its own.
127
+ */
128
+ list(provider: 'microsoft'): Promise<ConnectedAccount[]>
129
+
130
+ /**
131
+ * An access token for one of those accounts, for the scopes this extension
132
+ * declared.
133
+ *
134
+ * Rejects when the account is gone, when the user has not granted every
135
+ * declared scope yet, or when the provider has revoked the sign-in. The
136
+ * message says which, in words the user can act on - it is fine to show it.
137
+ */
138
+ getAccessToken(accountId: string): Promise<AccountAccessToken>
139
+ }
140
+
141
+ /** An account the user has connected, as an extension sees it. */
142
+ export interface ConnectedAccount {
143
+ /** Stable id, to store alongside whatever the extension keeps for this account. */
144
+ id: string
145
+ provider: 'microsoft'
146
+ /** The address the user signs in with. */
147
+ email: string
148
+ displayName?: string
149
+ /**
150
+ * Whether the user has granted every scope this extension declared. False when
151
+ * the extension was installed after the account was connected, and the user has
152
+ * yet to reconnect it in the settings.
153
+ */
154
+ hasRequiredScopes: boolean
155
+ /** Set when the provider has refused the stored sign-in and it has to be redone. */
156
+ needsReconnect: boolean
157
+ }
158
+
159
+ /** An access token, and when it stops working. */
160
+ export interface AccountAccessToken {
161
+ /** Bearer token for Microsoft Graph. */
162
+ token: string
163
+ /** ISO timestamp. Ask again after this; the host refreshes. */
164
+ expiresAt: string
97
165
  }
98
166
 
99
167
  /**
@@ -25,6 +25,8 @@ export interface ExtensionContributions {
25
25
  commands?: CommandDefinition[]
26
26
  /** Prompt contributions for the system prompt */
27
27
  prompts?: PromptContribution[]
28
+ /** External accounts the extension works through, signed in once in Stina's settings */
29
+ accounts?: AccountContribution[]
28
30
  /** Storage collection declarations */
29
31
  storage?: {
30
32
  collections: {
@@ -36,6 +38,51 @@ export interface ExtensionContributions {
36
38
  }
37
39
  }
38
40
 
41
+ // ============================================================================
42
+ // Connected Accounts
43
+ // ============================================================================
44
+
45
+ /**
46
+ * An external account provider the host can sign in to on the user's behalf.
47
+ *
48
+ * Only Microsoft for now, and only through Microsoft Graph: mail and calendar
49
+ * then share one sign-in and one token, instead of an IMAP token and a Graph
50
+ * token that an admin has to approve separately.
51
+ */
52
+ export type AccountProvider = 'microsoft'
53
+
54
+ /**
55
+ * What an extension needs from an account the user connects to Stina.
56
+ *
57
+ * Stina asks the provider for every scope that the enabled extensions declare,
58
+ * all at once, so that the user - and, at work, their administrator - approves
59
+ * one request instead of one per extension. Declaring a scope here is therefore
60
+ * also what makes it show up in that request, and in the list of what is asked
61
+ * for in the settings.
62
+ *
63
+ * @example
64
+ * ```json
65
+ * "accounts": [
66
+ * {
67
+ * "provider": "microsoft",
68
+ * "scopes": ["Calendars.ReadWrite"],
69
+ * "reason": { "en": "Read and book events in your calendar", "sv": "Läsa och boka i din kalender" }
70
+ * }
71
+ * ]
72
+ * ```
73
+ */
74
+ export interface AccountContribution {
75
+ provider: AccountProvider
76
+ /**
77
+ * Microsoft Graph delegated permissions, by name: `Mail.Read`,
78
+ * `Calendars.ReadWrite`. The host adds `offline_access` and `User.Read`
79
+ * itself, so they are not declared here.
80
+ */
81
+ scopes: string[]
82
+ /** Why the extension needs them, shown next to the scopes in the settings. */
83
+ reason?: LocalizedString
84
+ }
85
+
39
86
  // ============================================================================
40
87
  // Tool Settings Views
41
88
  // ============================================================================
@@ -35,6 +35,14 @@ export type UserDataPermission =
35
35
  * extension.
36
36
  */
37
37
  | 'attachments.read'
38
+ /**
39
+ * Get access tokens for the external accounts the user has connected to Stina
40
+ * itself - a Microsoft account, say - for the scopes the extension declares under
41
+ * `contributes.accounts`. The user signs in once, in Stina's settings, and every
42
+ * extension that declares the provider can use that sign-in. Refresh tokens stay
43
+ * with the host.
44
+ */
45
+ | 'accounts.use'
38
46
 
39
47
  /** Capability permissions */
40
48
  export type CapabilityPermission =
package/src/types.ts CHANGED
@@ -47,6 +47,9 @@ export type {
47
47
  // Prompts
48
48
  PromptSection,
49
49
  PromptContribution,
50
+ // Connected accounts
51
+ AccountProvider,
52
+ AccountContribution,
50
53
  } from './types.contributions.js'
51
54
 
52
55
  // Manifest
@@ -103,6 +106,9 @@ export type {
103
106
  BackgroundWorkersAPI,
104
107
  AttachmentsAPI,
105
108
  AttachmentContent,
109
+ ConnectedAccountsAPI,
110
+ ConnectedAccount,
111
+ AccountAccessToken,
106
112
  } from './types.context.js'
107
113
 
108
114
  // Storage and Secrets