@stackable-labs/mcp-app-extension 1.21.1 → 1.23.0
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/index.js +575 -225
- package/dist/server.js +575 -225
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -465,11 +465,19 @@ ${iconList}
|
|
|
465
465
|
var HOOK_SNIPPETS = {
|
|
466
466
|
"events:identity": `import { useIdentityEvent } from '@stackable-labs/sdk-extension-react'
|
|
467
467
|
|
|
468
|
+
// manifest events: ["identity:login", "identity:logout", "identity:refresh"]
|
|
468
469
|
useIdentityEvent('login', (event) => {
|
|
469
|
-
|
|
470
|
+
// event.data.state.user.metadata is populated with any enrichment from sibling
|
|
471
|
+
// extensions with identity:extend (declared in their manifest.identityClaims)
|
|
472
|
+
console.log('User logged in:', event.data.state.user?.email, event.data.state.user?.metadata)
|
|
470
473
|
})
|
|
471
474
|
useIdentityEvent('logout', () => {
|
|
472
475
|
console.log('User logged out')
|
|
476
|
+
})
|
|
477
|
+
// identity:refresh fires after any extension calls capabilities.identity.extend({...}).
|
|
478
|
+
// Listen here to react to post-login enrichment (verification, tier upgrades, etc.).
|
|
479
|
+
useIdentityEvent('refresh', (event) => {
|
|
480
|
+
console.log('Identity refreshed \u2014 metadata:', event.data.state.user?.metadata)
|
|
473
481
|
})`,
|
|
474
482
|
"events:messaging": `import { useMessagingEvent } from '@stackable-labs/sdk-extension-react'
|
|
475
483
|
|
|
@@ -481,11 +489,23 @@ useMessagingEvent('postback:Buy Now', (event) => {
|
|
|
481
489
|
useActivityEvent('product_view', (event) => {
|
|
482
490
|
console.log('Activity:', event.eventName, event.data)
|
|
483
491
|
})`,
|
|
484
|
-
"extend
|
|
485
|
-
|
|
492
|
+
"identity.extend": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
|
|
493
|
+
|
|
494
|
+
// manifest.json:
|
|
495
|
+
// {
|
|
496
|
+
// "permissions": ["identity:extend"],
|
|
497
|
+
// "identityClaims": ["loyalty_tier", "verified", "verified_by", "verified_at"]
|
|
498
|
+
// }
|
|
499
|
+
// Standard JWT claims (external_id, email, name) are exempt from declaration.
|
|
500
|
+
// Custom claims must be in manifest.identityClaims or they're dropped with a warn.
|
|
501
|
+
//
|
|
502
|
+
// Fires ONCE at initial login \u2014 return what's known synchronously. For post-login
|
|
503
|
+
// async updates (e.g., after verification completes via a webhook or polling),
|
|
504
|
+
// use capabilities.identity.extend(patch) \u2014 see the 'identity.extend' capability.
|
|
486
505
|
useExtendIdentity((claims) => ({
|
|
487
|
-
external_id: \`custom_\${claims.external_id}\`,
|
|
488
|
-
loyalty_tier: '
|
|
506
|
+
external_id: \`custom_\${claims.external_id}\`, // standard claim override (exempt)
|
|
507
|
+
loyalty_tier: 'bronze', // custom \u2014 sync, known at login
|
|
508
|
+
verified: false, // custom \u2014 default; updated async post-verification
|
|
489
509
|
}))`
|
|
490
510
|
};
|
|
491
511
|
var HOOK_SNIPPETS_MEMOIZED = {
|
|
@@ -497,13 +517,19 @@ const handlePostback = useCallback<MessagingEventHandler>((event) => {
|
|
|
497
517
|
console.log('Postback:', event.data.actionName, event.data.conversationId)
|
|
498
518
|
}, [])
|
|
499
519
|
useMessagingEvent('postback:Buy Now', handlePostback)`,
|
|
500
|
-
"extend
|
|
520
|
+
"identity.extend": `import { useCallback } from 'react'
|
|
501
521
|
import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
|
|
502
522
|
import type { ExtendIdentityHandler } from '@stackable-labs/sdk-extension-contracts'
|
|
503
523
|
|
|
524
|
+
// manifest.json:
|
|
525
|
+
// {
|
|
526
|
+
// "permissions": ["identity:extend"],
|
|
527
|
+
// "identityClaims": ["loyalty_tier", "verified", "verified_by", "verified_at"]
|
|
528
|
+
// }
|
|
504
529
|
const handleExtend = useCallback<ExtendIdentityHandler>((claims) => ({
|
|
505
|
-
external_id: \`custom_\${claims.external_id}\`,
|
|
506
|
-
loyalty_tier: '
|
|
530
|
+
external_id: \`custom_\${claims.external_id}\`, // standard claim override (exempt)
|
|
531
|
+
loyalty_tier: 'bronze', // custom \u2014 sync, known at login
|
|
532
|
+
verified: false, // custom \u2014 default; updated async post-verification
|
|
507
533
|
}), [])
|
|
508
534
|
useExtendIdentity(handleExtend)`
|
|
509
535
|
};
|
|
@@ -515,7 +541,7 @@ var generateCapabilities = () => {
|
|
|
515
541
|
const fm = frontmatter({
|
|
516
542
|
root: false,
|
|
517
543
|
targets: ["*"],
|
|
518
|
-
description: "Extension capabilities: data.query, data.fetch, context.read, actions.toast, actions.invoke, extend
|
|
544
|
+
description: "Extension capabilities: data.query, data.fetch, context.read, actions.toast, actions.invoke, identity.extend, events:identity, events:messaging, events:activity",
|
|
519
545
|
globs: ["packages/extension/src/**/*.tsx", "packages/extension/src/**/*.ts"]
|
|
520
546
|
});
|
|
521
547
|
return `${fm}
|
|
@@ -722,22 +748,89 @@ ${HOOK_SNIPPETS["events:activity"]}
|
|
|
722
748
|
|
|
723
749
|
**Generic alternative:** \`useEvent('activity:product_view', handler)\` \u2014 a cross-domain hook that accepts fully-qualified event types. Domain wildcard (e.g., \`'activity'\`) receives all events in that domain.
|
|
724
750
|
|
|
725
|
-
## extend
|
|
726
|
-
Enrich identity JWT claims
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
751
|
+
## identity.extend \u2014 Identity Claim Enrichment
|
|
752
|
+
Enrich identity JWT claims and \`identityState.user.metadata\` so the current user's signed token AND any sibling extension can react to them. Two complementary APIs:
|
|
753
|
+
|
|
754
|
+
1. **\`useExtendIdentity(handler)\`** \u2014 synchronous hook that fires ONCE at initial login.
|
|
755
|
+
2. **\`capabilities.identity.extend(patch)\`** \u2014 imperative call that fires at any time after login (post-verification, post-checkout, any user-triggered async flow). Re-signs the JWT, updates \`user.metadata\`, and broadcasts \`identity:refresh\`.
|
|
756
|
+
|
|
757
|
+
Both share the **\`identity:extend\`** permission and the **\`manifest.identityClaims\`** declaration gate.
|
|
758
|
+
|
|
759
|
+
### Manifest contract
|
|
731
760
|
|
|
732
761
|
\`\`\`json
|
|
733
762
|
{
|
|
734
|
-
"permissions": ["extend
|
|
763
|
+
"permissions": ["identity:extend"],
|
|
764
|
+
"identityClaims": ["loyalty_tier", "verified", "verified_by", "verified_at"]
|
|
735
765
|
}
|
|
736
766
|
\`\`\`
|
|
737
767
|
|
|
768
|
+
- **Standard JWT claims (\`external_id\`, \`email\`, \`name\`) are exempt** \u2014 they're part of the signing contract and may be overridden without declaration.
|
|
769
|
+
- **Custom keys MUST be declared in \`identityClaims\`** or the host filter drops them with a \`console.warn\`.
|
|
770
|
+
- **Reserved JWT/Zendesk keys** (\`iss\`, \`sub\`, \`aud\`, \`exp\`, \`nbf\`, \`iat\`, \`jti\`, \`scope\`, \`email_verified\`, \`user_fields\`) **cannot** appear in \`identityClaims\` \u2014 the Lambda sanitizer always wins on collision.
|
|
771
|
+
- **Key format:** \`/^[a-z_][a-z0-9_]{0,63}$/\` (lowercase identifier, \u226464 chars).
|
|
772
|
+
- **Maximum 20 entries.**
|
|
773
|
+
|
|
774
|
+
### Initial-login enrichment (handler-style)
|
|
775
|
+
|
|
776
|
+
- **Hook:** \`useExtendIdentity(handler)\` \u2014 \`ExtendIdentityHandler\` type exported for use with \`useCallback\`
|
|
777
|
+
- **Handler signature:** \`(claims: IdentityBaseClaims) => Record<string, unknown> | Promise<Record<string, unknown>>\`
|
|
778
|
+
- **IdentityBaseClaims:** \`{ external_id: string, email?: string, name?: string, [key: string]: unknown }\`
|
|
779
|
+
|
|
780
|
+
\`\`\`tsx
|
|
781
|
+
${HOOK_SNIPPETS["identity.extend"]}
|
|
782
|
+
\`\`\`
|
|
783
|
+
|
|
784
|
+
### Async post-login push (imperative)
|
|
785
|
+
|
|
786
|
+
\`\`\`tsx
|
|
787
|
+
const capabilities = useCapabilities()
|
|
788
|
+
|
|
789
|
+
// Anytime after login \u2014 webhook callback, user action, async verification, etc.
|
|
790
|
+
await capabilities.identity.extend({
|
|
791
|
+
verified: true,
|
|
792
|
+
verified_by: 'xyzProvider',
|
|
793
|
+
verified_at: new Date().toISOString(),
|
|
794
|
+
})
|
|
795
|
+
// \u2192 host filters against manifest.identityClaims
|
|
796
|
+
// \u2192 user.metadata updated
|
|
797
|
+
// \u2192 JWT re-signed, pushed to Zendesk loginUser
|
|
798
|
+
// \u2192 identity:refresh broadcast to all extensions with events:identity
|
|
799
|
+
\`\`\`
|
|
800
|
+
|
|
801
|
+
Per-extension calls are serialized to prevent concurrent Zendesk \`loginUser\` callbacks from orphaning each other (empirically verified in the loginUser re-auth spike).
|
|
802
|
+
|
|
803
|
+
### Consuming enriched state from another extension
|
|
804
|
+
|
|
805
|
+
Any extension with \`events:identity\` permission can react to enrichment updates. Same minimal pattern as the \`events:identity\` section above \u2014 \`'login'\` covers the initial enriched state, \`'refresh'\` covers post-login pushes:
|
|
806
|
+
|
|
807
|
+
\`\`\`tsx
|
|
808
|
+
${HOOK_SNIPPETS["events:identity"]}
|
|
809
|
+
\`\`\`
|
|
810
|
+
|
|
811
|
+
For a snapshot read instead of event-driven reaction (auto re-renders when context changes):
|
|
812
|
+
|
|
738
813
|
\`\`\`tsx
|
|
739
|
-
|
|
814
|
+
const { identity } = useContextData()
|
|
815
|
+
const verified = Boolean(identity?.user?.metadata?.verified)
|
|
740
816
|
\`\`\`
|
|
817
|
+
|
|
818
|
+
### Install-time enforcement
|
|
819
|
+
|
|
820
|
+
Two enabled extensions on the same instance **MUST NOT** declare overlapping \`identityClaims\` keys. The runtime merge is order-dependent (\`Object.assign\` across per-extension contributions), so one extension's value would silently overwrite the other's. The marketplace install API blocks the install with a 409 conflict, surfacing the specific overlapping key + conflicting extension name in the admin install dialog. Coordinate keys with downstream extensions or namespace them (e.g. \`<vendor>_loyalty_tier\`).
|
|
821
|
+
|
|
822
|
+
### Bundle-scan findings at upload
|
|
823
|
+
|
|
824
|
+
The publisher-side bundle scan validates your declaration at submission time:
|
|
825
|
+
|
|
826
|
+
| Finding | Severity | Triggers when |
|
|
827
|
+
| --- | --- | --- |
|
|
828
|
+
| \`identityClaims_missing\` | warning | \`identity:extend\` declared, \`identityClaims\` empty (custom claims would be dropped) |
|
|
829
|
+
| \`identityClaims_no_permission\` | warning | \`identityClaims\` declared, \`identity:extend\` permission missing |
|
|
830
|
+
| \`identityClaims_invalid_key\` | error | Key fails the format regex |
|
|
831
|
+
| \`identityClaims_reserved_key\` | error | Collides with a reserved JWT / Zendesk claim |
|
|
832
|
+
| \`identityClaims_standard_key\` | warning | Redundant \u2014 standard claims (\`external_id\`, \`email\`, \`name\`) are exempt |
|
|
833
|
+
| \`identityClaims_too_many\` | error | More than 20 entries |
|
|
741
834
|
`;
|
|
742
835
|
};
|
|
743
836
|
|
|
@@ -975,35 +1068,94 @@ export function Content(): React.ReactElement {
|
|
|
975
1068
|
</Surface>
|
|
976
1069
|
)
|
|
977
1070
|
}`,
|
|
978
|
-
"extend
|
|
979
|
-
import type {
|
|
1071
|
+
"identity.extend": `import { useCapabilities, useContextData, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
1072
|
+
import type { ContextData } from '@stackable-labs/sdk-extension-contracts'
|
|
980
1073
|
|
|
981
|
-
//
|
|
982
|
-
//
|
|
983
|
-
//
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1074
|
+
// manifest.json:
|
|
1075
|
+
// {
|
|
1076
|
+
// "permissions": ["identity:extend"],
|
|
1077
|
+
// "identityClaims": ["verified", "verified_by", "verified_at"]
|
|
1078
|
+
// }
|
|
1079
|
+
//
|
|
1080
|
+
// PUSH side \u2014 capabilities.identity.extend(patch) sends new claims to the host
|
|
1081
|
+
// AFTER initial login. The host filters the patch against manifest.identityClaims,
|
|
1082
|
+
// merges into user.metadata, re-signs the JWT, and broadcasts identity:refresh.
|
|
1083
|
+
//
|
|
1084
|
+
// CONSUMER side \u2014 useContextData() already re-renders on every host-pushed
|
|
1085
|
+
// context update (login/logout/refresh/expired). For pure rendering, read the
|
|
1086
|
+
// enriched value directly from ctx.identity.user.metadata \u2014 no event listener
|
|
1087
|
+
// needed. Use useIdentityEvent only when you need a SIDE EFFECT (analytics,
|
|
1088
|
+
// cache invalidation, etc.) on a specific event.
|
|
1089
|
+
//
|
|
1090
|
+
// Standard JWT claims (external_id, email, name) are exempt from declaration.
|
|
1091
|
+
// Undeclared custom keys are dropped client-side with a console.warn.
|
|
1092
|
+
export function Content(): React.ReactElement {
|
|
1093
|
+
const capabilities = useCapabilities()
|
|
1094
|
+
const ctx = useContextData() as ContextData & { loading: boolean }
|
|
1095
|
+
|
|
1096
|
+
// Push: trigger after async verification (webhook, polling, user action)
|
|
1097
|
+
const runVerification = async () => {
|
|
1098
|
+
await new Promise(r => setTimeout(r, 1000)) // simulated async work
|
|
1099
|
+
await capabilities.identity.extend({
|
|
1100
|
+
verified: true,
|
|
1101
|
+
verified_by: 'xyzProvider',
|
|
1102
|
+
verified_at: new Date().toISOString(),
|
|
1103
|
+
})
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Consume: read directly from ctx \u2014 re-renders automatically on identity:refresh.
|
|
1107
|
+
const verified = Boolean(ctx.identity?.user?.metadata?.verified)
|
|
1108
|
+
|
|
1109
|
+
return (
|
|
1110
|
+
<Surface id="slot.content">
|
|
1111
|
+
<ui.Stack direction="column" gap="2" className="p-3">
|
|
1112
|
+
<ui.Button onClick={runVerification}>Verify</ui.Button>
|
|
1113
|
+
<ui.Text>Status: {verified ? 'verified' : 'unverified'}</ui.Text>
|
|
1114
|
+
</ui.Stack>
|
|
1115
|
+
</Surface>
|
|
1116
|
+
)
|
|
1117
|
+
}`,
|
|
988
1118
|
// ── Per-event example snippets ─────────────────────────────────────────────
|
|
989
|
-
"events:identity": `import {
|
|
1119
|
+
"events:identity": `import { Surface, ui, useContextData, useIdentityEvent } from '@stackable-labs/sdk-extension-react'
|
|
990
1120
|
import type { IdentityEvent } from '@stackable-labs/sdk-extension-contracts'
|
|
991
|
-
import { useState } from 'react'
|
|
992
1121
|
|
|
1122
|
+
// useIdentityEvent \u2014 for SIDE EFFECTS on identity lifecycle events.
|
|
1123
|
+
// If you just want to RENDER based on identity state (e.g. show the current
|
|
1124
|
+
// user's email), use useContextData() \u2014 it re-renders reactively on every
|
|
1125
|
+
// host-pushed identity change. Use useIdentityEvent only when you need to
|
|
1126
|
+
// RUN imperative code on a specific event: analytics beacons, cookie writes,
|
|
1127
|
+
// downstream system notifications, cache invalidation, etc.
|
|
1128
|
+
//
|
|
1129
|
+
// manifest.json:
|
|
1130
|
+
// {
|
|
1131
|
+
// "permissions": ["context:read", "events:identity"],
|
|
1132
|
+
// "events": ["identity:login", "identity:logout"]
|
|
1133
|
+
// }
|
|
993
1134
|
export function Header(): React.ReactElement {
|
|
994
|
-
const
|
|
1135
|
+
const ctx = useContextData()
|
|
995
1136
|
|
|
1137
|
+
// Side effect: persist a "last login" hint to localStorage on every login.
|
|
1138
|
+
// Replace with your real-world side effect \u2014 analytics beacon, cookie write,
|
|
1139
|
+
// downstream system notification, etc.
|
|
996
1140
|
useIdentityEvent('login', (event: IdentityEvent) => {
|
|
997
|
-
|
|
1141
|
+
localStorage.setItem('last-login', JSON.stringify({
|
|
1142
|
+
userId: event.data.state.user?.id,
|
|
1143
|
+
timestamp: new Date().toISOString(),
|
|
1144
|
+
}))
|
|
998
1145
|
})
|
|
999
1146
|
|
|
1147
|
+
// Clear the hint on logout / session expiry.
|
|
1000
1148
|
useIdentityEvent('logout', () => {
|
|
1001
|
-
|
|
1149
|
+
localStorage.removeItem('last-login')
|
|
1002
1150
|
})
|
|
1003
1151
|
|
|
1152
|
+
// Rendering: read directly from ctx \u2014 DON'T mirror identity into local
|
|
1153
|
+
// useState via the event listener; useContextData is already reactive.
|
|
1154
|
+
const email = ctx?.identity?.user?.email ?? null
|
|
1155
|
+
|
|
1004
1156
|
return (
|
|
1005
1157
|
<Surface id="slot.header">
|
|
1006
|
-
<ui.Text className="text-xs">{
|
|
1158
|
+
<ui.Text className="text-xs">{email ?? 'Not logged in'}</ui.Text>
|
|
1007
1159
|
</Surface>
|
|
1008
1160
|
)
|
|
1009
1161
|
}`,
|
|
@@ -1082,7 +1234,7 @@ const capabilities = useCapabilities()
|
|
|
1082
1234
|
// capabilities.data.fetch(url, init?)
|
|
1083
1235
|
// capabilities.actions.toast(payload)
|
|
1084
1236
|
// capabilities.actions.invoke(action, payload?) \u2014 actions: newConversation, setConversationTags, setConversationFields, open, close, show, hide
|
|
1085
|
-
// capabilities.extend
|
|
1237
|
+
// capabilities.identity.extend(patch) \u2014 push enrichment claims to user.metadata + JWT custom_claims (imperative; for handler-style at login, use useExtendIdentity hook). Each patch key MUST be declared in manifest.identityClaims or the host filter drops it.
|
|
1086
1238
|
\`\`\`
|
|
1087
1239
|
|
|
1088
1240
|
## useStore(store, selector?)
|
|
@@ -1180,17 +1332,17 @@ useEvent('activity', (event) => {
|
|
|
1180
1332
|
\`\`\`
|
|
1181
1333
|
|
|
1182
1334
|
## useExtendIdentity(handler)
|
|
1183
|
-
Register a handler to enrich identity JWT claims before signing. Requires \`extend
|
|
1335
|
+
Register a handler to enrich identity JWT claims before signing. Requires \`identity:extend\` permission.
|
|
1184
1336
|
- \`handler: ExtendIdentityHandler\` \u2014 \`(claims: IdentityBaseClaims) => Record<string, unknown> | Promise<Record<string, unknown>>\`
|
|
1185
1337
|
- \`IdentityBaseClaims: { external_id: string, email?: string, name?: string, [key: string]: unknown }\`
|
|
1186
1338
|
|
|
1187
1339
|
\`\`\`tsx
|
|
1188
|
-
${stripImports(HOOK_SNIPPETS["extend
|
|
1340
|
+
${stripImports(HOOK_SNIPPETS["identity.extend"])}
|
|
1189
1341
|
\`\`\`
|
|
1190
1342
|
|
|
1191
1343
|
With \`useCallback\` (for memoized handlers):
|
|
1192
1344
|
\`\`\`tsx
|
|
1193
|
-
${HOOK_SNIPPETS_MEMOIZED["extend
|
|
1345
|
+
${HOOK_SNIPPETS_MEMOIZED["identity.extend"]}
|
|
1194
1346
|
\`\`\`
|
|
1195
1347
|
|
|
1196
1348
|
## Identity via context.read()
|
|
@@ -1897,15 +2049,19 @@ The dev command:
|
|
|
1897
2049
|
The CLI outputs a query param like:
|
|
1898
2050
|
|
|
1899
2051
|
\`\`\`
|
|
1900
|
-
?_stackable_dev=ext-123
|
|
2052
|
+
?_stackable_dev=ext-123:eyJ1cmwiOiJodHRwczovL2FiYy50cnljbG91ZGZsYXJlLmNvbSIsInRva2VuIjoiZXlKaGJHY2lPaUpJVXp...
|
|
1901
2053
|
\`\`\`
|
|
1902
2054
|
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
2055
|
+
The value after the colon is a \`base64url\`-encoded JSON \`{url, token}\` blob (the
|
|
2056
|
+
default, when the CLI obtained a dev session token) or a plain URL (legacy
|
|
2057
|
+
fallback when no token was available).
|
|
2058
|
+
|
|
2059
|
+
Copy the full param and **append it to the host site's URL** (the site or product
|
|
2060
|
+
where your extension is installed/authorized) to load your local extension instead
|
|
2061
|
+
of the production bundle. For example:
|
|
1906
2062
|
|
|
1907
2063
|
\`\`\`
|
|
1908
|
-
https://your-host-site.com/dashboard?_stackable_dev=ext-123
|
|
2064
|
+
https://your-host-site.com/dashboard?_stackable_dev=ext-123:eyJ1cmwi...
|
|
1909
2065
|
\`\`\`
|
|
1910
2066
|
|
|
1911
2067
|
This override is **browser-session only** \u2014 no database changes, no shared state.
|
|
@@ -2086,16 +2242,27 @@ ${CLI.dev}
|
|
|
2086
2242
|
|
|
2087
2243
|
### Host-Site Override
|
|
2088
2244
|
|
|
2089
|
-
The CLI outputs a query param
|
|
2245
|
+
The CLI outputs a query param you append to your deployed host site's URL (the
|
|
2246
|
+
site or product where your extension is installed/authorized) to load your local
|
|
2247
|
+
extension instead of the production bundle. The override is browser-session only \u2014
|
|
2248
|
+
no DB changes, no shared state. Each developer gets isolated overrides.
|
|
2249
|
+
|
|
2250
|
+
**Blob format (default \u2014 when authenticated):** the value after the colon is a
|
|
2251
|
+
\`base64url\`-encoded JSON \`{url, token}\` blob; the token is verified by the host:
|
|
2090
2252
|
|
|
2091
2253
|
\`\`\`
|
|
2092
|
-
?_stackable_dev=ext-123
|
|
2254
|
+
?_stackable_dev=ext-123:eyJ1cmwiOiJodHRwczovL2FiYy50cnljbG91ZGZsYXJlLmNvbSIsInRva2VuIjoiZXlKaGJHY2lPaUpJVXp...
|
|
2093
2255
|
\`\`\`
|
|
2094
2256
|
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2257
|
+
**Legacy format (fallback \u2014 when no token):** plain URL after the colon:
|
|
2258
|
+
|
|
2259
|
+
\`\`\`
|
|
2260
|
+
?_stackable_dev=ext-123:https://abc.trycloudflare.com
|
|
2261
|
+
\`\`\`
|
|
2262
|
+
|
|
2263
|
+
The CLI also surfaces a \`_stackable_staging=...\` variant (blob-only) for testing
|
|
2264
|
+
against staging-mode handling. For multiple extensions, comma-join entries in a
|
|
2265
|
+
single param \u2014 mixed blob + legacy is fine.
|
|
2099
2266
|
|
|
2100
2267
|
## validate *(coming soon)*
|
|
2101
2268
|
|
|
@@ -2491,7 +2658,7 @@ filtered out. Clicking a surface adds it to your manifest and inserts a
|
|
|
2491
2658
|
### Capabilities
|
|
2492
2659
|
|
|
2493
2660
|
The SDK capabilities your extension can use: \`data.query\`, \`data.fetch\`,
|
|
2494
|
-
\`context.read\`, \`actions.toast\`, \`actions.invoke\`, \`extend
|
|
2661
|
+
\`context.read\`, \`actions.toast\`, \`actions.invoke\`, \`identity.extend\`,
|
|
2495
2662
|
\`events:identity\`, \`events:messaging\`, and \`events:activity\`. Clicking a
|
|
2496
2663
|
capability adds the permission to your manifest and AI-inserts the hook usage.
|
|
2497
2664
|
|
|
@@ -3539,30 +3706,13 @@ var generateCookbookEvents = () => {
|
|
|
3539
3706
|
});
|
|
3540
3707
|
return `${fm}
|
|
3541
3708
|
|
|
3542
|
-
# Events &
|
|
3543
|
-
|
|
3544
|
-
Subscribe to real-time events pushed from the host via the framework, and extend identity claims.
|
|
3545
|
-
Each event type has a dedicated hook \u2014 never use \`capabilities.events.*\` directly.
|
|
3546
|
-
|
|
3547
|
-
## Identity Events
|
|
3548
|
-
|
|
3549
|
-
Subscribe to login, logout, refresh, and expired events. Useful for tracking
|
|
3550
|
-
agent authentication state in your extension.
|
|
3551
|
-
|
|
3552
|
-
**Permission:** \`events:identity\`
|
|
3553
|
-
**Event types:** ${identityEventTypes2}
|
|
3554
|
-
|
|
3555
|
-
### Hook usage
|
|
3556
|
-
|
|
3557
|
-
\`\`\`tsx
|
|
3558
|
-
${HOOK_SNIPPETS["events:identity"]}
|
|
3559
|
-
\`\`\`
|
|
3709
|
+
# Events & Identity
|
|
3560
3710
|
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3711
|
+
Subscribe to real-time events pushed from the host via the framework, and enrich identity
|
|
3712
|
+
claims. Each event type has a dedicated hook (\`useIdentityEvent\` / \`useMessagingEvent\` /
|
|
3713
|
+
\`useActivityEvent\`) \u2014 never use \`capabilities.events.*\` directly. Identity enrichment
|
|
3714
|
+
supports both a login-time hook (\`useExtendIdentity\`) and an imperative post-login API
|
|
3715
|
+
(\`capabilities.identity.extend\`).
|
|
3566
3716
|
|
|
3567
3717
|
## Messaging Events
|
|
3568
3718
|
|
|
@@ -3604,24 +3754,54 @@ ${HOOK_SNIPPETS["events:activity"]}
|
|
|
3604
3754
|
${EXAMPLE_SNIPPETS["events:activity"]}
|
|
3605
3755
|
\`\`\`
|
|
3606
3756
|
|
|
3607
|
-
##
|
|
3757
|
+
## Identity Events
|
|
3608
3758
|
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3759
|
+
Subscribe to login, logout, refresh, and expired events. Useful for tracking
|
|
3760
|
+
agent authentication state and reacting to identity enrichment pushed from sibling
|
|
3761
|
+
extensions (via \`capabilities.identity.extend\` \u2014 see *Extend Identity* below).
|
|
3612
3762
|
|
|
3613
|
-
**Permission:** \`
|
|
3763
|
+
**Permission:** \`events:identity\`
|
|
3764
|
+
**Event types:** ${identityEventTypes2}
|
|
3614
3765
|
|
|
3615
3766
|
### Hook usage
|
|
3616
3767
|
|
|
3617
3768
|
\`\`\`tsx
|
|
3618
|
-
${HOOK_SNIPPETS["
|
|
3769
|
+
${HOOK_SNIPPETS["events:identity"]}
|
|
3619
3770
|
\`\`\`
|
|
3620
3771
|
|
|
3621
3772
|
### Full component example
|
|
3622
3773
|
|
|
3623
3774
|
\`\`\`tsx
|
|
3624
|
-
${EXAMPLE_SNIPPETS["
|
|
3775
|
+
${EXAMPLE_SNIPPETS["events:identity"]}
|
|
3776
|
+
\`\`\`
|
|
3777
|
+
|
|
3778
|
+
## Extend Identity
|
|
3779
|
+
|
|
3780
|
+
Enrich identity JWT claims and \`user.metadata\` so the signed token AND any sibling
|
|
3781
|
+
extension with \`events:identity\` can react. Two complementary paths:
|
|
3782
|
+
|
|
3783
|
+
- **\`useExtendIdentity(handler)\`** \u2014 synchronous hook, fires ONCE at initial login. Use
|
|
3784
|
+
for known-at-login enrichment.
|
|
3785
|
+
- **\`capabilities.identity.extend(patch)\`** \u2014 imperative call, fires post-login (after
|
|
3786
|
+
async verification, webhook callbacks, user-triggered flows). Re-signs the JWT and
|
|
3787
|
+
broadcasts \`identity:refresh\` to all extensions with \`events:identity\`.
|
|
3788
|
+
|
|
3789
|
+
Both paths share the **\`identity:extend\`** permission and the **\`manifest.identityClaims\`**
|
|
3790
|
+
declaration gate. Standard JWT claims (\`external_id\`, \`email\`, \`name\`) are exempt;
|
|
3791
|
+
custom keys must be declared or they're dropped by the host filter with a \`console.warn\`.
|
|
3792
|
+
|
|
3793
|
+
**Permission:** \`identity:extend\`
|
|
3794
|
+
|
|
3795
|
+
### Login-time hook (useExtendIdentity)
|
|
3796
|
+
|
|
3797
|
+
\`\`\`tsx
|
|
3798
|
+
${HOOK_SNIPPETS["identity.extend"]}
|
|
3799
|
+
\`\`\`
|
|
3800
|
+
|
|
3801
|
+
### Imperative post-login (capabilities.identity.extend)
|
|
3802
|
+
|
|
3803
|
+
\`\`\`tsx
|
|
3804
|
+
${EXAMPLE_SNIPPETS["identity.extend"]}
|
|
3625
3805
|
\`\`\`
|
|
3626
3806
|
`;
|
|
3627
3807
|
};
|
|
@@ -3685,10 +3865,148 @@ Only add permissions that aren't already declared.
|
|
|
3685
3865
|
`;
|
|
3686
3866
|
};
|
|
3687
3867
|
|
|
3868
|
+
// ../../sdk/extension/ai-docs/src/snippets/capabilities.ts
|
|
3869
|
+
var EXTEND_IDENTITY_HOOK_EXAMPLE = `// Enrich identity JWT claims before signing.
|
|
3870
|
+
// The host sends base claims (external_id, email, name); your handler returns
|
|
3871
|
+
// additional claims to merge. Returned keys are filtered against manifest.identityClaims:
|
|
3872
|
+
// - Standard JWT claims (external_id, email, name) are exempt \u2014 always allowed
|
|
3873
|
+
// - Custom claims must be declared in manifest.identityClaims
|
|
3874
|
+
// - Undeclared keys are dropped with a console.warn
|
|
3875
|
+
// Merged claims land in BOTH identityState.user.metadata (for sibling extensions)
|
|
3876
|
+
// AND the signed JWT's custom_claims (for downstream JWT consumers).
|
|
3877
|
+
// Pattern: name the handler with useCallback<ExtendIdentityHandler> for stable
|
|
3878
|
+
// reference + clean separation; pass the named const to useExtendIdentity.
|
|
3879
|
+
//
|
|
3880
|
+
// useExtendIdentity fires ONCE at initial login \u2014 return what's known synchronously.
|
|
3881
|
+
// For post-login async updates (e.g., after verification completes via a webhook
|
|
3882
|
+
// or polling), use capabilities.identity.extend(patch) \u2014 see the 'identity.extend'
|
|
3883
|
+
// capability for the imperative path.
|
|
3884
|
+
|
|
3885
|
+
${HOOK_SNIPPETS_MEMOIZED["identity.extend"]}`;
|
|
3886
|
+
var CAPABILITY_SNIPPETS = {
|
|
3887
|
+
"data.query": `
|
|
3888
|
+
const capabilities = useCapabilities()
|
|
3889
|
+
const result = await capabilities.data.query({ path: '/your-endpoint', method: 'GET' })
|
|
3890
|
+
`,
|
|
3891
|
+
"data.fetch": `
|
|
3892
|
+
const capabilities = useCapabilities()
|
|
3893
|
+
const response = await capabilities.data.fetch('https://api.example.com/endpoint')
|
|
3894
|
+
`,
|
|
3895
|
+
"context.read": `
|
|
3896
|
+
// Read host context + extension settings
|
|
3897
|
+
const { customerId, settings } = useContextData()
|
|
3898
|
+
|
|
3899
|
+
// Or use the convenience hook for settings only
|
|
3900
|
+
const settings = useSettings()
|
|
3901
|
+
`,
|
|
3902
|
+
"actions.toast": `
|
|
3903
|
+
const capabilities = useCapabilities()
|
|
3904
|
+
capabilities.actions.toast({ type: 'success', message: 'Done!' })
|
|
3905
|
+
`,
|
|
3906
|
+
"actions.invoke": `
|
|
3907
|
+
const capabilities = useCapabilities()
|
|
3908
|
+
|
|
3909
|
+
// New conversation with tags and fields
|
|
3910
|
+
await capabilities.actions.invoke('newConversation', {
|
|
3911
|
+
tags: ['stackable', 'order-lookup'],
|
|
3912
|
+
fields: [{ id: 'stackable_action', value: 'order_status' }],
|
|
3913
|
+
metadata: { orderId: '12345' },
|
|
3914
|
+
})
|
|
3915
|
+
|
|
3916
|
+
// Standalone: set tags on current/next conversation
|
|
3917
|
+
await capabilities.actions.invoke('setConversationTags', ['escalated', 'order-issue'])
|
|
3918
|
+
`,
|
|
3919
|
+
"events:identity": HOOK_SNIPPETS["events:identity"],
|
|
3920
|
+
"events:messaging": HOOK_SNIPPETS["events:messaging"],
|
|
3921
|
+
"events:activity": HOOK_SNIPPETS["events:activity"],
|
|
3922
|
+
"identity.extend": `${EXTEND_IDENTITY_HOOK_EXAMPLE}
|
|
3923
|
+
|
|
3924
|
+
// \u2500\u2500 For post-login async push (e.g., after async verification completes): \u2500\u2500\u2500\u2500\u2500
|
|
3925
|
+
const capabilities = useCapabilities()
|
|
3926
|
+
await capabilities.identity.extend({ verified: true })
|
|
3927
|
+
|
|
3928
|
+
// Consumer side \u2014 same or sibling extension reacts via identity:refresh
|
|
3929
|
+
useIdentityEvent('refresh', (event) => {
|
|
3930
|
+
console.log('verified =>', event.data.state.user?.metadata?.verified)
|
|
3931
|
+
})
|
|
3932
|
+
`
|
|
3933
|
+
};
|
|
3934
|
+
var EVENT_SNIPPETS = {
|
|
3935
|
+
"events:identity": `import { useIdentityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3936
|
+
import type { IdentityEvent } from '@stackable-labs/sdk-extension-contracts'
|
|
3937
|
+
import { useState } from 'react'
|
|
3938
|
+
|
|
3939
|
+
// manifest events: ["identity:login", "identity:logout", "identity:refresh"]
|
|
3940
|
+
//
|
|
3941
|
+
// 'login' and 'refresh' both deliver the full IdentityState, including
|
|
3942
|
+
// state.user.metadata \u2014 populated by sibling extensions with identity:extend
|
|
3943
|
+
// (declared in their manifest.identityClaims). Subscribe to BOTH to cover the
|
|
3944
|
+
// initial login AND any post-login push via capabilities.identity.extend().
|
|
3945
|
+
export function Header(): React.ReactElement {
|
|
3946
|
+
const [user, setUser] = useState<string | null>(null)
|
|
3947
|
+
const [verified, setVerified] = useState(false)
|
|
3948
|
+
|
|
3949
|
+
useIdentityEvent('login', (event: IdentityEvent) => {
|
|
3950
|
+
setUser(event.data.state.user?.email ?? null)
|
|
3951
|
+
setVerified(Boolean(event.data.state.user?.metadata?.verified))
|
|
3952
|
+
})
|
|
3953
|
+
|
|
3954
|
+
useIdentityEvent('refresh', (event: IdentityEvent) => {
|
|
3955
|
+
setVerified(Boolean(event.data.state.user?.metadata?.verified))
|
|
3956
|
+
})
|
|
3957
|
+
|
|
3958
|
+
useIdentityEvent('logout', () => {
|
|
3959
|
+
setUser(null)
|
|
3960
|
+
setVerified(false)
|
|
3961
|
+
})
|
|
3962
|
+
|
|
3963
|
+
return (
|
|
3964
|
+
<Surface id="slot.header">
|
|
3965
|
+
<ui.Text className="text-xs">{user ?? 'Not logged in'} {verified && '\u2713'}</ui.Text>
|
|
3966
|
+
</Surface>
|
|
3967
|
+
)
|
|
3968
|
+
}`,
|
|
3969
|
+
"events:messaging": `import { useMessagingEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3970
|
+
import type { MessagingEventHandler } from '@stackable-labs/sdk-extension-contracts'
|
|
3971
|
+
import { useState } from 'react'
|
|
3972
|
+
|
|
3973
|
+
export function Content(): React.ReactElement {
|
|
3974
|
+
const [lastPostback, setLastPostback] = useState<string | null>(null)
|
|
3975
|
+
|
|
3976
|
+
useMessagingEvent('postback', (event) => {
|
|
3977
|
+
setLastPostback(event.data.actionName)
|
|
3978
|
+
})
|
|
3979
|
+
|
|
3980
|
+
return (
|
|
3981
|
+
<Surface id="slot.content">
|
|
3982
|
+
<ui.Text className="text-xs">{lastPostback ?? 'No postbacks yet'}</ui.Text>
|
|
3983
|
+
</Surface>
|
|
3984
|
+
)
|
|
3985
|
+
}`,
|
|
3986
|
+
"events:activity": `import { useActivityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
3987
|
+
import type { ActivityEventHandler } from '@stackable-labs/sdk-extension-contracts'
|
|
3988
|
+
import { useState } from 'react'
|
|
3989
|
+
|
|
3990
|
+
export function Content(): React.ReactElement {
|
|
3991
|
+
const [lastEvent, setLastEvent] = useState<string | null>(null)
|
|
3992
|
+
|
|
3993
|
+
useActivityEvent('page_view', (event) => {
|
|
3994
|
+
setLastEvent(event.data.url as string)
|
|
3995
|
+
})
|
|
3996
|
+
|
|
3997
|
+
return (
|
|
3998
|
+
<Surface id="slot.content">
|
|
3999
|
+
<ui.Text className="text-xs">{lastEvent ?? 'No activity yet'}</ui.Text>
|
|
4000
|
+
</Surface>
|
|
4001
|
+
)
|
|
4002
|
+
}`,
|
|
4003
|
+
"identity.extend": EXTEND_IDENTITY_HOOK_EXAMPLE
|
|
4004
|
+
};
|
|
4005
|
+
|
|
3688
4006
|
// ../../sdk/extension/ai-docs/src/commands/add-capability.ts
|
|
3689
4007
|
var generateAddCapabilityCommand = () => {
|
|
3690
4008
|
const fm = frontmatter({
|
|
3691
|
-
description: "Wire up a new capability (data.fetch, data.query, context.read, actions.toast, actions.invoke, extend
|
|
4009
|
+
description: "Wire up a new capability (data.fetch, data.query, context.read, actions.toast, actions.invoke, identity.extend, events:identity, events:messaging, events:activity) in this extension",
|
|
3692
4010
|
targets: ["*"]
|
|
3693
4011
|
});
|
|
3694
4012
|
return `${fm}
|
|
@@ -3704,7 +4022,7 @@ Ask which capability to add. Valid capabilities:
|
|
|
3704
4022
|
- \`context.read\` \u2014 read host-provided context (customerId, customerEmail, messaging.conversationId, etc.)
|
|
3705
4023
|
- \`actions.toast\` \u2014 show toast notifications (success, error, info, warning)
|
|
3706
4024
|
- \`actions.invoke\` \u2014 invoke host actions (e.g., open new conversation)
|
|
3707
|
-
- \`extend
|
|
4025
|
+
- \`identity.extend\` \u2014 enrich identity JWT claims before signing
|
|
3708
4026
|
- \`events:identity\` \u2014 subscribe to identity events (login, logout, refresh, expired)
|
|
3709
4027
|
- \`events:messaging\` \u2014 subscribe to messaging events (postback button clicks)
|
|
3710
4028
|
- \`events:activity\` \u2014 subscribe to activity events (page views, clicks, purchases)
|
|
@@ -3716,7 +4034,7 @@ Add the corresponding permission to \`packages/extension/public/manifest.json\`:
|
|
|
3716
4034
|
- \`context.read\` \u2192 \`"context:read"\`
|
|
3717
4035
|
- \`actions.toast\` \u2192 \`"actions:toast"\`
|
|
3718
4036
|
- \`actions.invoke\` \u2192 \`"actions:invoke"\`
|
|
3719
|
-
- \`extend
|
|
4037
|
+
- \`identity.extend\` \u2192 \`"identity:extend"\`
|
|
3720
4038
|
- \`events:identity\` \u2192 \`"events:identity"\` (also add entries to \`events\` array, e.g. \`["identity:login", "identity:logout"]\`)
|
|
3721
4039
|
- \`events:messaging\` \u2192 \`"events:messaging"\` (also add entries to \`events\` array, e.g. \`["messaging:postback:Buy Now"]\`)
|
|
3722
4040
|
- \`events:activity\` \u2192 \`"events:activity"\` (also add entries to \`events\` array, e.g. \`["activity:product_view"]\`)
|
|
@@ -3758,8 +4076,14 @@ const capabilities = useCapabilities()
|
|
|
3758
4076
|
// actions.invoke: capabilities.actions.invoke('newConversation', { tags: ['order'], fields: [{ id: 'field_id', value: 'val' }] })
|
|
3759
4077
|
\`\`\`
|
|
3760
4078
|
|
|
3761
|
-
###
|
|
3762
|
-
|
|
4079
|
+
### Special handling (events + identity.extend):
|
|
4080
|
+
|
|
4081
|
+
#### For events \u2014 ALWAYS use dedicated hooks (INSTEAD of useCapabilities direct):
|
|
4082
|
+
Events are subscribed via \`useIdentityEvent\` / \`useMessagingEvent\` / \`useActivityEvent\` \u2014 never use \`capabilities.events.*\` directly (events are not part of the \`capabilities\` object).
|
|
4083
|
+
|
|
4084
|
+
#### For identity.extend \u2014 choose the CORRECT option:
|
|
4085
|
+
- **\`useExtendIdentity(handler)\`** \u2014 synchronous hook, fires only once at initial login. ALWAYS use for known-at-login enrichment.
|
|
4086
|
+
- **\`capabilities.identity.extend(patch)\`** \u2014 imperative call via \`useCapabilities()\`, fires post-login (after async verification, webhook callbacks, user-triggered flows). Re-signs the JWT and broadcasts \`identity:refresh\`.
|
|
3763
4087
|
|
|
3764
4088
|
\`\`\`tsx
|
|
3765
4089
|
// events:identity \u2014 use useIdentityEvent hook
|
|
@@ -3777,8 +4101,8 @@ ${HOOK_SNIPPETS["events:activity"]}
|
|
|
3777
4101
|
\`\`\`
|
|
3778
4102
|
|
|
3779
4103
|
\`\`\`tsx
|
|
3780
|
-
// extend
|
|
3781
|
-
${
|
|
4104
|
+
// identity.extend \u2014 login-time useExtendIdentity hook OR post-login imperative capabilities.identity.extend
|
|
4105
|
+
${CAPABILITY_SNIPPETS["identity.extend"]}
|
|
3782
4106
|
\`\`\`
|
|
3783
4107
|
|
|
3784
4108
|
## 6. Verify
|
|
@@ -3786,7 +4110,7 @@ ${HOOK_SNIPPETS["extend.identity"]}
|
|
|
3786
4110
|
- If data.fetch, confirm the domain is in allowedDomains
|
|
3787
4111
|
- For data/context/actions: confirm accessed via \`useCapabilities()\` hook
|
|
3788
4112
|
- For events: confirm using \`useIdentityEvent\`, \`useMessagingEvent\`, or \`useActivityEvent\` hooks
|
|
3789
|
-
- For extend: confirm using \`useExtendIdentity\` hook
|
|
4113
|
+
- For identity.extend: confirm using \`useExtendIdentity\` hook (login-time) and/or \`capabilities.identity.extend(patch)\` (imperative post-login)
|
|
3790
4114
|
`;
|
|
3791
4115
|
};
|
|
3792
4116
|
|
|
@@ -3877,12 +4201,13 @@ Scan all \`.tsx\` files in \`packages/extension/src/\` for capability usage:
|
|
|
3877
4201
|
- \`capabilities.context.read\` or \`useContextData\` \u2192 needs \`context:read\` permission
|
|
3878
4202
|
- \`capabilities.actions.toast\` \u2192 needs \`actions:toast\` permission
|
|
3879
4203
|
- \`capabilities.actions.invoke\` \u2192 needs \`actions:invoke\` permission
|
|
3880
|
-
- \`useExtendIdentity\` \u2192 needs \`extend
|
|
4204
|
+
- \`useExtendIdentity\` or \`capabilities.identity.extend\` \u2192 needs \`identity:extend\` permission AND every custom key returned by the handler / passed to \`extend(patch)\` MUST be declared in \`manifest.identityClaims\` (standard JWT claims \`external_id\` / \`email\` / \`name\` are exempt). Undeclared keys are dropped by the host filter with a \`console.warn\`.
|
|
3881
4205
|
${eventHookBullets}
|
|
3882
4206
|
|
|
3883
4207
|
Report:
|
|
3884
4208
|
- **Missing permissions:** capabilities used in code but not declared in manifest
|
|
3885
4209
|
- **Unused permissions:** permissions declared in manifest but not used in code
|
|
4210
|
+
- **Undeclared identityClaims:** keys returned from \`useExtendIdentity\` or passed to \`capabilities.identity.extend\` that are not in \`manifest.identityClaims\` (excluding the standard-claim exemption list)
|
|
3886
4211
|
|
|
3887
4212
|
## 3. Surface-to-target matching
|
|
3888
4213
|
- Each \`.tsx\` file with a \`<Surface id="...">\` should have a matching target in manifest.json
|
|
@@ -4383,114 +4708,6 @@ var findRelevantSkills = (skills, query) => {
|
|
|
4383
4708
|
});
|
|
4384
4709
|
};
|
|
4385
4710
|
|
|
4386
|
-
// ../../sdk/extension/ai-docs/src/snippets/capabilities.ts
|
|
4387
|
-
var CAPABILITY_SNIPPETS = {
|
|
4388
|
-
"data.query": `
|
|
4389
|
-
const capabilities = useCapabilities()
|
|
4390
|
-
const result = await capabilities.data.query({ path: '/your-endpoint', method: 'GET' })
|
|
4391
|
-
`,
|
|
4392
|
-
"data.fetch": `
|
|
4393
|
-
const capabilities = useCapabilities()
|
|
4394
|
-
const response = await capabilities.data.fetch('https://api.example.com/endpoint')
|
|
4395
|
-
`,
|
|
4396
|
-
"context.read": `
|
|
4397
|
-
// Read host context + extension settings
|
|
4398
|
-
const { customerId, settings } = useContextData()
|
|
4399
|
-
|
|
4400
|
-
// Or use the convenience hook for settings only
|
|
4401
|
-
const settings = useSettings()
|
|
4402
|
-
`,
|
|
4403
|
-
"actions.toast": `
|
|
4404
|
-
const capabilities = useCapabilities()
|
|
4405
|
-
capabilities.actions.toast({ type: 'success', message: 'Done!' })
|
|
4406
|
-
`,
|
|
4407
|
-
"actions.invoke": `
|
|
4408
|
-
const capabilities = useCapabilities()
|
|
4409
|
-
|
|
4410
|
-
// New conversation with tags and fields
|
|
4411
|
-
await capabilities.actions.invoke('newConversation', {
|
|
4412
|
-
tags: ['stackable', 'order-lookup'],
|
|
4413
|
-
fields: [{ id: 'stackable_action', value: 'order_status' }],
|
|
4414
|
-
metadata: { orderId: '12345' },
|
|
4415
|
-
})
|
|
4416
|
-
|
|
4417
|
-
// Standalone: set tags on current/next conversation
|
|
4418
|
-
await capabilities.actions.invoke('setConversationTags', ['escalated', 'order-issue'])
|
|
4419
|
-
`,
|
|
4420
|
-
"events:identity": HOOK_SNIPPETS["events:identity"],
|
|
4421
|
-
"events:messaging": HOOK_SNIPPETS["events:messaging"],
|
|
4422
|
-
"events:activity": HOOK_SNIPPETS["events:activity"],
|
|
4423
|
-
"extend.identity": HOOK_SNIPPETS["extend.identity"]
|
|
4424
|
-
};
|
|
4425
|
-
var EVENT_SNIPPETS = {
|
|
4426
|
-
"events:identity": `import { useIdentityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
4427
|
-
import type { IdentityEvent } from '@stackable-labs/sdk-extension-contracts'
|
|
4428
|
-
import { useState } from 'react'
|
|
4429
|
-
|
|
4430
|
-
export function Header(): React.ReactElement {
|
|
4431
|
-
const [user, setUser] = useState<string | null>(null)
|
|
4432
|
-
|
|
4433
|
-
useIdentityEvent('login', (event: IdentityEvent) => {
|
|
4434
|
-
setUser(event.data.state.user?.email ?? null)
|
|
4435
|
-
})
|
|
4436
|
-
|
|
4437
|
-
useIdentityEvent('logout', () => {
|
|
4438
|
-
setUser(null)
|
|
4439
|
-
})
|
|
4440
|
-
|
|
4441
|
-
return (
|
|
4442
|
-
<Surface id="slot.header">
|
|
4443
|
-
<ui.Text className="text-xs">{user ?? 'Not logged in'}</ui.Text>
|
|
4444
|
-
</Surface>
|
|
4445
|
-
)
|
|
4446
|
-
}`,
|
|
4447
|
-
"events:messaging": `import { useMessagingEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
4448
|
-
import type { MessagingEventHandler } from '@stackable-labs/sdk-extension-contracts'
|
|
4449
|
-
import { useState } from 'react'
|
|
4450
|
-
|
|
4451
|
-
export function Content(): React.ReactElement {
|
|
4452
|
-
const [lastPostback, setLastPostback] = useState<string | null>(null)
|
|
4453
|
-
|
|
4454
|
-
useMessagingEvent('postback', (event) => {
|
|
4455
|
-
setLastPostback(event.data.actionName)
|
|
4456
|
-
})
|
|
4457
|
-
|
|
4458
|
-
return (
|
|
4459
|
-
<Surface id="slot.content">
|
|
4460
|
-
<ui.Text className="text-xs">{lastPostback ?? 'No postbacks yet'}</ui.Text>
|
|
4461
|
-
</Surface>
|
|
4462
|
-
)
|
|
4463
|
-
}`,
|
|
4464
|
-
"events:activity": `import { useActivityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
4465
|
-
import type { ActivityEventHandler } from '@stackable-labs/sdk-extension-contracts'
|
|
4466
|
-
import { useState } from 'react'
|
|
4467
|
-
|
|
4468
|
-
export function Content(): React.ReactElement {
|
|
4469
|
-
const [lastEvent, setLastEvent] = useState<string | null>(null)
|
|
4470
|
-
|
|
4471
|
-
useActivityEvent('page_view', (event) => {
|
|
4472
|
-
setLastEvent(event.data.url as string)
|
|
4473
|
-
})
|
|
4474
|
-
|
|
4475
|
-
return (
|
|
4476
|
-
<Surface id="slot.content">
|
|
4477
|
-
<ui.Text className="text-xs">{lastEvent ?? 'No activity yet'}</ui.Text>
|
|
4478
|
-
</Surface>
|
|
4479
|
-
)
|
|
4480
|
-
}`,
|
|
4481
|
-
"extend.identity": `import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
|
|
4482
|
-
import type { ExtendIdentityHandler } from '@stackable-labs/sdk-extension-contracts'
|
|
4483
|
-
|
|
4484
|
-
// Enrich identity JWT claims before signing.
|
|
4485
|
-
// The host sends base claims (external_id, email, name),
|
|
4486
|
-
// and your handler returns additional claims to merge.
|
|
4487
|
-
// Use ExtendIdentityHandler type with useCallback for memoized handlers.
|
|
4488
|
-
useExtendIdentity((claims) => ({
|
|
4489
|
-
external_id: \`shopify_\${claims.external_id}\`,
|
|
4490
|
-
loyalty_tier: 'gold',
|
|
4491
|
-
}))`
|
|
4492
|
-
};
|
|
4493
|
-
|
|
4494
4711
|
// ../../sdk/extension/ai-docs/src/generated/example-snippets-jsx.ts
|
|
4495
4712
|
var EXAMPLE_SNIPPETS2 = {
|
|
4496
4713
|
"bootstrap": `import { createExtension } from '@stackable-labs/sdk-extension-react'
|
|
@@ -4715,32 +4932,91 @@ export function Content() {
|
|
|
4715
4932
|
</Surface>
|
|
4716
4933
|
)
|
|
4717
4934
|
}`,
|
|
4718
|
-
"extend
|
|
4935
|
+
"identity.extend": `import { useCapabilities, useContextData, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
4719
4936
|
|
|
4720
|
-
//
|
|
4721
|
-
//
|
|
4722
|
-
//
|
|
4723
|
-
|
|
4724
|
-
|
|
4725
|
-
|
|
4726
|
-
|
|
4727
|
-
|
|
4728
|
-
|
|
4937
|
+
// manifest.json:
|
|
4938
|
+
// {
|
|
4939
|
+
// "permissions": ["identity:extend"],
|
|
4940
|
+
// "identityClaims": ["verified", "verified_by", "verified_at"]
|
|
4941
|
+
// }
|
|
4942
|
+
//
|
|
4943
|
+
// PUSH side \u2014 capabilities.identity.extend(patch) sends new claims to the host
|
|
4944
|
+
// AFTER initial login. The host filters the patch against manifest.identityClaims,
|
|
4945
|
+
// merges into user.metadata, re-signs the JWT, and broadcasts identity:refresh.
|
|
4946
|
+
//
|
|
4947
|
+
// CONSUMER side \u2014 useContextData() already re-renders on every host-pushed
|
|
4948
|
+
// context update (login/logout/refresh/expired). For pure rendering, read the
|
|
4949
|
+
// enriched value directly from ctx.identity.user.metadata \u2014 no event listener
|
|
4950
|
+
// needed. Use useIdentityEvent only when you need a SIDE EFFECT (analytics,
|
|
4951
|
+
// cache invalidation, etc.) on a specific event.
|
|
4952
|
+
//
|
|
4953
|
+
// Standard JWT claims (external_id, email, name) are exempt from declaration.
|
|
4954
|
+
// Undeclared custom keys are dropped client-side with a console.warn.
|
|
4955
|
+
export function Content() {
|
|
4956
|
+
const capabilities = useCapabilities()
|
|
4957
|
+
const ctx = useContextData()
|
|
4958
|
+
|
|
4959
|
+
// Push: trigger after async verification (webhook, polling, user action)
|
|
4960
|
+
const runVerification = async () => {
|
|
4961
|
+
await new Promise(r => setTimeout(r, 1000)) // simulated async work
|
|
4962
|
+
await capabilities.identity.extend({
|
|
4963
|
+
verified: true,
|
|
4964
|
+
verified_by: 'xyzProvider',
|
|
4965
|
+
verified_at: new Date().toISOString(),
|
|
4966
|
+
})
|
|
4967
|
+
}
|
|
4968
|
+
|
|
4969
|
+
// Consume: read directly from ctx \u2014 re-renders automatically on identity:refresh.
|
|
4970
|
+
const verified = Boolean(ctx.identity?.user?.metadata?.verified)
|
|
4729
4971
|
|
|
4972
|
+
return (
|
|
4973
|
+
<Surface id="slot.content">
|
|
4974
|
+
<ui.Stack direction="column" gap="2" className="p-3">
|
|
4975
|
+
<ui.Button onClick={runVerification}>Verify</ui.Button>
|
|
4976
|
+
<ui.Text>Status: {verified ? 'verified' : 'unverified'}</ui.Text>
|
|
4977
|
+
</ui.Stack>
|
|
4978
|
+
</Surface>
|
|
4979
|
+
)
|
|
4980
|
+
}`,
|
|
4981
|
+
"events:identity": `import { Surface, ui, useContextData, useIdentityEvent } from '@stackable-labs/sdk-extension-react'
|
|
4982
|
+
|
|
4983
|
+
// useIdentityEvent \u2014 for SIDE EFFECTS on identity lifecycle events.
|
|
4984
|
+
// If you just want to RENDER based on identity state (e.g. show the current
|
|
4985
|
+
// user's email), use useContextData() \u2014 it re-renders reactively on every
|
|
4986
|
+
// host-pushed identity change. Use useIdentityEvent only when you need to
|
|
4987
|
+
// RUN imperative code on a specific event: analytics beacons, cookie writes,
|
|
4988
|
+
// downstream system notifications, cache invalidation, etc.
|
|
4989
|
+
//
|
|
4990
|
+
// manifest.json:
|
|
4991
|
+
// {
|
|
4992
|
+
// "permissions": ["context:read", "events:identity"],
|
|
4993
|
+
// "events": ["identity:login", "identity:logout"]
|
|
4994
|
+
// }
|
|
4730
4995
|
export function Header() {
|
|
4731
|
-
const
|
|
4996
|
+
const ctx = useContextData()
|
|
4732
4997
|
|
|
4998
|
+
// Side effect: persist a "last login" hint to localStorage on every login.
|
|
4999
|
+
// Replace with your real-world side effect \u2014 analytics beacon, cookie write,
|
|
5000
|
+
// downstream system notification, etc.
|
|
4733
5001
|
useIdentityEvent('login', (event) => {
|
|
4734
|
-
|
|
5002
|
+
localStorage.setItem('last-login', JSON.stringify({
|
|
5003
|
+
userId: event.data.state.user?.id,
|
|
5004
|
+
timestamp: new Date().toISOString(),
|
|
5005
|
+
}))
|
|
4735
5006
|
})
|
|
4736
5007
|
|
|
5008
|
+
// Clear the hint on logout / session expiry.
|
|
4737
5009
|
useIdentityEvent('logout', () => {
|
|
4738
|
-
|
|
5010
|
+
localStorage.removeItem('last-login')
|
|
4739
5011
|
})
|
|
4740
5012
|
|
|
5013
|
+
// Rendering: read directly from ctx \u2014 DON'T mirror identity into local
|
|
5014
|
+
// useState via the event listener; useContextData is already reactive.
|
|
5015
|
+
const email = ctx?.identity?.user?.email ?? null
|
|
5016
|
+
|
|
4741
5017
|
return (
|
|
4742
5018
|
<Surface id="slot.header">
|
|
4743
|
-
<ui.Text className="text-xs">{
|
|
5019
|
+
<ui.Text className="text-xs">{email ?? 'Not logged in'}</ui.Text>
|
|
4744
5020
|
</Surface>
|
|
4745
5021
|
)
|
|
4746
5022
|
}`,
|
|
@@ -4804,11 +5080,19 @@ await capabilities.actions.invoke('newConversation', {
|
|
|
4804
5080
|
await capabilities.actions.invoke('setConversationTags', ['escalated', 'order-issue'])`,
|
|
4805
5081
|
"events:identity": `import { useIdentityEvent } from '@stackable-labs/sdk-extension-react'
|
|
4806
5082
|
|
|
5083
|
+
// manifest events: ["identity:login", "identity:logout", "identity:refresh"]
|
|
4807
5084
|
useIdentityEvent('login', (event) => {
|
|
4808
|
-
|
|
5085
|
+
// event.data.state.user.metadata is populated with any enrichment from sibling
|
|
5086
|
+
// extensions with identity:extend (declared in their manifest.identityClaims)
|
|
5087
|
+
console.log('User logged in:', event.data.state.user?.email, event.data.state.user?.metadata)
|
|
4809
5088
|
})
|
|
4810
5089
|
useIdentityEvent('logout', () => {
|
|
4811
5090
|
console.log('User logged out')
|
|
5091
|
+
})
|
|
5092
|
+
// identity:refresh fires after any extension calls capabilities.identity.extend({...}).
|
|
5093
|
+
// Listen here to react to post-login enrichment (verification, tier upgrades, etc.).
|
|
5094
|
+
useIdentityEvent('refresh', (event) => {
|
|
5095
|
+
console.log('Identity refreshed \u2014 metadata:', event.data.state.user?.metadata)
|
|
4812
5096
|
})`,
|
|
4813
5097
|
"events:messaging": `import { useMessagingEvent } from '@stackable-labs/sdk-extension-react'
|
|
4814
5098
|
|
|
@@ -4820,31 +5104,77 @@ useMessagingEvent('postback:Buy Now', (event) => {
|
|
|
4820
5104
|
useActivityEvent('product_view', (event) => {
|
|
4821
5105
|
console.log('Activity:', event.eventName, event.data)
|
|
4822
5106
|
})`,
|
|
4823
|
-
"extend
|
|
5107
|
+
"identity.extend": `// Enrich identity JWT claims before signing.
|
|
5108
|
+
// The host sends base claims (external_id, email, name); your handler returns
|
|
5109
|
+
// additional claims to merge. Returned keys are filtered against manifest.identityClaims:
|
|
5110
|
+
// - Standard JWT claims (external_id, email, name) are exempt \u2014 always allowed
|
|
5111
|
+
// - Custom claims must be declared in manifest.identityClaims
|
|
5112
|
+
// - Undeclared keys are dropped with a console.warn
|
|
5113
|
+
// Merged claims land in BOTH identityState.user.metadata (for sibling extensions)
|
|
5114
|
+
// AND the signed JWT's custom_claims (for downstream JWT consumers).
|
|
5115
|
+
// Pattern: name the handler with useCallback<ExtendIdentityHandler> for stable
|
|
5116
|
+
// reference + clean separation; pass the named const to useExtendIdentity.
|
|
5117
|
+
//
|
|
5118
|
+
// useExtendIdentity fires ONCE at initial login \u2014 return what's known synchronously.
|
|
5119
|
+
// For post-login async updates (e.g., after verification completes via a webhook
|
|
5120
|
+
// or polling), use capabilities.identity.extend(patch) \u2014 see the 'identity.extend'
|
|
5121
|
+
// capability for the imperative path.
|
|
4824
5122
|
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
5123
|
+
import { useCallback } from 'react'
|
|
5124
|
+
import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
|
|
5125
|
+
|
|
5126
|
+
// manifest.json:
|
|
5127
|
+
// {
|
|
5128
|
+
// "permissions": ["identity:extend"],
|
|
5129
|
+
// "identityClaims": ["loyalty_tier", "verified", "verified_by", "verified_at"]
|
|
5130
|
+
// }
|
|
5131
|
+
const handleExtend = useCallback((claims) => ({
|
|
5132
|
+
external_id: \`custom_\${claims.external_id}\`, // standard claim override (exempt)
|
|
5133
|
+
loyalty_tier: 'bronze', // custom \u2014 sync, known at login
|
|
5134
|
+
verified: false, // custom \u2014 default; updated async post-verification
|
|
5135
|
+
}), [])
|
|
5136
|
+
useExtendIdentity(handleExtend)
|
|
5137
|
+
|
|
5138
|
+
// \u2500\u2500 For post-login async push (e.g., after async verification completes): \u2500\u2500\u2500\u2500\u2500
|
|
5139
|
+
const capabilities = useCapabilities()
|
|
5140
|
+
await capabilities.identity.extend({ verified: true })
|
|
5141
|
+
|
|
5142
|
+
// Consumer side \u2014 same or sibling extension reacts via identity:refresh
|
|
5143
|
+
useIdentityEvent('refresh', (event) => {
|
|
5144
|
+
console.log('verified =>', event.data.state.user?.metadata?.verified)
|
|
5145
|
+
})`
|
|
4829
5146
|
};
|
|
4830
5147
|
var EVENT_SNIPPETS2 = {
|
|
4831
5148
|
"events:identity": `import { useIdentityEvent, Surface, ui } from '@stackable-labs/sdk-extension-react'
|
|
4832
5149
|
import { useState } from 'react'
|
|
4833
5150
|
|
|
5151
|
+
// manifest events: ["identity:login", "identity:logout", "identity:refresh"]
|
|
5152
|
+
//
|
|
5153
|
+
// 'login' and 'refresh' both deliver the full IdentityState, including
|
|
5154
|
+
// state.user.metadata \u2014 populated by sibling extensions with identity:extend
|
|
5155
|
+
// (declared in their manifest.identityClaims). Subscribe to BOTH to cover the
|
|
5156
|
+
// initial login AND any post-login push via capabilities.identity.extend().
|
|
4834
5157
|
export function Header() {
|
|
4835
5158
|
const [user, setUser] = useState(null)
|
|
5159
|
+
const [verified, setVerified] = useState(false)
|
|
4836
5160
|
|
|
4837
5161
|
useIdentityEvent('login', (event) => {
|
|
4838
5162
|
setUser(event.data.state.user?.email ?? null)
|
|
5163
|
+
setVerified(Boolean(event.data.state.user?.metadata?.verified))
|
|
5164
|
+
})
|
|
5165
|
+
|
|
5166
|
+
useIdentityEvent('refresh', (event) => {
|
|
5167
|
+
setVerified(Boolean(event.data.state.user?.metadata?.verified))
|
|
4839
5168
|
})
|
|
4840
5169
|
|
|
4841
5170
|
useIdentityEvent('logout', () => {
|
|
4842
5171
|
setUser(null)
|
|
5172
|
+
setVerified(false)
|
|
4843
5173
|
})
|
|
4844
5174
|
|
|
4845
5175
|
return (
|
|
4846
5176
|
<Surface id="slot.header">
|
|
4847
|
-
<ui.Text className="text-xs">{user ?? 'Not logged in'}</ui.Text>
|
|
5177
|
+
<ui.Text className="text-xs">{user ?? 'Not logged in'} {verified && '\u2713'}</ui.Text>
|
|
4848
5178
|
</Surface>
|
|
4849
5179
|
)
|
|
4850
5180
|
}`,
|
|
@@ -4880,16 +5210,36 @@ export function Content() {
|
|
|
4880
5210
|
</Surface>
|
|
4881
5211
|
)
|
|
4882
5212
|
}`,
|
|
4883
|
-
"extend
|
|
5213
|
+
"identity.extend": `// Enrich identity JWT claims before signing.
|
|
5214
|
+
// The host sends base claims (external_id, email, name); your handler returns
|
|
5215
|
+
// additional claims to merge. Returned keys are filtered against manifest.identityClaims:
|
|
5216
|
+
// - Standard JWT claims (external_id, email, name) are exempt \u2014 always allowed
|
|
5217
|
+
// - Custom claims must be declared in manifest.identityClaims
|
|
5218
|
+
// - Undeclared keys are dropped with a console.warn
|
|
5219
|
+
// Merged claims land in BOTH identityState.user.metadata (for sibling extensions)
|
|
5220
|
+
// AND the signed JWT's custom_claims (for downstream JWT consumers).
|
|
5221
|
+
// Pattern: name the handler with useCallback<ExtendIdentityHandler> for stable
|
|
5222
|
+
// reference + clean separation; pass the named const to useExtendIdentity.
|
|
5223
|
+
//
|
|
5224
|
+
// useExtendIdentity fires ONCE at initial login \u2014 return what's known synchronously.
|
|
5225
|
+
// For post-login async updates (e.g., after verification completes via a webhook
|
|
5226
|
+
// or polling), use capabilities.identity.extend(patch) \u2014 see the 'identity.extend'
|
|
5227
|
+
// capability for the imperative path.
|
|
4884
5228
|
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
|
|
4888
|
-
//
|
|
4889
|
-
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
}
|
|
5229
|
+
import { useCallback } from 'react'
|
|
5230
|
+
import { useExtendIdentity } from '@stackable-labs/sdk-extension-react'
|
|
5231
|
+
|
|
5232
|
+
// manifest.json:
|
|
5233
|
+
// {
|
|
5234
|
+
// "permissions": ["identity:extend"],
|
|
5235
|
+
// "identityClaims": ["loyalty_tier", "verified", "verified_by", "verified_at"]
|
|
5236
|
+
// }
|
|
5237
|
+
const handleExtend = useCallback((claims) => ({
|
|
5238
|
+
external_id: \`custom_\${claims.external_id}\`, // standard claim override (exempt)
|
|
5239
|
+
loyalty_tier: 'bronze', // custom \u2014 sync, known at login
|
|
5240
|
+
verified: false, // custom \u2014 default; updated async post-verification
|
|
5241
|
+
}), [])
|
|
5242
|
+
useExtendIdentity(handleExtend)`
|
|
4893
5243
|
};
|
|
4894
5244
|
|
|
4895
5245
|
// ../../sdk/extension/ai-docs/src/index.ts
|