@flamingo-stack/openframe-frontend-core 0.0.442 → 0.0.443-snapshot.20260717175152

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/{chunk-J5NO7PAG.cjs → chunk-2CJ734N2.cjs} +3 -3
  2. package/dist/{chunk-J5NO7PAG.cjs.map → chunk-2CJ734N2.cjs.map} +1 -1
  3. package/dist/{chunk-KUXGQ2FQ.cjs → chunk-2PLNDTRG.cjs} +32 -32
  4. package/dist/{chunk-KUXGQ2FQ.cjs.map → chunk-2PLNDTRG.cjs.map} +1 -1
  5. package/dist/{chunk-Y3NFB6HR.js → chunk-37ZZQXHK.js} +4 -4
  6. package/dist/{chunk-YZJUHEPZ.js → chunk-3SCNEGL2.js} +5 -5
  7. package/dist/{chunk-DUQQEQ7G.cjs → chunk-4R3SVOKH.cjs} +190 -156
  8. package/dist/chunk-4R3SVOKH.cjs.map +1 -0
  9. package/dist/{chunk-QO6MCQYE.cjs → chunk-7CZ4MJSI.cjs} +13 -10
  10. package/dist/chunk-7CZ4MJSI.cjs.map +1 -0
  11. package/dist/{chunk-W3JZXLJ2.js → chunk-ABIRMYG5.js} +6 -6
  12. package/dist/{chunk-TAQMYVO6.cjs → chunk-BYTJNRJW.cjs} +40 -40
  13. package/dist/{chunk-TAQMYVO6.cjs.map → chunk-BYTJNRJW.cjs.map} +1 -1
  14. package/dist/{chunk-IAZ5AN2E.js → chunk-CE3AITV2.js} +6 -6
  15. package/dist/{chunk-FPE3SQ5D.js → chunk-DCDB3SM4.js} +3 -3
  16. package/dist/{chunk-LFWPWIAC.cjs → chunk-DSW6FIEF.cjs} +7 -7
  17. package/dist/{chunk-LFWPWIAC.cjs.map → chunk-DSW6FIEF.cjs.map} +1 -1
  18. package/dist/{chunk-JYI4R25Q.js → chunk-DTMJWBDQ.js} +3 -3
  19. package/dist/{chunk-SM26C2L7.js → chunk-F2XHBAHT.js} +2 -2
  20. package/dist/{chunk-JNC4LVD6.cjs → chunk-F6NMMPDL.cjs} +29 -29
  21. package/dist/{chunk-JNC4LVD6.cjs.map → chunk-F6NMMPDL.cjs.map} +1 -1
  22. package/dist/{chunk-7QP65IE4.cjs → chunk-FO4CI4R5.cjs} +5 -5
  23. package/dist/{chunk-7QP65IE4.cjs.map → chunk-FO4CI4R5.cjs.map} +1 -1
  24. package/dist/{chunk-7KDR5P23.js → chunk-FQASXKBK.js} +2 -2
  25. package/dist/{chunk-7KNOHGMI.js → chunk-H7H3AFLF.js} +2 -2
  26. package/dist/{chunk-QAG357PE.js → chunk-LCJGOV6P.js} +2 -2
  27. package/dist/{chunk-2BTDPDME.js → chunk-MG4AMIFX.js} +4 -1
  28. package/dist/chunk-MG4AMIFX.js.map +1 -0
  29. package/dist/{chunk-ZQYFDYWV.js → chunk-NDWIFCLS.js} +120 -86
  30. package/dist/chunk-NDWIFCLS.js.map +1 -0
  31. package/dist/{chunk-UFBUQO7Z.cjs → chunk-OXZLBIR7.cjs} +18 -18
  32. package/dist/{chunk-UFBUQO7Z.cjs.map → chunk-OXZLBIR7.cjs.map} +1 -1
  33. package/dist/{chunk-63TSTDME.js → chunk-PMTR5W5G.js} +5 -5
  34. package/dist/{chunk-XMCVUYKC.cjs → chunk-Q6XOEVPH.cjs} +12 -12
  35. package/dist/{chunk-XMCVUYKC.cjs.map → chunk-Q6XOEVPH.cjs.map} +1 -1
  36. package/dist/{chunk-IZ6A6K6N.cjs → chunk-RALJEQWM.cjs} +15 -15
  37. package/dist/{chunk-IZ6A6K6N.cjs.map → chunk-RALJEQWM.cjs.map} +1 -1
  38. package/dist/{chunk-AGV26PUR.js → chunk-U7U2CZBZ.js} +3 -3
  39. package/dist/{chunk-LUKRPHEW.cjs → chunk-VI2QQIGR.cjs} +73 -73
  40. package/dist/{chunk-LUKRPHEW.cjs.map → chunk-VI2QQIGR.cjs.map} +1 -1
  41. package/dist/{chunk-JOH5D76X.cjs → chunk-WXU6A6CA.cjs} +73 -73
  42. package/dist/{chunk-JOH5D76X.cjs.map → chunk-WXU6A6CA.cjs.map} +1 -1
  43. package/dist/{chunk-IPEIOR77.cjs → chunk-YP7VJPCJ.cjs} +40 -40
  44. package/dist/{chunk-IPEIOR77.cjs.map → chunk-YP7VJPCJ.cjs.map} +1 -1
  45. package/dist/components/case-studies/index.cjs +9 -9
  46. package/dist/components/case-studies/index.js +3 -3
  47. package/dist/components/chat/index.cjs +3 -3
  48. package/dist/components/chat/index.js +2 -2
  49. package/dist/components/contact/index.cjs +4 -4
  50. package/dist/components/contact/index.js +3 -3
  51. package/dist/components/docs/index.cjs +6 -6
  52. package/dist/components/docs/index.js +5 -5
  53. package/dist/components/embeds/index.cjs +4 -4
  54. package/dist/components/embeds/index.js +3 -3
  55. package/dist/components/faq/index.cjs +5 -5
  56. package/dist/components/faq/index.js +4 -4
  57. package/dist/components/features/index.cjs +3 -3
  58. package/dist/components/features/index.js +2 -2
  59. package/dist/components/features/policy-configuration-panel.d.ts +11 -0
  60. package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
  61. package/dist/components/help-center-pages/index.cjs +22 -22
  62. package/dist/components/help-center-pages/index.js +13 -13
  63. package/dist/components/index.cjs +135 -135
  64. package/dist/components/index.js +11 -11
  65. package/dist/components/navigation/index.cjs +3 -3
  66. package/dist/components/navigation/index.js +2 -2
  67. package/dist/components/onboarding-guides/index.cjs +7 -7
  68. package/dist/components/onboarding-guides/index.js +6 -6
  69. package/dist/components/related-content/index.cjs +5 -5
  70. package/dist/components/related-content/index.js +4 -4
  71. package/dist/components/tickets/index.cjs +6 -6
  72. package/dist/components/tickets/index.js +5 -5
  73. package/dist/components/ui/checkbox-block.d.ts +6 -0
  74. package/dist/components/ui/checkbox-block.d.ts.map +1 -1
  75. package/dist/components/ui/index.cjs +3 -3
  76. package/dist/components/ui/index.js +2 -2
  77. package/dist/hooks/index.cjs +2 -2
  78. package/dist/hooks/index.js +1 -1
  79. package/dist/index.cjs +3 -3
  80. package/dist/index.js +2 -2
  81. package/dist/types/permissions.d.ts +9 -6
  82. package/dist/types/permissions.d.ts.map +1 -1
  83. package/dist/utils/embed-authed-fetch.d.ts +19 -3
  84. package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
  85. package/dist/utils/index.cjs +3 -0
  86. package/dist/utils/index.cjs.map +1 -1
  87. package/dist/utils/index.js +3 -0
  88. package/dist/utils/index.js.map +1 -1
  89. package/package.json +1 -1
  90. package/src/components/features/policy-configuration-panel.tsx +182 -122
  91. package/src/components/ui/checkbox-block.tsx +12 -1
  92. package/src/stories/CheckboxBlock.stories.tsx +18 -0
  93. package/src/stories/PolicyConfigurationPanel.stories.tsx +254 -0
  94. package/src/types/permissions.ts +10 -7
  95. package/src/utils/__tests__/embed-authed-fetch.test.ts +39 -0
  96. package/src/utils/embed-authed-fetch.ts +27 -3
  97. package/dist/chunk-2BTDPDME.js.map +0 -1
  98. package/dist/chunk-DUQQEQ7G.cjs.map +0 -1
  99. package/dist/chunk-QO6MCQYE.cjs.map +0 -1
  100. package/dist/chunk-ZQYFDYWV.js.map +0 -1
  101. /package/dist/{chunk-Y3NFB6HR.js.map → chunk-37ZZQXHK.js.map} +0 -0
  102. /package/dist/{chunk-YZJUHEPZ.js.map → chunk-3SCNEGL2.js.map} +0 -0
  103. /package/dist/{chunk-W3JZXLJ2.js.map → chunk-ABIRMYG5.js.map} +0 -0
  104. /package/dist/{chunk-IAZ5AN2E.js.map → chunk-CE3AITV2.js.map} +0 -0
  105. /package/dist/{chunk-FPE3SQ5D.js.map → chunk-DCDB3SM4.js.map} +0 -0
  106. /package/dist/{chunk-JYI4R25Q.js.map → chunk-DTMJWBDQ.js.map} +0 -0
  107. /package/dist/{chunk-SM26C2L7.js.map → chunk-F2XHBAHT.js.map} +0 -0
  108. /package/dist/{chunk-7KDR5P23.js.map → chunk-FQASXKBK.js.map} +0 -0
  109. /package/dist/{chunk-7KNOHGMI.js.map → chunk-H7H3AFLF.js.map} +0 -0
  110. /package/dist/{chunk-QAG357PE.js.map → chunk-LCJGOV6P.js.map} +0 -0
  111. /package/dist/{chunk-63TSTDME.js.map → chunk-PMTR5W5G.js.map} +0 -0
  112. /package/dist/{chunk-AGV26PUR.js.map → chunk-U7U2CZBZ.js.map} +0 -0
@@ -0,0 +1,254 @@
1
+ import type { Meta, StoryObj } from '@storybook/nextjs-vite'
2
+ import { useState } from 'react'
3
+ import { fn } from 'storybook/test'
4
+ import { PolicyConfigurationPanel } from '../components/features/policy-configuration-panel'
5
+ import type { ApprovalLevel, PermissionCategory } from '../types/permissions'
6
+
7
+ const meta = {
8
+ title: 'Features/PolicyConfigurationPanel',
9
+ component: PolicyConfigurationPanel,
10
+ parameters: {
11
+ layout: 'padded',
12
+ docs: {
13
+ description: {
14
+ component:
15
+ 'Grouped list of AI guardrails permission categories with per-policy approval dropdowns. ' +
16
+ 'Categories are pure data — the panel owns its presentation state internally: which categories ' +
17
+ 'are expanded and the last bulk level picked per category (bulk selections reset when `editMode` ' +
18
+ 'changes). In edit mode each category header offers a bulk "global permission" dropdown that ' +
19
+ 'fires `onCategoryPermissionChange`; each policy row fires `onPolicyPermissionChange`. ' +
20
+ 'Used by the OpenFrame AI Settings → Guardrails tab.',
21
+ },
22
+ },
23
+ },
24
+ tags: ['autodocs'],
25
+ argTypes: {
26
+ editMode: { control: 'boolean' },
27
+ },
28
+ decorators: [
29
+ (Story) => (
30
+ <div style={{ maxWidth: '960px' }}>
31
+ <Story />
32
+ </div>
33
+ ),
34
+ ],
35
+ } satisfies Meta<typeof PolicyConfigurationPanel>
36
+
37
+ export default meta
38
+ type Story = StoryObj<typeof meta>
39
+
40
+ const deviceCategories: PermissionCategory[] = [
41
+ {
42
+ id: 'device-actions',
43
+ name: 'Device Actions',
44
+ policies: [
45
+ {
46
+ id: 'OPENFRAME_RMM:DEVICE_REBOOT',
47
+ naturalKey: 'OPENFRAME_RMM:DEVICE_REBOOT',
48
+ name: 'Reboot Device',
49
+ commandPattern: 'device.reboot --id {deviceId}',
50
+ toolName: 'OPENFRAME_RMM',
51
+ approvalLevel: 'ASK_USER',
52
+ },
53
+ {
54
+ id: 'FLEET_MDM:DEVICE_LOCK',
55
+ naturalKey: 'FLEET_MDM:DEVICE_LOCK',
56
+ name: 'Lock Device',
57
+ commandPattern: 'fleet mdm lock --host {hostId}',
58
+ toolName: 'FLEET_MDM',
59
+ approvalLevel: 'ASK_TECHNICIAN',
60
+ },
61
+ {
62
+ id: 'FLEET_MDM:DEVICE_WIPE',
63
+ naturalKey: 'FLEET_MDM:DEVICE_WIPE',
64
+ name: 'Wipe Device',
65
+ commandPattern: 'fleet mdm wipe --host {hostId}',
66
+ toolName: 'FLEET_MDM',
67
+ approvalLevel: 'DENY',
68
+ },
69
+ ],
70
+ },
71
+ {
72
+ id: 'remote-access',
73
+ name: 'Remote Access',
74
+ policies: [
75
+ {
76
+ id: 'MESHCENTRAL:REMOTE_SHELL',
77
+ naturalKey: 'MESHCENTRAL:REMOTE_SHELL',
78
+ name: 'Open Remote Shell',
79
+ commandPattern: 'mesh.shell --node {nodeId}',
80
+ toolName: 'MESHCENTRAL',
81
+ approvalLevel: 'ASK_TECHNICIAN',
82
+ },
83
+ {
84
+ id: 'MESHCENTRAL:FILE_TRANSFER',
85
+ naturalKey: 'MESHCENTRAL:FILE_TRANSFER',
86
+ name: 'Transfer Files',
87
+ commandPattern: 'mesh.files --node {nodeId} --upload|--download',
88
+ toolName: 'MESHCENTRAL',
89
+ approvalLevel: 'DENY',
90
+ },
91
+ ],
92
+ },
93
+ {
94
+ id: 'scripts-automation',
95
+ name: 'Scripts & Automation',
96
+ policies: [
97
+ {
98
+ id: 'OPENFRAME_RMM:RUN_POWERSHELL',
99
+ naturalKey: 'OPENFRAME_RMM:RUN_POWERSHELL',
100
+ name: 'Run PowerShell Script',
101
+ commandPattern: 'powershell -ExecutionPolicy Bypass -File {script}',
102
+ toolName: 'OPENFRAME_RMM',
103
+ approvalLevel: 'ASK_USER',
104
+ },
105
+ {
106
+ id: 'OPENFRAME_RMM:RUN_BASH',
107
+ naturalKey: 'OPENFRAME_RMM:RUN_BASH',
108
+ name: 'Run Bash Script',
109
+ commandPattern: 'bash -c {script}',
110
+ toolName: 'OPENFRAME_RMM',
111
+ approvalLevel: 'ASK_USER',
112
+ },
113
+ ],
114
+ },
115
+ ]
116
+
117
+ const dataCategories: PermissionCategory[] = [
118
+ {
119
+ id: 'data-read',
120
+ name: 'Read Access',
121
+ policies: [
122
+ {
123
+ id: 'OSQUERY:QUERY_INVENTORY',
124
+ naturalKey: 'OSQUERY:QUERY_INVENTORY',
125
+ name: 'Query Device Inventory',
126
+ commandPattern: 'SELECT * FROM system_info;',
127
+ toolName: 'OSQUERY',
128
+ approvalLevel: 'ALLOW',
129
+ },
130
+ {
131
+ id: 'SYSTEM:READ_LOGS',
132
+ naturalKey: 'SYSTEM:READ_LOGS',
133
+ name: 'Read System Logs',
134
+ commandPattern: 'logs.read --stream {stream} --since {ts}',
135
+ toolName: 'SYSTEM',
136
+ approvalLevel: 'ALLOW',
137
+ },
138
+ ],
139
+ },
140
+ ]
141
+
142
+ /** Applies a level to one policy; mirrors what the guardrails editor hook does. */
143
+ const applyPolicyLevel = (
144
+ categories: PermissionCategory[],
145
+ categoryId: string,
146
+ policyId: string,
147
+ level: ApprovalLevel,
148
+ ): PermissionCategory[] =>
149
+ categories.map(category =>
150
+ category.id !== categoryId
151
+ ? category
152
+ : {
153
+ ...category,
154
+ policies: category.policies.map(policy =>
155
+ policy.id === policyId ? { ...policy, approvalLevel: level } : policy,
156
+ ),
157
+ },
158
+ )
159
+
160
+ /** Bulk-applies a level to every policy in a category. */
161
+ const applyCategoryLevel = (
162
+ categories: PermissionCategory[],
163
+ categoryId: string,
164
+ level: ApprovalLevel,
165
+ ): PermissionCategory[] =>
166
+ categories.map(category =>
167
+ category.id !== categoryId
168
+ ? category
169
+ : { ...category, policies: category.policies.map(policy => ({ ...policy, approvalLevel: level })) },
170
+ )
171
+
172
+ /** Read-only view: approval levels render as text, no bulk dropdowns. */
173
+ export const ReadOnly: Story = {
174
+ args: {
175
+ categories: deviceCategories,
176
+ editMode: false,
177
+ onPolicyPermissionChange: fn(),
178
+ onCategoryPermissionChange: fn(),
179
+ },
180
+ }
181
+
182
+ /**
183
+ * Interactive edit mode: per-policy dropdowns plus a bulk "global permission"
184
+ * dropdown per category header. The wrapper applies changes back into the
185
+ * category data the same way the OpenFrame guardrails editor does.
186
+ */
187
+ export const EditMode: Story = {
188
+ args: {
189
+ categories: deviceCategories,
190
+ editMode: true,
191
+ onPolicyPermissionChange: fn(),
192
+ onCategoryPermissionChange: fn(),
193
+ },
194
+ render: args => {
195
+ const [categories, setCategories] = useState(args.categories)
196
+
197
+ return (
198
+ <PolicyConfigurationPanel
199
+ {...args}
200
+ categories={categories}
201
+ onPolicyPermissionChange={(categoryId, policyId, level) => {
202
+ args.onPolicyPermissionChange(categoryId, policyId, level)
203
+ setCategories(prev => applyPolicyLevel(prev, categoryId, policyId, level))
204
+ }}
205
+ onCategoryPermissionChange={(categoryId, level) => {
206
+ args.onCategoryPermissionChange(categoryId, level)
207
+ setCategories(prev => applyCategoryLevel(prev, categoryId, level))
208
+ }}
209
+ />
210
+ )
211
+ },
212
+ }
213
+
214
+ /**
215
+ * How the guardrails tab composes the panel: one panel per policy group, each
216
+ * under a small secondary heading.
217
+ */
218
+ export const GroupedPolicySections: Story = {
219
+ args: {
220
+ categories: deviceCategories,
221
+ editMode: true,
222
+ onPolicyPermissionChange: fn(),
223
+ onCategoryPermissionChange: fn(),
224
+ },
225
+ render: args => {
226
+ const [groups, setGroups] = useState<Record<string, PermissionCategory[]>>({
227
+ 'Device Management': deviceCategories,
228
+ 'Data Access': dataCategories,
229
+ })
230
+
231
+ const updateGroup = (groupName: string, next: (categories: PermissionCategory[]) => PermissionCategory[]) =>
232
+ setGroups(prev => ({ ...prev, [groupName]: next(prev[groupName]) }))
233
+
234
+ return (
235
+ <div className="flex flex-col gap-[var(--spacing-system-l)]">
236
+ {Object.entries(groups).map(([groupName, categories]) => (
237
+ <div key={groupName} className="flex flex-col gap-[var(--spacing-system-xxs)]">
238
+ <p className="text-h5 text-ods-text-secondary truncate">{groupName}</p>
239
+ <PolicyConfigurationPanel
240
+ categories={categories}
241
+ editMode={args.editMode}
242
+ onPolicyPermissionChange={(categoryId, policyId, level) =>
243
+ updateGroup(groupName, categories => applyPolicyLevel(categories, categoryId, policyId, level))
244
+ }
245
+ onCategoryPermissionChange={(categoryId, level) =>
246
+ updateGroup(groupName, categories => applyCategoryLevel(categories, categoryId, level))
247
+ }
248
+ />
249
+ </div>
250
+ ))}
251
+ </div>
252
+ )
253
+ },
254
+ }
@@ -11,21 +11,24 @@ export interface PermissionPolicy {
11
11
  approvalLevel: ApprovalLevel;
12
12
  }
13
13
 
14
+ /**
15
+ * Pure data describing one permission category. Presentation state (expansion,
16
+ * bulk-selection display) is owned by PolicyConfigurationPanel itself.
17
+ */
14
18
  export interface PermissionCategory {
15
19
  id: string;
16
20
  name: string;
17
- icon: React.ReactNode;
18
- configurationsCount: number;
19
- globalPermission?: ApprovalLevel;
20
- isExpanded: boolean;
21
+ /** Optional leading icon; the panel falls back to a shield. */
22
+ icon?: React.ReactNode;
21
23
  policies: PermissionPolicy[];
22
24
  }
23
25
 
24
26
  export interface PolicyConfigurationPanelProps {
25
27
  categories: PermissionCategory[];
26
28
  editMode: boolean;
27
- onCategoryToggle: (categoryId: string) => void;
28
- onGlobalPermissionChange: (categoryId: string, level: ApprovalLevel | undefined) => void;
29
+ /** Change a single policy's approval level. `policyId` is the policy's `id`. */
29
30
  onPolicyPermissionChange: (categoryId: string, policyId: string, level: ApprovalLevel) => void;
31
+ /** Bulk-apply an approval level to every policy in a category. */
32
+ onCategoryPermissionChange: (categoryId: string, level: ApprovalLevel) => void;
30
33
  className?: string;
31
- }
34
+ }
@@ -166,3 +166,42 @@ describe('embedAuthedFetch 401 self-heal (adapter.refresh)', () => {
166
166
  expect(fetchSpy).toHaveBeenCalledTimes(1)
167
167
  })
168
168
  })
169
+
170
+ describe('embedAuthedFetch adapter.allowedOrigins (native-shell cross-origin sanction)', () => {
171
+ const originalFetch = globalThis.fetch
172
+
173
+ beforeEach(() => {
174
+ globalThis.fetch = vi.fn(async () => new Response('ok')) as typeof fetch
175
+ // Production mode so these tests exercise the real guard, not the
176
+ // dev-mode warn-and-allow hatch.
177
+ vi.stubEnv('NODE_ENV', 'production')
178
+ })
179
+ afterEach(() => {
180
+ globalThis.fetch = originalFetch
181
+ setEmbedAuthAdapter(null)
182
+ vi.unstubAllEnvs()
183
+ vi.restoreAllMocks()
184
+ })
185
+
186
+ it('allows a cross-origin URL whose exact origin the adapter sanctions', async () => {
187
+ setEmbedAuthAdapter({ allowedOrigins: ['https://gateway.example'] })
188
+ await expect(embedAuthedFetch('https://gateway.example/content/api/faqs')).resolves.toBeDefined()
189
+ })
190
+
191
+ it('still rejects origins outside the allowlist', () => {
192
+ setEmbedAuthAdapter({ allowedOrigins: ['https://gateway.example'] })
193
+ expect(() => embedAuthedFetch('https://evil.com/x')).toThrow(/cross-origin/i)
194
+ })
195
+
196
+ it('does not treat an allowlisted origin as a prefix (suffix-domain spoof rejected)', () => {
197
+ setEmbedAuthAdapter({ allowedOrigins: ['https://gateway.example'] })
198
+ expect(() => embedAuthedFetch('https://gateway.example.evil.com/x')).toThrow(/cross-origin/i)
199
+ })
200
+
201
+ it('cannot allowlist non-http(s) schemes (protocol check runs first)', () => {
202
+ // `javascript:` URLs have origin 'null'; even a (misguided) 'null' entry
203
+ // must not get past the protocol gate.
204
+ setEmbedAuthAdapter({ allowedOrigins: ['null'] })
205
+ expect(() => embedAuthedFetch('javascript:alert(1)')).toThrow(/non-http\(s\)/i)
206
+ })
207
+ })
@@ -86,6 +86,19 @@ export interface EmbedAuthAdapter {
86
86
  * host's own auth layer, not to this fetch wrapper.
87
87
  */
88
88
  refresh?: () => Promise<boolean>
89
+ /**
90
+ * Exact extra origins (`scheme://host[:port]`, as produced by `URL.origin`)
91
+ * the same-origin guard additionally accepts. The guard normally rejects
92
+ * every cross-origin URL in production — but native-shell hosts serve the
93
+ * page from a local pseudo-origin (`capacitor://localhost`,
94
+ * `tauri://localhost`) with NO server behind it, so the gateway their
95
+ * bearer already belongs to is unavoidably cross-origin. Listing it here
96
+ * is the host's explicit sanction to send the adapter's credentials there.
97
+ * Compared AFTER the http(s)-protocol check, so non-http(s) schemes can
98
+ * never be allowlisted. Omit (or leave empty) everywhere else — same-origin
99
+ * remains the rule, this is the narrow exception.
100
+ */
101
+ allowedOrigins?: string[]
89
102
  }
90
103
 
91
104
  /**
@@ -158,9 +171,12 @@ export function hasEmbedAuthAdapter(): boolean {
158
171
  *
159
172
  * **Cross-origin defense:** the wrapper assumes a same-origin `/api/…`
160
173
  * relative URL. Absolute URLs are accepted only when their origin matches
161
- * the current window's origin; cross-origin URLs throw before the bearer
162
- * leaves the page. This is a defense-in-depth guard for future call sites
163
- * — there is no legitimate cross-origin use of this fetch wrapper.
174
+ * the current window's origin — or appears in the registered adapter's
175
+ * `allowedOrigins` (the native-shell hatch; see that field's doc) — and
176
+ * cross-origin URLs otherwise throw before the bearer leaves the page.
177
+ * This is a defense-in-depth guard for future call sites — outside the
178
+ * allowlisted-shell case there is no legitimate cross-origin use of this
179
+ * fetch wrapper.
164
180
  *
165
181
  * **401 self-heal:** when a registered adapter supplies `refresh`, a `401`
166
182
  * response triggers a single token refresh + retry of the same request
@@ -352,6 +368,14 @@ function assertSameOrigin(url: string): void {
352
368
  )
353
369
  }
354
370
  if (target.origin !== pageOrigin) {
371
+ // Host-sanctioned cross-origin target (`EmbedAuthAdapter.allowedOrigins`).
372
+ // Native shells serve the page from a local pseudo-origin with no server
373
+ // behind it, so their gateway — the same backend the host already trusts
374
+ // with this bearer — is unavoidably cross-origin. Exact `URL.origin`
375
+ // match, and only reachable for http(s) targets (checked above).
376
+ if (getRegisteredAuthAdapter()?.allowedOrigins?.includes(target.origin)) {
377
+ return
378
+ }
355
379
  // Dev-mode escape hatch — embedded apps (e.g. openframe-frontend)
356
380
  // run on a different origin from their gateway during local dev,
357
381
  // and forcing a Next.js `rewrites()` workaround is more error-prone