Skip to content

Commit 2208632

Browse files
committed
Changed: BREAKING: All integrations are now individual, always acting on behalf of a particular user.
1 parent 0004e24 commit 2208632

23 files changed

Lines changed: 869 additions & 391 deletions

File tree

.changeset/ninety-paths-kiss.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@plotday/tool-outlook-calendar": minor
3+
"@plotday/tool-google-calendar": minor
4+
"@plotday/tool-google-contacts": minor
5+
"@plotday/tool-linear": minor
6+
"@plotday/tool-asana": minor
7+
"@plotday/tool-gmail": minor
8+
"@plotday/tool-slack": minor
9+
"@plotday/tool-jira": minor
10+
"@plotday/twister": minor
11+
---
12+
13+
Changed: BREAKING: All integrations are now individual, always acting on behalf of a particular user.

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ All type definitions are in `twister/src/` with full JSDoc:
2828
- **Runtime Environment**: `twister/docs/RUNTIME.md`
2929
- **Tools Guide**: `twister/docs/TOOLS_GUIDE.md`
3030
- **Twist Development**: `twister/cli/templates/AGENTS.template.md`
31+
- **Multi-User Auth**: `twister/docs/MULTI_USER_AUTH.md`
3132
- **Working Examples**: `tools/google-calendar/`, `tools/google-contacts/`, `tools/linear/`
3233

3334
## Common Pitfalls
@@ -37,6 +38,8 @@ All type definitions are in `twister/src/` with full JSDoc:
3738
3. **❌ Forgetting to clean up** - Delete callbacks and stored state when done
3839
4. **❌ Not handling missing auth** - Always check for stored tokens before operations
3940
5. **❌ Passing functions to `this.callback()`** - See `tools/AGENTS.md` for critical callback serialization pattern
41+
6. **❌ Non-private auth activities** - Auth activities in `activate()` should be `private: true` with mentions targeting `context.actor`
42+
7. **❌ Using installer auth for all write-backs** - Try acting user's credentials first for user-attributed actions (comments). See `twister/docs/MULTI_USER_AUTH.md`
4043

4144
---
4245

tools/AGENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,8 @@ Building a tool? Follow this checklist:
270270
- [ ] Size batches appropriately - calculate requests per item to determine safe batch size
271271
- [ ] Use `this.runTask()` to create new executions with fresh request limits
272272
- [ ] Clean up stored state and callbacks in lifecycle methods
273+
- [ ] **Per-user auth for write-backs**: Try `actorId` as `authToken` first, fall back to installer's token
274+
- [ ] **Private auth activities**: Set `private: true` and add `mentions: [{ id: context.actor.id }]` in `activate()`
273275

274276
## Common Tool Pitfalls
275277

@@ -279,7 +281,9 @@ Building a tool? Follow this checklist:
279281
4. **❌ Forgetting to store the callback token** - Store it immediately after creating
280282
5. **❌ Passing undefined instead of null** - Use `null` for optional values
281283
6. **❌ Not breaking loops into batches** - Each execution has ~1000 request limit; use `runTask()` for fresh limits
282-
7. **❌ Two-way sync without metadata correlation** - When pushing Plot items to an external system, embed the Plot ID (`Activity.id` / `Note.id`) in the external item's metadata, and update `source`/`key` after creation. In webhook handlers, check metadata for the Plot ID first. This prevents duplicates from a race condition where the webhook arrives before the `source`/`key` update. See SYNC_STRATEGIES.md §6 for a full example.
284+
7. **❌ Using installer auth for all write-backs** - In multi-user priorities, try the acting user's credentials first (`note.author.id` as authToken) before falling back to installer auth
285+
8. **❌ Non-private auth activities** - Auth activities from `activate()` should be `private: true` with mentions so only the installing user sees them
286+
9. **❌ Two-way sync without metadata correlation** - When pushing Plot items to an external system, embed the Plot ID (`Activity.id` / `Note.id`) in the external item's metadata, and update `source`/`key` after creation. In webhook handlers, check metadata for the Plot ID first. This prevents duplicates from a race condition where the webhook arrives before the `source`/`key` update. See SYNC_STRATEGIES.md §6 for a full example.
283287

284288
---
285289

tools/asana/src/asana.ts

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ActivityLinkType,
77
ActivityMeta,
88
ActivityType,
9+
type ActorId,
910
type NewActivity,
1011
type NewActivityWithNotes,
1112
type NewNote,
@@ -21,7 +22,6 @@ import type { NewContact } from "@plotday/twister/plot";
2122
import { Tool, type ToolBuilder } from "@plotday/twister/tool";
2223
import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks";
2324
import {
24-
AuthLevel,
2525
AuthProvider,
2626
type Authorization,
2727
Integrations,
@@ -58,16 +58,22 @@ export class Asana extends Tool<Asana> implements ProjectTool {
5858
* Create Asana API client with auth token
5959
*/
6060
private async getClient(authToken: string): Promise<asana.Client> {
61-
const authorization = await this.get<Authorization>(
62-
`authorization:${authToken}`
63-
);
64-
if (!authorization) {
65-
throw new Error("Authorization no longer available");
66-
}
61+
// Try new flow: look up by provider + actor ID
62+
let token = await this.tools.integrations.get(AuthProvider.Asana, authToken as ActorId);
6763

68-
const token = await this.tools.integrations.get(authorization);
64+
// Fall back to legacy authorization lookup
6965
if (!token) {
70-
throw new Error("Authorization no longer available");
66+
const authorization = await this.get<Authorization>(
67+
`authorization:${authToken}`
68+
);
69+
if (!authorization) {
70+
throw new Error("Authorization no longer available");
71+
}
72+
73+
token = await this.tools.integrations.get(authorization.provider, authorization.actor.id);
74+
if (!token) {
75+
throw new Error("Authorization no longer available");
76+
}
7177
}
7278

7379
return asana.Client.create().useAccessToken(token.token);
@@ -82,9 +88,6 @@ export class Asana extends Tool<Asana> implements ProjectTool {
8288
>(callback: TCallback, ...extraArgs: TArgs): Promise<ActivityLink> {
8389
const asanaScopes = ["default"];
8490

85-
// Generate opaque token for authorization
86-
const authToken = crypto.randomUUID();
87-
8891
const callbackToken = await this.tools.callbacks.createFromParent(
8992
callback,
9093
...extraArgs
@@ -94,11 +97,9 @@ export class Asana extends Tool<Asana> implements ProjectTool {
9497
return await this.tools.integrations.request(
9598
{
9699
provider: AuthProvider.Asana,
97-
level: AuthLevel.User,
98100
scopes: asanaScopes,
99101
},
100102
this.onAuthSuccess,
101-
authToken,
102103
callbackToken
103104
);
104105
}
@@ -108,14 +109,10 @@ export class Asana extends Tool<Asana> implements ProjectTool {
108109
*/
109110
private async onAuthSuccess(
110111
authorization: Authorization,
111-
authToken: string,
112112
callbackToken: Callback
113113
): Promise<void> {
114-
// Store authorization for later use
115-
await this.set(`authorization:${authToken}`, authorization);
116-
117-
// Execute the callback with the auth token
118-
await this.run(callbackToken, { authToken });
114+
// Execute the callback with the actor ID as auth token
115+
await this.run(callbackToken, { authToken: authorization.actor.id as string });
119116
}
120117

121118
/**

tools/gmail/src/gmail.ts

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
type ActorId,
23
type ActivityLink,
34
type NewActivityWithNotes,
45
Serializable,
@@ -13,7 +14,6 @@ import {
1314
} from "@plotday/twister/common/messaging";
1415
import { type Callback } from "@plotday/twister/tools/callbacks";
1516
import {
16-
AuthLevel,
1717
AuthProvider,
1818
type Authorization,
1919
Integrations,
@@ -121,9 +121,6 @@ export class Gmail extends Tool<Gmail> implements MessagingTool {
121121
"https://www.googleapis.com/auth/gmail.modify",
122122
];
123123

124-
// Generate opaque token for authorization
125-
const authToken = crypto.randomUUID();
126-
127124
const callbackToken = await this.tools.callbacks.createFromParent(
128125
callback,
129126
...extraArgs
@@ -134,29 +131,34 @@ export class Gmail extends Tool<Gmail> implements MessagingTool {
134131
return await this.tools.integrations.request(
135132
{
136133
provider: AuthProvider.Google,
137-
level: AuthLevel.User,
138134
scopes: gmailScopes,
139135
},
140136
this.onAuthSuccess,
141-
authToken,
142137
callbackToken
143138
);
144139
}
145140

146141
private async getApi(authToken: string): Promise<GmailApi> {
142+
// Try new flow: authToken is an ActorId
143+
const token = await this.tools.integrations.get(AuthProvider.Google, authToken as ActorId);
144+
if (token) {
145+
return new GmailApi(token.token);
146+
}
147+
148+
// Fall back to legacy flow: authToken is an opaque key
147149
const authorization = await this.get<Authorization>(
148150
`authorization:${authToken}`
149151
);
150152
if (!authorization) {
151153
throw new Error("Authorization no longer available");
152154
}
153155

154-
const token = await this.tools.integrations.get(authorization);
155-
if (!token) {
156+
const legacyToken = await this.tools.integrations.get(authorization.provider, authorization.actor.id);
157+
if (!legacyToken) {
156158
throw new Error("Authorization no longer available");
157159
}
158160

159-
return new GmailApi(token.token);
161+
return new GmailApi(legacyToken.token);
160162
}
161163

162164
async getChannels(authToken: string): Promise<MessageChannel[]> {
@@ -454,20 +456,13 @@ export class Gmail extends Tool<Gmail> implements MessagingTool {
454456

455457
async onAuthSuccess(
456458
authResult: Authorization,
457-
authToken: string,
458459
callback: Callback
459460
): Promise<void> {
460-
// Store the actual auth token using opaque token as key
461-
await this.set(`authorization:${authToken}`, authResult);
462-
463461
const authSuccessResult: MessagingAuth = {
464-
authToken,
462+
authToken: authResult.actor.id as string,
465463
};
466464

467465
await this.run(callback, authSuccessResult);
468-
469-
// Clean up the callback token
470-
await this.clear(`auth_callback_token:${authToken}`);
471466
}
472467
}
473468

tools/google-calendar/README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ npm install @plotday/tool-google-calendar @plotday/twister
1313
```typescript
1414
import { Twist, Tools } from "@plotday/twister";
1515
import { GoogleCalendar } from "@plotday/tool-google-calendar";
16-
import { Integrations, AuthLevel, AuthProvider } from "@plotday/twister/tools/integrations";
16+
import { Integrations, AuthProvider } from "@plotday/twister/tools/integrations";
1717

1818
export default class extends Twist {
1919
private googleCalendar: GoogleCalendar;
@@ -30,7 +30,6 @@ export default class extends Twist {
3030
const authLink = await this.integrations.request(
3131
{
3232
provider: AuthProvider.Google,
33-
level: AuthLevel.User,
3433
scopes: ["https://www.googleapis.com/auth/calendar.readonly"],
3534
},
3635
{

0 commit comments

Comments
 (0)