@assembly-js/app-foundation 0.0.0 → 0.0.1-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.
Files changed (54) hide show
  1. package/CLAUDE.md +1119 -0
  2. package/dist/api-errors.d.ts +5 -0
  3. package/dist/api-errors.js +33 -0
  4. package/dist/app-storage.d.ts +7 -0
  5. package/dist/app-storage.js +22 -0
  6. package/dist/assembly.d.ts +49 -0
  7. package/dist/assembly.js +235 -0
  8. package/dist/auth.d.ts +17 -0
  9. package/dist/auth.js +118 -0
  10. package/dist/client-companies.d.ts +16 -0
  11. package/dist/client-companies.js +23 -0
  12. package/dist/client.d.ts +5 -2
  13. package/dist/client.js +14 -3
  14. package/dist/labels.d.ts +9 -0
  15. package/dist/labels.js +17 -0
  16. package/dist/mint-bearer.d.ts +18 -0
  17. package/dist/mint-bearer.js +152 -0
  18. package/dist/nango.d.ts +174 -0
  19. package/dist/nango.js +430 -0
  20. package/dist/network-capture.d.ts +36 -0
  21. package/dist/network-capture.js +473 -0
  22. package/dist/params.d.ts +3 -0
  23. package/dist/params.js +20 -0
  24. package/dist/resource-resolution.d.ts +89 -0
  25. package/dist/resource-resolution.js +142 -0
  26. package/dist/server.d.ts +9 -2
  27. package/dist/server.js +15 -5
  28. package/dist/session-exchange.d.ts +20 -0
  29. package/dist/session-exchange.js +101 -0
  30. package/dist/theme.d.ts +5 -0
  31. package/dist/theme.js +20 -0
  32. package/package.json +9 -3
  33. package/skills/assembly-ai/SKILL.md +133 -0
  34. package/skills/assembly-app-bridge-header/SKILL.md +88 -0
  35. package/skills/assembly-app-bridge-toast/SKILL.md +79 -0
  36. package/skills/assembly-avatar-sub/SKILL.md +103 -0
  37. package/skills/assembly-client-users/SKILL.md +45 -0
  38. package/skills/assembly-clients-companies/SKILL.md +143 -0
  39. package/skills/assembly-companies/SKILL.md +45 -0
  40. package/skills/assembly-custom-fields/SKILL.md +86 -0
  41. package/skills/assembly-db-transactions/SKILL.md +79 -0
  42. package/skills/assembly-dev-server-logs/SKILL.md +26 -0
  43. package/skills/assembly-forms/SKILL.md +103 -0
  44. package/skills/assembly-internal-users/SKILL.md +69 -0
  45. package/skills/assembly-mutations/SKILL.md +58 -0
  46. package/skills/assembly-native-resources/SKILL.md +117 -0
  47. package/skills/assembly-route-auth/SKILL.md +223 -0
  48. package/skills/assembly-route-errors/SKILL.md +81 -0
  49. package/skills/assembly-semantic-colors/SKILL.md +76 -0
  50. package/skills/assembly-session-token/SKILL.md +153 -0
  51. package/skills/assembly-storage/SKILL.md +157 -0
  52. package/skills/assembly-user-company-selector/SKILL.md +98 -0
  53. package/skills/assembly-workspace-labels/SKILL.md +50 -0
  54. package/skills/clearing-client-notifications/SKILL.md +100 -0
@@ -0,0 +1,152 @@
1
+ import { assemblyApi, OpenAPI } from '@assembly-js/node-sdk';
2
+ import { BEARER_TTL_MS, classifyExchangeFailure, } from './session-exchange';
3
+ // Trusted API origin for this stack (build-env supplied); empty in prod where
4
+ // the SDK default is correct.
5
+ const mintBaseUrl = process.env.ASSEMBLY_API_BASE_URL || undefined;
6
+ // Loose bound for the destructive /session exchange. Aborting it does NOT undo
7
+ // the server-side mint — the single-use session token is consumed either way —
8
+ // so an early abort strands the caller with no bearer AND a spent token. Wait
9
+ // out a cold platform-api lambda rather than risk that.
10
+ const EXCHANGE_TIMEOUT_MS = 15_000;
11
+ // Trades a single-use session token for a short-lived, user-scoped bearer by
12
+ // calling platform-api POST /v1/session. This is the ONE sanctioned use of the
13
+ // app API key as a request credential — it mints the bearer; every subsequent
14
+ // data call uses the bearer alone.
15
+ //
16
+ // Shared by the proxy (first-load exchange) and the /api/assembly-reexchange
17
+ // route (silent renewal on bearer expiry). Deliberately uses only assemblyApi +
18
+ // fetch so it is safe in BOTH the Edge middleware runtime and the Node route-
19
+ // handler runtime.
20
+ //
21
+ // On failure returns ok:false with a reason (see MintFailure) so the caller can
22
+ // tell a spent token from an exchange it simply could not complete.
23
+ export async function mintBearerFromSessionToken(token) {
24
+ let assembly;
25
+ try {
26
+ assembly = await assemblyApi({
27
+ apiKey: process.env.ASSEMBLY_API_KEY ?? '',
28
+ token,
29
+ baseUrl: mintBaseUrl,
30
+ });
31
+ }
32
+ catch {
33
+ return { ok: false, reason: 'rejected' };
34
+ }
35
+ // An exchange-capable app now receives a slim token carrying only workspaceId
36
+ // + tokenId (see marketplace_application CreateAppToken), so identity
37
+ // (clientId/internalUserId/companyId) is NOT read from the decrypted payload —
38
+ // it is sourced below from the /session response, the authoritative
39
+ // server-side record that backs both slim and legacy full tokens.
40
+ let payload = null;
41
+ if (typeof assembly.getTokenPayload === 'function') {
42
+ try {
43
+ payload = await assembly.getTokenPayload();
44
+ }
45
+ catch { }
46
+ }
47
+ // Undecodable, or no tokenId → not exchange-capable. Fail closed: a bearer-
48
+ // capable app never authenticates from a raw token.
49
+ if (!payload?.workspaceId || !payload.tokenId) {
50
+ return { ok: false, reason: 'rejected' };
51
+ }
52
+ const now = Date.now();
53
+ const controller = new AbortController();
54
+ const timeoutId = setTimeout(() => controller.abort(), EXCHANGE_TIMEOUT_MS);
55
+ try {
56
+ const resp = await assembly.fetch('/v1/session', {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/json' },
59
+ body: JSON.stringify({ sessionId: payload.tokenId }),
60
+ signal: controller.signal,
61
+ });
62
+ if (!resp.ok) {
63
+ return {
64
+ ok: false,
65
+ reason: classifyExchangeFailure(resp.status),
66
+ };
67
+ }
68
+ // session.* mirrors platform-api's ExchangeResponse.Session — the
69
+ // authoritative server-side record backing the (possibly slim) token, so
70
+ // identity is sourced from it rather than the decrypted token payload.
71
+ const json = await resp.json();
72
+ const bearerExpiresAt = Math.min(json.tokenExpiresAt * 1_000, now + BEARER_TTL_MS);
73
+ return {
74
+ ok: true,
75
+ bearer: json.token,
76
+ bearerExpiresAt,
77
+ session: {
78
+ workspaceId: json.session?.portalId || payload.workspaceId,
79
+ clientId: json.session?.clientUserId || undefined,
80
+ internalUserId: json.session?.internalUserId || undefined,
81
+ companyId: json.session?.companyId || undefined,
82
+ notificationId: json.session?.notificationId || undefined,
83
+ instanceId: json.session?.instanceId || undefined,
84
+ sessionId: payload.tokenId,
85
+ },
86
+ };
87
+ }
88
+ catch {
89
+ // Network failure, or the abort above fired. Either way the exchange did not
90
+ // complete, so we cannot claim the token was spent.
91
+ return { ok: false, reason: 'unavailable' };
92
+ }
93
+ finally {
94
+ clearTimeout(timeoutId);
95
+ }
96
+ }
97
+ // Mints a short-lived, APP-scoped bearer from the app API key ALONE by calling
98
+ // platform-api POST /v1/token (client-credentials). There is NO session token
99
+ // and NO user in the loop: this is the OFFLINE/background path (cron, webhooks)
100
+ // where no iframe ever handed us a session token to exchange. The app key mints
101
+ // the bearer once; every subsequent data call then uses that bearer alone.
102
+ //
103
+ // Uses a runtime-agnostic fetch (safe in BOTH the Edge middleware and Node route-
104
+ // handler runtimes) with the same abort-based timeout as the session exchange,
105
+ // and fails closed (null) on any error — including a malformed 2xx — so a caller
106
+ // never proceeds with a missing or bad credential.
107
+ export async function mintBearerFromAppKey() {
108
+ const apiKey = process.env.ASSEMBLY_API_KEY;
109
+ if (!apiKey)
110
+ return null;
111
+ // Client-credentials mint: POST /v1/token authenticated by the app key sent as
112
+ // X-API-Key. Unlike mintBearerFromSessionToken (which decrypts a session token
113
+ // and lets the SDK derive the header), there is no token here, and the SDK only
114
+ // turns a bare apiKey into an X-API-Key in local/staging env modes — so in prod
115
+ // `assemblyApi({ apiKey })` would fail closed before ever reaching /v1/token.
116
+ // Send the credential directly against the trusted build-env base (or the SDK
117
+ // default in prod, where it is already correct).
118
+ const base = (mintBaseUrl ?? OpenAPI.BASE).replace(/\/$/, '');
119
+ const now = Date.now();
120
+ const controller = new AbortController();
121
+ const timeoutId = setTimeout(() => controller.abort(), EXCHANGE_TIMEOUT_MS);
122
+ try {
123
+ const resp = await fetch(`${base}/v1/token`, {
124
+ method: 'POST',
125
+ headers: { 'X-API-Key': apiKey },
126
+ signal: controller.signal,
127
+ });
128
+ if (!resp.ok)
129
+ return null;
130
+ const json = (await resp.json());
131
+ // Fail closed on a malformed or already-expired 2xx: a missing/mistyped token
132
+ // would yield a client that sends `Authorization: Bearer undefined`, a
133
+ // missing/NaN tokenExpiresAt would produce `bearerExpiresAt: NaN`, and an
134
+ // expiry at/before now would return a bearer whose first data call fails with
135
+ // auth errors instead of failing closed here at mint time. None is usable.
136
+ if (typeof json.token !== 'string' ||
137
+ json.token.length === 0 ||
138
+ typeof json.tokenExpiresAt !== 'number' ||
139
+ !Number.isFinite(json.tokenExpiresAt) ||
140
+ json.tokenExpiresAt * 1_000 <= now) {
141
+ return null;
142
+ }
143
+ const bearerExpiresAt = Math.min(json.tokenExpiresAt * 1_000, now + BEARER_TTL_MS);
144
+ return { bearer: json.token, bearerExpiresAt };
145
+ }
146
+ catch {
147
+ return null;
148
+ }
149
+ finally {
150
+ clearTimeout(timeoutId);
151
+ }
152
+ }
@@ -0,0 +1,174 @@
1
+ import { type ResourceFieldMap, type ResourcePage } from './resource-resolution';
2
+ export type Provider = 'slack' | 'gmail' | 'google-calendar' | 'google-drive' | 'google-sheet' | 'hubspot' | 'salesforce' | 'notion' | 'linear' | 'stripe' | 'github';
3
+ export type ConnectorErrorCode = 'app_key_required' | 'provider_not_declared' | 'connection_required' | 'needs_reauth' | 'needs_reauth_scope' | 'connection_throttled' | 'connector_unavailable' | 'action_failed' | 'invalid_request';
4
+ export interface ConnectionStatus {
5
+ provider: string;
6
+ connected: boolean;
7
+ status: 'connected' | 'connection_required' | 'needs_reauth' | 'needs_reauth_scope' | string;
8
+ }
9
+ export declare class ConnectorError extends Error {
10
+ readonly code: ConnectorErrorCode | string;
11
+ readonly provider: string;
12
+ readonly status: number;
13
+ /**
14
+ * For `action_failed`: what the action script threw — usually the
15
+ * provider-level failure, e.g. `{ type: 'slack_error', message, response }`
16
+ * where `response.error` is Slack's error code.
17
+ */
18
+ readonly detail?: unknown;
19
+ constructor(args: {
20
+ code: ConnectorErrorCode | string;
21
+ provider: string;
22
+ status: number;
23
+ detail?: unknown;
24
+ });
25
+ }
26
+ export interface ProxyOpts {
27
+ endpoint: string;
28
+ method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | string;
29
+ data?: unknown;
30
+ params?: Record<string, string | number | boolean | undefined>;
31
+ headers?: Record<string, string>;
32
+ }
33
+ /**
34
+ * Returns a thin, server-side-only client for a connected provider. Every method
35
+ * POSTs to the Assembly connector broker (`/v1/connectors/:provider/...`) via
36
+ * getAssembly().fetch — there is no Nango client and no provider credentials in
37
+ * the app. The broker resolves the workspace's connection server-side and
38
+ * relays the provider response transparently.
39
+ *
40
+ * MUST be called from server-side code only (API routes, server components,
41
+ * server actions) within an authenticated request scope. `getAssembly()` reads
42
+ * the viewer bearer from the request cookie (same as every other data call);
43
+ * there is no `x-assembly-token` header and no token argument. Background/cron
44
+ * calls with no viewer are out of scope for v1 — see connector Phase-2.
45
+ */
46
+ export declare function getIntegration(provider: Provider): Promise<{
47
+ proxy: (opts: ProxyOpts) => Promise<Response>;
48
+ get: (opts: Omit<ProxyOpts, "method">) => Promise<Response>;
49
+ post: (opts: Omit<ProxyOpts, "method">) => Promise<Response>;
50
+ put: (opts: Omit<ProxyOpts, "method">) => Promise<Response>;
51
+ patch: (opts: Omit<ProxyOpts, "method">) => Promise<Response>;
52
+ delete: (opts: Omit<ProxyOpts, "method">) => Promise<Response>;
53
+ action: <T = unknown>(name: string, input?: unknown) => Promise<T>;
54
+ status: () => Promise<ConnectionStatus>;
55
+ records: (_model: string, _query?: Record<string, unknown>) => never;
56
+ }>;
57
+ export type IntegrationClient = Awaited<ReturnType<typeof getIntegration>>;
58
+ /**
59
+ * Report whether a provider is connected for the current workspace via the
60
+ * broker's read-only status endpoint — no provider call is made, so an
61
+ * unrelated call failure can never masquerade as "not connected".
62
+ *
63
+ * - status says connected → true
64
+ * - status says connection_required /
65
+ * needs_reauth / needs_reauth_scope → false (user must (re)connect)
66
+ * - provider_not_declared / app_key_required → false (a wiring bug — the
67
+ * provider is unusable until the manifest / call site is fixed)
68
+ * - the status call itself fails
69
+ * (connector_unavailable, transport) → true (unknown ≠ disconnected; a
70
+ * healthy connection must never render a Connect prompt over a blip)
71
+ *
72
+ * Do NOT hand-roll connectivity checks by making a provider call and branching
73
+ * on whether it succeeded — that reports a healthy connection as "not
74
+ * connected" the moment any unrelated call fails (the classic bug: probing a
75
+ * non-existent endpoint 404s, and the app shows "not connected" while real
76
+ * reads/writes succeed).
77
+ */
78
+ export declare function isConnected(provider: Provider): Promise<boolean>;
79
+ export interface ResolveResourceOpts {
80
+ /** Search text. Trimmed; an empty query returns the unfiltered first page. */
81
+ query?: string;
82
+ /** Opaque next-page cursor from the previous page's result. */
83
+ cursor?: string;
84
+ /** Where id/label/items/cursor live on the action's result — take it from the action's catalog signature. */
85
+ fields: ResourceFieldMap;
86
+ /**
87
+ * Action input key that carries the search query. Omit when the resolver
88
+ * action has no server-side search — the fetched page is then filtered by
89
+ * label locally instead.
90
+ */
91
+ queryInput?: string;
92
+ /** Action input key that carries the page cursor. Defaults to 'cursor'. */
93
+ cursorInput?: string;
94
+ /** Extra action input merged verbatim (e.g. `{ baseId }` for Airtable). */
95
+ input?: Record<string, unknown>;
96
+ }
97
+ /**
98
+ * Fetch one page of provider resources as normalized `{ id, label }` options —
99
+ * the server half of a resource picker (channel/folder/spreadsheet/base
100
+ * selection). Runs the resolver action through the broker and maps the result
101
+ * via `fields`, so apps never re-derive pagination, id/label mapping, or
102
+ * connector error handling per picker.
103
+ *
104
+ * Returns a serializable ResourcePage rather than throwing ConnectorError:
105
+ * call it from a server action and hand the result (or the action itself) to
106
+ * `<ResourcePicker>`, which renders the matching option list, "Load more",
107
+ * and reconnect/error states. Non-connector errors still propagate.
108
+ *
109
+ * @example
110
+ * // app/actions/channels.ts
111
+ * 'use server';
112
+ * import { getIntegration, resolveResource } from '@assembly-js/app-foundation/server';
113
+ *
114
+ * export async function searchChannels(args: { query: string; cursor?: string }) {
115
+ * // list-channels has no query input, so pages are label-filtered locally.
116
+ * return resolveResource('slack', 'list-channels', {
117
+ * ...args,
118
+ * fields: { id: 'id', label: 'name', items: 'conversations', nextCursor: 'next_cursor' },
119
+ * });
120
+ * }
121
+ */
122
+ export declare function resolveResource(provider: Provider, resolverAction: string, opts: ResolveResourceOpts): Promise<ResourcePage>;
123
+ /**
124
+ * Resolve a Slack channel name to its ID, walking the cursor-paginated
125
+ * conversations.list until found (Slack treats `limit` as a per-page cap and
126
+ * may return fewer; a single un-paginated call misses channels in large
127
+ * workspaces). Input is normalized (leading '#' stripped, lowercased — Slack
128
+ * channel names are always lowercase). Resolved IDs are memoized for the
129
+ * process lifetime; Slack rate limits (conversations.list is a low-tier
130
+ * method) are absorbed by waiting and resuming from the same cursor.
131
+ *
132
+ * Use the returned ID for every follow-up call — conversations.join /
133
+ * conversations.history / chat.postMessage should all be given IDs; most
134
+ * methods reject a `#name` with `invalid_arguments`.
135
+ *
136
+ * Membership: public channels are listed whether or not the bot joined, but
137
+ * posting still needs membership — on a `not_in_channel` error from
138
+ * chat.postMessage, call conversations.join with the ID and retry once.
139
+ * Private channels are listed only when the bot is already a member (and only
140
+ * when the connection has the groups:read scope — without it this helper
141
+ * automatically falls back to public channels only). A private channel cannot
142
+ * be self-joined: surface "invite the bot to #channel in Slack" to the user.
143
+ */
144
+ export declare function slackFindChannelId(slack: IntegrationClient, name: string): Promise<string | null>;
145
+ export interface SlackPostMessageResult {
146
+ ok: boolean;
147
+ /** Timestamp ID of the posted message (present when ok). */
148
+ ts?: string;
149
+ /** Resolved channel ID (present once the channel was found). */
150
+ channelId?: string;
151
+ /** Slack error code when not ok, e.g. 'channel_not_found', 'not_in_channel'. */
152
+ error?: string;
153
+ }
154
+ /**
155
+ * Post a message to a Slack channel by name or ID. Use this instead of calling
156
+ * the send-message action or chat.postMessage yourself: it resolves `#name` →
157
+ * ID via slackFindChannelId (Slack rejects names with `invalid_arguments`),
158
+ * sends via the pre-built `send-message` action, and on `not_in_channel` it
159
+ * joins the channel and retries once (posting can require membership; joining
160
+ * needs the `channels:join` scope).
161
+ *
162
+ * Slack-level failures come back as `{ ok: false, error }` rather than throwing,
163
+ * so render the right state per code:
164
+ * - `channel_not_found` — no such channel; check the name.
165
+ * - `not_in_channel` — the bot couldn't self-join (private channel, or
166
+ * `channels:join` not declared in the manifest scopes): tell the user to
167
+ * invite the bot to the channel in Slack.
168
+ * Broker-level failures (provider not connected, throttled) still throw
169
+ * ConnectorError like every other call.
170
+ */
171
+ export declare function slackPostMessage(slack: IntegrationClient, args: {
172
+ channel: string;
173
+ text: string;
174
+ }): Promise<SlackPostMessageResult>;