@assembly-js/app-foundation 0.14.2 → 0.14.4
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.
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,12 @@ A patch release never changes what an app's build requires; a minor release may
|
|
|
19
19
|
not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
|
|
20
20
|
`bump: minor` change file.
|
|
21
21
|
|
|
22
|
+
## 0.14.4
|
|
23
|
+
- [guidance] A selected company now gets one message on its company channel instead of a new individual channel per client.
|
|
24
|
+
|
|
25
|
+
## 0.14.3
|
|
26
|
+
- [guidance] The build agent now sets up services that sign in with OAuth (such as Dropbox) with a connection that renews itself, explains every setup step up front, and never asks for a token in chat.
|
|
27
|
+
|
|
22
28
|
## 0.14.2
|
|
23
29
|
- [guidance] The build agent now keeps each row's own id in forms with add-and-remove rows (answer options, line items), so a selected row is no longer rejected as missing.
|
|
24
30
|
|
package/CLAUDE.md
CHANGED
|
@@ -62,6 +62,7 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
|
|
|
62
62
|
- `assembly-ai` — AI features, including speech-to-text, via Assembly's OpenAI proxy (no API keys needed)
|
|
63
63
|
- `assembly-storage` — app-private file uploads/downloads (NOT the portal Files module). The storage helpers come from `@assembly-js/app-foundation/server` — import them, don't rewrite them. POST only `{ fileName }` (a bare name) and store the key the platform returns; never build the S3 key yourself.
|
|
64
64
|
- `assembly-integrations` (present only when third-party integrations are enabled for this workspace) — third-party providers (Slack, etc.) via `requiredIntegrations` + `getIntegration()`. If this skill is present, read it and `.claude/skills/assembly-integrations/providers/<slug>.md` before declaring an integration or writing any provider call — the skill lists what's connectable, the provider doc has that provider's gotchas — and read them first when provider data displays wrongly (wrong format, raw IDs). If it is absent, managed OAuth integrations are not available here; use `requiredSecrets` for any external service
|
|
65
|
+
- `assembly-oauth-secrets` — REQUIRED before a `requiredSecrets` entry for a provider that signs in with OAuth (Dropbox, Google, Microsoft; any access token that expires): store the app key, app secret and refresh token, never the access token; give the user every setup step in one message; never ask for a token in chat.
|
|
65
66
|
- `assembly-avatar-sub` — AvatarSub company badge wherever a client is rendered, including tables of your own records that reference a client; row duplication only for lists OF clients
|
|
66
67
|
- `assembly-user-company-selector` — UserCompanySelector for picking clients, internal users, companies
|
|
67
68
|
- `assembly-forms` — react-hook-form + zod + Field component validation pattern
|
package/package.json
CHANGED
|
@@ -69,6 +69,41 @@ await assembly.sendMessage({
|
|
|
69
69
|
|
|
70
70
|
`retrieveMessageChannel({ id })` is for a channel id you already resolved this request — never for one typed into the source.
|
|
71
71
|
|
|
72
|
+
## A company is one channel
|
|
73
|
+
|
|
74
|
+
When the user selects a **company**, send one message on that company's channel. Do not expand the company into its clients, and do not create an individual channel per client. That fan-out opens a new 1:1 thread for every person in the company and delivers the message there instead of the company thread they asked for.
|
|
75
|
+
|
|
76
|
+
Individual channels are only for a recipient the user picked as a specific client. The same rule applies to file channels: a company attachment goes on the company file channel.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
async function findOrCreateCompanyMessageChannel(
|
|
80
|
+
assembly: AssemblyAPI,
|
|
81
|
+
companyId: string,
|
|
82
|
+
): Promise<string> {
|
|
83
|
+
const { data } = await assembly.listMessageChannels({
|
|
84
|
+
companyId,
|
|
85
|
+
membershipType: 'company',
|
|
86
|
+
limit: 1,
|
|
87
|
+
});
|
|
88
|
+
// A 1:1 thread for someone at this company also carries companyId. Send only
|
|
89
|
+
// when the row is the company channel.
|
|
90
|
+
const existing = data?.find((channel) => channel.membershipType === 'company');
|
|
91
|
+
if (existing?.id) return existing.id;
|
|
92
|
+
|
|
93
|
+
// membershipType is a string enum the package root does not re-export, so a
|
|
94
|
+
// bare 'company' literal does not typecheck. Assert the request body.
|
|
95
|
+
const created = await assembly.createMessageChannel({
|
|
96
|
+
requestBody: { companyId, membershipType: 'company' } as Parameters<
|
|
97
|
+
AssemblyAPI['createMessageChannel']
|
|
98
|
+
>[0]['requestBody'],
|
|
99
|
+
});
|
|
100
|
+
if (!created.id) throw new Error('Company channel was not created');
|
|
101
|
+
return created.id;
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Pass `companyId` when creating an individual channel for a client who belongs to more than one company. A create with only `clientId` is rejected for those clients.
|
|
106
|
+
|
|
72
107
|
## Discovering a file channel
|
|
73
108
|
|
|
74
109
|
```ts
|
|
@@ -113,6 +148,7 @@ Render an explicit, honest empty state and stop — "The intake form hasn't been
|
|
|
113
148
|
| Matched on the id instead of a stable attribute | Same — an id is not stable across workspaces, a name is |
|
|
114
149
|
| Matched a form on `form.name` | Always undefined; the display name is `form.fields.name`, so nothing ever matches and the app renders its empty state forever |
|
|
115
150
|
| Fell back to a literal id when discovery found nothing | The feature appears to work and writes into the wrong place, or fails on a resource the workspace can't see |
|
|
151
|
+
| Expanded a selected company into one individual channel per client | The workspace gets a burst of new 1:1 threads, and the message never lands in the company channel |
|
|
116
152
|
| Used the Files module for app-private uploads | Attachments show up in the client's portal Files tab |
|
|
117
153
|
|
|
118
154
|
## Edge cases
|
|
@@ -121,5 +157,5 @@ Render an explicit, honest empty state and stop — "The intake form hasn't been
|
|
|
121
157
|
- **Every list method takes an options object**, even when you pass no filters — `listForms({})`, not `listForms()`.
|
|
122
158
|
- **List responses are paginated envelopes**, and `data` is nullable: destructure with a default and still index defensively (`const { data: forms = [] } = …; forms?.[0]`).
|
|
123
159
|
- **Narrow before you index.** Taking `[0]` off an unfiltered list is the same guess as a hardcoded id. Filter by `membershipType` (and `companyId` for a client in multiple companies); if two candidates genuinely remain, surface the choice rather than picking one.
|
|
124
|
-
- **
|
|
160
|
+
- **Look up the channel, then create it only if that company or client does not have one yet.** Use `membershipType: 'company'` with `companyId` for a company, and `membershipType: 'individual'` with `clientId` (plus `companyId` when the client is in multiple companies) for one client. The enum is not re-exported from the package root, so assert the request body as in the company example. Never turn a company selection into one individual channel per member.
|
|
125
161
|
- **Ids from the app bridge or a route param** are runtime values, not literals — those are fine to pass straight to `retrieve*`, after the usual auth and scope checks (see `assembly-auth`).
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: assembly-oauth-secrets
|
|
3
|
+
description: Use BEFORE declaring a requiredSecrets entry for a provider that signs in with OAuth 2 and is not connectable through assembly-integrations (Dropbox, Google, Microsoft, Box, Zoom, any API whose access tokens expire), and when a saved access token is rejected with 401 or expired_access_token. Covers declaring the app key, app secret and refresh token instead of an access token, renewing the access token in server code, and the setup steps to give the user in one message without asking for any token in chat.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
An OAuth access token expires within hours (Dropbox: about four), so one saved as a secret works briefly and is then rejected until someone replaces it by hand. If the `assembly-integrations` skill is present and lists the provider, use it and stop here. Otherwise the app stores three long-lived values and mints its own access tokens.
|
|
7
|
+
|
|
8
|
+
## Declare the app key, app secret and refresh token
|
|
9
|
+
|
|
10
|
+
Never declare the access token. Declare all three in one change, each with a `description` saying where the value comes from:
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
"requiredSecrets": [
|
|
14
|
+
{ "key": "DROPBOX_APP_KEY", "label": "Dropbox app key", "description": "From your app's Settings tab in the Dropbox App Console.", "type": "text" },
|
|
15
|
+
{ "key": "DROPBOX_APP_SECRET", "label": "Dropbox app secret", "description": "Shown next to the app key in the Dropbox App Console.", "type": "secret" },
|
|
16
|
+
{ "key": "DROPBOX_REFRESH_TOKEN", "label": "Dropbox refresh token", "description": "The refresh_token value from the one-time setup step.", "type": "secret" }
|
|
17
|
+
]
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Renew the access token in server code
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// lib/dropbox.ts (server only)
|
|
24
|
+
let cached: { token: string; expiresAt: number } | null = null;
|
|
25
|
+
|
|
26
|
+
export async function getDropboxAccessToken(): Promise<string> {
|
|
27
|
+
if (cached && cached.expiresAt > Date.now() + 60_000) return cached.token;
|
|
28
|
+
const res = await fetch('https://api.dropboxapi.com/oauth2/token', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
body: new URLSearchParams({
|
|
31
|
+
grant_type: 'refresh_token',
|
|
32
|
+
refresh_token: process.env.DROPBOX_REFRESH_TOKEN ?? '',
|
|
33
|
+
client_id: process.env.DROPBOX_APP_KEY ?? '',
|
|
34
|
+
client_secret: process.env.DROPBOX_APP_SECRET ?? '',
|
|
35
|
+
}),
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) throw new Error('Dropbox refused to renew access');
|
|
38
|
+
const data = await res.json();
|
|
39
|
+
cached = { token: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 };
|
|
40
|
+
return cached.token;
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Give the user the whole setup in one message
|
|
45
|
+
|
|
46
|
+
Send every step in the reply where you choose this path, before they try anything. A user who learns the steps one failure at a time spends hours on it. Use the form labels, not the key names. For Dropbox:
|
|
47
|
+
|
|
48
|
+
1. In the Dropbox App Console, create an app and turn on the permissions the app needs (changing them later means repeating steps 2 and 3). Copy the app key and app secret.
|
|
49
|
+
2. Open this link in a browser with their app key in place of APP_KEY, approve, and copy the code Dropbox shows: `https://www.dropbox.com/oauth2/authorize?client_id=APP_KEY&response_type=code&token_access_type=offline`. Without `token_access_type=offline` step 3 returns no refresh token (Google's equivalent is `access_type=offline&prompt=consent`).
|
|
50
|
+
3. Exchange the code once, on their own computer. This step needs a terminal; say so up front, and that the code works once and expires within minutes:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
curl https://api.dropboxapi.com/oauth2/token -d grant_type=authorization_code -d code=CODE -u APP_KEY:APP_SECRET
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The reply contains a `refresh_token`. If it has only an `access_token`, the link in step 2 lacked the offline parameter: repeat steps 2 and 3.
|
|
57
|
+
4. Enter the app key, app secret and refresh token in the secure form under your reply (later: composer **+** menu, **App secrets**). The refresh token keeps working until they revoke it.
|
|
58
|
+
|
|
59
|
+
## Never ask for a token in chat
|
|
60
|
+
|
|
61
|
+
- Never ask the user to paste an access token, refresh token, authorization code, app secret or the command's output. To debug step 3, ask for the error text, or whether the reply had a `refresh_token` field.
|
|
62
|
+
- Never run the exchange or a test call yourself. If a value lands in chat anyway, CLAUDE.md "When the user offers you a key" applies.
|