@frontera-sdk/cli 1.50.79 → 1.50.81
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 -0
- package/package.json +4 -4
- package/src/api/blueprint-authoring-api.ts +52 -0
- package/src/api/credential-failure.ts +34 -4
- package/src/api/dataset-api.ts +1 -1
- package/src/api/governed-action-api.ts +75 -7
- package/src/api/media-api.ts +181 -0
- package/src/api/platform-api.ts +59 -0
- package/src/api/workflow-api.ts +1 -1
- package/src/auth-verify.ts +1 -1
- package/src/commands/action/available.ts +59 -0
- package/src/commands/action/cancel.ts +34 -0
- package/src/commands/action/decide.ts +54 -0
- package/src/commands/action/deploy.ts +4 -1
- package/src/commands/action/index-commands.ts +16 -6
- package/src/commands/action/prepare.ts +3 -1
- package/src/commands/action/request-summary.ts +13 -0
- package/src/commands/action/requests.ts +6 -5
- package/src/commands/action/submit.ts +183 -0
- package/src/commands/blueprint/get.ts +139 -29
- package/src/commands/chat-app/index-commands.ts +475 -0
- package/src/commands/knowledge/upload-plan.ts +14 -8
- package/src/commands/marking/index-commands.ts +171 -0
- package/src/commands/media/index-commands.ts +592 -0
- package/src/commands/registry.ts +28 -6
- package/src/commands/types.ts +6 -0
- package/src/commands/workflow/list.ts +1 -1
- package/src/errors.ts +3 -2
- package/src/exit.ts +126 -1
- package/src/flag-help.ts +24 -2
- package/src/forge/touches.ts +2 -0
- package/src/harness.ts +5 -3
- package/src/main.ts +23 -38
- package/src/scopes.ts +43 -0
- package/src/template.ts +6 -4
- package/src/templates/next-app-files.ts +8 -4
- package/src/vendor/sdk-sources.json +16 -15
package/README.md
CHANGED
|
@@ -97,8 +97,14 @@ project) and `frontera app init` (scaffold a new app) are different commands.
|
|
|
97
97
|
| `frontera app save` | package the working tree to storage |
|
|
98
98
|
| `frontera app deploy` | build output → an immutable version |
|
|
99
99
|
| `frontera app list` / `versions` | what exists, what is live |
|
|
100
|
+
| `frontera chat-app list` / `get` | Chat Apps, and one's live version, roster, audience and endpoints |
|
|
101
|
+
| `frontera chat-app draft` / `publish` | stage a configuration file, then publish it live |
|
|
102
|
+
| `frontera chat-app access` / `endpoint` | who may open it, and where it is reachable |
|
|
100
103
|
| `frontera blueprint list` / `get` | what data an app can read |
|
|
101
104
|
| `frontera blueprint generate-types` | generate committed App-local types from that data contract |
|
|
105
|
+
| `frontera marking list` / `grants` / `create` / `grant` / `revoke` | security markings — needs a person's session, never an API key; or the platform, in Blueprint → Access → Markings |
|
|
106
|
+
| `frontera media create` / `grant` / `upload` | fill a Media Set from local files (session only) |
|
|
107
|
+
| `frontera media process` / `status --watch` / `search` | switch processing on, wait for it, spot-check the passages |
|
|
102
108
|
|
|
103
109
|
`frontera help --json` returns the whole table as data.
|
|
104
110
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.50.
|
|
3
|
+
"version": "1.50.81",
|
|
4
4
|
"description": "The frontera CLI — scaffold, pull, save and deploy Frontera apps and automations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"frontera",
|
|
@@ -39,14 +39,14 @@
|
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
41
|
"@anthropic-ai/claude-agent-sdk": "^0.3.251",
|
|
42
|
-
"@frontera-sdk/functions": "1.50.
|
|
43
|
-
"@frontera-sdk/core": "1.50.
|
|
42
|
+
"@frontera-sdk/functions": "1.50.81",
|
|
43
|
+
"@frontera-sdk/core": "1.50.81",
|
|
44
44
|
"ai": "^6.0.116",
|
|
45
45
|
"gray-matter": "^4.0.3",
|
|
46
46
|
"yaml": "^2.9.0"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@frontera-sdk/forge-contracts": "1.50.
|
|
49
|
+
"@frontera-sdk/forge-contracts": "1.50.81",
|
|
50
50
|
"@types/bun": "^1.3.14",
|
|
51
51
|
"typescript": "^5.9.3"
|
|
52
52
|
}
|
|
@@ -4,6 +4,19 @@ import { credentialFailure } from './credential-failure'
|
|
|
4
4
|
import { formatValidationDetails } from './validation-detail'
|
|
5
5
|
import type { DefinitionBundle } from '../blueprint/model'
|
|
6
6
|
|
|
7
|
+
/** A row of `GET /v1/blueprint/markings`. */
|
|
8
|
+
export interface MarkingRow {
|
|
9
|
+
marking: string
|
|
10
|
+
displayName: string | null
|
|
11
|
+
description: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Who a marking is granted to. A role is named; the other kinds are ids. */
|
|
15
|
+
export interface MarkingPrincipal {
|
|
16
|
+
kind: 'workspace' | 'role' | 'user' | 'agent'
|
|
17
|
+
id: string
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
/**
|
|
8
21
|
* Blueprint AUTHORING over HTTP.
|
|
9
22
|
*
|
|
@@ -45,8 +58,18 @@ export class BlueprintAuthoringApi {
|
|
|
45
58
|
// The credential failures worth telling apart, because each has a different
|
|
46
59
|
// fix and a generic "request failed" sends the reader to the wrong one.
|
|
47
60
|
// Shared with the other clients so the three of them cannot drift again.
|
|
61
|
+
// A lane refusal, worded before the generic credential one gets it — same
|
|
62
|
+
// reason as `workflow-api.ts`: "check the key's capability" cannot work on
|
|
63
|
+
// a route closed to organization keys, since no grant reopens it.
|
|
64
|
+
if (payload?.detail === 'org-key-route-closed') {
|
|
65
|
+
throw new CliError(`${path.split('?')[0]} is closed to organization keys by design`, {
|
|
66
|
+
code: (payload?.code as string) ?? 'FORBIDDEN',
|
|
67
|
+
hint: 'no grant on the key opens it — this needs a person’s session, or the platform',
|
|
68
|
+
})
|
|
69
|
+
}
|
|
48
70
|
const credential = credentialFailure(res.status, payload?.message, {
|
|
49
71
|
token: this.token,
|
|
72
|
+
code: payload?.code,
|
|
50
73
|
whenSilent: "The key is not permitted to author this organization's Blueprint.",
|
|
51
74
|
})
|
|
52
75
|
if (credential) throw credential
|
|
@@ -514,6 +537,35 @@ export class BlueprintAuthoringApi {
|
|
|
514
537
|
)
|
|
515
538
|
}
|
|
516
539
|
|
|
540
|
+
// ── security markings: who may see which marked rows ─────────────────────
|
|
541
|
+
|
|
542
|
+
markings(): Promise<{ markings: MarkingRow[] }> {
|
|
543
|
+
return this.call('/v1/blueprint/markings')
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
createMarking(body: { marking: string; displayName?: string; description?: string }) {
|
|
547
|
+
return this.call<{ marking: string }>('/v1/blueprint/markings', { method: 'POST', body })
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
principalMarkings(principal: MarkingPrincipal): Promise<{ markings: string[] }> {
|
|
551
|
+
const query = new URLSearchParams({ principalKind: principal.kind, principalId: principal.id })
|
|
552
|
+
return this.call(`/v1/blueprint/markings/grants?${query}`)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
grantMarking(marking: string, principal: MarkingPrincipal) {
|
|
556
|
+
return this.call('/v1/blueprint/markings/grants', {
|
|
557
|
+
method: 'POST',
|
|
558
|
+
body: { marking, principalKind: principal.kind, principalId: principal.id },
|
|
559
|
+
})
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
revokeMarking(marking: string, principal: MarkingPrincipal) {
|
|
563
|
+
return this.call('/v1/blueprint/markings/grants', {
|
|
564
|
+
method: 'DELETE',
|
|
565
|
+
body: { marking, principalKind: principal.kind, principalId: principal.id },
|
|
566
|
+
})
|
|
567
|
+
}
|
|
568
|
+
|
|
517
569
|
// ── datasets: read-only, and reachable because the key holds `dataset:read` ─
|
|
518
570
|
|
|
519
571
|
/**
|
|
@@ -24,6 +24,10 @@ import { orgScopedServiceNouns } from '../scopes'
|
|
|
24
24
|
* request leaves.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
/** A person's session refused for permission: no key would help, a role would. */
|
|
28
|
+
export const PERSON_FORBIDDEN_HINT =
|
|
29
|
+
'the signed-in person lacks the permission this needs — ask an administrator for a role that grants it'
|
|
30
|
+
|
|
27
31
|
/** The lane split, which is the first thing to check and the easiest to miss. */
|
|
28
32
|
function laneNote(token: string): string {
|
|
29
33
|
// A workspace key is refused organization scope BY DESIGN — it is not a
|
|
@@ -48,13 +52,39 @@ function laneNote(token: string): string {
|
|
|
48
52
|
export function credentialFailure(
|
|
49
53
|
status: number,
|
|
50
54
|
message: string | undefined,
|
|
51
|
-
opts: { token: string; capability?: string; whenSilent?: string },
|
|
55
|
+
opts: { token: string; code?: string; capability?: string; whenSilent?: string },
|
|
52
56
|
): CliError | null {
|
|
57
|
+
// A person's session, not a key: "the key may not carry the capability"
|
|
58
|
+
// sends its holder hunting for a credential they were never meant to use.
|
|
59
|
+
const person = !opts.token.startsWith('sk-')
|
|
60
|
+
|
|
53
61
|
if (status === 401) {
|
|
62
|
+
return person
|
|
63
|
+
? new CliError('The session token was refused — it has expired or been signed out.', {
|
|
64
|
+
code: 'UNAUTHORIZED',
|
|
65
|
+
hint: 'sign in again, then set FRONTERA_TOKEN to the new session token',
|
|
66
|
+
})
|
|
67
|
+
: new CliError(
|
|
68
|
+
'The credential was refused. An organization key that has been revoked or has '
|
|
69
|
+
+ 'expired reads exactly like this — mint or rotate one in Settings → API keys.',
|
|
70
|
+
{ code: 'UNAUTHORIZED', hint: 'frontera auth verify — then `frontera login --api-url <url>`' },
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (status === 403 && person) {
|
|
75
|
+
// Only the generic RBAC refusal is a missing role. A 403 with its own code
|
|
76
|
+
// — `ACTION_FORBIDDEN` for separation of duties, say — is a rule no role
|
|
77
|
+
// lifts, so its code and message go back to the caller untouched.
|
|
78
|
+
if (opts.code !== undefined && opts.code !== 'FORBIDDEN') return null
|
|
54
79
|
return new CliError(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
{
|
|
80
|
+
// The service's own words; the hint below says what they mean.
|
|
81
|
+
message ?? 'The signed-in person is not permitted to reach that resource.',
|
|
82
|
+
{
|
|
83
|
+
code: 'FORBIDDEN',
|
|
84
|
+
hint: opts.capability
|
|
85
|
+
? `the signed-in person needs ${opts.capability} — ask an administrator for a role that grants it`
|
|
86
|
+
: PERSON_FORBIDDEN_HINT,
|
|
87
|
+
},
|
|
58
88
|
)
|
|
59
89
|
}
|
|
60
90
|
|
package/src/api/dataset-api.ts
CHANGED
|
@@ -106,7 +106,7 @@ export class DatasetApi {
|
|
|
106
106
|
// Datasets and sources are organization-scoped, so a workspace key is
|
|
107
107
|
// refused here by design — and this client used to answer that with a
|
|
108
108
|
// bare "Insufficient permissions" and no route out.
|
|
109
|
-
const credential = credentialFailure(response.status, payload?.message, { token: this.token })
|
|
109
|
+
const credential = credentialFailure(response.status, payload?.message, { token: this.token, code: payload?.code })
|
|
110
110
|
if (credential) throw credential
|
|
111
111
|
// `details` is TypeBox's own error array. Stringified it was unreadable,
|
|
112
112
|
// and this is the last gate before a Source exists.
|
|
@@ -38,6 +38,22 @@ export interface ActionRequestSummary {
|
|
|
38
38
|
deadlineAt?: string | null
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** A row of `GET /v1/blueprint/governed-actions/discovery`. */
|
|
42
|
+
export interface ActionDiscovery {
|
|
43
|
+
actionDefinitionId: string
|
|
44
|
+
apiName: string
|
|
45
|
+
displayName?: string
|
|
46
|
+
description?: string
|
|
47
|
+
/** `existing` Actions act on one object, named by `subjectRef`. */
|
|
48
|
+
subject?: { mode?: string; objectTypeId?: string }
|
|
49
|
+
approval?: { mode?: string; threshold?: number; separationOfDuties?: boolean }
|
|
50
|
+
/** JSON Schema of the invocation envelope: `input`, `subjectRef`, `reason`… */
|
|
51
|
+
inputSchema?: {
|
|
52
|
+
required?: string[]
|
|
53
|
+
properties?: { input?: { required?: string[]; properties?: Record<string, unknown> } }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
export class GovernedActionApi {
|
|
42
58
|
/**
|
|
43
59
|
* `workspaceId` for the one verb on this plane that needs it: the deployment
|
|
@@ -60,10 +76,16 @@ export class GovernedActionApi {
|
|
|
60
76
|
}
|
|
61
77
|
}
|
|
62
78
|
|
|
63
|
-
private async call<T>(
|
|
79
|
+
private async call<T>(
|
|
80
|
+
path: string,
|
|
81
|
+
init: { method?: string; body?: unknown; headers?: Record<string, string> } = {},
|
|
82
|
+
): Promise<T> {
|
|
64
83
|
const response = await fetch(`${this.apiUrl}${path}`, {
|
|
65
84
|
method: init.method ?? 'GET',
|
|
66
|
-
headers: this.headers(
|
|
85
|
+
headers: this.headers({
|
|
86
|
+
...(init.body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
87
|
+
...init.headers,
|
|
88
|
+
}),
|
|
67
89
|
...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
68
90
|
})
|
|
69
91
|
const text = await response.text()
|
|
@@ -72,11 +94,14 @@ export class GovernedActionApi {
|
|
|
72
94
|
|
|
73
95
|
if (!response.ok) {
|
|
74
96
|
const body = payload as { message?: string; code?: string; details?: unknown } | null
|
|
75
|
-
const credential = credentialFailure(response.status, body?.message, { token: this.token })
|
|
97
|
+
const credential = credentialFailure(response.status, body?.message, { token: this.token, code: body?.code })
|
|
76
98
|
if (credential) throw credential
|
|
77
99
|
throw new CliError(body?.message ?? `${response.status} from ${path}`, {
|
|
78
100
|
code: body?.code ?? 'FAILURE',
|
|
79
101
|
...(body?.details ? { hint: JSON.stringify(body.details) } : {}),
|
|
102
|
+
// The status is what separates "refused" from "unknown": `action
|
|
103
|
+
// submit` offers a retry only for the second.
|
|
104
|
+
cause: { status: response.status, details: body?.details },
|
|
80
105
|
})
|
|
81
106
|
}
|
|
82
107
|
const envelope = payload as Envelope<T>
|
|
@@ -93,10 +118,9 @@ export class GovernedActionApi {
|
|
|
93
118
|
* neither says whether anything has ever run through it, or what happened
|
|
94
119
|
* when it did.
|
|
95
120
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* `actionRequest: ['read']`, which a key can hold.
|
|
121
|
+
* The LIST gate is `actionRequest: ['read']`, which a workspace key can hold
|
|
122
|
+
* — unlike invoking, whose check joins organization membership and so needs
|
|
123
|
+
* a session (see `submit` below).
|
|
100
124
|
*
|
|
101
125
|
* `nextCursor` rides on the envelope beside `data`, so this cannot go
|
|
102
126
|
* through `call` — that unwraps to `data` and would drop the cursor,
|
|
@@ -126,6 +150,7 @@ export class GovernedActionApi {
|
|
|
126
150
|
// needs; the shared mapper adds the lane note the bare hint was missing.
|
|
127
151
|
const credential = credentialFailure(response.status, body?.message, {
|
|
128
152
|
token: this.token,
|
|
153
|
+
code: body?.code,
|
|
129
154
|
capability: 'actionRequest:read',
|
|
130
155
|
})
|
|
131
156
|
if (credential) throw credential
|
|
@@ -147,6 +172,49 @@ export class GovernedActionApi {
|
|
|
147
172
|
return this.call(`/v1/blueprint/governed-actions/requests/${encodeURIComponent(requestId)}`)
|
|
148
173
|
}
|
|
149
174
|
|
|
175
|
+
/**
|
|
176
|
+
* The Actions THIS caller may invoke, with the input schema each one takes.
|
|
177
|
+
*
|
|
178
|
+
* Filtered by the same membership check submit runs, per Action, and a
|
|
179
|
+
* refused Action is skipped rather than reported: the service answers a
|
|
180
|
+
* workspace key with an empty list, not a refusal. The verbs on this half of
|
|
181
|
+
* the plane are session-lane, so the dispatcher refuses a key before it asks.
|
|
182
|
+
*/
|
|
183
|
+
discover(): Promise<ActionDiscovery[]> {
|
|
184
|
+
return this.call('/v1/blueprint/governed-actions/discovery')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Raise a request against a deployed Action.
|
|
189
|
+
*
|
|
190
|
+
* The key is the caller's, not ours: the service answers a repeated key with
|
|
191
|
+
* the request it already made, which is what makes a retry after a timeout
|
|
192
|
+
* safe — and only if the retry sends the same key.
|
|
193
|
+
*/
|
|
194
|
+
submit(apiName: string, invocation: Record<string, unknown>, idempotencyKey: string): Promise<ActionRequestSummary> {
|
|
195
|
+
return this.call(`/v1/blueprint/governed-actions/actions/${encodeURIComponent(apiName)}/requests`, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
body: { invocation },
|
|
198
|
+
headers: { 'idempotency-key': idempotencyKey },
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
decideApproval(requestId: string, decision: 'approve' | 'reject', reason: string): Promise<{
|
|
203
|
+
approval: Record<string, unknown>
|
|
204
|
+
request: ActionRequestSummary
|
|
205
|
+
}> {
|
|
206
|
+
return this.call(`/v1/blueprint/governed-actions/requests/${encodeURIComponent(requestId)}/approvals`, {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
body: { decision, reason },
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
cancelRequest(requestId: string): Promise<ActionRequestSummary> {
|
|
213
|
+
return this.call(`/v1/blueprint/governed-actions/requests/${encodeURIComponent(requestId)}/cancel`, {
|
|
214
|
+
method: 'POST',
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
150
218
|
/** The published Action: its definition and the digest a Binding pins. */
|
|
151
219
|
publishedAction(apiName: string): Promise<{
|
|
152
220
|
definition: PublishedActionDefinition
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { CliError } from '../errors'
|
|
2
|
+
import { organizationHeaders } from '../organization'
|
|
3
|
+
import { credentialFailure } from './credential-failure'
|
|
4
|
+
import { formatValidationDetails } from './validation-detail'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Media Sets, from the CLI: create a set, fill it from local files, switch its
|
|
8
|
+
* processing on, watch it finish, and grant it to a workspace.
|
|
9
|
+
*
|
|
10
|
+
* SESSION ONLY (`authLane: 'session'` on every verb). The router accepts
|
|
11
|
+
* programmatic auth, but its direct plane asks ORGANIZATION-scoped permissions
|
|
12
|
+
* a workspace key cannot satisfy, and it is closed to organization keys. The
|
|
13
|
+
* dispatcher words that refusal once for every session-lane noun.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface Options {
|
|
17
|
+
method?: string
|
|
18
|
+
body?: unknown
|
|
19
|
+
/** Multipart. Never set alongside `body` — the two choose different content types. */
|
|
20
|
+
form?: FormData
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MediaSet {
|
|
24
|
+
id: string
|
|
25
|
+
apiName: string
|
|
26
|
+
displayName: string
|
|
27
|
+
description?: string | null
|
|
28
|
+
createdAt?: string
|
|
29
|
+
itemCount?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MediaItem {
|
|
33
|
+
mediaItemId: string
|
|
34
|
+
path: string
|
|
35
|
+
mimeType: string
|
|
36
|
+
sizeBytes: number
|
|
37
|
+
version: number
|
|
38
|
+
isLatest: boolean
|
|
39
|
+
supersedesMediaItemId?: string | null
|
|
40
|
+
createdAt?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ExtractionStatus {
|
|
44
|
+
extraction: { extractEnabled: boolean; extractProfile: 'text' | 'ocr'; ocrModel?: string }
|
|
45
|
+
coverage: { ready: number; pending: number; failed: number; skipped: number; items: number }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ChunkingStatus {
|
|
49
|
+
chunking: { chunkEnabled: boolean; embedEnabled: boolean; embedModel: string | null }
|
|
50
|
+
coverage: {
|
|
51
|
+
ready: number
|
|
52
|
+
pending: number
|
|
53
|
+
failed: number
|
|
54
|
+
skipped: number
|
|
55
|
+
extracts: number
|
|
56
|
+
chunks: number
|
|
57
|
+
embedded: number
|
|
58
|
+
truncated: number
|
|
59
|
+
embedFailed: number
|
|
60
|
+
embedPending: number
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ChunkMatch {
|
|
65
|
+
chunkId: string
|
|
66
|
+
mediaItemId: string
|
|
67
|
+
path: string
|
|
68
|
+
ordinal: number
|
|
69
|
+
text: string
|
|
70
|
+
distance: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class MediaApi {
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly apiUrl: string,
|
|
76
|
+
private readonly token: string,
|
|
77
|
+
/** Sent as `x-workspace-id`: a non-administrator sees only the sets granted to it. */
|
|
78
|
+
private readonly workspaceId?: string,
|
|
79
|
+
) {}
|
|
80
|
+
|
|
81
|
+
private async call<T>(path: string, options: Options = {}): Promise<T> {
|
|
82
|
+
const response = await fetch(`${this.apiUrl}/v1/media${path}`, {
|
|
83
|
+
method: options.form ? 'POST' : (options.method ?? 'GET'),
|
|
84
|
+
headers: {
|
|
85
|
+
Authorization: `Bearer ${this.token}`,
|
|
86
|
+
...organizationHeaders(),
|
|
87
|
+
...(this.workspaceId ? { 'x-workspace-id': this.workspaceId } : {}),
|
|
88
|
+
// Never set for multipart: `fetch` writes the boundary itself.
|
|
89
|
+
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
90
|
+
},
|
|
91
|
+
...(options.form ? { body: options.form } : {}),
|
|
92
|
+
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
|
93
|
+
})
|
|
94
|
+
const text = await response.text()
|
|
95
|
+
let payload: { data?: unknown; message?: string; code?: string; details?: unknown } | null
|
|
96
|
+
try { payload = text ? JSON.parse(text) : null } catch { payload = null }
|
|
97
|
+
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
const credential = credentialFailure(response.status, payload?.message, { token: this.token, code: payload?.code })
|
|
100
|
+
if (credential) throw credential
|
|
101
|
+
const detail = formatValidationDetails(payload?.details)
|
|
102
|
+
throw new CliError(payload?.message ?? `Request failed (${response.status}).`, {
|
|
103
|
+
code: payload?.code ?? 'FAILURE',
|
|
104
|
+
...(detail ? { hint: detail } : {}),
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
return (payload?.data ?? payload) as T
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private set(mediaSetId: string): string {
|
|
111
|
+
return `/sets/${encodeURIComponent(mediaSetId)}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async list(): Promise<MediaSet[]> {
|
|
115
|
+
return (await this.call<{ mediaSets: MediaSet[] }>('/sets')).mediaSets
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Ready items, newest first, as the calling workspace may see them. */
|
|
119
|
+
async items(mediaSetId: string): Promise<MediaItem[]> {
|
|
120
|
+
return (await this.call<{ items: MediaItem[] }>(`${this.set(mediaSetId)}/items`)).items
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async create(body: { apiName: string; displayName: string; description?: string }): Promise<MediaSet> {
|
|
124
|
+
return (await this.call<{ mediaSet: MediaSet }>('/sets', { method: 'POST', body })).mediaSet
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One file into the set. `logicalPath` is the item's identity inside the
|
|
129
|
+
* set: uploading to a path that already holds a file makes a new VERSION of
|
|
130
|
+
* it, not a second item.
|
|
131
|
+
*/
|
|
132
|
+
async upload(
|
|
133
|
+
mediaSetId: string,
|
|
134
|
+
file: { bytes: Uint8Array; filename: string; mime: string; logicalPath: string; markings?: string },
|
|
135
|
+
): Promise<MediaItem> {
|
|
136
|
+
const form = new FormData()
|
|
137
|
+
form.set('file', new File([new Blob([file.bytes.buffer as ArrayBuffer])], file.filename, { type: file.mime }))
|
|
138
|
+
form.set('logicalPath', file.logicalPath)
|
|
139
|
+
if (file.markings) form.set('markings', file.markings)
|
|
140
|
+
return (await this.call<{ item: MediaItem }>(`${this.set(mediaSetId)}/items`, { form })).item
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
extraction(mediaSetId: string): Promise<ExtractionStatus> {
|
|
144
|
+
return this.call(`${this.set(mediaSetId)}/extraction`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
chunking(mediaSetId: string): Promise<ChunkingStatus> {
|
|
148
|
+
return this.call(`${this.set(mediaSetId)}/chunking`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
setExtraction(mediaSetId: string, body: { enabled: boolean; profile?: 'text' | 'ocr' }): Promise<unknown> {
|
|
152
|
+
return this.call(`${this.set(mediaSetId)}/extraction`, { method: 'PUT', body })
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
setChunking(mediaSetId: string, body: { enabled: boolean; embed?: { enabled: boolean } }): Promise<unknown> {
|
|
156
|
+
return this.call(`${this.set(mediaSetId)}/chunking`, { method: 'PUT', body })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
retryExtraction(mediaSetId: string): Promise<{ retried: number }> {
|
|
160
|
+
return this.call(`${this.set(mediaSetId)}/extraction/retry`, { method: 'POST' })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
retryChunking(mediaSetId: string): Promise<{ retried: { chunking: number; embedding: number } }> {
|
|
164
|
+
return this.call(`${this.set(mediaSetId)}/chunking/retry`, { method: 'POST' })
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
search(
|
|
168
|
+
mediaSetId: string,
|
|
169
|
+
body: { query: string; limit?: number; maxDistance?: number },
|
|
170
|
+
): Promise<{ model: string; matches: ChunkMatch[] }> {
|
|
171
|
+
return this.call(`${this.set(mediaSetId)}/chunks/search`, { method: 'POST', body })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
grant(mediaSetId: string, body: { workspaceId: string; permissions?: string[] }): Promise<{ permissions: string[] }> {
|
|
175
|
+
return this.call(`${this.set(mediaSetId)}/grants`, { method: 'POST', body })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
revoke(mediaSetId: string, workspaceId: string): Promise<{ revoked: boolean }> {
|
|
179
|
+
return this.call(`${this.set(mediaSetId)}/grants/${encodeURIComponent(workspaceId)}`, { method: 'DELETE' })
|
|
180
|
+
}
|
|
181
|
+
}
|
package/src/api/platform-api.ts
CHANGED
|
@@ -196,6 +196,65 @@ export class PlatformApi {
|
|
|
196
196
|
return this.getList<unknown>('/v1/platform-apps/')
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
// ── Chat Apps ─────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Create a Chat App, or return the one already holding this slug.
|
|
203
|
+
*
|
|
204
|
+
* The same upsert `app deploy` uses, with `kind: 'chat'`. The service refuses
|
|
205
|
+
* a slug held by a Custom App rather than converting it.
|
|
206
|
+
*/
|
|
207
|
+
ensureChatApp(body: { slug: string; displayName: string; description?: string; accent?: string }) {
|
|
208
|
+
return this.client.request<{ id: string; slug: string; created: boolean }>('/v1/platform-apps/ensure', {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
body: { ...body, kind: 'chat' },
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Draft, live version, audience, endpoints and validation findings. */
|
|
215
|
+
chatAppEditor(appId: string) {
|
|
216
|
+
return this.get<unknown>(`/v1/chat-apps/${encodeURIComponent(appId)}/editor`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Drafts are per principal: a key's draft is its own, not a person's. */
|
|
220
|
+
saveChatAppDraft(appId: string, configuration: unknown) {
|
|
221
|
+
return this.client.request<unknown>(`/v1/chat-apps/${encodeURIComponent(appId)}/draft`, {
|
|
222
|
+
method: 'PUT',
|
|
223
|
+
body: { configuration },
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Without `configuration`, publishes the caller's saved draft. */
|
|
228
|
+
publishChatApp(appId: string, body: { configuration?: unknown; dryRun?: boolean }) {
|
|
229
|
+
return this.client.request<unknown>(`/v1/chat-apps/${encodeURIComponent(appId)}/publish`, {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
body,
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Replaces the whole audience. No grants closes the App to everyone. */
|
|
236
|
+
setChatAppAccess(
|
|
237
|
+
appId: string,
|
|
238
|
+
grants: Array<{ principalKind: 'workspace' | 'user' | 'group'; principalId?: string }>,
|
|
239
|
+
) {
|
|
240
|
+
return this.client.request<unknown>(`/v1/chat-apps/${encodeURIComponent(appId)}/access`, {
|
|
241
|
+
method: 'PUT',
|
|
242
|
+
body: { grants },
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
setChatAppEndpoint(appId: string, kind: 'platform' | 'standalone' | 'widget', enabled: boolean) {
|
|
247
|
+
return this.client.request<unknown>(
|
|
248
|
+
`/v1/chat-apps/${encodeURIComponent(appId)}/endpoints/${kind}`,
|
|
249
|
+
{ method: 'PUT', body: { enabled } },
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Workspace members — the user ids an access grant names. */
|
|
254
|
+
chatAppMembers(appId: string) {
|
|
255
|
+
return this.getList<unknown>(`/v1/chat-apps/${encodeURIComponent(appId)}/members`)
|
|
256
|
+
}
|
|
257
|
+
|
|
199
258
|
/** `{ agentId, revision, snapshot }`, or null when nothing is staged. */
|
|
200
259
|
async agentDraft(id: string): Promise<AgentDraft | null> {
|
|
201
260
|
// Wrapped twice: `ok({ data: … })` around the SDK's own envelope.
|
package/src/api/workflow-api.ts
CHANGED
|
@@ -178,7 +178,7 @@ export class WorkflowApi {
|
|
|
178
178
|
+ 'credential runs as is still open, so no capability on an organization key opens this.',
|
|
179
179
|
})
|
|
180
180
|
}
|
|
181
|
-
const credential = credentialFailure(response.status, body?.message, { token: this.token })
|
|
181
|
+
const credential = credentialFailure(response.status, body?.message, { token: this.token, code: body?.code })
|
|
182
182
|
if (credential) throw credential
|
|
183
183
|
// A rejected definition carries its reasons in `details.errors`, and they
|
|
184
184
|
// are the whole answer — a caller told only "Invalid workflow definition"
|
package/src/auth-verify.ts
CHANGED
|
@@ -25,7 +25,7 @@ export function classifyKey(token: string): CredentialKind {
|
|
|
25
25
|
throw new UsageError(
|
|
26
26
|
'that does not look like a Frontera key',
|
|
27
27
|
'workspace keys start with sk-ws- (Workspace settings → API Keys); '
|
|
28
|
-
+ 'organization keys start with sk-org- (
|
|
28
|
+
+ 'organization keys start with sk-org- (Organization settings → API Keys)',
|
|
29
29
|
)
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { GovernedActionApi } from '../../api/governed-action-api'
|
|
2
|
+
import { table } from '../../table'
|
|
3
|
+
import type { Command } from '../types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The Actions this caller may submit, and what each one takes.
|
|
7
|
+
*
|
|
8
|
+
* Not `action list`: that is the organization's catalog and its deployment
|
|
9
|
+
* state, whoever is asking. This is one person's view in one workspace —
|
|
10
|
+
* every Action whose capability, subject grant and deployment all line up for
|
|
11
|
+
* them. An Action in `list` and missing here is the gap to chase.
|
|
12
|
+
*/
|
|
13
|
+
export const actionAvailable: Command = {
|
|
14
|
+
meta: {
|
|
15
|
+
noun: 'action',
|
|
16
|
+
verb: 'available',
|
|
17
|
+
// Invoking needs a person: the check joins organization and workspace
|
|
18
|
+
// membership. The service would answer a key with an empty list, so the
|
|
19
|
+
// dispatcher refuses a key before the request.
|
|
20
|
+
authLane: 'session',
|
|
21
|
+
scope: 'workspace' as const,
|
|
22
|
+
args: [],
|
|
23
|
+
flags: {},
|
|
24
|
+
summary: 'List the Actions you can submit in this workspace, and their inputs',
|
|
25
|
+
examples: ['frontera action available', 'frontera action available --json'],
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
async run(ctx) {
|
|
29
|
+
const actions = await new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId).discover()
|
|
30
|
+
|
|
31
|
+
if (actions.length === 0) {
|
|
32
|
+
return {
|
|
33
|
+
data: { actions: [] },
|
|
34
|
+
text: 'No Actions you can submit here.\n'
|
|
35
|
+
+ ' `frontera action list` shows what is published and deployed; an Action there\n'
|
|
36
|
+
+ ' and not here is missing a capability grant or a subject grant for you.',
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
data: { actions },
|
|
42
|
+
text: table(
|
|
43
|
+
['ACTION', 'SUBJECT', 'APPROVAL', 'INPUTS'],
|
|
44
|
+
actions.map((a) => [
|
|
45
|
+
a.apiName,
|
|
46
|
+
a.subject?.mode ?? '',
|
|
47
|
+
a.approval?.mode === 'none' || !a.approval?.mode
|
|
48
|
+
? 'none'
|
|
49
|
+
: `${a.approval.mode} ×${a.approval.threshold ?? 1}`,
|
|
50
|
+
// Required ones marked: the envelope refuses a missing one without
|
|
51
|
+
// naming it, so this is the only place the name is visible.
|
|
52
|
+
Object.keys(a.inputSchema?.properties?.input?.properties ?? {})
|
|
53
|
+
.map((name) => (a.inputSchema?.properties?.input?.required ?? []).includes(name) ? `${name}*` : name)
|
|
54
|
+
.join(' '),
|
|
55
|
+
]),
|
|
56
|
+
),
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { GovernedActionApi } from '../../api/governed-action-api'
|
|
2
|
+
import { UsageError } from '../../errors'
|
|
3
|
+
import type { Command } from '../types'
|
|
4
|
+
import { requestSummary } from './request-summary'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Withdraw a request before it runs.
|
|
8
|
+
*
|
|
9
|
+
* Only while nothing has been attempted: `awaiting_approval` or `ready`, never
|
|
10
|
+
* dispatched. Anything later answers 409 "can no longer be cancelled", because
|
|
11
|
+
* an effect may already be on the far side. Cancelling twice is a no-op.
|
|
12
|
+
* Session-lane: the cancel gate locks the caller's workspace membership.
|
|
13
|
+
*/
|
|
14
|
+
export const actionCancel: Command = {
|
|
15
|
+
meta: {
|
|
16
|
+
noun: 'action',
|
|
17
|
+
verb: 'cancel',
|
|
18
|
+
authLane: 'session',
|
|
19
|
+
scope: 'workspace' as const,
|
|
20
|
+
args: [{ name: 'requestId', required: true, description: 'The request to cancel' }],
|
|
21
|
+
flags: {},
|
|
22
|
+
summary: 'Cancel an Action request that has not run yet',
|
|
23
|
+
examples: ['frontera action cancel <requestId>'],
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async run(ctx) {
|
|
27
|
+
const requestId = ctx.positional[0]
|
|
28
|
+
if (!requestId) {
|
|
29
|
+
throw new UsageError('missing <requestId>', 'frontera action requests — to find it')
|
|
30
|
+
}
|
|
31
|
+
const request = await new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId).cancelRequest(requestId)
|
|
32
|
+
return { data: request, text: requestSummary(request) }
|
|
33
|
+
},
|
|
34
|
+
}
|