@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.
@@ -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: {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.23",
3
+ "version": "0.0.24",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -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