@assembly-js/app-foundation 0.0.1-rc.2 → 0.0.2-rc.1

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.
@@ -0,0 +1,205 @@
1
+ ---
2
+ name: assembly-integrations
3
+ description: Use BEFORE declaring requiredIntegrations in .assembly/app.json and before writing any call through getIntegration() from @/lib/nango (Slack and other OAuth providers — supported list inside). Covers manifest declaration, the broker client, per-provider gotchas (pagination, IDs vs names, membership/join), and ConnectorError handling.
4
+ ---
5
+
6
+ ## How it works
7
+
8
+ When your app needs to talk to a third-party provider's API, declare the provider in `requiredIntegrations` in `.assembly/app.json`. The workspace admin connects the provider once via an OAuth popup; Assembly stores the connection through Nango.
9
+
10
+ At runtime you call the provider through `getIntegration(provider)` from `@/lib/nango`. Calls go to the Assembly **connector broker** over your app's own authenticated session — you never see, handle, or store OAuth tokens or any Nango credentials, and there are **no per-provider environment variables**. The broker resolves the workspace's connection server-side (from your app's identity) and relays the provider's response back to you.
11
+
12
+ The flow:
13
+
14
+ 1. You declare `requiredIntegrations` in the manifest.
15
+ 2. The app parks in an `awaiting_config` state until every declared integration is connected (the same gate that waits on `requiredSecrets`).
16
+ 3. The admin connects each provider via the OAuth popup in the app builder UI.
17
+ 4. Once all integrations are connected, the deploy proceeds.
18
+ 5. Your server-side code calls `getIntegration(provider)` to reach the provider's API through the broker.
19
+
20
+ ## When to use this vs requiredSecrets
21
+
22
+ The Nango whitelist takes priority. If the provider is one of the supported providers below, always use `requiredIntegrations` — it gives the admin a one-click OAuth flow and Assembly manages token refresh for you.
23
+
24
+ Supported providers (the v1 whitelist):
25
+
26
+ <!-- ASSEMBLY_PROVIDERS -->
27
+
28
+ For anything **not** on this list (a provider with only an API key, an internal service, a niche SaaS), fall back to a static `requiredSecret`. Do not try to shoehorn an unsupported provider into `requiredIntegrations` — the build will fail with an "unsupported provider" error.
29
+
30
+ ## Manifest declaration
31
+
32
+ Add a top-level `requiredIntegrations` array to `.assembly/app.json`. Each entry is a provider key plus the OAuth scopes your app needs:
33
+
34
+ ```json
35
+ {
36
+ "requiredIntegrations": [
37
+ { "provider": "slack", "scopes": ["chat:write", "channels:read"] }
38
+ ]
39
+ }
40
+ ```
41
+
42
+ - `provider` must be one of the supported keys above.
43
+ - `scopes` lists the provider's OAuth scopes; request the minimum your app needs.
44
+
45
+ ## Calling the API
46
+
47
+ `getIntegration` is **async** and must run **server-side, inside an authenticated request scope** — an API route handler, a server component, or a server action where `getAssembly()` can read the viewer bearer cookie. It authenticates to the broker as the current viewer the same way every other data call does — no `x-assembly-token` header, no token argument. Do **not** call it from a client component, from a background/cron job, or outside a user request — it will fail to authenticate. In practice: call it from a route handler invoked via `authFetch`, or from a server component/action that uses `getAssembly()`.
48
+
49
+ ```typescript
50
+ import { getIntegration } from '@/lib/nango';
51
+ ```
52
+
53
+ `getIntegration(provider)` resolves to a thin client:
54
+
55
+ - **`action(name, input)`** — synchronously trigger a pre-built Nango Action and get its normalized result back inline as already-parsed JSON. Actions are vetted server-side implementations that stay fixed as providers evolve — **use the pre-built action whenever one below covers the operation.** Only the names listed below exist: never invent an action name (an unknown name fails at runtime after a full round trip).
56
+ - **`proxy(opts)`** plus verb sugar **`get` / `post` / `put` / `patch` / `delete`(opts)** — relay an arbitrary provider HTTP call, for everything an action doesn't cover. `opts` = `{ endpoint, method?, data?, params?, headers? }`. Each returns the **raw `Response`** (the provider's status/headers/body, relayed transparently) — read it with `await res.json()` / `await res.text()`.
57
+ - **`status()`** — read the workspace's stored connection state (`{ provider, connected, status }`) without touching the provider. See "Checking whether a provider is connected" below.
58
+
59
+ ### Pre-built actions
60
+
61
+ <!-- ASSEMBLY_ACTIONS -->
62
+
63
+ ### Resource inputs
64
+
65
+ Some action inputs are flagged above as **resource inputs**: the value is the ID of something that lives in the provider (a channel, a spreadsheet, a list), and users do not know these IDs. **Never collect a resource input with a free-text ID field.** Use the foundation `ResourcePicker` component if your foundation provides one; otherwise build the same pattern yourself: a select/combobox whose options come from your own API route calling the annotated resolver action, rendering each item's label field and storing its id field **verbatim** as the input value. Apply any note on the annotation (a required filter, a resolver parameter, a multi-select shape) in the fetch. When an annotation says a resource input has no connectable resolver, fall back to a clearly labeled, validated field (e.g. paste the resource's URL and extract the ID from it) — still never a bare "enter the ID" box.
66
+
67
+ ### Example: post a message to Slack
68
+
69
+ ```typescript
70
+ // app/api/slack/post/route.ts
71
+ import { NextRequest, NextResponse } from 'next/server';
72
+ import { getIntegration, slackPostMessage } from '@/lib/nango';
73
+
74
+ export async function POST(request: NextRequest) {
75
+ const { channel, text } = await request.json();
76
+ const slack = await getIntegration('slack');
77
+ const result = await slackPostMessage(slack, { channel, text });
78
+ if (!result.ok) {
79
+ // result.error is Slack's code — 'not_in_channel' means the bot must be
80
+ // invited to the channel in Slack; 'channel_not_found' means a bad name.
81
+ return NextResponse.json({ error: result.error }, { status: 502 });
82
+ }
83
+ return NextResponse.json(result);
84
+ }
85
+ ```
86
+
87
+ ### Example: read a channel's recent messages (action)
88
+
89
+ ```typescript
90
+ // app/api/slack/history/route.ts
91
+ import { NextRequest, NextResponse } from 'next/server';
92
+ import { getIntegration } from '@/lib/nango';
93
+
94
+ export async function GET(request: NextRequest) {
95
+ const channelId = request.nextUrl.searchParams.get('channelId') ?? '';
96
+ const slack = await getIntegration('slack');
97
+ const history = await slack.action('get-conversation-history', {
98
+ channel_id: channelId,
99
+ limit: 10,
100
+ });
101
+ return NextResponse.json(history);
102
+ }
103
+ ```
104
+
105
+ For an operation no action covers, relay the provider call through the proxy verbs — e.g.
106
+ `slack.post({ endpoint: '/chat.update', data: { channel: channelId, ts, text } })` returns the raw
107
+ provider `Response`.
108
+
109
+ ## Letting the user pick a provider resource — ResourcePicker + resolveResource
110
+
111
+ When the UI needs the user to choose a provider-side resource (a Slack channel, a Drive folder, a spreadsheet, an Airtable base), do NOT hand-roll the combobox plumbing — the foundation ships both halves:
112
+
113
+ - **`resolveResource(provider, resolverAction, { query?, cursor?, fields, queryInput?, cursorInput?, input? })`** from `@/lib/nango` — server-side. Runs the resolver action and normalizes the provider's shape to `{ id, label }` options plus `nextCursor`. It returns a serializable `ResourcePage` — broker failures come back as `{ ok: false, error: { code, provider } }` instead of throwing, so the result survives a Server Action boundary (a thrown error is redacted in production). `fields` maps the action's result shape (dot paths allowed) — read it off the action's catalog signature above. Pass `queryInput` only when the action has a server-side search input; otherwise each page is filtered by label locally.
114
+ - **`<ResourcePicker>`** from `@/app/components/resource-picker` — client-side searchable combobox with debounced search, cursor pagination ("Load more"), loading/empty states, and connector-error surfaces (`needs_reauth` renders a reconnect hint instead of an empty list). Store `value.id`, render `value.label`.
115
+
116
+ ```typescript
117
+ // app/actions/channels.ts
118
+ 'use server';
119
+ import { resolveResource } from '@/lib/nango';
120
+
121
+ export async function searchChannels(args: { query: string; cursor?: string }) {
122
+ // list-channels returns { conversations, next_cursor? } and has no query
123
+ // input, so pages are label-filtered locally.
124
+ return resolveResource('slack', 'list-channels', {
125
+ ...args,
126
+ fields: { id: 'id', label: 'name', items: 'conversations', nextCursor: 'next_cursor' },
127
+ });
128
+ }
129
+ ```
130
+
131
+ ```tsx
132
+ // client component
133
+ import { ResourcePicker } from '@/app/components/resource-picker';
134
+ import type { ResourceOption } from '@/lib/resource-resolution';
135
+
136
+ const [channel, setChannel] = useState<ResourceOption | null>(null);
137
+ <ResourcePicker fetchPage={searchChannels} value={channel} onValueChange={setChannel} placeholder="Search channels…" />
138
+ ```
139
+
140
+ ## Slack specifics
141
+
142
+ - **Post messages with `slackPostMessage` from `@/lib/nango` — never hand-roll `chat.postMessage`.** It resolves a `#name` to the channel ID, posts, and on `not_in_channel` joins the channel and retries once. Slack-level failures come back as `{ ok: false, error }`; on `not_in_channel` render "invite the bot to #channel in Slack" (private channels cannot be self-joined).
143
+ - **Resolve channel names with `slackFindChannelId` from `@/lib/nango` for every other per-channel call — never hand-roll the lookup.** It handles cursor pagination (a single `conversations.list` call misses channels in large workspaces), name normalization, scope fallback, rate limits, and caching. Use the returned ID for every follow-up call; most Slack methods reject a `#name` with `invalid_arguments`.
144
+ - **Writes need membership, not just visibility.** `slackPostMessage` handles this for posting. For any other write (reactions, uploads), a `not_in_channel` error means join first: the `join-channel` action with the channel ID, then retry once. Private channels cannot be self-joined — they fail `method_not_supported_for_channel_type`, so tell the user to invite the bot instead of retrying.
145
+ - **Scopes:** `chat:write.public` lets posting reach any public channel without joining it, so only the other writes need `join-channel`. Add `groups:read` if the app must find private channels.
146
+
147
+ ```typescript
148
+ import { getIntegration, slackFindChannelId } from '@/lib/nango';
149
+
150
+ const slack = await getIntegration('slack');
151
+ const channelId = await slackFindChannelId(slack, 'general');
152
+ ```
153
+
154
+ ## Error handling
155
+
156
+ A connector call throws a **`ConnectorError`** (with `.code`, `.provider`, `.status`) when the broker returns a structured failure. Catch it and render an empty/error state instead of crashing the page. The codes:
157
+
158
+ - `connection_required` / `needs_reauth` / `needs_reauth_scope` — the workspace admin needs to (re)connect the provider in app settings. Render a "needs setup / reconnect" state, not a crash.
159
+ - `connection_throttled` — too many calls on the shared connection; back off and retry.
160
+ - `connector_unavailable` — the provider or Nango is temporarily unavailable.
161
+ - `action_failed` — the pre-built action ran but the provider rejected the operation. The provider-level failure is in `err.detail` (e.g. `detail.response.error` is Slack's error code) — branch on it the way you would on a proxy response's error field.
162
+ - `app_key_required` / `provider_not_declared` — a wiring bug: you called from an unauthenticated scope, or didn't declare the provider in `requiredIntegrations`. Fix the call site / manifest.
163
+ - `invalid_request` — malformed relay input (bad action name, unreadable body). Fix the call site.
164
+
165
+ ```typescript
166
+ import { getIntegration } from '@/lib/nango';
167
+
168
+ try {
169
+ const slack = await getIntegration('slack');
170
+ const res = await slack.get({ endpoint: '/conversations.list' });
171
+ const data = await res.json();
172
+ // ... use data
173
+ } catch (err) {
174
+ // err is a ConnectorError — branch on err.code to pick the right empty state
175
+ return NextResponse.json({ error: 'Integration unavailable' }, { status: 503 });
176
+ }
177
+ ```
178
+
179
+ ### Checking whether a provider is connected
180
+
181
+ To gate UI on connectivity ("show a Connect prompt if Slack isn't connected"), use the **`isConnected`** helper from `@/lib/nango`. It asks the broker's read-only status endpoint for the stored connection state — **no provider call is made**, so it is cheap, has no side effects, and an unrelated call failure can never masquerade as "not connected". Do **not** probe by making a provider call and branching on whether it succeeded — a provider `404`/`400`/`500`, `action_failed`, or `connector_unavailable` all mean the connection **exists** and that call merely failed.
182
+
183
+ ```typescript
184
+ import { isConnected } from '@/lib/nango';
185
+
186
+ const slackConnected = await isConnected('slack');
187
+ ```
188
+
189
+ When the UI needs to distinguish *why* a provider isn't usable (render "Connect" vs "Reconnect"), read the full state from the client's **`status()`** method instead — it returns `{ provider, connected, status }` where `status` is `connected`, `connection_required`, `needs_reauth`, or `needs_reauth_scope`:
190
+
191
+ ```typescript
192
+ import { getIntegration } from '@/lib/nango';
193
+
194
+ const slack = await getIntegration('slack');
195
+ const { connected, status } = await slack.status();
196
+ ```
197
+
198
+ ## What NOT to do
199
+
200
+ - Do NOT call `getIntegration` from client components, from background/cron code, or outside a user request — it authenticates via `getAssembly()` (bearer cookie) and fails without a viewer. Server-side, authenticated request scope only.
201
+ - Do NOT persist, log, or cache OAuth tokens or connection details — the broker holds and resolves everything; you only ever get relayed responses.
202
+ - Do NOT install or import a provider's own SDK (`@slack/web-api`, `googleapis`, `@hubspot/api-client`, etc.). Always go through `getIntegration(provider)`.
203
+ - Do NOT install `@nangohq/node` or reference any `NANGO_*` environment variables — there are none; the broker is the only path to a connected provider.
204
+ - Do NOT use `requiredIntegrations` for a provider outside the supported whitelist — fall back to `requiredSecrets`.
205
+ - Do NOT infer "not connected" from a call failing (a non-2xx, a thrown error, a 404 from a wrong endpoint). A connection you can see in **Manage connections** stays connected regardless of individual call failures. Use `isConnected(provider)` / `status()` (above) for connectivity checks — never a live provider call.