@assembly-js/app-foundation 0.0.23 → 0.0.24
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/dist/lib-client.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// never be dragged into the Edge middleware bundle that imports ./client
|
|
6
6
|
// (proxy.ts pulls constants from that barrel).
|
|
7
7
|
import { AssemblyBridge } from '@assembly-js/app-bridge';
|
|
8
|
-
import { APP_CSRF_HEADER, TOKEN_EXPIRED_HEADER } from './session-exchange.js';
|
|
8
|
+
import { APP_CSRF_HEADER, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
|
|
9
9
|
const REEXCHANGE_PATH = '/api/assembly-reexchange';
|
|
10
10
|
// Single-flight gate for renewals. Session tokens are single-use for exchange:
|
|
11
11
|
// when the 60-minute bearer expires, every in-flight fetch on the page 401s at
|
|
@@ -33,23 +33,32 @@ async function renewBearer() {
|
|
|
33
33
|
let token = AssemblyBridge.sessionToken.getCurrent()?.token ?? '';
|
|
34
34
|
// Proactively refresh: we're recovering from an expiry, so avoid re-minting
|
|
35
35
|
// from a session token that may itself be stale.
|
|
36
|
+
let bridgeRefreshFailed = false;
|
|
36
37
|
try {
|
|
37
38
|
const refreshed = await AssemblyBridge.sessionToken.refresh();
|
|
38
39
|
token = refreshed?.token ?? token;
|
|
39
40
|
}
|
|
40
41
|
catch {
|
|
41
42
|
// Ignore: fall back to the current token below.
|
|
43
|
+
bridgeRefreshFailed = true;
|
|
42
44
|
}
|
|
43
|
-
if (!token)
|
|
45
|
+
if (!token) {
|
|
46
|
+
logRenewOutcome('no_token', { bridgeRefreshFailed });
|
|
44
47
|
return false;
|
|
48
|
+
}
|
|
45
49
|
try {
|
|
46
50
|
const res = await fetch(REEXCHANGE_PATH, {
|
|
47
51
|
method: 'POST',
|
|
48
52
|
headers: { 'x-assembly-token': token, [APP_CSRF_HEADER]: '1' },
|
|
49
53
|
});
|
|
54
|
+
logRenewOutcome(res.ok ? 'renewed' : 'rejected', {
|
|
55
|
+
bridgeRefreshFailed,
|
|
56
|
+
status: res.status,
|
|
57
|
+
});
|
|
50
58
|
return res.ok;
|
|
51
59
|
}
|
|
52
60
|
catch {
|
|
61
|
+
logRenewOutcome('unavailable', { bridgeRefreshFailed });
|
|
53
62
|
return false;
|
|
54
63
|
}
|
|
55
64
|
}
|
package/dist/nango.d.ts
CHANGED
|
@@ -217,3 +217,5 @@ export declare function slackPostMessage(slack: IntegrationClient, args: {
|
|
|
217
217
|
channel: string;
|
|
218
218
|
text: string;
|
|
219
219
|
}): Promise<SlackPostMessageResult>;
|
|
220
|
+
/** Slack history returns mention tokens, not names. Resolve them before render. */
|
|
221
|
+
export declare function slackResolveMentions(slack: IntegrationClient, text: string): Promise<string>;
|
package/dist/nango.js
CHANGED
|
@@ -496,3 +496,119 @@ export async function slackPostMessage(slack, args) {
|
|
|
496
496
|
}
|
|
497
497
|
return { ok: result.ok, ts: result.ts, channelId, error: result.error };
|
|
498
498
|
}
|
|
499
|
+
// Memoize successful mention lookups so a history page does not re-hit Slack
|
|
500
|
+
// for the same ID. Misses stay uncached so a later reconnect can succeed.
|
|
501
|
+
const SLACK_MENTION_RE = /<@([UW][A-Z0-9]+)(?:\|([^>]+))?>|<#([CG][A-Z0-9]+)(?:\|([^>]+))?>|<!subteam\^(S[A-Z0-9]+)(?:\|([^>]+))?>/g;
|
|
502
|
+
const slackMentionNameCache = new Map();
|
|
503
|
+
const SLACK_MENTION_FALLBACK = {
|
|
504
|
+
user: 'a user',
|
|
505
|
+
channel: 'a channel',
|
|
506
|
+
subteam: 'a user group',
|
|
507
|
+
};
|
|
508
|
+
function slackMentionRe() {
|
|
509
|
+
return new RegExp(SLACK_MENTION_RE.source, 'g');
|
|
510
|
+
}
|
|
511
|
+
function withMentionSigil(label, sigil) {
|
|
512
|
+
return label.startsWith(sigil) ? label : `${sigil}${label}`;
|
|
513
|
+
}
|
|
514
|
+
async function slackLookupJson(slack, endpoint, params) {
|
|
515
|
+
try {
|
|
516
|
+
const res = await slack.get({ endpoint, params });
|
|
517
|
+
const data = (await res.json());
|
|
518
|
+
if (!data.ok)
|
|
519
|
+
return null;
|
|
520
|
+
return data;
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
// Lookup is best-effort: a missing scope or a vanished user must not
|
|
524
|
+
// fail the whole history render.
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function slackUserDisplayName(user) {
|
|
529
|
+
const display = user.profile?.display_name?.trim();
|
|
530
|
+
if (display)
|
|
531
|
+
return display;
|
|
532
|
+
const real = user.profile?.real_name?.trim() || user.real_name?.trim();
|
|
533
|
+
if (real)
|
|
534
|
+
return real;
|
|
535
|
+
return user.name?.trim() || undefined;
|
|
536
|
+
}
|
|
537
|
+
async function slackResolveUserName(slack, id) {
|
|
538
|
+
const cacheKey = `user:${id}`;
|
|
539
|
+
const cached = slackMentionNameCache.get(cacheKey);
|
|
540
|
+
if (cached)
|
|
541
|
+
return cached;
|
|
542
|
+
const data = await slackLookupJson(slack, '/users.info', { user: id });
|
|
543
|
+
const user = data?.user;
|
|
544
|
+
const name = user ? slackUserDisplayName(user) : undefined;
|
|
545
|
+
if (!name)
|
|
546
|
+
return SLACK_MENTION_FALLBACK.user;
|
|
547
|
+
const label = withMentionSigil(name, '@');
|
|
548
|
+
slackMentionNameCache.set(cacheKey, label);
|
|
549
|
+
return label;
|
|
550
|
+
}
|
|
551
|
+
async function slackResolveChannelName(slack, id) {
|
|
552
|
+
const cacheKey = `channel:${id}`;
|
|
553
|
+
const cached = slackMentionNameCache.get(cacheKey);
|
|
554
|
+
if (cached)
|
|
555
|
+
return cached;
|
|
556
|
+
const data = await slackLookupJson(slack, '/conversations.info', { channel: id });
|
|
557
|
+
const channel = data?.channel;
|
|
558
|
+
const name = channel?.name?.trim();
|
|
559
|
+
if (!name)
|
|
560
|
+
return SLACK_MENTION_FALLBACK.channel;
|
|
561
|
+
const label = withMentionSigil(name, '#');
|
|
562
|
+
slackMentionNameCache.set(cacheKey, label);
|
|
563
|
+
return label;
|
|
564
|
+
}
|
|
565
|
+
async function slackResolveSubteamName(slack, id) {
|
|
566
|
+
const cacheKey = `subteam:${id}`;
|
|
567
|
+
const cached = slackMentionNameCache.get(cacheKey);
|
|
568
|
+
if (cached)
|
|
569
|
+
return cached;
|
|
570
|
+
const data = await slackLookupJson(slack, '/usergroups.info', { usergroup: id });
|
|
571
|
+
const group = data?.usergroup;
|
|
572
|
+
const name = group?.handle?.trim() || group?.name?.trim();
|
|
573
|
+
if (!name)
|
|
574
|
+
return SLACK_MENTION_FALLBACK.subteam;
|
|
575
|
+
const label = withMentionSigil(name, '@');
|
|
576
|
+
slackMentionNameCache.set(cacheKey, label);
|
|
577
|
+
return label;
|
|
578
|
+
}
|
|
579
|
+
/** Slack history returns mention tokens, not names. Resolve them before render. */
|
|
580
|
+
export async function slackResolveMentions(slack, text) {
|
|
581
|
+
if (!text)
|
|
582
|
+
return text;
|
|
583
|
+
const replacements = new Map();
|
|
584
|
+
const lookups = new Map();
|
|
585
|
+
for (const match of text.matchAll(slackMentionRe())) {
|
|
586
|
+
const [raw, userId, userLabel, channelId, channelLabel, subteamId, subteamLabel] = match;
|
|
587
|
+
if (replacements.has(raw) || lookups.has(raw))
|
|
588
|
+
continue;
|
|
589
|
+
if (userLabel) {
|
|
590
|
+
replacements.set(raw, withMentionSigil(userLabel, '@'));
|
|
591
|
+
}
|
|
592
|
+
else if (channelLabel) {
|
|
593
|
+
replacements.set(raw, withMentionSigil(channelLabel, '#'));
|
|
594
|
+
}
|
|
595
|
+
else if (subteamLabel) {
|
|
596
|
+
replacements.set(raw, withMentionSigil(subteamLabel, '@'));
|
|
597
|
+
}
|
|
598
|
+
else if (userId) {
|
|
599
|
+
lookups.set(raw, slackResolveUserName(slack, userId));
|
|
600
|
+
}
|
|
601
|
+
else if (channelId) {
|
|
602
|
+
lookups.set(raw, slackResolveChannelName(slack, channelId));
|
|
603
|
+
}
|
|
604
|
+
else if (subteamId) {
|
|
605
|
+
lookups.set(raw, slackResolveSubteamName(slack, subteamId));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
await Promise.all([...lookups.entries()].map(async ([raw, pending]) => {
|
|
609
|
+
replacements.set(raw, await pending);
|
|
610
|
+
}));
|
|
611
|
+
if (replacements.size === 0)
|
|
612
|
+
return text;
|
|
613
|
+
return text.replace(slackMentionRe(), (raw) => replacements.get(raw) ?? raw);
|
|
614
|
+
}
|
|
@@ -15,6 +15,12 @@ export type CookieOutcome = 'served_from_cookie' | 'minted' | `fail_closed:${str
|
|
|
15
15
|
export declare function logCookieOutcome(outcome: CookieOutcome, detail?: {
|
|
16
16
|
rejectedToken?: string;
|
|
17
17
|
}): void;
|
|
18
|
+
export declare const RENEW_OUTCOME_EVENT = "assembly.renew_outcome";
|
|
19
|
+
export type RenewOutcome = 'renewed' | 'no_token' | 'rejected' | 'unavailable';
|
|
20
|
+
export declare function logRenewOutcome(outcome: RenewOutcome, detail?: {
|
|
21
|
+
bridgeRefreshFailed?: boolean;
|
|
22
|
+
status?: number;
|
|
23
|
+
}): void;
|
|
18
24
|
export declare function isDocumentRequest(request: {
|
|
19
25
|
method: string;
|
|
20
26
|
headers: {
|
package/dist/session-exchange.js
CHANGED
|
@@ -88,6 +88,25 @@ detail) {
|
|
|
88
88
|
...(detail?.rejectedToken ? { rejected_token: detail.rejectedToken } : {}),
|
|
89
89
|
}));
|
|
90
90
|
}
|
|
91
|
+
// One structured line per bearer renewal attempt. The renewal path is the one
|
|
92
|
+
// an idle session takes on its way back: the 60-minute bearer has expired and
|
|
93
|
+
// everything now depends on the parent still holding an exchangeable session
|
|
94
|
+
// token. Today every way that can fail collapses into the same `false`, so a
|
|
95
|
+
// dead parent bridge, a refused token and a network blip are indistinguishable
|
|
96
|
+
// after the fact. Outcomes are a closed set, mirroring COOKIE_OUTCOME_EVENT.
|
|
97
|
+
export const RENEW_OUTCOME_EVENT = 'assembly.renew_outcome';
|
|
98
|
+
export function logRenewOutcome(outcome,
|
|
99
|
+
// `bridgeRefreshFailed` records that the pre-renewal token refresh threw and
|
|
100
|
+
// we fell back to the token already in hand — on its own that is survivable,
|
|
101
|
+
// but paired with a rejection it names the parent as the failing side.
|
|
102
|
+
detail) {
|
|
103
|
+
console.log(JSON.stringify({
|
|
104
|
+
event_type: RENEW_OUTCOME_EVENT,
|
|
105
|
+
outcome,
|
|
106
|
+
...(detail?.bridgeRefreshFailed ? { bridge_refresh_failed: true } : {}),
|
|
107
|
+
...(typeof detail?.status === 'number' ? { status: detail.status } : {}),
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
91
110
|
// Two-tier detection: Sec-Fetch-Dest when the browser sends it (all evergreen
|
|
92
111
|
// browsers; 'iframe' is what an embedded app's navigations carry), Accept
|
|
93
112
|
// text/html otherwise. Deliberately NOT matching `*/*`: browser fetch() GETs
|
package/package.json
CHANGED
|
@@ -8,8 +8,9 @@ Everything except `send-message` needs membership, not just visibility. Posting
|
|
|
8
8
|
- **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.
|
|
9
9
|
- **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`.
|
|
10
10
|
- **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.
|
|
11
|
+
- **Resolve mention IDs with `slackResolveMentions` from `@assembly-js/app-foundation/server` before rendering Slack message text.** History returns raw `<@U…>`, `<#C…|name>`, and `<!subteam^ID>` tokens. The helper replaces them with display names and falls back to "a user" / "a channel" / "a user group" when a lookup fails or `usergroups:read` is missing. Leave `<!here>` / `<!channel>` / `<!everyone>` as-is.
|
|
11
12
|
- **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.
|
|
12
|
-
- **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.
|
|
13
|
+
- **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. `usergroups:read` is what lets `slackResolveMentions` name a `<!subteam^…>` token; without it the helper falls back to "a user group".
|
|
13
14
|
|
|
14
15
|
```typescript
|
|
15
16
|
import {
|
|
@@ -18,12 +19,14 @@ import {
|
|
|
18
19
|
slackFindChannelId,
|
|
19
20
|
slackListChannels,
|
|
20
21
|
slackPostMessage,
|
|
22
|
+
slackResolveMentions,
|
|
21
23
|
} from '@assembly-js/app-foundation/server';
|
|
22
24
|
|
|
23
25
|
const slack = await getIntegration('slack');
|
|
24
26
|
const channels = await slackListChannels(slack); // dropdown options
|
|
25
27
|
const channelId = await slackFindChannelId(slack, 'general');
|
|
26
28
|
const posted = await slackPostMessage(slack, { channel: '#general', text: 'Hello' });
|
|
29
|
+
const readable = await slackResolveMentions(slack, rawMessageText);
|
|
27
30
|
```
|
|
28
31
|
|
|
29
32
|
```typescript
|