@assembly-js/app-foundation 0.0.20 → 0.0.22
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/CLAUDE.md +3 -0
- package/bin/check-paint-beacon.mjs +81 -0
- package/dist/client.d.ts +2 -1
- package/dist/client.js +5 -3
- package/dist/mint-bearer.d.ts +1 -0
- package/dist/mint-bearer.js +34 -0
- package/dist/nango.d.ts +51 -6
- package/dist/nango.js +116 -49
- package/dist/network-capture.js +7 -39
- package/dist/origin-bootstrap.d.ts +22 -0
- package/dist/origin-bootstrap.js +120 -0
- package/dist/paint-beacon.d.ts +10 -0
- package/dist/paint-beacon.js +68 -0
- package/dist/session-exchange.d.ts +24 -0
- package/dist/session-exchange.js +38 -0
- package/package.json +1 -1
- package/skills/assembly-dev-server-logs/SKILL.md +7 -0
- package/skills/assembly-integrations/SKILL.md +1 -1
- package/skills/assembly-integrations/providers/slack.md +24 -8
- package/skills/assembly-session-token/SKILL.md +2 -0
- package/dist/parent-origin.d.ts +0 -17
- package/dist/parent-origin.js +0 -57
package/CLAUDE.md
CHANGED
|
@@ -197,6 +197,7 @@ Because the app runs in an iframe inside the parent dashboard, several normal Ne
|
|
|
197
197
|
```
|
|
198
198
|
`bun run build` fails (`check:next-config`) if `next.config.ts` is rewritten away from the wrapper form.
|
|
199
199
|
- **Leave `instrumentation-client.ts` in place.** It's a dev-only preview helper (hides Next's red error overlay and relays build status to the builder preview) that no-ops in production. Don't remove or repurpose it.
|
|
200
|
+
- **Leave the paint beacon alone.** After hydration the foundation posts `app.painted` to the parent and `sendBeacon('/__assembly/painted')` so the preview can tell executed from rendered. Do not add your own `/__assembly/painted` route, do not post `app.painted` yourself, and do not remove `<PaintBeacon />` from the root layout. None of this needs agent action. `bun run build` fails (`check:paint-beacon`) if the layout stops rendering it or the proxy stops routing its beacon POST.
|
|
200
201
|
- **`localStorage` works** but is scoped per-origin — the app's own Vercel domain. Useful for caching, not for sharing state with the parent.
|
|
201
202
|
- **Mobile-first layout with appropriate width.** The app renders in the iframe and must work down to 320 px. Design mobile-first: start with a single-column layout and scale up with `sm:`/`md:`/`lg:` breakpoints. **Content width depends on the app type:** most apps (tables, dashboards, boards) should use the full available width. Form-centric, single-column, or text-heavy apps that would look sparse at full width should center their content in a narrower column (`max-w-3xl mx-auto`). If the task description or plan specifies a content width, follow it; otherwise choose whichever looks better for the content. **When narrowing, place the `max-w-*` wrapper as the outermost page-level container** — outside `Tabs`, filter bars, and all other page chrome — so everything aligns to the same column. Do not narrow only the inner content of each tab panel while leaving `TabsList` or other siblings full-width.
|
|
202
203
|
- **Page gutters.** Every page must have horizontal padding so content never touches the iframe edges. Add `px-4 sm:px-6` to the outermost page container (typically `<main>` or the top-level wrapper div). This applies to both full-width and narrow layouts. Tables, filter bars, and cards should all sit inside this padded container — never render content edge-to-edge against the viewport.
|
|
@@ -644,6 +645,8 @@ import { loadItems } from '@/app/actions/items';
|
|
|
644
645
|
export default function MyWidget() {
|
|
645
646
|
const { data: items, isLoading, error, reload } = useServerAction(() => loadItems(), []);
|
|
646
647
|
// `deps` ([]) works like useEffect deps — list anything the action closes over.
|
|
648
|
+
// Deps must be primitives (an id, a cursor, `items.length`). A derived array or
|
|
649
|
+
// object is a new identity every render and spins the page in a fetch loop.
|
|
647
650
|
}
|
|
648
651
|
```
|
|
649
652
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fails the build when the post-paint beacon has been removed or bypassed.
|
|
3
|
+
// Dependency-free on purpose: it runs inside the generated app's sandbox
|
|
4
|
+
// before any deps beyond Node exist.
|
|
5
|
+
//
|
|
6
|
+
// The beacon (`<PaintBeacon />` in the root layout, `sendBeacon('/__assembly/painted')`
|
|
7
|
+
// + `app.painted` from paint-beacon.ts, and the proxy's beacon route) is how the
|
|
8
|
+
// builder preview tells "the page executed" from "the page actually rendered" —
|
|
9
|
+
// see the foundation CLAUDE.md's "Leave the paint beacon alone" and the
|
|
10
|
+
// assembly-dev-server-logs skill's "foundation-owned" lines. Both are prose;
|
|
11
|
+
// this check is the enforcement, mirroring check-next-config.mjs for the
|
|
12
|
+
// platform-owned Next config wrapper.
|
|
13
|
+
//
|
|
14
|
+
// An app without app/components/paint-beacon.tsx predates the vNext beacon
|
|
15
|
+
// (cloned from an older foundation). There the beacon was never the contract,
|
|
16
|
+
// so this check exits 0 — those apps have no paint signal to protect.
|
|
17
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
|
|
20
|
+
const ROOT = process.cwd();
|
|
21
|
+
const BEACON_COMPONENT = join('app', 'components', 'paint-beacon.tsx');
|
|
22
|
+
const LAYOUT_FILE = join('app', 'layout.tsx');
|
|
23
|
+
const PROXY_FILE = 'proxy.ts';
|
|
24
|
+
|
|
25
|
+
if (!existsSync(join(ROOT, BEACON_COMPONENT))) {
|
|
26
|
+
process.exit(0); // pre-beacon app: not this check's contract
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!existsSync(join(ROOT, LAYOUT_FILE))) {
|
|
30
|
+
console.error(
|
|
31
|
+
`check-paint-beacon: ${BEACON_COMPONENT} exists but ${LAYOUT_FILE} is missing. ` +
|
|
32
|
+
'The root layout is where <PaintBeacon /> must render — restore it.',
|
|
33
|
+
);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const layout = readFileSync(join(ROOT, LAYOUT_FILE), 'utf8');
|
|
38
|
+
const importsBeacon = /from\s+['"]@\/app\/components\/paint-beacon['"]/.test(layout);
|
|
39
|
+
const rendersBeacon = /<PaintBeacon\b/.test(layout);
|
|
40
|
+
|
|
41
|
+
if (!importsBeacon || !rendersBeacon) {
|
|
42
|
+
console.error(
|
|
43
|
+
`check-paint-beacon: ${LAYOUT_FILE} must import and render the foundation-owned paint beacon:\n\n` +
|
|
44
|
+
" import PaintBeacon from '@/app/components/paint-beacon';\n" +
|
|
45
|
+
' // …inside the root layout body:\n' +
|
|
46
|
+
' <PaintBeacon />\n\n' +
|
|
47
|
+
'This posts app.painted and sendBeacon(\'/__assembly/painted\') after hydration so the ' +
|
|
48
|
+
'builder preview can tell "executed" from "actually rendered". Do not remove it, and do not ' +
|
|
49
|
+
'post app.painted or hit /__assembly/painted yourself — the beacon already does both.',
|
|
50
|
+
);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (existsSync(join(ROOT, PROXY_FILE))) {
|
|
55
|
+
// Strip whole-line comments first so a commented-out route can't satisfy
|
|
56
|
+
// the check. Line comments only: a naive /* */ strip eats string literals
|
|
57
|
+
// like 'https://*.assembly.com' in the CSP list, and dependency-free means
|
|
58
|
+
// no real tokenizer.
|
|
59
|
+
const proxy = readFileSync(join(ROOT, PROXY_FILE), 'utf8').replace(/^\s*\/\/.*$/gm, '');
|
|
60
|
+
const importsHelpers =
|
|
61
|
+
/isPaintedBeaconPath/.test(proxy) && /handlePaintedBeacon/.test(proxy);
|
|
62
|
+
const usesHelpers =
|
|
63
|
+
/isPaintedBeaconPath\s*\(/.test(proxy) && /handlePaintedBeacon\s*\(/.test(proxy);
|
|
64
|
+
|
|
65
|
+
if (!importsHelpers || !usesHelpers) {
|
|
66
|
+
console.error(
|
|
67
|
+
`check-paint-beacon: ${PROXY_FILE} must route the beacon's POST before CSRF/exchange:\n\n` +
|
|
68
|
+
" import { isPaintedBeaconPath, handlePaintedBeacon } from '@assembly-js/app-foundation/client';\n" +
|
|
69
|
+
' // …first thing in the proxy function:\n' +
|
|
70
|
+
" if (request.method === 'POST' && isPaintedBeaconPath(request.nextUrl.pathname)) {\n" +
|
|
71
|
+
' return handlePaintedBeacon();\n' +
|
|
72
|
+
' }\n\n' +
|
|
73
|
+
"sendBeacon is a headerless POST — routing it through the normal CSRF/exchange path 403s it, " +
|
|
74
|
+
'silently breaking the paint signal. POST only: a GET to the path must fall through, not ' +
|
|
75
|
+
'mint a fake painted line.',
|
|
76
|
+
);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
process.exit(0);
|
package/dist/client.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './session-exchange.js';
|
|
2
2
|
export * from './network-capture.js';
|
|
3
|
-
export * from './
|
|
3
|
+
export * from './paint-beacon.js';
|
|
4
|
+
export * from './origin-bootstrap.js';
|
|
4
5
|
export * from './client-companies.js';
|
|
5
6
|
export * from './pagination.js';
|
|
6
7
|
export * from './theme.js';
|
package/dist/client.js
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// also runs in the Edge middleware runtime that proxy.ts uses.
|
|
8
8
|
export * from './session-exchange.js';
|
|
9
9
|
export * from './network-capture.js';
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
|
|
10
|
+
export * from './paint-beacon.js';
|
|
11
|
+
// Self-running on client module load: register the proxy-resolved portal
|
|
12
|
+
// origin with the bridge allowlist, and schedule the post-paint beacon. See
|
|
13
|
+
// each module's comment.
|
|
14
|
+
export * from './origin-bootstrap.js';
|
|
13
15
|
export * from './client-companies.js';
|
|
14
16
|
export * from './pagination.js';
|
|
15
17
|
export * from './theme.js';
|
package/dist/mint-bearer.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface MintSessionOptions {
|
|
|
21
21
|
cookieSessionId?: string;
|
|
22
22
|
}
|
|
23
23
|
export declare function mintBearerFromSessionToken(token: string, options?: MintSessionOptions): Promise<MintOutcome>;
|
|
24
|
+
export declare function fetchSessionFlags(bearer: string): Promise<string[] | null>;
|
|
24
25
|
export interface MintedAppBearer {
|
|
25
26
|
bearer: string;
|
|
26
27
|
bearerExpiresAt: number;
|
package/dist/mint-bearer.js
CHANGED
|
@@ -90,6 +90,7 @@ export async function mintBearerFromSessionToken(token, options) {
|
|
|
90
90
|
notificationId: json.session?.notificationId || undefined,
|
|
91
91
|
instanceId: json.session?.instanceId || undefined,
|
|
92
92
|
sessionId: payload.tokenId,
|
|
93
|
+
flags: json.flags?.length ? json.flags : undefined,
|
|
93
94
|
},
|
|
94
95
|
};
|
|
95
96
|
}
|
|
@@ -102,6 +103,39 @@ export async function mintBearerFromSessionToken(token, options) {
|
|
|
102
103
|
clearTimeout(timeoutId);
|
|
103
104
|
}
|
|
104
105
|
}
|
|
106
|
+
// Non-destructive read; a hung call only delays one cookie refresh, so give it
|
|
107
|
+
// far less rope than the exchange.
|
|
108
|
+
const FLAGS_TIMEOUT_MS = 5_000;
|
|
109
|
+
// Re-resolves the app-foundation-visible flags for the workspace behind an
|
|
110
|
+
// already-minted bearer (GET /v1/session/flags). The freshness half of the
|
|
111
|
+
// flag channel: the exchange response seeds the proxy's flags cookie at mint
|
|
112
|
+
// time, and this refreshes it on the cookie's own cadence — no re-mint, no
|
|
113
|
+
// auth churn. Returns null when the value could not be determined (network
|
|
114
|
+
// failure, expired bearer, malformed body) so the caller can keep whatever it
|
|
115
|
+
// has rather than treating the miss as "all flags off".
|
|
116
|
+
export async function fetchSessionFlags(bearer) {
|
|
117
|
+
const base = (mintBaseUrl ?? OpenAPI.BASE).replace(/\/$/, '');
|
|
118
|
+
const controller = new AbortController();
|
|
119
|
+
const timeoutId = setTimeout(() => controller.abort(), FLAGS_TIMEOUT_MS);
|
|
120
|
+
try {
|
|
121
|
+
const resp = await fetch(`${base}/v1/session/flags`, {
|
|
122
|
+
headers: { Authorization: `Bearer ${bearer}` },
|
|
123
|
+
signal: controller.signal,
|
|
124
|
+
});
|
|
125
|
+
if (!resp.ok)
|
|
126
|
+
return null;
|
|
127
|
+
const json = (await resp.json());
|
|
128
|
+
if (!Array.isArray(json.flags))
|
|
129
|
+
return null;
|
|
130
|
+
return json.flags.filter((f) => typeof f === 'string');
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
clearTimeout(timeoutId);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
105
139
|
// Mints a short-lived, APP-scoped bearer from the app API key ALONE by calling
|
|
106
140
|
// platform-api POST /v1/token (client-credentials). There is NO session token
|
|
107
141
|
// and NO user in the loop: this is the OFFLINE/background path (cron, webhooks)
|
package/dist/nango.d.ts
CHANGED
|
@@ -12,8 +12,9 @@ export declare class ConnectorError extends Error {
|
|
|
12
12
|
readonly status: number;
|
|
13
13
|
/**
|
|
14
14
|
* For `action_failed`: what the action script threw — usually the
|
|
15
|
-
* provider-level failure
|
|
16
|
-
*
|
|
15
|
+
* provider-level failure. Scripts disagree on where the provider's error
|
|
16
|
+
* code sits inside it (`response.error`, `error`, or `message`), so don't
|
|
17
|
+
* read it by hand: for Slack use `slackErrorCode(err)`.
|
|
17
18
|
*/
|
|
18
19
|
readonly detail?: unknown;
|
|
19
20
|
constructor(args: {
|
|
@@ -120,6 +121,49 @@ export interface ResolveResourceOpts {
|
|
|
120
121
|
* }
|
|
121
122
|
*/
|
|
122
123
|
export declare function resolveResource(provider: Provider, resolverAction: string, opts: ResolveResourceOpts): Promise<ResourcePage>;
|
|
124
|
+
/**
|
|
125
|
+
* Slack's error code (e.g. 'not_in_channel', 'channel_not_found',
|
|
126
|
+
* 'is_archived') from a failed Slack action, or undefined when `err` is not an
|
|
127
|
+
* action failure. Branch on this instead of reading `err.detail` yourself: the
|
|
128
|
+
* vendored Slack scripts put the code in different places (`response.error`,
|
|
129
|
+
* `error`, or `message`), and this reads them in that order.
|
|
130
|
+
*
|
|
131
|
+
* ```typescript
|
|
132
|
+
* try {
|
|
133
|
+
* await slack.action('get-conversation-history', { channel_id, limit: 10 });
|
|
134
|
+
* } catch (err) {
|
|
135
|
+
* if (slackErrorCode(err) === 'not_in_channel') {
|
|
136
|
+
* // join with the channel ID and retry once; private channels can't be
|
|
137
|
+
* // self-joined — tell the user to invite the bot instead
|
|
138
|
+
* }
|
|
139
|
+
* throw err;
|
|
140
|
+
* }
|
|
141
|
+
* ```
|
|
142
|
+
*/
|
|
143
|
+
export declare function slackErrorCode(err: unknown): string | undefined;
|
|
144
|
+
export interface SlackChannel {
|
|
145
|
+
/** Channel ID (`C…`/`G…`) — what every other Slack call takes. */
|
|
146
|
+
id: string;
|
|
147
|
+
/** Channel name without the leading '#'. Slack keeps these lowercase. */
|
|
148
|
+
name: string;
|
|
149
|
+
/** True for private channels (listed only when the bot is a member and the connection has `groups:read`). */
|
|
150
|
+
isPrivate: boolean;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Every channel the bot can see, sorted by name. Use this to populate a
|
|
154
|
+
* channel dropdown instead of paging the `list-channels` action yourself: that
|
|
155
|
+
* action tops out at 200 per page and a large workspace needs dozens of
|
|
156
|
+
* sequential calls, which is a 20–30 second dropdown. This walks
|
|
157
|
+
* conversations.list at Slack's maximum page size and handles rate limits and
|
|
158
|
+
* the private-channel scope fallback. Not cached: the client carries no
|
|
159
|
+
* workspace identity to key on, and an app transfer briefly serves two
|
|
160
|
+
* workspaces from one deployment.
|
|
161
|
+
*
|
|
162
|
+
* Public channels are listed whether or not the bot has joined; private
|
|
163
|
+
* channels only when it is already a member. Listing a channel does not make
|
|
164
|
+
* it readable — see slackFindChannelId for the membership rules.
|
|
165
|
+
*/
|
|
166
|
+
export declare function slackListChannels(slack: IntegrationClient): Promise<SlackChannel[]>;
|
|
123
167
|
/**
|
|
124
168
|
* Resolve a Slack channel name to its ID, walking the cursor-paginated
|
|
125
169
|
* conversations.list until found (Slack treats `limit` as a per-page cap and
|
|
@@ -134,10 +178,11 @@ export declare function resolveResource(provider: Provider, resolverAction: stri
|
|
|
134
178
|
* methods reject a `#name` with `invalid_arguments`.
|
|
135
179
|
*
|
|
136
180
|
* Membership: public channels are listed whether or not the bot joined, but
|
|
137
|
-
* posting
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
181
|
+
* everything except posting (`send-message`) needs membership — reading
|
|
182
|
+
* history, adding reactions, inviting users all fail `not_in_channel` until
|
|
183
|
+
* the bot joins. On that code, call `join-channel` with the ID and retry
|
|
184
|
+
* once. Private channels are listed only when the bot is already a member (and
|
|
185
|
+
* only when the connection has the groups:read scope — without it this helper
|
|
141
186
|
* automatically falls back to public channels only). A private channel cannot
|
|
142
187
|
* be self-joined: surface "invite the bot to #channel in Slack" to the user.
|
|
143
188
|
*/
|
package/dist/nango.js
CHANGED
|
@@ -10,8 +10,9 @@ export class ConnectorError extends Error {
|
|
|
10
10
|
status;
|
|
11
11
|
/**
|
|
12
12
|
* For `action_failed`: what the action script threw — usually the
|
|
13
|
-
* provider-level failure
|
|
14
|
-
*
|
|
13
|
+
* provider-level failure. Scripts disagree on where the provider's error
|
|
14
|
+
* code sits inside it (`response.error`, `error`, or `message`), so don't
|
|
15
|
+
* read it by hand: for Slack use `slackErrorCode(err)`.
|
|
15
16
|
*/
|
|
16
17
|
detail;
|
|
17
18
|
constructor(args) {
|
|
@@ -281,47 +282,40 @@ function sleep(ms) {
|
|
|
281
282
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
282
283
|
}
|
|
283
284
|
/**
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
* process lifetime; Slack rate limits (conversations.list is a low-tier
|
|
290
|
-
* method) are absorbed by waiting and resuming from the same cursor.
|
|
285
|
+
* Slack's error code (e.g. 'not_in_channel', 'channel_not_found',
|
|
286
|
+
* 'is_archived') from a failed Slack action, or undefined when `err` is not an
|
|
287
|
+
* action failure. Branch on this instead of reading `err.detail` yourself: the
|
|
288
|
+
* vendored Slack scripts put the code in different places (`response.error`,
|
|
289
|
+
* `error`, or `message`), and this reads them in that order.
|
|
291
290
|
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
291
|
+
* ```typescript
|
|
292
|
+
* try {
|
|
293
|
+
* await slack.action('get-conversation-history', { channel_id, limit: 10 });
|
|
294
|
+
* } catch (err) {
|
|
295
|
+
* if (slackErrorCode(err) === 'not_in_channel') {
|
|
296
|
+
* // join with the channel ID and retry once; private channels can't be
|
|
297
|
+
* // self-joined — tell the user to invite the bot instead
|
|
298
|
+
* }
|
|
299
|
+
* throw err;
|
|
300
|
+
* }
|
|
301
|
+
* ```
|
|
303
302
|
*/
|
|
304
|
-
export
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
303
|
+
export function slackErrorCode(err) {
|
|
304
|
+
if (!(err instanceof ConnectorError) || err.code !== 'action_failed')
|
|
305
|
+
return undefined;
|
|
306
|
+
const detail = err.detail;
|
|
307
|
+
const code = detail?.response?.error ?? detail?.error ?? detail?.message;
|
|
308
|
+
return typeof code === 'string' ? code : undefined;
|
|
309
|
+
}
|
|
310
|
+
// One-page fetcher over conversations.list that owns the Slack quirks a naive
|
|
311
|
+
// walk gets wrong: rate-limit waits resume from the SAME cursor (throwing would
|
|
312
|
+
// discard the walk and a retry would restart from page 1), and a missing_scope
|
|
313
|
+
// for private_channel (no groups:read) fails the WHOLE first call, so retrying
|
|
314
|
+
// public-only from the same cursor restarts the walk correctly.
|
|
315
|
+
function slackChannelPageFetcher(slack) {
|
|
317
316
|
let types = 'public_channel,private_channel';
|
|
318
317
|
let rateLimitWaits = 0;
|
|
319
|
-
|
|
320
|
-
// rate-limit waits resume from the SAME cursor (throwing would discard the
|
|
321
|
-
// walk and a retry would restart from page 1), and a missing_scope for
|
|
322
|
-
// private_channel (no groups:read) fails the WHOLE first call, so retrying
|
|
323
|
-
// public-only from the same cursor restarts the walk correctly.
|
|
324
|
-
const fetchPage = async (cursor) => {
|
|
318
|
+
return async (cursor) => {
|
|
325
319
|
for (;;) {
|
|
326
320
|
let res;
|
|
327
321
|
try {
|
|
@@ -361,14 +355,90 @@ export async function slackFindChannelId(slack, name) {
|
|
|
361
355
|
}
|
|
362
356
|
throw new Error(data.error ?? 'Slack conversations.list failed');
|
|
363
357
|
}
|
|
364
|
-
|
|
358
|
+
const channels = [];
|
|
359
|
+
for (const channel of data.channels ?? []) {
|
|
360
|
+
if (!channel.id || !channel.name)
|
|
361
|
+
continue;
|
|
362
|
+
channels.push({
|
|
363
|
+
id: channel.id,
|
|
364
|
+
name: channel.name,
|
|
365
|
+
isPrivate: channel.is_private === true,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
// Slack sends an empty string, not an absent key, on the last page; a
|
|
369
|
+
// cursor identical to the one just used would loop forever.
|
|
370
|
+
const next = data.response_metadata?.next_cursor || undefined;
|
|
371
|
+
return { channels, nextCursor: next && next !== cursor ? next : undefined };
|
|
365
372
|
}
|
|
366
373
|
};
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Every channel the bot can see, sorted by name. Use this to populate a
|
|
377
|
+
* channel dropdown instead of paging the `list-channels` action yourself: that
|
|
378
|
+
* action tops out at 200 per page and a large workspace needs dozens of
|
|
379
|
+
* sequential calls, which is a 20–30 second dropdown. This walks
|
|
380
|
+
* conversations.list at Slack's maximum page size and handles rate limits and
|
|
381
|
+
* the private-channel scope fallback. Not cached: the client carries no
|
|
382
|
+
* workspace identity to key on, and an app transfer briefly serves two
|
|
383
|
+
* workspaces from one deployment.
|
|
384
|
+
*
|
|
385
|
+
* Public channels are listed whether or not the bot has joined; private
|
|
386
|
+
* channels only when it is already a member. Listing a channel does not make
|
|
387
|
+
* it readable — see slackFindChannelId for the membership rules.
|
|
388
|
+
*/
|
|
389
|
+
export async function slackListChannels(slack) {
|
|
390
|
+
const fetchPage = slackChannelPageFetcher(slack);
|
|
391
|
+
const channels = [];
|
|
392
|
+
let cursor;
|
|
393
|
+
for (let page = 0; page < SLACK_LIST_MAX_PAGES; page++) {
|
|
394
|
+
const result = await fetchPage(cursor);
|
|
395
|
+
channels.push(...result.channels);
|
|
396
|
+
if (!result.nextCursor)
|
|
397
|
+
break;
|
|
398
|
+
cursor = result.nextCursor;
|
|
399
|
+
}
|
|
400
|
+
channels.sort((a, b) => a.name.localeCompare(b.name));
|
|
401
|
+
return channels;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Resolve a Slack channel name to its ID, walking the cursor-paginated
|
|
405
|
+
* conversations.list until found (Slack treats `limit` as a per-page cap and
|
|
406
|
+
* may return fewer; a single un-paginated call misses channels in large
|
|
407
|
+
* workspaces). Input is normalized (leading '#' stripped, lowercased — Slack
|
|
408
|
+
* channel names are always lowercase). Resolved IDs are memoized for the
|
|
409
|
+
* process lifetime; Slack rate limits (conversations.list is a low-tier
|
|
410
|
+
* method) are absorbed by waiting and resuming from the same cursor.
|
|
411
|
+
*
|
|
412
|
+
* Use the returned ID for every follow-up call — conversations.join /
|
|
413
|
+
* conversations.history / chat.postMessage should all be given IDs; most
|
|
414
|
+
* methods reject a `#name` with `invalid_arguments`.
|
|
415
|
+
*
|
|
416
|
+
* Membership: public channels are listed whether or not the bot joined, but
|
|
417
|
+
* everything except posting (`send-message`) needs membership — reading
|
|
418
|
+
* history, adding reactions, inviting users all fail `not_in_channel` until
|
|
419
|
+
* the bot joins. On that code, call `join-channel` with the ID and retry
|
|
420
|
+
* once. Private channels are listed only when the bot is already a member (and
|
|
421
|
+
* only when the connection has the groups:read scope — without it this helper
|
|
422
|
+
* automatically falls back to public channels only). A private channel cannot
|
|
423
|
+
* be self-joined: surface "invite the bot to #channel in Slack" to the user.
|
|
424
|
+
*/
|
|
425
|
+
export async function slackFindChannelId(slack, name) {
|
|
426
|
+
const target = name.replace(/^#/, '').trim().toLowerCase();
|
|
427
|
+
if (!target)
|
|
428
|
+
return null;
|
|
429
|
+
const cached = slackChannelIdCache.get(target);
|
|
430
|
+
if (cached)
|
|
431
|
+
return cached;
|
|
432
|
+
const fetchPage = slackChannelPageFetcher(slack);
|
|
367
433
|
// Slack channel names are always lowercase, so an exact label match is the
|
|
368
434
|
// same comparison the raw walk made against channel.name.
|
|
369
|
-
const hit = await findResource(
|
|
370
|
-
|
|
371
|
-
|
|
435
|
+
const hit = await findResource(async (cursor) => {
|
|
436
|
+
const page = await fetchPage(cursor);
|
|
437
|
+
return {
|
|
438
|
+
options: page.channels.map((channel) => ({ id: channel.id, label: channel.name })),
|
|
439
|
+
nextCursor: page.nextCursor,
|
|
440
|
+
};
|
|
441
|
+
}, (option) => option.label === target, { maxPages: SLACK_LIST_MAX_PAGES });
|
|
372
442
|
if (hit)
|
|
373
443
|
slackChannelIdCache.set(target, hit.id);
|
|
374
444
|
return hit?.id ?? null;
|
|
@@ -408,12 +478,9 @@ export async function slackPostMessage(slack, args) {
|
|
|
408
478
|
return { ok: result.ok, ts: result.ts };
|
|
409
479
|
}
|
|
410
480
|
catch (err) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (typeof slackError === 'string')
|
|
415
|
-
return { ok: false, error: slackError };
|
|
416
|
-
}
|
|
481
|
+
const slackError = slackErrorCode(err);
|
|
482
|
+
if (slackError)
|
|
483
|
+
return { ok: false, error: slackError };
|
|
417
484
|
throw err;
|
|
418
485
|
}
|
|
419
486
|
};
|
package/dist/network-capture.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Client-side network capture: monkey-patches window.fetch and XMLHttpRequest so every
|
|
2
2
|
// generated app records its own HTTP traffic and reports it to the parent frame for
|
|
3
|
-
// debugging.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// debugging. Posts go through the bridge's postToParent as droppable telemetry:
|
|
4
|
+
// delivered once the parent origin is learned, dropped before that — capture
|
|
5
|
+
// volume must never evict contract messages (app.ready) from the bridge's
|
|
6
|
+
// pre-learn buffer.
|
|
7
7
|
//
|
|
8
8
|
// Redaction here is best-effort and CLIENT-SIDE ONLY. The real security boundary is
|
|
9
9
|
// server-side, at ingest — never trust this module alone to keep secrets out of a
|
|
10
10
|
// downstream store.
|
|
11
|
+
import { AssemblyBridge } from '@assembly-js/app-bridge';
|
|
11
12
|
export const NETWORK_CAPTURE_MESSAGE_TYPE = 'assembly.debug.network';
|
|
12
13
|
// Header names whose values are fingerprinted instead of forwarded. Matched
|
|
13
14
|
// case-insensitively against both request and response header names.
|
|
@@ -255,46 +256,13 @@ const ring = [];
|
|
|
255
256
|
export function getCapturedRecords() {
|
|
256
257
|
return ring;
|
|
257
258
|
}
|
|
258
|
-
let cachedParentOrigin;
|
|
259
|
-
// Mirrors sdk/app-bridge's src/core/origins.ts allowlist. Kept as a local copy (rather
|
|
260
|
-
// than importing the npm package) because only its public AssemblyBridge API — not this
|
|
261
|
-
// internal helper — is exported for consumers.
|
|
262
|
-
const ALLOWED_PARENT_ORIGIN_PATTERNS = [
|
|
263
|
-
/^https?:\/\/localhost(:\d+)?$/,
|
|
264
|
-
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
|
|
265
|
-
/^https:\/\/([a-z0-9-]+\.)?copilot\.app$/,
|
|
266
|
-
/^https:\/\/([a-z0-9-]+\.)?myassembly\.com$/,
|
|
267
|
-
/^https:\/\/dashboard\.assembly\.com$/,
|
|
268
|
-
/^https:\/\/([a-z0-9-]+\.)?copilot-staging\.app$/,
|
|
269
|
-
/^https:\/\/([a-z0-9-]+\.)?myassembly-staging\.com$/,
|
|
270
|
-
/^https:\/\/dashboard\.assembly-staging\.com$/,
|
|
271
|
-
];
|
|
272
|
-
function isAllowedParentOrigin(origin) {
|
|
273
|
-
return ALLOWED_PARENT_ORIGIN_PATTERNS.some((pattern) => pattern.test(origin));
|
|
274
|
-
}
|
|
275
|
-
function getParentOrigin() {
|
|
276
|
-
if (cachedParentOrigin !== undefined)
|
|
277
|
-
return cachedParentOrigin;
|
|
278
|
-
if (typeof document === 'undefined' || !document.referrer) {
|
|
279
|
-
cachedParentOrigin = null;
|
|
280
|
-
return null;
|
|
281
|
-
}
|
|
282
|
-
try {
|
|
283
|
-
cachedParentOrigin = new URL(document.referrer).origin;
|
|
284
|
-
}
|
|
285
|
-
catch {
|
|
286
|
-
cachedParentOrigin = null;
|
|
287
|
-
}
|
|
288
|
-
return cachedParentOrigin;
|
|
289
|
-
}
|
|
290
259
|
function postToParent(record) {
|
|
291
260
|
if (typeof window === 'undefined' || window.parent === window)
|
|
292
261
|
return;
|
|
293
|
-
|
|
294
|
-
if (!origin || !isAllowedParentOrigin(origin))
|
|
262
|
+
if (!AssemblyBridge.postToParent)
|
|
295
263
|
return;
|
|
296
264
|
try {
|
|
297
|
-
|
|
265
|
+
AssemblyBridge.postToParent({ type: NETWORK_CAPTURE_MESSAGE_TYPE, record }, { droppable: true });
|
|
298
266
|
}
|
|
299
267
|
catch {
|
|
300
268
|
// Best-effort telemetry — never let a postMessage failure surface to the app.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const PORTAL_ORIGIN_COOKIE_NAME = "assembly-portal-origin";
|
|
2
|
+
export declare const CLIENT_FLAGS_COOKIE_NAME = "assembly-flags";
|
|
3
|
+
export declare const PREVIEW_V2_FLAG = "app-builder-preview-v2";
|
|
4
|
+
/**
|
|
5
|
+
* Reads a single cookie by name from a cookie string (defaults to
|
|
6
|
+
* `document.cookie`). Shared by every module that reads a proxy-mirrored
|
|
7
|
+
* cookie, so the parse/decode logic lives in exactly one place.
|
|
8
|
+
*/
|
|
9
|
+
export declare function readCookie(name: string, cookieString?: string): string | null;
|
|
10
|
+
/**
|
|
11
|
+
* Whether the given flag key is present in the proxy-mirrored flags cookie.
|
|
12
|
+
* Exported for other vNext modules (e.g. the paint beacon) to check the same
|
|
13
|
+
* cookie without re-parsing it themselves.
|
|
14
|
+
*/
|
|
15
|
+
export declare function hasClientFlag(key: string, cookieString?: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Register the workspace's portal origin with the bridge allowlist — from the
|
|
18
|
+
* proxy-set cookie when the preview-v2 flag is on, from the legacy framing
|
|
19
|
+
* origin otherwise. Returns the origin it registered, or null when there was
|
|
20
|
+
* nothing to do. Exported for tests; runs once at module load in the browser.
|
|
21
|
+
*/
|
|
22
|
+
export declare function bootstrapPortalOrigin(cookieString?: string): string | null;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Self-running: every app's client bundle imports this package, and module
|
|
2
|
+
// evaluation precedes hydration — so the workspace's portal origin joins the
|
|
3
|
+
// bridge allowlist before any component's first header/session-token call.
|
|
4
|
+
//
|
|
5
|
+
// The preview-v2 flag selects WHICH trust source registers the origin — it
|
|
6
|
+
// never selects whether anything registers at all (flag-off must keep custom
|
|
7
|
+
// portal domains working exactly as pre-vNext package versions did):
|
|
8
|
+
//
|
|
9
|
+
// - flag ON: the proxy-set cookie. The value is platform-resolved, never
|
|
10
|
+
// inferred client-side — the proxy already fetches workspace.portalUrl for
|
|
11
|
+
// its CSP frame-ancestors header and mirrors it into this client-readable
|
|
12
|
+
// cookie. Immune to the navigation-state poisoning below.
|
|
13
|
+
// - flag OFF (or flags cookie unreadable): the legacy browser-reported
|
|
14
|
+
// framing origin (ancestorOrigins, referrer fallback). Trusting it is
|
|
15
|
+
// sound because the proxy's CSP frame-ancestors already gates who may
|
|
16
|
+
// frame the app, but it rides navigation state — strip-able by privacy
|
|
17
|
+
// modes and poisoned by in-iframe reloads — which is why the cookie path
|
|
18
|
+
// replaces it.
|
|
19
|
+
//
|
|
20
|
+
// The bridge has no trust bypass, so one of these registrations is the only
|
|
21
|
+
// way a custom portal domain becomes a valid parent (dashboard/platform
|
|
22
|
+
// domains are built into the bridge's defaults). Nothing resolvable → no-op:
|
|
23
|
+
// the app still works on every default-allowlisted surface.
|
|
24
|
+
import { AssemblyBridge } from '@assembly-js/app-bridge';
|
|
25
|
+
export const PORTAL_ORIGIN_COOKIE_NAME = 'assembly-portal-origin';
|
|
26
|
+
// Mirrors the bearer's `flags` claim (see session-exchange.ts's
|
|
27
|
+
// ExchangedSession.flags / hasFlag) — the bearer itself is HttpOnly and
|
|
28
|
+
// unreadable client-side, so the proxy mirrors the decoded array into this
|
|
29
|
+
// plain, comma-separated cookie on every request (set or cleared, never
|
|
30
|
+
// left stale).
|
|
31
|
+
export const CLIENT_FLAGS_COOKIE_NAME = 'assembly-flags';
|
|
32
|
+
export const PREVIEW_V2_FLAG = 'app-builder-preview-v2';
|
|
33
|
+
/**
|
|
34
|
+
* Reads a single cookie by name from a cookie string (defaults to
|
|
35
|
+
* `document.cookie`). Shared by every module that reads a proxy-mirrored
|
|
36
|
+
* cookie, so the parse/decode logic lives in exactly one place.
|
|
37
|
+
*/
|
|
38
|
+
export function readCookie(name, cookieString) {
|
|
39
|
+
const cookies = cookieString ?? (typeof document !== 'undefined' ? document.cookie : '');
|
|
40
|
+
if (!cookies)
|
|
41
|
+
return null;
|
|
42
|
+
const record = cookies.split(/; */).find((c) => c.startsWith(`${name}=`));
|
|
43
|
+
if (!record)
|
|
44
|
+
return null;
|
|
45
|
+
try {
|
|
46
|
+
return decodeURIComponent(record.slice(name.length + 1));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Whether the given flag key is present in the proxy-mirrored flags cookie.
|
|
54
|
+
* Exported for other vNext modules (e.g. the paint beacon) to check the same
|
|
55
|
+
* cookie without re-parsing it themselves.
|
|
56
|
+
*/
|
|
57
|
+
export function hasClientFlag(key, cookieString) {
|
|
58
|
+
const raw = readCookie(CLIENT_FLAGS_COOKIE_NAME, cookieString);
|
|
59
|
+
if (!raw)
|
|
60
|
+
return false;
|
|
61
|
+
return raw.split(',').includes(key);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Legacy (pre-vNext) framing-origin resolution: `location.ancestorOrigins`
|
|
65
|
+
* (browser-attested; Chromium/WebKit), falling back to `document.referrer`
|
|
66
|
+
* (Firefox). Null outside an iframe or when neither source is available.
|
|
67
|
+
*/
|
|
68
|
+
function resolveLegacyParentOrigin() {
|
|
69
|
+
if (typeof window === 'undefined')
|
|
70
|
+
return null;
|
|
71
|
+
if (window.parent === window)
|
|
72
|
+
return null;
|
|
73
|
+
const ancestor = window.location.ancestorOrigins?.[0];
|
|
74
|
+
if (ancestor)
|
|
75
|
+
return ancestor;
|
|
76
|
+
try {
|
|
77
|
+
const referrer = document.referrer;
|
|
78
|
+
if (referrer)
|
|
79
|
+
return new URL(referrer).origin;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Unparseable referrer — fall through to null.
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Register the workspace's portal origin with the bridge allowlist — from the
|
|
88
|
+
* proxy-set cookie when the preview-v2 flag is on, from the legacy framing
|
|
89
|
+
* origin otherwise. Returns the origin it registered, or null when there was
|
|
90
|
+
* nothing to do. Exported for tests; runs once at module load in the browser.
|
|
91
|
+
*/
|
|
92
|
+
export function bootstrapPortalOrigin(cookieString) {
|
|
93
|
+
if (!hasClientFlag(PREVIEW_V2_FLAG, cookieString)) {
|
|
94
|
+
// Flag off: behave exactly like pre-vNext package versions so custom
|
|
95
|
+
// portal domains keep a working bridge — and so turning the flag OFF is
|
|
96
|
+
// a true rollback, not a third state. Https-only: local dev origins are
|
|
97
|
+
// already on the default allowlist.
|
|
98
|
+
const origin = resolveLegacyParentOrigin();
|
|
99
|
+
if (!origin || !origin.startsWith('https://'))
|
|
100
|
+
return null;
|
|
101
|
+
AssemblyBridge.configure({ additionalOrigins: [origin] });
|
|
102
|
+
return origin;
|
|
103
|
+
}
|
|
104
|
+
const record = readCookie(PORTAL_ORIGIN_COOKIE_NAME, cookieString);
|
|
105
|
+
if (!record)
|
|
106
|
+
return null;
|
|
107
|
+
// Accept only a bare https origin — anything else (paths, http, garbage)
|
|
108
|
+
// is not a value the proxy writes and must not reach the allowlist.
|
|
109
|
+
try {
|
|
110
|
+
if (!record.startsWith('https://') || new URL(record).origin !== record) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
AssemblyBridge.configure({ additionalOrigins: [record] });
|
|
118
|
+
return record;
|
|
119
|
+
}
|
|
120
|
+
bootstrapPortalOrigin();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const PAINTED_BEACON_PATH = "/__assembly/painted";
|
|
2
|
+
export declare const PAINTED_BEACON_EVENT = "assembly.painted";
|
|
3
|
+
/**
|
|
4
|
+
* Schedule the post-paint beacon. Safe to call more than once — the first
|
|
5
|
+
* hydration + rAF wins. Does nothing on the server.
|
|
6
|
+
*/
|
|
7
|
+
export declare function installPaintBeacon(): void;
|
|
8
|
+
export declare function isPaintedBeaconPath(pathname: string): boolean;
|
|
9
|
+
export declare function logPaintedBeacon(): void;
|
|
10
|
+
export declare function handlePaintedBeacon(): Response;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Post-paint beacon: tells the parent (bridge) and the proxy (HTTP) that the
|
|
2
|
+
// iframe actually rendered. Must not fire at import — an at-import beacon
|
|
3
|
+
// reproduces the executed-isn't-rendered blind spot. Callers schedule this
|
|
4
|
+
// after hydration + one animation frame.
|
|
5
|
+
//
|
|
6
|
+
// The HTTP route is handled in proxy.ts via the helpers below (same
|
|
7
|
+
// package-delegation pattern as session-exchange). The bridge post rides
|
|
8
|
+
// buffer-until-learned, so a paint that beats the parent's hello is not lost.
|
|
9
|
+
//
|
|
10
|
+
// Gated on the preview-v2 flag (see origin-bootstrap.ts's hasClientFlag):
|
|
11
|
+
// installPaintBeacon() no-ops for a flag-off workspace, so the client never
|
|
12
|
+
// calls sendBeacon and the proxy's route never sees a request — no need to
|
|
13
|
+
// separately gate handlePaintedBeacon() below.
|
|
14
|
+
import { AssemblyBridge } from '@assembly-js/app-bridge';
|
|
15
|
+
import { hasClientFlag, PREVIEW_V2_FLAG } from './origin-bootstrap.js';
|
|
16
|
+
export const PAINTED_BEACON_PATH = '/__assembly/painted';
|
|
17
|
+
export const PAINTED_BEACON_EVENT = 'assembly.painted';
|
|
18
|
+
let scheduled = false;
|
|
19
|
+
let fired = false;
|
|
20
|
+
function firePaintBeacon() {
|
|
21
|
+
if (fired)
|
|
22
|
+
return;
|
|
23
|
+
fired = true;
|
|
24
|
+
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
|
25
|
+
navigator.sendBeacon(PAINTED_BEACON_PATH);
|
|
26
|
+
}
|
|
27
|
+
if (AssemblyBridge.postToParent) {
|
|
28
|
+
AssemblyBridge.postToParent({ type: 'app.painted' });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Schedule the post-paint beacon. Safe to call more than once — the first
|
|
33
|
+
* hydration + rAF wins. Does nothing on the server.
|
|
34
|
+
*/
|
|
35
|
+
export function installPaintBeacon() {
|
|
36
|
+
if (typeof window === 'undefined' || scheduled)
|
|
37
|
+
return;
|
|
38
|
+
if (!hasClientFlag(PREVIEW_V2_FLAG))
|
|
39
|
+
return;
|
|
40
|
+
scheduled = true;
|
|
41
|
+
const afterPaint = () => {
|
|
42
|
+
requestAnimationFrame(firePaintBeacon);
|
|
43
|
+
};
|
|
44
|
+
// Already past load (late client import): one macrotask so this is not
|
|
45
|
+
// synchronous with module evaluation, then one rAF for paint.
|
|
46
|
+
if (document.readyState === 'complete') {
|
|
47
|
+
setTimeout(afterPaint, 0);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
window.addEventListener('load', afterPaint, { once: true });
|
|
51
|
+
}
|
|
52
|
+
export function isPaintedBeaconPath(pathname) {
|
|
53
|
+
return pathname === PAINTED_BEACON_PATH;
|
|
54
|
+
}
|
|
55
|
+
export function logPaintedBeacon() {
|
|
56
|
+
console.log(JSON.stringify({ event_type: PAINTED_BEACON_EVENT }));
|
|
57
|
+
}
|
|
58
|
+
// Callers gate on POST (see the proxy) so navigations/prefetches to the path
|
|
59
|
+
// can't mint painted lines. The signal is still spoofable by design —
|
|
60
|
+
// sendBeacon is an unauthenticated headerless POST — so consumers must treat
|
|
61
|
+
// it as a hint, never proof.
|
|
62
|
+
export function handlePaintedBeacon() {
|
|
63
|
+
logPaintedBeacon();
|
|
64
|
+
return new Response(null, { status: 204 });
|
|
65
|
+
}
|
|
66
|
+
if (typeof window !== 'undefined') {
|
|
67
|
+
installPaintBeacon();
|
|
68
|
+
}
|
|
@@ -10,6 +10,18 @@ export declare const EXCHANGE_REJECTION_HEADER = "x-assembly-exchange-rejection"
|
|
|
10
10
|
export type ExchangeRejectedViewer = 'same_viewer' | 'different_viewer';
|
|
11
11
|
export declare function parseExchangeRejection(value: string | null): ExchangeRejectedViewer | undefined;
|
|
12
12
|
export declare function isSessionExchangeEnabled(): boolean;
|
|
13
|
+
export declare const COOKIE_OUTCOME_EVENT = "assembly.cookie_outcome";
|
|
14
|
+
export type CookieOutcome = 'served_from_cookie' | 'minted' | `fail_closed:${string}`;
|
|
15
|
+
export declare function logCookieOutcome(outcome: CookieOutcome, detail?: {
|
|
16
|
+
rejectedToken?: string;
|
|
17
|
+
}): void;
|
|
18
|
+
export declare function isDocumentRequest(request: {
|
|
19
|
+
method: string;
|
|
20
|
+
headers: {
|
|
21
|
+
get(name: string): string | null;
|
|
22
|
+
has(name: string): boolean;
|
|
23
|
+
};
|
|
24
|
+
}): boolean;
|
|
13
25
|
export interface ExchangedSession {
|
|
14
26
|
workspaceId: string;
|
|
15
27
|
clientId?: string;
|
|
@@ -20,5 +32,17 @@ export interface ExchangedSession {
|
|
|
20
32
|
/** Session token this bearer was exchanged from — lets the proxy tell a
|
|
21
33
|
* reload of an already-exchanged token apart from a new open. */
|
|
22
34
|
sessionId?: string;
|
|
35
|
+
/** Keys of whichever PostHog flags — from platform-api's small, explicit
|
|
36
|
+
* app-foundation-visible-flags allowlist (session.go's appFoundationVisibleFlags) — are on
|
|
37
|
+
* for this workspace. Not every flag: most are irrelevant or sensitive and
|
|
38
|
+
* never reach this channel. Sourced from the exchange RESPONSE at mint time
|
|
39
|
+
* (see mint-bearer.ts), deliberately not a bearer claim — flags are
|
|
40
|
+
* configuration with their own freshness (refresh via fetchSessionFlags),
|
|
41
|
+
* so decodeBearer never populates this. Use hasFlag() to check one; any
|
|
42
|
+
* AND/OR combination is the caller's own logic over this raw set. */
|
|
43
|
+
flags?: string[];
|
|
23
44
|
}
|
|
45
|
+
/** Whether a decoded session carries the given flag key. Null-safe so callers
|
|
46
|
+
* can chain straight off decodeBearer()'s result without a separate guard. */
|
|
47
|
+
export declare function hasFlag(session: ExchangedSession | null, key: string): boolean;
|
|
24
48
|
export declare function decodeBearer(bearer: string): ExchangedSession | null;
|
package/dist/session-exchange.js
CHANGED
|
@@ -73,6 +73,44 @@ export function parseExchangeRejection(value) {
|
|
|
73
73
|
export function isSessionExchangeEnabled() {
|
|
74
74
|
return process.env.ENABLE_APP_SESSION_TOKEN_EXCHANGE === 'true';
|
|
75
75
|
}
|
|
76
|
+
// One structured line per document request. Outcomes are a closed set so the
|
|
77
|
+
// reporter can parse them without guessing. Exchange semantics are unchanged —
|
|
78
|
+
// this is log-only.
|
|
79
|
+
export const COOKIE_OUTCOME_EVENT = 'assembly.cookie_outcome';
|
|
80
|
+
export function logCookieOutcome(outcome,
|
|
81
|
+
// `rejectedToken` carries the rejection reason when a ?token= was refused
|
|
82
|
+
// but the warm cookie still answered — otherwise served_from_cookie would
|
|
83
|
+
// silently swallow the replay/rejection signal.
|
|
84
|
+
detail) {
|
|
85
|
+
console.log(JSON.stringify({
|
|
86
|
+
event_type: COOKIE_OUTCOME_EVENT,
|
|
87
|
+
outcome,
|
|
88
|
+
...(detail?.rejectedToken ? { rejected_token: detail.rejectedToken } : {}),
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
// Two-tier detection: Sec-Fetch-Dest when the browser sends it (all evergreen
|
|
92
|
+
// browsers; 'iframe' is what an embedded app's navigations carry), Accept
|
|
93
|
+
// text/html otherwise. Deliberately NOT matching `*/*`: browser fetch() GETs
|
|
94
|
+
// default to it and image/font Accept headers contain it, which would put the
|
|
95
|
+
// per-document Set-Cookie writes, outcome log line, and flag re-read in the
|
|
96
|
+
// data/subresource path.
|
|
97
|
+
export function isDocumentRequest(request) {
|
|
98
|
+
if (request.method.toUpperCase() !== 'GET')
|
|
99
|
+
return false;
|
|
100
|
+
if (request.headers.has('next-action') || request.headers.has('RSC'))
|
|
101
|
+
return false;
|
|
102
|
+
const dest = request.headers.get('sec-fetch-dest');
|
|
103
|
+
if (dest) {
|
|
104
|
+
return dest === 'document' || dest === 'iframe' || dest === 'frame';
|
|
105
|
+
}
|
|
106
|
+
const accept = request.headers.get('accept') ?? '';
|
|
107
|
+
return accept.includes('text/html');
|
|
108
|
+
}
|
|
109
|
+
/** Whether a decoded session carries the given flag key. Null-safe so callers
|
|
110
|
+
* can chain straight off decodeBearer()'s result without a separate guard. */
|
|
111
|
+
export function hasFlag(session, key) {
|
|
112
|
+
return session?.flags?.includes(key) ?? false;
|
|
113
|
+
}
|
|
76
114
|
// Decodes a base64url JWT segment to a UTF-8 string using only Web APIs (atob,
|
|
77
115
|
// TextDecoder), so decodeBearer runs in BOTH the Edge middleware runtime (proxy)
|
|
78
116
|
// and the Node route-handler runtime (requireAuth / getAssemblyForRequest) —
|
package/package.json
CHANGED
|
@@ -23,4 +23,11 @@ grep -n "Error" /tmp/dev-server.log
|
|
|
23
23
|
|
|
24
24
|
Read or grep the file directly — it's a plain text file on the sandbox's own filesystem, not something fetched over a tool call. The file persists across dev server restarts — each restart appends a `=== dev server launch <timestamp> ===` marker, so you can see output from before a restart (earlier crashes, port-bind failures) and tell launches apart. Size is capped automatically, so `tail` always shows recent activity; it does not accumulate forever.
|
|
25
25
|
|
|
26
|
+
The proxy also appends two structured JSON lines the reporter parses (you do not need to act on them):
|
|
27
|
+
|
|
28
|
+
- `{"event_type":"assembly.painted"}` — the post-paint beacon (`/__assembly/painted`)
|
|
29
|
+
- `{"event_type":"assembly.cookie_outcome","outcome":"served_from_cookie"|"minted"|"fail_closed:<reason>"}` — one line per document request; a `served_from_cookie` line may also carry `"rejected_token":"<reason>"` when a replayed `?token=` was refused but the warm cookie answered
|
|
30
|
+
|
|
31
|
+
Leave those lines alone; they are foundation-owned.
|
|
32
|
+
|
|
26
33
|
If the log doesn't explain the symptom (e.g. a client-side rendering issue), the problem is likely in the browser, not the server — this file only covers the server side.
|
|
@@ -98,7 +98,7 @@ A connector call throws a **`ConnectorError`** (with `.code`, `.provider`, `.sta
|
|
|
98
98
|
- `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.
|
|
99
99
|
- `connection_throttled` — too many calls on the shared connection; back off and retry.
|
|
100
100
|
- `connector_unavailable` — the provider or Nango is temporarily unavailable.
|
|
101
|
-
- `action_failed` — the pre-built action ran but the provider rejected the operation. The provider-level failure is in `err.detail` (e.g. `
|
|
101
|
+
- `action_failed` — the pre-built action ran but the provider rejected the operation. The provider-level failure is in `err.detail`, but scripts disagree on where the provider's code sits inside it, so never read `detail` by hand. For Slack call `slackErrorCode(err)` (returns e.g. `'not_in_channel'`, `'is_archived'`) and branch on that; for other providers branch on `err.detail` the way you would on a proxy response's error field.
|
|
102
102
|
- `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.
|
|
103
103
|
- `invalid_request` — malformed relay input (bad action name, unreadable body). Fix the call site.
|
|
104
104
|
|
|
@@ -1,25 +1,41 @@
|
|
|
1
1
|
# Slack
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Everything except `send-message` needs membership, not just visibility. Posting reaches any public channel without joining (`chat:write.public`); reading history, adding reactions, and inviting users all fail `not_in_channel` until the bot joins.
|
|
4
4
|
|
|
5
5
|
- **Post with `slackPostMessage` from `@assembly-js/app-foundation/server` — 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).
|
|
6
|
+
- **List channels with `slackListChannels` from `@assembly-js/app-foundation/server` — never page the `list-channels` action yourself.** That action returns at most 200 per page, so a large workspace is dozens of sequential calls and a 20–30 second dropdown; the helper walks at Slack's maximum page size and handles rate limits and the private-channel scope fallback. It returns `{ id, name, isPrivate }` sorted by name.
|
|
6
7
|
- **Resolve channel names with `slackFindChannelId` from `@assembly-js/app-foundation/server` for every other per-channel call.** It handles cursor pagination (a single `conversations.list` misses channels in large workspaces), name normalization, scope fallback, rate limits, and caching. Use the returned ID; most Slack methods reject a `#name` with `invalid_arguments`.
|
|
7
|
-
- **
|
|
8
|
-
- **
|
|
8
|
+
- **Read the Slack error code with `slackErrorCode(err)` from `@assembly-js/app-foundation/server` — never read `err.detail` yourself.** The vendored scripts put the code in different places inside `detail`; the helper returns it (`'not_in_channel'`, `'channel_not_found'`, `'is_archived'`, …) or `undefined` when the error isn't a Slack rejection.
|
|
9
|
+
- **Every other per-channel call (history, reactions, invites):** when `slackErrorCode(err) === 'not_in_channel'`, call `join-channel` with the channel ID, then retry once. Private channels fail the join with `method_not_supported_for_channel_type` — tell the user to invite the bot instead of retrying.
|
|
10
|
+
- **Scopes:** `chat:write.public` lets posting reach any public channel without joining, so only the other calls need `join-channel` (which needs `channels:join`). Add `groups:read` if the app must find private channels.
|
|
9
11
|
|
|
10
12
|
```typescript
|
|
11
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
getIntegration,
|
|
15
|
+
slackErrorCode,
|
|
16
|
+
slackFindChannelId,
|
|
17
|
+
slackListChannels,
|
|
18
|
+
slackPostMessage,
|
|
19
|
+
} from '@assembly-js/app-foundation/server';
|
|
12
20
|
|
|
13
21
|
const slack = await getIntegration('slack');
|
|
22
|
+
const channels = await slackListChannels(slack); // dropdown options
|
|
14
23
|
const channelId = await slackFindChannelId(slack, 'general');
|
|
15
24
|
const posted = await slackPostMessage(slack, { channel: '#general', text: 'Hello' });
|
|
16
25
|
```
|
|
17
26
|
|
|
18
27
|
```typescript
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
limit: 10
|
|
22
|
-
|
|
28
|
+
// Reads need membership too: join on not_in_channel and retry once.
|
|
29
|
+
async function history(channelId: string) {
|
|
30
|
+
const input = { channel_id: channelId, limit: 10 };
|
|
31
|
+
try {
|
|
32
|
+
return await slack.action('get-conversation-history', input);
|
|
33
|
+
} catch (err) {
|
|
34
|
+
if (slackErrorCode(err) !== 'not_in_channel') throw err;
|
|
35
|
+
await slack.action('join-channel', { channel_id: channelId });
|
|
36
|
+
return slack.action('get-conversation-history', input);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
23
39
|
```
|
|
24
40
|
|
|
25
41
|
For an operation no action covers, relay through the proxy — e.g. `slack.post({ endpoint: '/chat.update', data: { channel: channelId, ts, text } })`.
|
|
@@ -66,6 +66,8 @@ export function ClientList() {
|
|
|
66
66
|
}
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
`deps` works like a `useEffect` dependency array, with one rule: **deps must be primitives** — an id, a cursor, a `length`, a boolean. A derived array or object (`records`, `{ baseId }`, `items.filter(...)`) is a new identity on every render, so the action re-runs, its state update re-renders, and the page spins forever without a network call or an error to point at. Pass `records.length` or the id the array came from instead.
|
|
70
|
+
|
|
69
71
|
`deps` works like a `useEffect` dependency array — list any values your action closes over so it re-runs when they change.
|
|
70
72
|
|
|
71
73
|
For an imperative one-off (a button handler, a mutation), use `callAction` from `useAssembly()` — same re-exchange-and-retry behavior, no hook:
|
package/dist/parent-origin.d.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resolve the origin of the window framing this app.
|
|
3
|
-
*
|
|
4
|
-
* Prefers `location.ancestorOrigins` (browser-attested, immune to referrer
|
|
5
|
-
* policy; Chromium/WebKit) and falls back to `document.referrer` (Firefox).
|
|
6
|
-
* Returns null outside an iframe or when neither source is available —
|
|
7
|
-
* e.g. a restrictive Referrer-Policy on the parent in Firefox.
|
|
8
|
-
*/
|
|
9
|
-
export declare function resolveParentOrigin(): string | null;
|
|
10
|
-
/**
|
|
11
|
-
* Add the framing origin to the bridge's postMessage allowlist.
|
|
12
|
-
*
|
|
13
|
-
* Idempotent (the bridge holds origins in a Set) and restricted to https
|
|
14
|
-
* parents — local development origins are already on the default allowlist,
|
|
15
|
-
* so an http parent never needs registering.
|
|
16
|
-
*/
|
|
17
|
-
export declare function registerParentOriginWithBridge(): void;
|
package/dist/parent-origin.js
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
// Registers the app's real framing origin with the app-bridge postMessage
|
|
2
|
-
// allowlist, so the bridge works on workspaces with custom portal domains.
|
|
3
|
-
//
|
|
4
|
-
// The bridge SDK ships a hardcoded origin allowlist (localhost + assembly
|
|
5
|
-
// domains) and silently drops every outgoing and incoming message when the
|
|
6
|
-
// parent origin isn't on it. On a custom-domain portal that kills header CTAs,
|
|
7
|
-
// session-token refresh, and theme sync. Trusting the framing origin here is
|
|
8
|
-
// sound because the proxy already gates who may frame the app at all: its CSP
|
|
9
|
-
// frame-ancestors directive allows only the static assembly domains plus this
|
|
10
|
-
// workspace's own portal URL, and the browser enforces that before any of this
|
|
11
|
-
// bundle runs. Inside the frame, the browser-reported parent origin is
|
|
12
|
-
// therefore always one the platform trusts.
|
|
13
|
-
import { AssemblyBridge } from '@assembly-js/app-bridge';
|
|
14
|
-
/**
|
|
15
|
-
* Resolve the origin of the window framing this app.
|
|
16
|
-
*
|
|
17
|
-
* Prefers `location.ancestorOrigins` (browser-attested, immune to referrer
|
|
18
|
-
* policy; Chromium/WebKit) and falls back to `document.referrer` (Firefox).
|
|
19
|
-
* Returns null outside an iframe or when neither source is available —
|
|
20
|
-
* e.g. a restrictive Referrer-Policy on the parent in Firefox.
|
|
21
|
-
*/
|
|
22
|
-
export function resolveParentOrigin() {
|
|
23
|
-
if (typeof window === 'undefined')
|
|
24
|
-
return null;
|
|
25
|
-
if (window.parent === window)
|
|
26
|
-
return null;
|
|
27
|
-
const ancestor = window.location.ancestorOrigins?.[0];
|
|
28
|
-
if (ancestor)
|
|
29
|
-
return ancestor;
|
|
30
|
-
try {
|
|
31
|
-
const referrer = document.referrer;
|
|
32
|
-
if (referrer)
|
|
33
|
-
return new URL(referrer).origin;
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
// Unparseable referrer — fall through to null.
|
|
37
|
-
}
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Add the framing origin to the bridge's postMessage allowlist.
|
|
42
|
-
*
|
|
43
|
-
* Idempotent (the bridge holds origins in a Set) and restricted to https
|
|
44
|
-
* parents — local development origins are already on the default allowlist,
|
|
45
|
-
* so an http parent never needs registering.
|
|
46
|
-
*/
|
|
47
|
-
export function registerParentOriginWithBridge() {
|
|
48
|
-
const origin = resolveParentOrigin();
|
|
49
|
-
if (!origin || !origin.startsWith('https://'))
|
|
50
|
-
return;
|
|
51
|
-
AssemblyBridge.configure({ additionalOrigins: [origin] });
|
|
52
|
-
}
|
|
53
|
-
// Run on module load: every app's client bundle imports this package (authFetch,
|
|
54
|
-
// network capture), and module evaluation precedes hydration — so the origin is
|
|
55
|
-
// registered before any component's first header/session-token call. Baked app
|
|
56
|
-
// files never call this module directly, which is exactly why it self-runs.
|
|
57
|
-
registerParentOriginWithBridge();
|