@cat-factory/app 0.198.1 → 0.199.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/README.md +6 -1
- package/app/components/layout/BoardToolbar.vue +1 -1
- package/app/components/layout/CommandBar.vue +8 -2
- package/app/components/layout/SideBar.vue +24 -3
- package/app/components/settings/WorkspaceMetadataSettings.vue +151 -0
- package/app/components/settings/WorkspaceSettingsPanel.vue +27 -0
- package/app/composables/useNavContributions.ts +91 -1
- package/app/docs/consumer-extensions.md +76 -10
- package/app/modular/external-tools.spec.ts +281 -0
- package/app/modular/external-tools.ts +265 -0
- package/app/modular/nav-contributions.spec.ts +32 -14
- package/app/modular/nav-contributions.ts +32 -1
- package/app/modular/registry.ts +2 -0
- package/app/modular/slots.ts +15 -0
- package/app/modular/workspace-metadata.spec.ts +160 -0
- package/app/modular/workspace-metadata.ts +173 -0
- package/app/stores/workspaceSettings.ts +3 -0
- package/app/types/domain.ts +1 -0
- package/i18n/locales/de.json +18 -0
- package/i18n/locales/en.json +18 -0
- package/i18n/locales/es.json +18 -0
- package/i18n/locales/fr.json +18 -0
- package/i18n/locales/he.json +18 -0
- package/i18n/locales/it.json +18 -0
- package/i18n/locales/ja.json +18 -0
- package/i18n/locales/pl.json +18 -0
- package/i18n/locales/tr.json +18 -0
- package/i18n/locales/uk.json +18 -0
- package/package.json +2 -2
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { missingI18nKeys } from '../../test/i18nKeys'
|
|
3
|
+
import {
|
|
4
|
+
EXTERNAL_TOOL_UNAVAILABLE_KEYS,
|
|
5
|
+
filterExternalTools,
|
|
6
|
+
projectExternalTools,
|
|
7
|
+
resolveExternalToolUrl,
|
|
8
|
+
type ExternalToolContext,
|
|
9
|
+
type ExternalToolContribution,
|
|
10
|
+
} from './external-tools'
|
|
11
|
+
import type { NavGates } from './nav-contributions'
|
|
12
|
+
|
|
13
|
+
const CONTEXT: ExternalToolContext = {
|
|
14
|
+
userId: 'usr_1',
|
|
15
|
+
userEmail: 'ada@example.com',
|
|
16
|
+
workspaceId: 'ws_1',
|
|
17
|
+
workspaceName: 'Zork',
|
|
18
|
+
metadata: { gameId: 'zork' },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MAP_EDITOR: ExternalToolContribution = {
|
|
22
|
+
id: 'acme:map-editor',
|
|
23
|
+
title: 'Map editor',
|
|
24
|
+
icon: 'i-lucide-map',
|
|
25
|
+
requiredMetadata: ['gameId'],
|
|
26
|
+
url: (ctx) =>
|
|
27
|
+
`https://maps.acme.dev/edit?game=${ctx.metadata.gameId}&ws=${ctx.workspaceId}&user=${ctx.userId ?? ''}`,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const GATES: NavGates = {
|
|
31
|
+
canWriteBoard: true,
|
|
32
|
+
canManageIntegrations: true,
|
|
33
|
+
canManageSettings: true,
|
|
34
|
+
githubAvailable: true,
|
|
35
|
+
libraryAvailable: true,
|
|
36
|
+
infrastructureAvailable: true,
|
|
37
|
+
accountsEnabled: true,
|
|
38
|
+
isAccountAdmin: true,
|
|
39
|
+
advancedMode: true,
|
|
40
|
+
boardHasService: true,
|
|
41
|
+
boardHasTask: true,
|
|
42
|
+
boardHasRun: true,
|
|
43
|
+
boardHasOpenDecision: true,
|
|
44
|
+
boardHasPendingApproval: true,
|
|
45
|
+
boardHasFinishedRun: true,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('resolveExternalToolUrl', () => {
|
|
49
|
+
it('folds the invocation context into the resolved URL', () => {
|
|
50
|
+
// The whole point of a resolver over a static link: the tool opens on the right game,
|
|
51
|
+
// for the right workspace, as the right user.
|
|
52
|
+
expect(resolveExternalToolUrl(MAP_EDITOR, CONTEXT)).toEqual({
|
|
53
|
+
ok: true,
|
|
54
|
+
url: 'https://maps.acme.dev/edit?game=zork&ws=ws_1&user=usr_1',
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('accepts a static URL with no resolver', () => {
|
|
59
|
+
const tool = { ...MAP_EDITOR, requiredMetadata: undefined, url: 'https://acme.dev/console' }
|
|
60
|
+
expect(resolveExternalToolUrl(tool, CONTEXT)).toMatchObject({ ok: true })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('names the unfilled metadata fields instead of opening a half-scoped tool', () => {
|
|
64
|
+
const empty = { ...CONTEXT, metadata: {} }
|
|
65
|
+
|
|
66
|
+
// `missing-metadata` + the key list is what lets the UI say "fill in gameId in workspace
|
|
67
|
+
// settings" — a bare "unavailable" would send the operator to the deployment's admins.
|
|
68
|
+
expect(resolveExternalToolUrl(MAP_EDITOR, empty)).toEqual({
|
|
69
|
+
ok: false,
|
|
70
|
+
reason: 'missing-metadata',
|
|
71
|
+
missing: ['gameId'],
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('treats a required field stored as blank as missing', () => {
|
|
76
|
+
// The store drops a cleared value, but a bag that arrived from anywhere else must not
|
|
77
|
+
// resolve to `?game=`.
|
|
78
|
+
const blank = { ...CONTEXT, metadata: { gameId: '' } }
|
|
79
|
+
expect(resolveExternalToolUrl(MAP_EDITOR, blank)).toMatchObject({ reason: 'missing-metadata' })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('reports a declining resolver separately from an unconfigured workspace', () => {
|
|
83
|
+
const tool: ExternalToolContribution = { ...MAP_EDITOR, url: () => null }
|
|
84
|
+
|
|
85
|
+
// Two different fixes: nobody to nag about settings here, this is the deployment's own
|
|
86
|
+
// condition not being met.
|
|
87
|
+
expect(resolveExternalToolUrl(tool, CONTEXT)).toEqual({
|
|
88
|
+
ok: false,
|
|
89
|
+
reason: 'unresolved',
|
|
90
|
+
missing: [],
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('reports a resolver that THREW separately, carrying the cause', () => {
|
|
95
|
+
const boom = new TypeError("Cannot read properties of undefined (reading 'split')")
|
|
96
|
+
const tool: ExternalToolContribution = {
|
|
97
|
+
...MAP_EDITOR,
|
|
98
|
+
requiredMetadata: undefined,
|
|
99
|
+
url: () => {
|
|
100
|
+
throw boom
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Distinct from `unresolved`: declining is a resolver working as written, throwing is a
|
|
105
|
+
// bug in it. The cause rides along so the caller can put a stack in the console — the
|
|
106
|
+
// deployment author who must fix it is not the person reading the toast.
|
|
107
|
+
expect(resolveExternalToolUrl(tool, CONTEXT)).toEqual({
|
|
108
|
+
ok: false,
|
|
109
|
+
reason: 'resolver-failed',
|
|
110
|
+
missing: [],
|
|
111
|
+
cause: boom,
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('treats a required field named after an Object member as missing', () => {
|
|
116
|
+
// `constructor` / `toString` / `valueOf` all pass the key pattern (only a leading `_` is
|
|
117
|
+
// barred, which is what keeps `__proto__` out). On a plain object an unfilled one reads as
|
|
118
|
+
// an INHERITED function, i.e. truthy — so a naive `metadata[key]` check would conclude the
|
|
119
|
+
// field is set and hand the resolver `Object` itself.
|
|
120
|
+
const tool: ExternalToolContribution = { ...MAP_EDITOR, requiredMetadata: ['constructor'] }
|
|
121
|
+
expect(resolveExternalToolUrl(tool, { ...CONTEXT, metadata: {} })).toEqual({
|
|
122
|
+
ok: false,
|
|
123
|
+
reason: 'missing-metadata',
|
|
124
|
+
missing: ['constructor'],
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it.each([
|
|
129
|
+
// The security-relevant one: the resolved string is handed to `window.open`, so a
|
|
130
|
+
// `javascript:` URL would execute in the SPA's own origin.
|
|
131
|
+
'javascript:alert(1)',
|
|
132
|
+
'data:text/html,<script>alert(1)</script>',
|
|
133
|
+
'file:///etc/passwd',
|
|
134
|
+
'/relative/path',
|
|
135
|
+
'not a url',
|
|
136
|
+
])('refuses %s rather than handing it to the browser', (url) => {
|
|
137
|
+
const tool: ExternalToolContribution = { ...MAP_EDITOR, url: () => url }
|
|
138
|
+
expect(resolveExternalToolUrl(tool, CONTEXT)).toMatchObject({ reason: 'unsafe-url' })
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('projectExternalTools', () => {
|
|
143
|
+
const handlers = () => ({ open: vi.fn(), onUnavailable: vi.fn() })
|
|
144
|
+
|
|
145
|
+
it('projects a tool onto a nav contribution in the External tools section', () => {
|
|
146
|
+
const [item] = projectExternalTools([MAP_EDITOR], CONTEXT, handlers())
|
|
147
|
+
|
|
148
|
+
expect(item?.contribution).toMatchObject({
|
|
149
|
+
id: 'acme:map-editor',
|
|
150
|
+
// Deployment DATA rides `label`, so the shells render it verbatim instead of through `t()`.
|
|
151
|
+
label: 'Map editor',
|
|
152
|
+
icon: 'i-lucide-map',
|
|
153
|
+
sidebar: { group: 'externalTools' },
|
|
154
|
+
command: { group: 'externalTools' },
|
|
155
|
+
testId: 'nav-external-tool-acme:map-editor',
|
|
156
|
+
})
|
|
157
|
+
expect(item?.contribution.surfaces).toEqual(['sidebar', 'command'])
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('orders by declared order, keeping registration order within a tie', () => {
|
|
161
|
+
const tool = (id: string, order?: number): ExternalToolContribution => ({
|
|
162
|
+
id,
|
|
163
|
+
title: id,
|
|
164
|
+
icon: 'i-lucide-link',
|
|
165
|
+
url: 'https://acme.dev',
|
|
166
|
+
...(order === undefined ? {} : { order }),
|
|
167
|
+
})
|
|
168
|
+
const items = projectExternalTools(
|
|
169
|
+
[tool('c', 20), tool('a'), tool('b'), tool('d', 10)],
|
|
170
|
+
CONTEXT,
|
|
171
|
+
handlers(),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
expect(items.map((i) => i.tool.id)).toEqual(['a', 'b', 'd', 'c'])
|
|
175
|
+
expect(items.map((i) => i.contribution.sidebar?.order)).toEqual([0, 1, 2, 3])
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('opens the resolved URL on click', () => {
|
|
179
|
+
const h = handlers()
|
|
180
|
+
const [item] = projectExternalTools([MAP_EDITOR], CONTEXT, h)
|
|
181
|
+
|
|
182
|
+
item?.contribution.run?.()
|
|
183
|
+
|
|
184
|
+
expect(h.open).toHaveBeenCalledWith(
|
|
185
|
+
'https://maps.acme.dev/edit?game=zork&ws=ws_1&user=usr_1',
|
|
186
|
+
MAP_EDITOR,
|
|
187
|
+
)
|
|
188
|
+
expect(h.onUnavailable).not.toHaveBeenCalled()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('keeps an unresolvable tool listed and reports why on click', () => {
|
|
192
|
+
const h = handlers()
|
|
193
|
+
const [item] = projectExternalTools([MAP_EDITOR], { ...CONTEXT, metadata: {} }, h)
|
|
194
|
+
|
|
195
|
+
// Listed, not hidden: hiding it makes "nobody filled in gameId" look exactly like
|
|
196
|
+
// "this deployment never registered a map editor", and the person who can fix it is
|
|
197
|
+
// the one reading the sidebar.
|
|
198
|
+
expect(item?.resolution).toMatchObject({ ok: false, reason: 'missing-metadata' })
|
|
199
|
+
item?.contribution.run?.()
|
|
200
|
+
expect(h.open).not.toHaveBeenCalled()
|
|
201
|
+
expect(h.onUnavailable).toHaveBeenCalledWith(
|
|
202
|
+
{ ok: false, reason: 'missing-metadata', missing: ['gameId'] },
|
|
203
|
+
MAP_EDITOR,
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('survives a throwing resolver instead of taking the nav catalog down with it', () => {
|
|
208
|
+
const h = handlers()
|
|
209
|
+
const exploding: ExternalToolContribution = {
|
|
210
|
+
id: 'acme:broken',
|
|
211
|
+
title: 'Broken',
|
|
212
|
+
icon: 'i-lucide-bug',
|
|
213
|
+
url: () => {
|
|
214
|
+
throw new Error('boom')
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// This projection runs inside the `externalToolItems` computed, which feeds the sidebar,
|
|
219
|
+
// the command palette AND the board toolbar. A registration mistake in a deployment's own
|
|
220
|
+
// code must cost that one item — not every nav shell in the app.
|
|
221
|
+
const items = projectExternalTools([exploding, MAP_EDITOR], CONTEXT, h)
|
|
222
|
+
|
|
223
|
+
expect(items.map((i) => i.tool.id)).toEqual(['acme:broken', 'acme:map-editor'])
|
|
224
|
+
expect(items[0]?.resolution).toMatchObject({ ok: false, reason: 'resolver-failed' })
|
|
225
|
+
expect(items[1]?.resolution.ok).toBe(true)
|
|
226
|
+
|
|
227
|
+
// Clicking the broken one reports, and still doesn't throw at the shell.
|
|
228
|
+
expect(() => items[0]?.contribution.run?.()).not.toThrow()
|
|
229
|
+
expect(h.open).not.toHaveBeenCalled()
|
|
230
|
+
expect(h.onUnavailable).toHaveBeenCalledWith(
|
|
231
|
+
expect.objectContaining({ reason: 'resolver-failed' }),
|
|
232
|
+
exploding,
|
|
233
|
+
)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('re-resolves at click time, so a value filled in meanwhile is picked up', () => {
|
|
237
|
+
const h = handlers()
|
|
238
|
+
const metadata: Record<string, string> = {}
|
|
239
|
+
// The composable passes a context built from reactive stores; a resolution captured at
|
|
240
|
+
// projection time would keep reporting a fix that has already happened.
|
|
241
|
+
const [item] = projectExternalTools([MAP_EDITOR], { ...CONTEXT, metadata }, h)
|
|
242
|
+
expect(item?.resolution.ok).toBe(false)
|
|
243
|
+
|
|
244
|
+
metadata.gameId = 'myst'
|
|
245
|
+
item?.contribution.run?.()
|
|
246
|
+
|
|
247
|
+
expect(h.open).toHaveBeenCalledWith(expect.stringContaining('game=myst'), MAP_EDITOR)
|
|
248
|
+
})
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
describe('filterExternalTools', () => {
|
|
252
|
+
const tools: ExternalToolContribution[] = [
|
|
253
|
+
{ id: 'a', title: 'A', icon: 'i', url: 'https://a.dev' },
|
|
254
|
+
{ id: 'b', title: 'B', icon: 'i', url: 'https://b.dev', gate: (g) => g.canManageIntegrations },
|
|
255
|
+
{ id: 'c', title: 'C', icon: 'i', url: 'https://c.dev', advanced: true },
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
it('applies the RBAC gate and the interface tier independently', () => {
|
|
259
|
+
expect(filterExternalTools(tools, GATES).map((t) => t.id)).toEqual(['a', 'b', 'c'])
|
|
260
|
+
expect(
|
|
261
|
+
filterExternalTools(tools, { ...GATES, canManageIntegrations: false }).map((t) => t.id),
|
|
262
|
+
).toEqual(['a', 'c'])
|
|
263
|
+
expect(filterExternalTools(tools, { ...GATES, advancedMode: false }).map((t) => t.id)).toEqual([
|
|
264
|
+
'a',
|
|
265
|
+
'b',
|
|
266
|
+
])
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('passes everything through with no gates service wired (dev-open parity)', () => {
|
|
270
|
+
expect(filterExternalTools(tools, undefined).map((t) => t.id)).toEqual(['a', 'b', 'c'])
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
describe('EXTERNAL_TOOL_UNAVAILABLE_KEYS', () => {
|
|
275
|
+
it('names copy that exists for every reason', () => {
|
|
276
|
+
// The exhaustive `Record` proves each reason HAS an entry; only this proves the entry still
|
|
277
|
+
// names a live key. Neither typed message keys nor `i18n:check` can see a lookup table, so a
|
|
278
|
+
// deleted key would otherwise read as a clean removal and render its own path in a toast.
|
|
279
|
+
expect(missingI18nKeys(Object.values(EXTERNAL_TOOL_UNAVAILABLE_KEYS))).toEqual([])
|
|
280
|
+
})
|
|
281
|
+
})
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { metadataValue } from './workspace-metadata'
|
|
2
|
+
import type { NavContribution, NavGates } from './nav-contributions'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* EXTERNAL TOOLS — a deployment's own web applications, registered programmatically and
|
|
6
|
+
* listed in their own "External tools" sidebar section (and in the command palette).
|
|
7
|
+
* Clicking one opens it in a separate browser page.
|
|
8
|
+
*
|
|
9
|
+
* The point is not the link; it is the CONTEXT that rides on it. A deployment's map editor,
|
|
10
|
+
* asset pipeline or admin console is almost always already scoped to something cat-factory
|
|
11
|
+
* knows — the signed-in user, the open workspace, or a workspace-specific identifier the
|
|
12
|
+
* deployment declared as a custom metadata field ("this board is game `zork`"). So a tool
|
|
13
|
+
* declares a {@link ExternalToolUrlResolver} — a pure function from the invocation context to
|
|
14
|
+
* a URL — instead of a static link, and lands the user on the right state instead of the
|
|
15
|
+
* tool's front door.
|
|
16
|
+
*
|
|
17
|
+
* Everything here is PURE (no Vue, no stores): the composable that renders these
|
|
18
|
+
* (`useNavContributions`) builds the {@link ExternalToolContext} from the auth/workspace/
|
|
19
|
+
* settings stores and hands it in, so the resolution rules stay unit-testable and the click
|
|
20
|
+
* behaviour stays in one place.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** What a resolver may read about the invocation. Every field is data the SPA already holds. */
|
|
24
|
+
export interface ExternalToolContext {
|
|
25
|
+
/** The signed-in user's id, or null when the deployment runs with auth disabled. */
|
|
26
|
+
userId: string | null
|
|
27
|
+
/** The signed-in user's email, or null (auth disabled, or a user record without one). */
|
|
28
|
+
userEmail: string | null
|
|
29
|
+
/** The open workspace's id. */
|
|
30
|
+
workspaceId: string
|
|
31
|
+
/** The open workspace's display name. */
|
|
32
|
+
workspaceName: string
|
|
33
|
+
/**
|
|
34
|
+
* The workspace's custom metadata values, keyed by the field keys the deployment declared
|
|
35
|
+
* (see {@link WorkspaceMetadataFieldDefinition}). A field nobody has filled in is ABSENT,
|
|
36
|
+
* never `''` — which is what lets {@link resolveExternalToolUrl} report a missing value as
|
|
37
|
+
* a missing value instead of building a URL with an empty parameter. The bag is hung on a
|
|
38
|
+
* null prototype (`toMetadataBag`), so a plain `ctx.metadata.gameId` on an unfilled field
|
|
39
|
+
* reads `undefined` even where the key names something on `Object.prototype`.
|
|
40
|
+
*
|
|
41
|
+
* TREAT EVERY VALUE AS UNTRUSTED INPUT. A workspace admin types these in, so a value is
|
|
42
|
+
* operator-supplied text that happens to be bounded — not a constant your deployment chose.
|
|
43
|
+
* Interpolate one into a query parameter (`url.searchParams.set(...)`) or an
|
|
44
|
+
* `encodeURIComponent`'d path segment, as the `acme:map-editor` example does. Never build the
|
|
45
|
+
* ORIGIN from a value: `` `https://${ctx.metadata.region}.acme.dev` `` with `region` set to
|
|
46
|
+
* `evil.com/x?a=` resolves to a URL on someone else's host, and the `http(s)` allow-list below
|
|
47
|
+
* cannot tell that apart from the link you meant.
|
|
48
|
+
*/
|
|
49
|
+
metadata: Readonly<Record<string, string>>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the tool's URL from the invocation context. Returning `null` (or an empty string)
|
|
54
|
+
* means "not resolvable right now" — the tool stays listed and says so when clicked, rather
|
|
55
|
+
* than opening something wrong.
|
|
56
|
+
*/
|
|
57
|
+
export type ExternalToolUrlResolver = (context: ExternalToolContext) => string | null
|
|
58
|
+
|
|
59
|
+
/** One registered external tool. */
|
|
60
|
+
export interface ExternalToolContribution {
|
|
61
|
+
/** Namespaced id (`<ns>:<name>`), like every other consumer contribution. */
|
|
62
|
+
id: string
|
|
63
|
+
/** Display name. Literal copy, not an i18n key: a tool's name is deployment DATA (the same
|
|
64
|
+
* class as a custom agent kind's `presentation.label`), and the deployment ships whatever
|
|
65
|
+
* locales it needs in its own catalog. */
|
|
66
|
+
title: string
|
|
67
|
+
/** One line of "what is this and why would I click it", shown as the item's tooltip. */
|
|
68
|
+
description?: string
|
|
69
|
+
/** Icon name (the same `i-lucide-*` vocabulary as every nav item). */
|
|
70
|
+
icon: string
|
|
71
|
+
/**
|
|
72
|
+
* Where the tool lives: a fixed URL, or a resolver that folds the invocation context in.
|
|
73
|
+
* Must resolve to an `http(s)` URL — anything else is refused (see
|
|
74
|
+
* {@link resolveExternalToolUrl}).
|
|
75
|
+
*/
|
|
76
|
+
url: string | ExternalToolUrlResolver
|
|
77
|
+
/**
|
|
78
|
+
* Metadata field keys the resolver needs. Checked BEFORE the resolver runs, so an
|
|
79
|
+
* unconfigured workspace gets a message naming the fields to fill in rather than the
|
|
80
|
+
* generic "couldn't work out where this goes" a null return can only mean. Declaring them
|
|
81
|
+
* is what turns "the tool is broken" into "somebody has to fill in `gameId`".
|
|
82
|
+
*/
|
|
83
|
+
requiredMetadata?: readonly string[]
|
|
84
|
+
/** Sidebar/palette order within the External tools section. Defaults to 0. */
|
|
85
|
+
order?: number
|
|
86
|
+
/** Reactive RBAC/availability predicate, exactly as on a {@link NavContribution}. */
|
|
87
|
+
gate?: (gates: NavGates) => boolean
|
|
88
|
+
/** Show only in advanced interface mode. */
|
|
89
|
+
advanced?: boolean
|
|
90
|
+
/** Stable selector for e2e. Defaults to `nav-external-tool-<id>`. */
|
|
91
|
+
testId?: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Why a tool's URL could not be produced. Each names a DIFFERENT fix. */
|
|
95
|
+
export type ExternalToolUnavailableReason =
|
|
96
|
+
/** The workspace has not filled in metadata fields the tool declared as required. */
|
|
97
|
+
| 'missing-metadata'
|
|
98
|
+
/** The resolver ran and declined to produce a URL (its own conditions weren't met). */
|
|
99
|
+
| 'unresolved'
|
|
100
|
+
/** The resolver THREW. A bug in the registration, distinct from it declining on purpose. */
|
|
101
|
+
| 'resolver-failed'
|
|
102
|
+
/** A URL was produced but isn't an `http(s)` link we may hand to the browser. */
|
|
103
|
+
| 'unsafe-url'
|
|
104
|
+
|
|
105
|
+
/** A resolved tool URL, or the reason there isn't one. */
|
|
106
|
+
export type ExternalToolResolution =
|
|
107
|
+
| { ok: true; url: string }
|
|
108
|
+
| {
|
|
109
|
+
ok: false
|
|
110
|
+
reason: ExternalToolUnavailableReason
|
|
111
|
+
missing: readonly string[]
|
|
112
|
+
/** What the resolver threw, on `resolver-failed`. The caller logs it; nothing else reads it. */
|
|
113
|
+
cause?: unknown
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Reason → the copy that says what to do about it, owned HERE rather than by the composable
|
|
118
|
+
* that renders it: the reasons are this module's vocabulary, and keeping the map beside them is
|
|
119
|
+
* what lets `external-tools.spec.ts` assert the keys resolve.
|
|
120
|
+
*
|
|
121
|
+
* An exhaustive `Record` over the union is the drift guard in one direction — a new reason fails
|
|
122
|
+
* to compile until it has copy. It cannot check the other direction, because the typed-message-key
|
|
123
|
+
* check only sees a key written literally at a `t()` call and these are looked up at runtime; the
|
|
124
|
+
* spec's `hasI18nKey` assertion over these values is what closes that half (see `test/i18nKeys.ts`).
|
|
125
|
+
*/
|
|
126
|
+
export const EXTERNAL_TOOL_UNAVAILABLE_KEYS: Record<ExternalToolUnavailableReason, string> = {
|
|
127
|
+
'missing-metadata': 'externalTools.unavailable.missingMetadata',
|
|
128
|
+
unresolved: 'externalTools.unavailable.unresolved',
|
|
129
|
+
'resolver-failed': 'externalTools.unavailable.resolverFailed',
|
|
130
|
+
'unsafe-url': 'externalTools.unavailable.unsafeUrl',
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve a tool's URL for one invocation. TOTAL: it reports a refusal for every way a
|
|
135
|
+
* registration can fail, and never throws.
|
|
136
|
+
*
|
|
137
|
+
* Four refusals, deliberately distinct (the "distinguish the causes that need different
|
|
138
|
+
* fixes" rule): a workspace that hasn't filled in `gameId` needs someone to open workspace
|
|
139
|
+
* settings; a resolver that declined needs the deployment's own attention; a resolver that
|
|
140
|
+
* THREW is a bug in code the host doesn't own; a non-`http(s)` URL is a bug in the
|
|
141
|
+
* registration. Collapsing them into one "unavailable" would send every one of those to the
|
|
142
|
+
* wrong person.
|
|
143
|
+
*
|
|
144
|
+
* Totality is the load-bearing part, not politeness about third-party bugs. This runs inside
|
|
145
|
+
* the `externalToolItems` computed, which is concatenated into the nav catalog that the
|
|
146
|
+
* sidebar, the command palette AND the board toolbar all render from — so a resolver throwing
|
|
147
|
+
* (`ctx.metadata.gameId.split('-')` on a field it forgot to declare as required) would take
|
|
148
|
+
* out all three shells at once, not the one item that is broken. A deployment's registration
|
|
149
|
+
* mistake must cost that registration and nothing else.
|
|
150
|
+
*
|
|
151
|
+
* The scheme check is load-bearing in the other sense: the resolved string is handed to
|
|
152
|
+
* `window.open`, so a `javascript:` URL from a mis-built resolver would execute in the SPA's
|
|
153
|
+
* own origin. An allow-list of `http:`/`https:` is the boundary — a relative or malformed
|
|
154
|
+
* URL fails the `URL` parse and lands in the same refusal.
|
|
155
|
+
*/
|
|
156
|
+
export function resolveExternalToolUrl(
|
|
157
|
+
tool: ExternalToolContribution,
|
|
158
|
+
context: ExternalToolContext,
|
|
159
|
+
): ExternalToolResolution {
|
|
160
|
+
const missing = (tool.requiredMetadata ?? []).filter(
|
|
161
|
+
(key) => !metadataValue(context.metadata, key),
|
|
162
|
+
)
|
|
163
|
+
if (missing.length > 0) return { ok: false, reason: 'missing-metadata', missing }
|
|
164
|
+
|
|
165
|
+
let raw: string | null
|
|
166
|
+
try {
|
|
167
|
+
raw = typeof tool.url === 'function' ? tool.url(context) : tool.url
|
|
168
|
+
} catch (cause) {
|
|
169
|
+
// Reported, never swallowed: `cause` rides the refusal so the caller can log what threw.
|
|
170
|
+
// A deployment author is the only person who can fix this, and they need the stack.
|
|
171
|
+
return { ok: false, reason: 'resolver-failed', missing: [], cause }
|
|
172
|
+
}
|
|
173
|
+
if (!raw) return { ok: false, reason: 'unresolved', missing: [] }
|
|
174
|
+
|
|
175
|
+
let parsed: URL
|
|
176
|
+
try {
|
|
177
|
+
parsed = new URL(raw)
|
|
178
|
+
} catch {
|
|
179
|
+
return { ok: false, reason: 'unsafe-url', missing: [] }
|
|
180
|
+
}
|
|
181
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
182
|
+
return { ok: false, reason: 'unsafe-url', missing: [] }
|
|
183
|
+
}
|
|
184
|
+
return { ok: true, url: parsed.toString() }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A tool projected onto the nav catalog, with its resolution already computed. */
|
|
188
|
+
export interface ExternalToolNavItem {
|
|
189
|
+
tool: ExternalToolContribution
|
|
190
|
+
resolution: ExternalToolResolution
|
|
191
|
+
contribution: NavContribution
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The sidebar/palette order a tool with no `order` takes. */
|
|
195
|
+
const DEFAULT_ORDER = 0
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Project registered tools onto {@link NavContribution}s in the `externalTools` section, so
|
|
199
|
+
* the three nav shells render them exactly like every other destination — one catalog, one
|
|
200
|
+
* renderer.
|
|
201
|
+
*
|
|
202
|
+
* Unresolvable tools are KEPT in the list, carrying their refusal. Hiding them would make a
|
|
203
|
+
* workspace that hasn't filled in `gameId` indistinguishable from a deployment that never
|
|
204
|
+
* registered the tool, and the person who can fix it is the one looking at the sidebar. The
|
|
205
|
+
* caller supplies `onUnavailable`, which is what turns a click into a message naming the fix.
|
|
206
|
+
*/
|
|
207
|
+
export function projectExternalTools(
|
|
208
|
+
tools: readonly ExternalToolContribution[],
|
|
209
|
+
context: ExternalToolContext,
|
|
210
|
+
handlers: {
|
|
211
|
+
open: (url: string, tool: ExternalToolContribution) => void
|
|
212
|
+
onUnavailable: (
|
|
213
|
+
resolution: Extract<ExternalToolResolution, { ok: false }>,
|
|
214
|
+
tool: ExternalToolContribution,
|
|
215
|
+
) => void
|
|
216
|
+
},
|
|
217
|
+
): ExternalToolNavItem[] {
|
|
218
|
+
return [...tools]
|
|
219
|
+
.sort((a, b) => (a.order ?? DEFAULT_ORDER) - (b.order ?? DEFAULT_ORDER))
|
|
220
|
+
.map((tool, index) => {
|
|
221
|
+
const resolution = resolveExternalToolUrl(tool, context)
|
|
222
|
+
// Placement is the ALREADY-SORTED position, not the declared `order`: the sort is stable,
|
|
223
|
+
// so tools sharing an order (the common case — nobody declares one) keep registration
|
|
224
|
+
// order instead of collapsing onto one nav slot.
|
|
225
|
+
const order = index
|
|
226
|
+
const contribution: NavContribution = {
|
|
227
|
+
id: tool.id,
|
|
228
|
+
// The title is deployment data, so it rides `label` (literal) rather than `labelKey`;
|
|
229
|
+
// `labelKey` still carries the section's own key so an accidental `t()` on it resolves.
|
|
230
|
+
labelKey: 'nav.externalTools',
|
|
231
|
+
label: tool.title,
|
|
232
|
+
description: tool.description,
|
|
233
|
+
icon: tool.icon,
|
|
234
|
+
surfaces: ['sidebar', 'command'],
|
|
235
|
+
testId: tool.testId ?? `nav-external-tool-${tool.id}`,
|
|
236
|
+
sidebar: { group: 'externalTools', order },
|
|
237
|
+
command: { group: 'externalTools', order },
|
|
238
|
+
run: () => {
|
|
239
|
+
// Re-resolve at CLICK time rather than reusing the projection's result: the context
|
|
240
|
+
// is reactive (a teammate can fill in `gameId` while this sidebar is open), and a
|
|
241
|
+
// captured stale resolution would keep reporting a fix that has already happened.
|
|
242
|
+
const now = resolveExternalToolUrl(tool, context)
|
|
243
|
+
if (now.ok) handlers.open(now.url, tool)
|
|
244
|
+
else handlers.onUnavailable(now, tool)
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
return { tool, resolution, contribution }
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Drop the tools the caller may not see, on the same two independent axes as `navSlotFilter`
|
|
253
|
+
* applies to `nav`: the interface tier, then the item's own RBAC/availability predicate. With
|
|
254
|
+
* no gates service wired (tests, a bare install) everything passes, matching the dev-open
|
|
255
|
+
* "absent access allows all" parity the nav filter keeps.
|
|
256
|
+
*/
|
|
257
|
+
export function filterExternalTools(
|
|
258
|
+
tools: readonly ExternalToolContribution[],
|
|
259
|
+
gates: NavGates | undefined,
|
|
260
|
+
): ExternalToolContribution[] {
|
|
261
|
+
if (!gates) return [...tools]
|
|
262
|
+
return tools.filter(
|
|
263
|
+
(t) => (t.advanced ? gates.advancedMode : true) && (t.gate ? t.gate(gates) : true),
|
|
264
|
+
)
|
|
265
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { hasI18nKey } from '../../test/i18nKeys'
|
|
3
3
|
import {
|
|
4
|
+
COMMAND_GROUP_ORDER,
|
|
4
5
|
groupCommands,
|
|
5
6
|
groupSidebar,
|
|
6
7
|
NAV_ACTIONS,
|
|
7
8
|
NAV_CONTRIBUTIONS,
|
|
8
9
|
navSlotFilter,
|
|
10
|
+
SIDEBAR_GROUP_ORDER,
|
|
9
11
|
sortToolbar,
|
|
10
12
|
} from './nav-contributions'
|
|
11
13
|
import type { AppSlots, NavGates } from './nav-contributions'
|
|
@@ -60,6 +62,8 @@ const slots = (): AppSlots => ({
|
|
|
60
62
|
taskTypeFormPanels: [],
|
|
61
63
|
appOverlays: [],
|
|
62
64
|
tutorialTours: [],
|
|
65
|
+
externalTools: [],
|
|
66
|
+
workspaceMetadataFields: [],
|
|
63
67
|
})
|
|
64
68
|
const ids = (s: unknown) => (s as AppSlots).nav.map((i) => i.id)
|
|
65
69
|
|
|
@@ -212,6 +216,30 @@ describe('navSlotFilter', () => {
|
|
|
212
216
|
})
|
|
213
217
|
})
|
|
214
218
|
|
|
219
|
+
describe('navSlotFilter external tools', () => {
|
|
220
|
+
const tools = [
|
|
221
|
+
{ id: 'acme:a', title: 'A', icon: 'i-lucide-link', url: 'https://a.dev' },
|
|
222
|
+
{
|
|
223
|
+
id: 'acme:b',
|
|
224
|
+
title: 'B',
|
|
225
|
+
icon: 'i-lucide-link',
|
|
226
|
+
url: 'https://b.dev',
|
|
227
|
+
gate: (g: NavGates) => g.canManageIntegrations,
|
|
228
|
+
},
|
|
229
|
+
]
|
|
230
|
+
const toolIds = (s: unknown) => (s as AppSlots).externalTools.map((t) => t.id)
|
|
231
|
+
|
|
232
|
+
it('gates registered tools in the SAME filter as the nav catalog', () => {
|
|
233
|
+
// They become nav items downstream (`useNavContributions` projects them), so gating them
|
|
234
|
+
// anywhere else would let a tool the caller can't use reach the palette while its sidebar
|
|
235
|
+
// twin was correctly hidden.
|
|
236
|
+
const withTools = (): AppSlots => ({ ...slots(), externalTools: [...tools] })
|
|
237
|
+
expect(toolIds(navSlotFilter(withTools(), { gates: ALL_GATES }))).toEqual(['acme:a', 'acme:b'])
|
|
238
|
+
expect(toolIds(navSlotFilter(withTools(), { gates: NO_GATES }))).toEqual(['acme:a'])
|
|
239
|
+
expect(toolIds(navSlotFilter(withTools(), {}))).toEqual(['acme:a', 'acme:b'])
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
215
243
|
describe('NAV_CONTRIBUTIONS catalog integrity', () => {
|
|
216
244
|
it('has unique ids and every item targets at least one surface', () => {
|
|
217
245
|
const seen = new Set<string>()
|
|
@@ -250,20 +278,10 @@ describe('NAV_CONTRIBUTIONS catalog integrity', () => {
|
|
|
250
278
|
const check = (key: string | undefined) => {
|
|
251
279
|
if (key && !hasKey(key)) missing.push(key)
|
|
252
280
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
256
|
-
for (const group of
|
|
257
|
-
'create',
|
|
258
|
-
'repositories',
|
|
259
|
-
'models',
|
|
260
|
-
'integrations',
|
|
261
|
-
'infrastructure',
|
|
262
|
-
'workspaceContext',
|
|
263
|
-
'configuration',
|
|
264
|
-
]) {
|
|
265
|
-
check(`nav.${group}`)
|
|
266
|
-
}
|
|
281
|
+
// Derived from the canonical orders rather than re-listed, so a NEW section (the
|
|
282
|
+
// deployment-contributed `externalTools` one) can't be added without its header key.
|
|
283
|
+
for (const group of COMMAND_GROUP_ORDER) check(`layout.commandBar.groups.${group}`)
|
|
284
|
+
for (const group of SIDEBAR_GROUP_ORDER) check(`nav.${group}`)
|
|
267
285
|
for (const item of NAV_CONTRIBUTIONS) {
|
|
268
286
|
check(item.labelKey)
|
|
269
287
|
if (item.command) {
|