@frontera-sdk/cli 1.52.5 → 1.52.7
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/package.json +4 -4
- package/src/api/governed-action-api.ts +75 -10
- package/src/blueprint/projection.ts +11 -0
- package/src/commands/action/available.ts +30 -4
- package/src/commands/action/decide.ts +25 -3
- package/src/commands/action/requests.ts +41 -3
- package/src/commands/action/review.ts +76 -26
- package/src/flag-help.ts +3 -1
- package/src/templates/next-skills.ts +3 -1
- package/src/vendor/sdk-sources.json +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frontera-sdk/cli",
|
|
3
|
-
"version": "1.52.
|
|
3
|
+
"version": "1.52.7",
|
|
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.52.
|
|
43
|
-
"@frontera-sdk/core": "1.52.
|
|
42
|
+
"@frontera-sdk/functions": "1.52.7",
|
|
43
|
+
"@frontera-sdk/core": "1.52.7",
|
|
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.52.
|
|
49
|
+
"@frontera-sdk/forge-contracts": "1.52.7",
|
|
50
50
|
"@types/bun": "^1.3.14",
|
|
51
51
|
"typescript": "^5.9.3"
|
|
52
52
|
}
|
|
@@ -46,7 +46,24 @@ export interface ActionDiscovery {
|
|
|
46
46
|
description?: string
|
|
47
47
|
/** `existing` Actions act on one object, named by `subjectRef`. */
|
|
48
48
|
subject?: { mode?: string; objectTypeId?: string }
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* `none`, `required` or `conditional` carry `threshold`; `chain` carries
|
|
51
|
+
* `stages`, its steps in order, and no threshold.
|
|
52
|
+
*/
|
|
53
|
+
approval?: {
|
|
54
|
+
mode?: string
|
|
55
|
+
threshold?: number
|
|
56
|
+
separationOfDuties?: boolean | string
|
|
57
|
+
expiresAfter?: string
|
|
58
|
+
stages?: Array<{
|
|
59
|
+
key?: string
|
|
60
|
+
quorum?: number
|
|
61
|
+
/** Who decides the step: `members`, or `{ role }` for an App role. */
|
|
62
|
+
approver?: 'members' | { role?: string }
|
|
63
|
+
/** Whether the step applies only to some requests. Its condition is never sent. */
|
|
64
|
+
conditional?: boolean
|
|
65
|
+
}>
|
|
66
|
+
}
|
|
50
67
|
/** JSON Schema of the invocation envelope: `input`, `subjectRef`, `reason`… */
|
|
51
68
|
inputSchema?: {
|
|
52
69
|
required?: string[]
|
|
@@ -126,11 +143,20 @@ export class GovernedActionApi {
|
|
|
126
143
|
* through `call` — that unwraps to `data` and would drop the cursor,
|
|
127
144
|
* silently capping every caller at one page.
|
|
128
145
|
*/
|
|
129
|
-
async listRequests(query: {
|
|
146
|
+
async listRequests(query: {
|
|
147
|
+
lifecycle?: string
|
|
148
|
+
limit?: number
|
|
149
|
+
cursor?: string
|
|
150
|
+
/** Only the waiting requests this person could decide right now. */
|
|
151
|
+
awaitingMe?: boolean
|
|
152
|
+
} = {}): Promise<{
|
|
130
153
|
requests: ActionRequestSummary[]
|
|
131
154
|
nextCursor?: string
|
|
155
|
+
/** `awaitingMe` only: the list stopped before it had looked at every waiting request. */
|
|
156
|
+
truncated?: true
|
|
132
157
|
}> {
|
|
133
158
|
const params = new URLSearchParams()
|
|
159
|
+
if (query.awaitingMe) params.set('awaiting', 'me')
|
|
134
160
|
if (query.lifecycle) params.set('lifecycle', query.lifecycle)
|
|
135
161
|
if (query.limit !== undefined) params.set('limit', String(query.limit))
|
|
136
162
|
if (query.cursor) params.set('cursor', query.cursor)
|
|
@@ -161,10 +187,15 @@ export class GovernedActionApi {
|
|
|
161
187
|
})
|
|
162
188
|
}
|
|
163
189
|
|
|
164
|
-
const envelope = payload as {
|
|
190
|
+
const envelope = payload as {
|
|
191
|
+
data?: ActionRequestSummary[]
|
|
192
|
+
nextCursor?: string
|
|
193
|
+
truncated?: boolean
|
|
194
|
+
} | null
|
|
165
195
|
return {
|
|
166
196
|
requests: envelope?.data ?? [],
|
|
167
197
|
...(envelope?.nextCursor ? { nextCursor: envelope.nextCursor } : {}),
|
|
198
|
+
...(envelope?.truncated === true ? { truncated: true as const } : {}),
|
|
168
199
|
}
|
|
169
200
|
}
|
|
170
201
|
|
|
@@ -200,13 +231,17 @@ export class GovernedActionApi {
|
|
|
200
231
|
})
|
|
201
232
|
}
|
|
202
233
|
|
|
203
|
-
|
|
234
|
+
/**
|
|
235
|
+
* `stage` names the approval-chain step being decided; the service refuses
|
|
236
|
+
* the decision if another step is waiting. Left out: whatever step waits.
|
|
237
|
+
*/
|
|
238
|
+
decideApproval(requestId: string, decision: 'approve' | 'reject', reason: string, stage?: string): Promise<{
|
|
204
239
|
approval: Record<string, unknown>
|
|
205
240
|
request: ActionRequestSummary
|
|
206
241
|
}> {
|
|
207
242
|
return this.call(`/v1/blueprint/governed-actions/requests/${encodeURIComponent(requestId)}/approvals`, {
|
|
208
243
|
method: 'POST',
|
|
209
|
-
body: { decision, reason },
|
|
244
|
+
body: { decision, reason, ...(stage ? { stage } : {}) },
|
|
210
245
|
})
|
|
211
246
|
}
|
|
212
247
|
|
|
@@ -344,10 +379,34 @@ export class GovernedActionApi {
|
|
|
344
379
|
)
|
|
345
380
|
}
|
|
346
381
|
|
|
382
|
+
/**
|
|
383
|
+
* One Binding revision with ITS OWN deployment state: the row that validate
|
|
384
|
+
* and review move, and the one activation takes.
|
|
385
|
+
*
|
|
386
|
+
* Not the same row as `listBindings().entries[].deployment`, which is the
|
|
387
|
+
* state the revision's Action is running on right now.
|
|
388
|
+
*/
|
|
389
|
+
getBindingRevision(revisionId: string): Promise<{
|
|
390
|
+
id: string
|
|
391
|
+
bindingId: string
|
|
392
|
+
actionDefinitionId: string
|
|
393
|
+
stateId: string
|
|
394
|
+
state: string
|
|
395
|
+
stateGeneration: number
|
|
396
|
+
isCurrent: boolean
|
|
397
|
+
}> {
|
|
398
|
+
return this.call(`/v1/blueprint/governed-actions/admin/binding-revisions/${revisionId}`)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* `expectedCurrentStateId` and `expectedCurrentGeneration` name the state
|
|
403
|
+
* the Action is on now, the one this activation replaces. Both are null
|
|
404
|
+
* when the Action has none.
|
|
405
|
+
*/
|
|
347
406
|
activateDeployment(stateId: string, input: {
|
|
348
407
|
expectedGeneration: number
|
|
349
|
-
expectedCurrentStateId: string
|
|
350
|
-
expectedCurrentGeneration: number
|
|
408
|
+
expectedCurrentStateId: string | null
|
|
409
|
+
expectedCurrentGeneration: number | null
|
|
351
410
|
reason: string
|
|
352
411
|
}): Promise<unknown> {
|
|
353
412
|
return this.call(
|
|
@@ -356,15 +415,21 @@ export class GovernedActionApi {
|
|
|
356
415
|
)
|
|
357
416
|
}
|
|
358
417
|
|
|
359
|
-
|
|
418
|
+
/**
|
|
419
|
+
* Bindings at their newest revision, each with `deployment`: the CURRENT
|
|
420
|
+
* state of the Action it binds (null when the Action has none). Every
|
|
421
|
+
* Binding of one Action therefore carries the same `deployment`.
|
|
422
|
+
*/
|
|
423
|
+
listBindings(actionDefinitionId?: string): Promise<{
|
|
360
424
|
entries: Array<{
|
|
361
425
|
bindingId: string
|
|
362
426
|
revisionId: string
|
|
363
427
|
actionDefinitionId: string
|
|
364
|
-
deployment?: { stateId?: string; state?: string; generation?: number }
|
|
428
|
+
deployment?: { stateId?: string; state?: string; generation?: number } | null
|
|
365
429
|
}>
|
|
366
430
|
hasMore?: boolean
|
|
367
431
|
}> {
|
|
368
|
-
|
|
432
|
+
const query = actionDefinitionId ? `?actionDefinitionId=${encodeURIComponent(actionDefinitionId)}` : ''
|
|
433
|
+
return this.call(`/v1/blueprint/governed-actions/admin/bindings${query}`)
|
|
369
434
|
}
|
|
370
435
|
}
|
|
@@ -340,6 +340,17 @@ function actionToFile(action: Record<string, unknown>, names: ActionNames): Reco
|
|
|
340
340
|
nameFieldRefs(criterion.assertion, inputNames, names.property)
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
// An approval chain's steps. A step's condition is the only part that references
|
|
344
|
+
// anything: it reads inputs and the subject as a criterion does, and is decided
|
|
345
|
+
// before the action runs, so it sees inputs only. `when: always` is a string and
|
|
346
|
+
// the walk leaves it alone; `key` and `approver` are names already.
|
|
347
|
+
const approval = isRecord(file.governance) ? file.governance.approval : undefined
|
|
348
|
+
if (isRecord(approval) && Array.isArray(approval.stages)) {
|
|
349
|
+
for (const stage of approval.stages) {
|
|
350
|
+
if (isRecord(stage)) nameFieldRefs(stage.when, inputNames, names.property)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
343
354
|
for (const outcome of Array.isArray(file.businessOutcomes) ? file.businessOutcomes : []) {
|
|
344
355
|
if (!isRecord(outcome)) continue
|
|
345
356
|
// Output fields are scoped to their outcome, exactly as the bundle validates them.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GovernedActionApi } from '../../api/governed-action-api'
|
|
1
|
+
import { GovernedActionApi, type ActionDiscovery } from '../../api/governed-action-api'
|
|
2
2
|
import { table } from '../../table'
|
|
3
3
|
import type { Command } from '../types'
|
|
4
4
|
|
|
@@ -10,6 +10,34 @@ import type { Command } from '../types'
|
|
|
10
10
|
* every Action whose capability, subject grant and deployment all line up for
|
|
11
11
|
* them. An Action in `list` and missing here is the gap to chase.
|
|
12
12
|
*/
|
|
13
|
+
/**
|
|
14
|
+
* The APPROVAL column: what a request for this Action waits for.
|
|
15
|
+
*
|
|
16
|
+
* A chain has no threshold, so `×N` would print `chain ×1` for any chain and
|
|
17
|
+
* say one approval where several steps may each ask for more.
|
|
18
|
+
*
|
|
19
|
+
* `App only` marks a chain with a step that applies to every request and is
|
|
20
|
+
* decided by an App role. That step is resolved against the App a request is
|
|
21
|
+
* raised through, so `action submit`, which acts through no App, is always
|
|
22
|
+
* refused for it: said here, before someone tries. `App role step` marks a
|
|
23
|
+
* chain where such a step applies to some requests only: those are refused
|
|
24
|
+
* here, the others are not.
|
|
25
|
+
*/
|
|
26
|
+
export function approvalSummary(approval: ActionDiscovery['approval']): string {
|
|
27
|
+
if (!approval?.mode || approval.mode === 'none') return 'none'
|
|
28
|
+
if (approval.mode === 'chain') {
|
|
29
|
+
const steps = approval.stages?.length ?? 0
|
|
30
|
+
const roleSteps = (approval.stages ?? []).filter((stage) => (
|
|
31
|
+
typeof stage?.approver === 'object' && typeof stage.approver?.role === 'string'
|
|
32
|
+
))
|
|
33
|
+
const appMark = roleSteps.some((stage) => stage.conditional === false)
|
|
34
|
+
? ', App only'
|
|
35
|
+
: roleSteps.length > 0 ? ', App role step' : ''
|
|
36
|
+
return `chain, ${steps} ${steps === 1 ? 'step' : 'steps'}${appMark}`
|
|
37
|
+
}
|
|
38
|
+
return `${approval.mode} ×${approval.threshold ?? 1}`
|
|
39
|
+
}
|
|
40
|
+
|
|
13
41
|
export const actionAvailable: Command = {
|
|
14
42
|
meta: {
|
|
15
43
|
noun: 'action',
|
|
@@ -44,9 +72,7 @@ export const actionAvailable: Command = {
|
|
|
44
72
|
actions.map((a) => [
|
|
45
73
|
a.apiName,
|
|
46
74
|
a.subject?.mode ?? '',
|
|
47
|
-
a.approval
|
|
48
|
-
? 'none'
|
|
49
|
-
: `${a.approval.mode} ×${a.approval.threshold ?? 1}`,
|
|
75
|
+
approvalSummary(a.approval),
|
|
50
76
|
// Required ones marked: the envelope refuses a missing one without
|
|
51
77
|
// naming it, so this is the only place the name is visible.
|
|
52
78
|
Object.keys(a.inputSchema?.properties?.input?.properties ?? {})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { GovernedActionApi } from '../../api/governed-action-api'
|
|
2
|
-
import { UsageError } from '../../errors'
|
|
2
|
+
import { CliError, UsageError } from '../../errors'
|
|
3
3
|
import { flagString, type Command, type CommandContext } from '../types'
|
|
4
4
|
import { requestSummary } from './request-summary'
|
|
5
5
|
|
|
@@ -15,6 +15,11 @@ import { requestSummary } from './request-summary'
|
|
|
15
15
|
* `awaiting_approval` until enough other people decide.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
function stepRequired(err: CliError): boolean {
|
|
19
|
+
const details = (err.cause as { details?: { reason?: unknown } } | undefined)?.details
|
|
20
|
+
return details?.reason === 'ACTION_APPROVAL_STEP_REQUIRED'
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
function decision(verb: 'approve' | 'reject'): Command {
|
|
19
24
|
return {
|
|
20
25
|
meta: {
|
|
@@ -25,7 +30,10 @@ function decision(verb: 'approve' | 'reject'): Command {
|
|
|
25
30
|
sessionAlternative: 'Blueprint → Actions → Requests',
|
|
26
31
|
scope: 'workspace' as const,
|
|
27
32
|
args: [{ name: 'requestId', required: true, description: `The request to ${verb}` }],
|
|
28
|
-
|
|
33
|
+
// `--stage` names the approval-chain step being decided. Required for a
|
|
34
|
+
// request approved in steps; a decision for a step that is no longer
|
|
35
|
+
// the one waiting is refused, not recorded.
|
|
36
|
+
flags: { reason: 'string', stage: 'string' },
|
|
29
37
|
summary: verb === 'approve'
|
|
30
38
|
? 'Approve an Action request waiting on approval'
|
|
31
39
|
: 'Reject an Action request waiting on approval',
|
|
@@ -43,8 +51,22 @@ function decision(verb: 'approve' | 'reject'): Command {
|
|
|
43
51
|
throw new UsageError('missing --reason', `frontera action ${verb} ${requestId} --reason "…"`)
|
|
44
52
|
}
|
|
45
53
|
|
|
54
|
+
const stage = flagString(ctx, 'stage')
|
|
46
55
|
const result = await new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
47
|
-
.decideApproval(requestId, verb, reason)
|
|
56
|
+
.decideApproval(requestId, verb, reason, stage || undefined)
|
|
57
|
+
.catch((err: unknown) => {
|
|
58
|
+
// A request approved in steps is decided one named step at a time.
|
|
59
|
+
// The service says so; the hint says where the step's name is read.
|
|
60
|
+
if (err instanceof CliError && stepRequired(err)) {
|
|
61
|
+
throw new CliError(err.message, {
|
|
62
|
+
code: err.code,
|
|
63
|
+
hint: `frontera action requests ${requestId} shows the step that is waiting, as approval.currentStage; then `
|
|
64
|
+
+ `frontera action ${verb} ${requestId} --stage <step> --reason "…"`,
|
|
65
|
+
cause: err.cause,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
throw err
|
|
69
|
+
})
|
|
48
70
|
return { data: result, text: requestSummary(result.request) }
|
|
49
71
|
},
|
|
50
72
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { GovernedActionApi } from '../../api/governed-action-api'
|
|
2
2
|
import { UsageError } from '../../errors'
|
|
3
3
|
import { table } from '../../table'
|
|
4
|
-
import { flagString, type Command } from '../types'
|
|
4
|
+
import { flagBool, flagString, type Command } from '../types'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* What has actually run through the write path.
|
|
@@ -14,6 +14,10 @@ import { flagString, type Command } from '../types'
|
|
|
14
14
|
* Read-only, so it runs on a key: listing gates on `actionRequest: ['read']`,
|
|
15
15
|
* which a key can hold. Invoking joins organization membership and needs a
|
|
16
16
|
* session — see `action submit`.
|
|
17
|
+
*
|
|
18
|
+
* `--awaiting-me` is the exception to "runs on a key": it lists what a PERSON
|
|
19
|
+
* could decide right now, so on a key it answers an empty list. A key
|
|
20
|
+
* approves nothing.
|
|
17
21
|
*/
|
|
18
22
|
export const actionRequests: Command = {
|
|
19
23
|
meta: {
|
|
@@ -29,11 +33,12 @@ export const actionRequests: Command = {
|
|
|
29
33
|
description: 'one request to open in full; omit to list recent ones',
|
|
30
34
|
},
|
|
31
35
|
],
|
|
32
|
-
flags: { lifecycle: 'string', limit: 'string', cursor: 'string' },
|
|
36
|
+
flags: { lifecycle: 'string', limit: 'string', cursor: 'string', 'awaiting-me': 'boolean' },
|
|
33
37
|
summary: 'List Action requests and how they resolved — or open one',
|
|
34
38
|
examples: [
|
|
35
39
|
'frontera action requests',
|
|
36
40
|
'frontera action requests --lifecycle failed',
|
|
41
|
+
'frontera action requests --awaiting-me',
|
|
37
42
|
'frontera action requests <requestId> --json',
|
|
38
43
|
],
|
|
39
44
|
},
|
|
@@ -70,14 +75,44 @@ export const actionRequests: Command = {
|
|
|
70
75
|
)
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
const awaitingMe = flagBool(ctx, 'awaiting-me')
|
|
79
|
+
if (awaitingMe && flagString(ctx, 'lifecycle')) {
|
|
80
|
+
throw new UsageError(
|
|
81
|
+
'--awaiting-me lists the requests waiting for your decision, so it takes no --lifecycle',
|
|
82
|
+
'try frontera action requests --awaiting-me',
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
73
86
|
const page = await client.listRequests({
|
|
87
|
+
...(awaitingMe ? { awaitingMe: true } : {}),
|
|
74
88
|
...(flagString(ctx, 'lifecycle') ? { lifecycle: flagString(ctx, 'lifecycle')! } : {}),
|
|
75
89
|
...(limit === undefined ? {} : { limit }),
|
|
76
90
|
...(flagString(ctx, 'cursor') ? { cursor: flagString(ctx, 'cursor')! } : {}),
|
|
77
91
|
})
|
|
78
92
|
|
|
93
|
+
// The inbox looks at a bounded number of waiting requests. When it stopped
|
|
94
|
+
// there, a short or empty list is not the whole answer.
|
|
95
|
+
const incomplete = page.truncated
|
|
96
|
+
? '\n\nThis list may be incomplete: it stopped after looking at the 5,000 most recent\n'
|
|
97
|
+
+ 'waiting requests. `frontera action requests --lifecycle awaiting_approval` lists\n'
|
|
98
|
+
+ 'every waiting request you can read.'
|
|
99
|
+
: ''
|
|
100
|
+
|
|
79
101
|
if (page.requests.length === 0) {
|
|
80
102
|
const filtered = flagString(ctx, 'lifecycle')
|
|
103
|
+
if (awaitingMe) {
|
|
104
|
+
return {
|
|
105
|
+
data: page,
|
|
106
|
+
text: (page.truncated
|
|
107
|
+
? 'Nothing found that is waiting for your decision.\n'
|
|
108
|
+
: 'Nothing is waiting for your decision.\n')
|
|
109
|
+
+ ' This lists the requests you could approve or reject right now: not ones\n'
|
|
110
|
+
+ ' you raised, already decided, or that wait on a step for an App role.\n'
|
|
111
|
+
+ ' It is a person\'s list: on a key it is always empty. Set FRONTERA_TOKEN\n'
|
|
112
|
+
+ ' to your session token to read your own.'
|
|
113
|
+
+ incomplete,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
81
116
|
return {
|
|
82
117
|
data: page,
|
|
83
118
|
text: filtered
|
|
@@ -109,7 +144,10 @@ export const actionRequests: Command = {
|
|
|
109
144
|
]),
|
|
110
145
|
[24, 12, 20, 38, undefined],
|
|
111
146
|
)
|
|
112
|
-
+ (page.nextCursor
|
|
147
|
+
+ (page.nextCursor
|
|
148
|
+
? `\n\nMore requests. Continue with ${awaitingMe ? '--awaiting-me ' : ''}--cursor ${page.nextCursor}`
|
|
149
|
+
: '')
|
|
150
|
+
+ incomplete,
|
|
113
151
|
}
|
|
114
152
|
},
|
|
115
153
|
}
|
|
@@ -12,16 +12,77 @@ import { flagString, type Command, type CommandContext } from '../types'
|
|
|
12
12
|
* non-production organization, and a real one required wherever the roles are
|
|
13
13
|
* held by people.
|
|
14
14
|
*
|
|
15
|
-
* Review and activate are one command because the
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* asked to carry
|
|
15
|
+
* Review and activate are one command because the bookkeeping between them is
|
|
16
|
+
* not a decision: activation names the generation review left the revision's
|
|
17
|
+
* state at and the state the Action is on now, and a caller who ran them
|
|
18
|
+
* separately would be asked to carry numbers they never chose.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
function api(ctx: CommandContext): GovernedActionApi {
|
|
22
22
|
return new GovernedActionApi(ctx.apiUrl, ctx.token, ctx.workspaceId)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
const READ_REFUSED_HINT =
|
|
26
|
+
'this account may not read the write path, which activating it needs, so nothing was reviewed — '
|
|
27
|
+
+ 'ask an administrator for a role that can read Action deployments, then run this again'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The revision, read before anything is reviewed.
|
|
31
|
+
*
|
|
32
|
+
* Activation takes the revision's own state, so it has to be readable. Asked
|
|
33
|
+
* first so that a reviewer who may review but not read is refused with nothing
|
|
34
|
+
* changed, rather than left with a reviewed write path this command can no
|
|
35
|
+
* longer switch on. The message stays the service's; the hint says which step
|
|
36
|
+
* refused and what did not happen.
|
|
37
|
+
*/
|
|
38
|
+
async function requireReadableRevision(client: GovernedActionApi, bindingRevisionId: string): Promise<void> {
|
|
39
|
+
try {
|
|
40
|
+
await client.getBindingRevision(bindingRevisionId)
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (!(err instanceof CliError) || err.code !== 'FORBIDDEN') throw err
|
|
43
|
+
throw new CliError(err.message, { code: 'FORBIDDEN', hint: READ_REFUSED_HINT, cause: err })
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The review has landed and the states activation names could not be read. */
|
|
48
|
+
function notReadable(cause?: unknown): CliError {
|
|
49
|
+
return new CliError('Reviewed, but the deployment state for this revision is not readable.', {
|
|
50
|
+
code: cause instanceof CliError ? cause.code : 'FAILURE',
|
|
51
|
+
hint: 'Activate it directly once the state id is known; the review itself has landed.',
|
|
52
|
+
...(cause === undefined ? {} : { cause }),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What activation must name, read AFTER the review.
|
|
58
|
+
*
|
|
59
|
+
* Two different states. `target` is the revision's own: the one validate and
|
|
60
|
+
* review moved, at the generation review left it. `current` is the one its
|
|
61
|
+
* Action is on now, which activation replaces: the disabled decision `action
|
|
62
|
+
* prepare` recorded, an active revision being superseded, or nothing.
|
|
63
|
+
*/
|
|
64
|
+
async function readActivation(client: GovernedActionApi, bindingRevisionId: string): Promise<{
|
|
65
|
+
target: { stateId: string; generation: number }
|
|
66
|
+
current: { stateId: string; generation: number } | null
|
|
67
|
+
}> {
|
|
68
|
+
const revision = await client.getBindingRevision(bindingRevisionId).catch((err: unknown) => {
|
|
69
|
+
throw notReadable(err)
|
|
70
|
+
})
|
|
71
|
+
if (!revision?.stateId || !Number.isInteger(revision.stateGeneration)) throw notReadable()
|
|
72
|
+
const bindings = await client.listBindings(revision.actionDefinitionId).catch((err: unknown) => {
|
|
73
|
+
throw notReadable(err)
|
|
74
|
+
})
|
|
75
|
+
const deployment = bindings.entries
|
|
76
|
+
.find((entry) => entry.actionDefinitionId === revision.actionDefinitionId)
|
|
77
|
+
?.deployment
|
|
78
|
+
return {
|
|
79
|
+
target: { stateId: revision.stateId, generation: revision.stateGeneration },
|
|
80
|
+
current: deployment?.stateId && typeof deployment.generation === 'number'
|
|
81
|
+
? { stateId: deployment.stateId, generation: deployment.generation }
|
|
82
|
+
: null,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
25
86
|
export const actionReview: Command = {
|
|
26
87
|
meta: {
|
|
27
88
|
noun: 'action',
|
|
@@ -50,45 +111,34 @@ export const actionReview: Command = {
|
|
|
50
111
|
}
|
|
51
112
|
|
|
52
113
|
const client = api(ctx)
|
|
114
|
+
const activate = ctx.flags['no-activate'] !== true
|
|
115
|
+
|
|
116
|
+
if (activate) await requireReadableRevision(client, bindingRevisionId)
|
|
53
117
|
|
|
54
|
-
//
|
|
55
|
-
// review takes it to 3, activation expects 3 and the disabled decision it
|
|
56
|
-
// replaces still at 1.
|
|
118
|
+
// The generation is the service's, not ours: validation left the state at 2.
|
|
57
119
|
await client.reviewBindingRevision(bindingRevisionId, {
|
|
58
120
|
validationReportId,
|
|
59
121
|
expectedStateGeneration: 2,
|
|
60
122
|
})
|
|
61
123
|
|
|
62
|
-
if (
|
|
124
|
+
if (!activate) {
|
|
63
125
|
return {
|
|
64
126
|
data: { bindingRevisionId, reviewed: true, activated: false },
|
|
65
127
|
text: 'Reviewed. Not activated — the write path is armed but switched off.',
|
|
66
128
|
}
|
|
67
129
|
}
|
|
68
130
|
|
|
69
|
-
const
|
|
70
|
-
const entry = binding.entries.find((e) => e.revisionId === bindingRevisionId)
|
|
71
|
-
const stateId = entry?.deployment?.stateId
|
|
72
|
-
if (!stateId) {
|
|
73
|
-
throw new CliError('Reviewed, but the deployment state for this revision is not readable.', {
|
|
74
|
-
code: 'FAILURE',
|
|
75
|
-
hint: 'Activate it directly once the state id is known; the review itself has landed.',
|
|
76
|
-
})
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const current = binding.entries.find(
|
|
80
|
-
(e) => e.actionDefinitionId === entry.actionDefinitionId && e.deployment?.state === 'disabled',
|
|
81
|
-
)
|
|
131
|
+
const { target, current } = await readActivation(client, bindingRevisionId)
|
|
82
132
|
|
|
83
|
-
await client.activateDeployment(stateId, {
|
|
84
|
-
expectedGeneration:
|
|
85
|
-
expectedCurrentStateId: current?.
|
|
86
|
-
expectedCurrentGeneration: current?.
|
|
133
|
+
await client.activateDeployment(target.stateId, {
|
|
134
|
+
expectedGeneration: target.generation,
|
|
135
|
+
expectedCurrentStateId: current?.stateId ?? null,
|
|
136
|
+
expectedCurrentGeneration: current?.generation ?? null,
|
|
87
137
|
reason: flagString(ctx, 'reason') ?? 'Activated from the CLI after review.',
|
|
88
138
|
})
|
|
89
139
|
|
|
90
140
|
return {
|
|
91
|
-
data: { bindingRevisionId, stateId, reviewed: true, activated: true },
|
|
141
|
+
data: { bindingRevisionId, stateId: target.stateId, reviewed: true, activated: true },
|
|
92
142
|
text: 'Reviewed and activated. The Action can now be invoked.',
|
|
93
143
|
}
|
|
94
144
|
},
|
package/src/flag-help.ts
CHANGED
|
@@ -31,8 +31,10 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
|
|
|
31
31
|
'dry-run': 'derive and print the write path without building anything',
|
|
32
32
|
'plan-id': 'reuse this mutation plan id instead of minting one, so a retry names the same artifact',
|
|
33
33
|
'binding-id': 'reuse this Binding id instead of minting one, so a retry names the same artifact',
|
|
34
|
-
reason: 'reason recorded with the deployment transition, request or approval decision',
|
|
34
|
+
reason: 'reason recorded with the deployment transition, request or approval decision; the reason of a rejection in an approval chain is shown to whoever raised the request',
|
|
35
|
+
stage: 'approval-chain step being decided: required for a request approved in steps, and refused if another step is waiting by then',
|
|
35
36
|
'no-activate': 'review the write path but leave it switched off',
|
|
37
|
+
'awaiting-me': 'list only the requests waiting for your own decision',
|
|
36
38
|
role: 'organization role the capability is held by',
|
|
37
39
|
add: 'comma-separated property API names to add to the current set',
|
|
38
40
|
remove: 'comma-separated property API names to drop from the current set',
|
|
@@ -528,9 +528,11 @@ const actions: Skill = {
|
|
|
528
528
|
'- Decide what to SAY with `actionEffectOf(request)`: `pending`, `applied`, `refused`, or',
|
|
529
529
|
' `uncertain`. `uncertain` means the outcome could not be established — rendering it as failure',
|
|
530
530
|
' invites a duplicate submit, rendering it as success is a lie. Say it is being checked.',
|
|
531
|
-
'- Approval is a mode, not an afterthought. When `action.approval.mode
|
|
531
|
+
'- Approval is a mode, not an afterthought. When `action.approval.mode !== "none"`, submitting',
|
|
532
532
|
' creates a Request someone else decides; the reviewer path is `useDecideActionRequest()`, which',
|
|
533
533
|
' takes `{ requestId, decision, reason }`. Do not design a flow that assumes instant application.',
|
|
534
|
+
' For an approval with steps, the decision must also pass the current step of the request,',
|
|
535
|
+
' `request.approval.currentStage`, as `stage`.',
|
|
534
536
|
'- Editing an existing record: pass `expectedVersion: recordVersionOf(instance.data)`, read from',
|
|
535
537
|
' `useObjectInstance` — a LIST row does not carry the version. Omitting it does not fail; the',
|
|
536
538
|
' compare-and-set is skipped and two people overwrite each other with no refusal and no evidence.',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"sdkVersion": "1.
|
|
2
|
+
"sdkVersion": "1.52.6",
|
|
3
3
|
"files": {
|
|
4
4
|
"frontera/core/LICENSE": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2026 Sebati\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n",
|
|
5
5
|
"frontera/core/bridge-client.ts": "import { isHostMessage, type BridgeInit, type AppMessage } from './bridge-protocol'\nimport { readRuntimeGlobal } from './config'\nimport type { HostTheme } from './theme'\n\n/**\n * App side of the bridge.\n *\n * The app announces itself with `frontera:ready` and waits for `frontera:init`,\n * which carries the API credential. It does NOT read the credential from its\n * own URL — the only thing there is the narrow asset token, and treating that\n * as a data credential would turn a loggable URL into data access.\n *\n * `event.origin` is checked against the parent's origin on every message.\n */\nexport interface BridgeSession {\n init: BridgeInit\n /** Send a message to the host. */\n send(message: AppMessage): void\n /** Subscribe to shared-state updates pushed by the host. */\n onState(handler: (state: Record<string, unknown>) => void): () => void\n /** Subscribe to credential refreshes. */\n onToken(handler: (token: string) => void): () => void\n /** Subscribe to host palette / colour-scheme changes. */\n onTheme(handler: (theme: HostTheme) => void): () => void\n /** Visibility changes do not revoke credentials or imply execution is paused. */\n onActivation(handler: (active: boolean) => void): () => void\n dispose(): void\n /**\n * The fetch the App's API client should use, when the session needs a say in\n * its requests — an externally hosted App re-signs in and retries once when\n * the service refuses a token that was revoked early. Absent: plain fetch.\n */\n fetchImpl?: typeof fetch\n}\n\nexport interface ConnectOptions {\n /**\n * Origin of the embedding platform. Defaults to the injected runtime config,\n * then `document.referrer`.\n */\n parentOrigin?: string\n timeoutMs?: number\n}\n\n/**\n * Who is framing us.\n *\n * The injected runtime config comes FIRST and `document.referrer` is only a\n * fallback. The injected value is preferred because it is exact and, when it\n * comes from the signed per-mount claim, unforgeable — not because the\n * referrer is empty. (An earlier version of this comment blamed the\n * `referrer-policy: no-referrer` these responses carry; that header governs\n * the referrer this document SENDS on its own requests, not the\n * `document.referrer` its parent handed it, which is set by the embedding\n * page's policy — the platform frame does populate it, and pre-1.45 Apps\n * mounted through exactly this fallback for months.)\n */\nfunction resolveParentOrigin(explicit?: string): string | null {\n if (explicit) return explicit\n const injected = readRuntimeGlobal().platformOrigin\n if (injected) return injected\n if (typeof document === 'undefined') return null\n try {\n return document.referrer ? new URL(document.referrer).origin : null\n } catch {\n return null\n }\n}\n\n/**\n * Complete the handshake.\n *\n * Rejects rather than hanging if the host never answers: a frame that waits\n * forever looks identical to a slow network, and an author debugging a broken\n * mount deserves a real error.\n */\nexport function connectToHost(options: ConnectOptions = {}): Promise<BridgeSession> {\n const parentOrigin = resolveParentOrigin(options.parentOrigin)\n const timeoutMs = options.timeoutMs ?? 10_000\n\n if (typeof window === 'undefined' || window.parent === window) {\n return Promise.reject(\n new Error('Not running inside a Frontera host frame — connectToHost() needs a parent window.'),\n )\n }\n if (!parentOrigin) {\n return Promise.reject(\n new Error('Could not determine the host origin; pass parentOrigin explicitly.'),\n )\n }\n\n const stateHandlers = new Set<(s: Record<string, unknown>) => void>()\n const tokenHandlers = new Set<(t: string) => void>()\n const themeHandlers = new Set<(t: HostTheme) => void>()\n const activationHandlers = new Set<(active: boolean) => void>()\n let active = true\n\n return new Promise<BridgeSession>((resolve, reject) => {\n let settled = false\n\n const send = (message: AppMessage) => window.parent.postMessage(message, parentOrigin)\n\n const onMessage = (event: MessageEvent) => {\n if (event.origin !== parentOrigin || event.source !== window.parent) return\n if (!isHostMessage(event.data)) return\n const message = event.data\n\n switch (message.type) {\n case 'frontera:init':\n if (settled) return\n settled = true\n clearTimeout(timer)\n active = message.active ?? active\n resolve({\n init: message,\n send,\n onState(handler) {\n stateHandlers.add(handler)\n return () => stateHandlers.delete(handler)\n },\n onToken(handler) {\n tokenHandlers.add(handler)\n return () => tokenHandlers.delete(handler)\n },\n onTheme(handler) {\n themeHandlers.add(handler)\n return () => themeHandlers.delete(handler)\n },\n onActivation(handler) {\n activationHandlers.add(handler)\n handler(active)\n return () => activationHandlers.delete(handler)\n },\n dispose() {\n window.removeEventListener('message', onMessage)\n stateHandlers.clear()\n tokenHandlers.clear()\n themeHandlers.clear()\n activationHandlers.clear()\n },\n })\n break\n case 'frontera:state':\n for (const handler of stateHandlers) handler(message.state)\n break\n case 'frontera:token':\n for (const handler of tokenHandlers) handler(message.token)\n break\n // The host pushes this on every platform theme change — a system\n // dark-mode switch, a surface that forces its own scheme. Without a\n // handler the message was received and dropped, so an app followed the\n // palette it was mounted with and then never again.\n case 'frontera:theme':\n for (const handler of themeHandlers) {\n handler({ tokens: message.tokens, colorScheme: message.colorScheme })\n }\n break\n case 'frontera:activation':\n active = message.active\n for (const handler of activationHandlers) handler(message.active)\n break\n default:\n break\n }\n }\n\n const timer = setTimeout(() => {\n if (settled) return\n settled = true\n window.removeEventListener('message', onMessage)\n reject(new Error(`Frontera host did not respond within ${timeoutMs}ms`))\n }, timeoutMs)\n\n window.addEventListener('message', onMessage)\n send({ type: 'frontera:ready' })\n })\n}\n",
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
"frontera/core/react.ts": "export {\n FronteraAppProvider,\n useFronteraApp,\n useViewer,\n type FronteraAppProviderProps,\n type FronteraAppValue,\n type FronteraProvider,\n} from './create-frontera-app'\nexport type { FronteraAppMode } from './app-session'\nexport type { FronteraViewer } from './bridge-protocol'\nexport {\n AppUserSignInError,\n exchangeCustomerToken,\n isAccessRefusal,\n type AccessRefusal,\n type AccessRefusalReason,\n type AppUserSummary,\n type ExternalAppSessionConfig,\n} from './external-session'\n",
|
|
16
16
|
"frontera/core/app-session.ts": "import type { BridgeSession } from './bridge-client'\nimport { isHostMessage, type BridgeInit } from './bridge-protocol'\nimport type { FronteraRuntimeGlobal } from './config'\n\nexport type FronteraAppMode = 'embedded' | 'standalone' | 'local'\n\n/**\n * Which transport this App should use.\n *\n * `framed` alone decides embedding — it is the only fact that answers \"is there\n * a parent to talk to\". `platformOrigin` is an INPUT to that conversation, not\n * evidence of it: `connectToHost` resolves the origin itself and falls back to\n * `document.referrer` when the host injected nothing.\n *\n * Requiring `platformOrigin` here made a framed App fall through to `null` and\n * report \"not configured\" — while the deployment was fine and only its\n * `CORS_ORIGIN` was `*`, which is the default. The App then hung before issuing\n * a single request, so nothing in the browser named the cause.\n */\nexport function detectAppMode(\n runtime: FronteraRuntimeGlobal,\n framed: boolean,\n): FronteraAppMode | null {\n if (framed) return 'embedded'\n if (runtime.sessionEndpoint) return 'standalone'\n if (runtime.devSessionEndpoint) return 'local'\n return null\n}\n\ninterface SessionResponse {\n init: BridgeInit\n expiresAt: number\n}\n\nexport interface EndpointSessionOptions {\n fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>\n now?: () => number\n setTimer?: (callback: () => void, delay: number) => unknown\n clearTimer?: (timer: unknown) => void\n}\n\nfunction parseExpiry(value: unknown): number {\n const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Date.parse(value) : Number.NaN\n if (!Number.isFinite(parsed)) throw new Error('Frontera session response has an invalid expiresAt')\n return parsed\n}\n\nfunction parseSessionResponse(value: unknown): SessionResponse {\n const envelope = value as { data?: unknown } | null\n const raw = (envelope && typeof envelope === 'object' && 'data' in envelope ? envelope.data : value) as\n | Record<string, unknown>\n | null\n if (!raw || typeof raw !== 'object' || !isHostMessage(raw.init) || raw.init.type !== 'frontera:init') {\n throw new Error('Frontera session response is malformed')\n }\n return { init: raw.init, expiresAt: parseExpiry(raw.expiresAt) }\n}\n\nasync function fetchSession(\n endpoint: string,\n fetchImpl: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,\n): Promise<SessionResponse> {\n const response = await fetchImpl(endpoint, {\n credentials: 'include',\n headers: { accept: 'application/json' },\n })\n if (!response.ok) throw new Error(`Frontera session request failed with ${response.status}`)\n return parseSessionResponse(await response.json())\n}\n\n/** Build a bridge-compatible session from standalone or local HTTP auth. */\nexport async function connectToSessionEndpoint(\n endpoint: string,\n options: EndpointSessionOptions = {},\n): Promise<BridgeSession> {\n const fetchImpl = options.fetchImpl ?? fetch\n const now = options.now ?? Date.now\n const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay))\n const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle as ReturnType<typeof setTimeout>))\n const first = await fetchSession(endpoint, fetchImpl)\n const tokenHandlers = new Set<(token: string) => void>()\n let disposed = false\n let timer: unknown\n\n const schedule = (expiresAt: number) => {\n if (disposed) return\n const delay = Math.max(0, Math.floor((expiresAt - now()) * 0.8))\n timer = setTimer(() => {\n void fetchSession(endpoint, fetchImpl)\n .then((next) => {\n if (disposed) return\n for (const handler of tokenHandlers) handler(next.init.token)\n schedule(next.expiresAt)\n })\n // A revoked session must not keep emitting credentials. Requests made\n // with the previous short-lived token naturally stop at its expiry.\n .catch(() => {})\n }, delay)\n }\n schedule(first.expiresAt)\n\n return {\n init: first.init,\n send() {},\n onState() {\n return () => {}\n },\n onToken(handler) {\n tokenHandlers.add(handler)\n return () => tokenHandlers.delete(handler)\n },\n onTheme() {\n return () => {}\n },\n onActivation() {\n return () => {}\n },\n dispose() {\n disposed = true\n if (timer !== undefined) clearTimer(timer)\n tokenHandlers.clear()\n },\n }\n}\n",
|
|
17
17
|
"frontera/blueprint/LICENSE": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2026 Sebati\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n",
|
|
18
|
-
"frontera/blueprint/action-types.ts": "/**\n * Types for the governed write plane.\n *\n * Reads and writes are deliberately separate surfaces. A read is answered from\n * a catalog snapshot; a write is a REQUEST against a durable ledger that may be\n * approved by someone else, dispatched by a background worker minutes later,\n * and reconciled after that. Modelling both as \"call the server\" would hide the\n * one fact a UI has to show: submitting is not the same as done.\n */\n\n/** Where a Request has got to. Only `succeeded` and the failures are terminal. */\nexport type ActionRequestLifecycle =\n | 'ready'\n | 'awaiting_approval'\n | 'dispatching'\n | 'finalizing'\n | 'succeeded'\n | 'failed'\n | 'cancelled'\n | 'rejected'\n | 'awaiting_resolution'\n\n/**\n * What is known about the effect on the target — NOT whether the request is\n * finished. `outcome_unknown` is the honest state after a dispatch whose\n * outcome could not be established, and a UI must not render it as failure:\n * the write may well have landed.\n */\nexport type ActionEffectCertainty =\n | 'not_attempted'\n | 'confirmed_applied'\n | 'confirmed_not_applied'\n | 'outcome_unknown'\n\nconst TERMINAL: ReadonlySet<ActionRequestLifecycle> = new Set([\n 'succeeded', 'failed', 'cancelled', 'rejected',\n])\n\nexport function isTerminalLifecycle(lifecycle: ActionRequestLifecycle): boolean {\n return TERMINAL.has(lifecycle)\n}\n\n/**\n * What to tell the person who pressed the button.\n *\n * Four answers, and they come from the CERTAINTY, not the lifecycle — which is\n * the distinction every app gets wrong, because \"succeeded\" reads like the\n * finish line and is not.\n *\n * A request reaches `confirmed_applied` the moment the write commits, and then\n * spends a while in `finalizing` while the platform verifies its own promise:\n * it re-reads the target, checks the properties the Action declared it would\n * change, evaluates the postconditions. That verification NEVER undoes the\n * write — its worst outcome is `awaiting_resolution`, which still carries\n * `confirmed_applied` and means a human should look at why the proof was\n * inconclusive. So waiting for `succeeded` before telling someone their ticket\n * exists leaves them staring at a spinner over a ticket that already exists.\n *\n * `uncertain` is the one that must not be collapsed into either neighbour. It\n * means a dispatch was attempted and the outcome could not be established —\n * the connection died mid-commit, and the write may well have landed. Rendering\n * it as failure invites a duplicate; rendering it as success invites a lie. Say\n * it is being checked; the platform reconciles it against the target and the\n * answer arrives on its own.\n */\nexport type ActionEffect = 'pending' | 'applied' | 'refused' | 'uncertain'\n\nexport function actionEffectOf(\n request: Pick<ActionRequest, 'lifecycle' | 'effectCertainty'> | null | undefined,\n): ActionEffect {\n if (!request) return 'pending'\n switch (request.effectCertainty) {\n case 'confirmed_applied': return 'applied'\n case 'confirmed_not_applied': return 'refused'\n case 'outcome_unknown': return 'uncertain'\n default: break\n }\n // No certainty reported. A terminal lifecycle still answers the question —\n // a rejected or cancelled Request never reached the target at all — while\n // anything else is genuinely still in flight.\n return request.lifecycle && isTerminalLifecycle(request.lifecycle)\n && request.lifecycle !== 'succeeded'\n ? 'refused'\n : 'pending'\n}\n\n/**\n * The record version to send with an edit, read off an object instance.\n *\n * The version arrives as `_meta.recordVersion`, and only from the INSTANCE\n * route — a list query's rows do not carry it, and only an editable type has\n * one at all. Reaching into `_meta` by hand is how a caller ends up sending\n * `undefined`, which does not fail: the compare-and-set is simply skipped, and\n * two people overwrite each other with no refusal and no evidence.\n *\n * So a control that edits a row from a table fetches the instance first:\n *\n * ```tsx\n * const instance = useObjectInstance('SupportTicket', selectedId)\n * submit.mutate({ …, expectedVersion: recordVersionOf(instance.data) })\n * ```\n *\n * Returns `undefined` for a type with no overlay, which is correct — there is\n * no version to assert, and the write path does not expect one.\n */\nexport function recordVersionOf(instance: unknown): number | undefined {\n if (!instance || typeof instance !== 'object') return undefined\n const meta = (instance as { _meta?: unknown })._meta\n if (!meta || typeof meta !== 'object') return undefined\n const version = (meta as { recordVersion?: unknown }).recordVersion\n return typeof version === 'number' ? version : undefined\n}\n\nexport interface ActionApprovalPolicy {\n mode: 'none' | 'required'\n threshold?: number\n separationOfDuties?: boolean\n}\n\n/**\n * One Action this caller may invoke.\n *\n * Discovery returns ONLY what the caller is authorized for. An Action absent\n * from this list may be unpublished, undeployed, or simply not permitted to\n * this user — the three are indistinguishable here, by design.\n */\nexport interface ActionDescriptor {\n actionDefinitionId: string\n apiName: string\n displayName: string\n description: string\n contractDigest: string\n activeReleaseId: string\n availability: string\n subject: { objectTypeId: string; mode: 'existing' | 'create' }\n approval: ActionApprovalPolicy\n /** JSON Schema for the whole invocation envelope, not just the inputs. */\n inputSchema: Record<string, unknown>\n}\n\n/**\n * What raised a request, in the only terms a reader can act on.\n *\n * `person` is someone acting for themselves. `agent` is an Agent acting under\n * a person's authority — `agentName` says which Agent, `userId` says whose\n * authority. `automation` is an unattended caller that is not an Agent, so\n * there is no Agent name to show; `userId` still names the person it ran for.\n *\n * The split matters when you build an approval screen. \"Raised by Alia\" and\n * \"raised by the Collections Agent, under Alia's authority\" are different\n * things to approve, and flattening them into one line hides the one an\n * approver most needs to see.\n *\n * `applicationId` names the App a request was raised through: by the person\n * directly, or by a Function they started from that App. It does not change\n * `kind`. Absent from a platform that predates the field.\n *\n * `appUserId` and `appUserName` are set when the person is an App user: someone\n * signed in to an externally hosted App, who is not a workspace member. `kind`\n * is then `person`, `userId` is `null`, and `applicationId` is their App. Show\n * `appUserName` when it is present; it can be `null` when the person's identity\n * provider sent no name. Both are `null` for every other request, and absent\n * from a platform that predates the fields.\n */\nexport interface ActionRequestRequester {\n kind: 'agent' | 'person' | 'automation'\n agentId: string | null\n agentName: string | null\n userId: string | null\n applicationId?: string | null\n appUserId?: string | null\n appUserName?: string | null\n}\n\n/** How to read one submitted parameter value: its label, declared kind and whether it is withheld. */\nexport interface ActionRequestInputField {\n /** The parameter's own name, for a label. */\n displayName: string\n /** The declared kind (`string`, `number`, `geopoint`, …), or `null` when the parameter is no longer published. */\n kind: string | null\n /** The value is withheld from this reader; `input` holds `\"[redacted]\"` in its place. */\n hidden: boolean\n}\n\n/**\n * What a request is asking for, ready to render.\n *\n * A queue row on its own says a request exists and what state it is in. This\n * says what would happen if it were approved: which Action, with which\n * parameter values, for what stated reason, raised by whom. Read-only — none\n * of it is sent back on a decision, so editing it locally changes nothing\n * about what the platform would run.\n *\n * `input` is keyed by the Action's parameter API names, the same names\n * `submit` takes — so a form that collects a value and a screen that reviews\n * it agree on what to call it.\n *\n * A parameter the Action declares as confidential or restricted arrives as the\n * placeholder string `'[redacted]'` with `inputFields[key].hidden` true. Decide\n * by `hidden`, never by matching the text: a submitted string can read\n * `'[redacted]'` too. Show it as withheld rather than as an empty value: the\n * parameter WAS supplied, and the approver is simply not the audience for it.\n */\nexport interface ActionRequestIntent {\n actionApiName: string\n actionDisplayName: string\n /** Submitted values, keyed by parameter API name. */\n input: Record<string, unknown>\n /**\n * How to read each `input` entry, under the same key. Format a value by its\n * declared `kind`, never by its shape (a `json` value can look like a\n * location); `hidden` means the value is withheld from this reader. Absent\n * on replies from a platform release that predates it.\n */\n inputFields?: Record<string, ActionRequestInputField>\n reason: string | null\n correlationId: string | null\n requester: ActionRequestRequester\n}\n\nexport interface ActionRequest {\n id: string\n actionDefinitionId: string\n apiName?: string\n lifecycle: ActionRequestLifecycle\n effectCertainty?: ActionEffectCertainty\n createdAt?: string\n updatedAt?: string\n /**\n * What the request is asking for. Present on reads of a single request and\n * on listed requests; absent on the reply to a write, where the caller\n * already holds what it just sent.\n *\n * `null` is a real state, not a failure to handle with a retry or an error\n * banner: the platform can still say what state the request is in but cannot\n * honestly say what it asks for — usually because the Action's published\n * definition has moved on and there is no longer a name or a parameter list\n * to show it against. Render the row with its lifecycle, say the details are\n * unavailable, and keep the decision controls governed as they already are.\n * Never fall back to a guess.\n */\n intent?: ActionRequestIntent | null\n}\n\n/**\n * What a caller supplies to invoke an Action.\n *\n * Deliberately flatter than the wire envelope. The service takes a subject\n * reference, a separate string `expectedSubjectVersion`, an `input` map and a\n * `reason`; and an Action with compare-and-set ALSO takes a numeric version as\n * an ordinary input. Two version fields, one string and one number, meaning\n * related but different things, is the kind of contract a hand-written caller\n * gets wrong once and then debugs for an hour. `submit` assembles it.\n */\nexport interface SubmitActionInput {\n /** Primary key of the object being changed. Omit only for `mode: 'create'`. */\n objectId?: string\n /** Parameter values, keyed by the Action's parameter API names. */\n input: Record<string, unknown>\n /**\n * The version the caller believes it read. Sent as the subject version AND,\n * when the Action declares a compare-and-set parameter, as that parameter —\n * so a stale write is refused rather than clobbering a concurrent one.\n */\n expectedVersion?: number | string\n /** Required when the Action declares `reason: 'required'`. */\n reason?: string\n correlationId?: string\n /**\n * Reused across retries of the SAME intended effect. Omit and the hook mints\n * one per user intent, which is almost always what you want: a retried\n * network call must not become a second escalation.\n */\n idempotencyKey?: string\n}\n",
|
|
19
|
-
"frontera/blueprint/action-client.ts": "import type { FronteraClient } from '@frontera-sdk/core/client'\nimport { FronteraError } from '@frontera-sdk/core/errors'\nimport type {\n ActionDescriptor,\n ActionRequest,\n ActionRequestLifecycle,\n SubmitActionInput,\n} from './action-types'\n\nconst BASE = '/v1/blueprint/governed-actions'\n\n/** The token of a person signed in to an externally hosted App. */\nconst APP_USER_TOKEN_PREFIX = 'sk-au-'\n\n/**\n * The governed write plane.\n *\n * Authorized against the PERSON, never the app. A hosted app is handed a token\n * carrying the signed-in user's id, and every call is checked against that\n * user's organization role, their workspace membership, and the workspace's\n * grant on the object type. So the same page can offer a button to one\n * colleague and not another, and neither the app nor its author decides which.\n *\n * A workspace key cannot invoke at all — its principal belongs to no\n * organization member — which is why a scaffolded dev host, holding one, will\n * read fine and refuse every write. See `README` on `frontera app init`.\n *\n * A person signed in to an EXTERNALLY HOSTED App is checked against the App's\n * roles instead: `discover` lists the Actions their roles hold, and `request`\n * reads a request they raised. `submit` is accepted only for records that a\n * role listing that Action lets the person read. A role that only reads never\n * widens what they can change. A referenced record need only be readable.\n * `requests`, `decide` and `cancel` are for Frontera members and are refused\n * here for such a person, before anything is sent.\n */\nexport class ActionClient {\n constructor(private readonly client: FronteraClient) {}\n\n /**\n * The refusal for a call only a Frontera member can make, when the session\n * belongs to a person signed in to an externally hosted App.\n *\n * The service answers such a call as if no credential had been sent. Left to\n * that, the App would see \"Authentication required\" and the session would\n * sign the person in again over a call that can never succeed. Said here\n * instead, by name.\n */\n private membersOnly(what: string, instead: string): FronteraError | null {\n const credential = this.client.config.credential\n if (credential.kind !== 'token' || !credential.token.startsWith(APP_USER_TOKEN_PREFIX)) return null\n return new FronteraError(\n `${what} is not available to people signed in to an externally hosted App. ${instead}`,\n { code: 'FORBIDDEN', status: 403 },\n )\n }\n\n /**\n * Actions this user may invoke, here, now.\n *\n * An empty list is ambiguous ON PURPOSE — unpublished, undeployed and\n * unpermitted are indistinguishable to a caller, so a probe cannot map what\n * exists. That is right for security and hostile to debugging, so treat an\n * unexpected empty list as a question about the CALLER's permissions first.\n */\n discover(): Promise<ActionDescriptor[]> {\n return this.client.request<ActionDescriptor[]>(`${BASE}/discovery`)\n }\n\n /**\n * Submit one Action. Returns as soon as the Request is recorded — NOT when\n * the effect has landed.\n *\n * The write is durable from this point: it survives a closed tab, a restarted\n * server, and a worker that is not running yet. What it does not do is finish\n * synchronously, so a UI that renders success here is lying. Poll the Request\n * (`useActionRequest`) and show the lifecycle.\n */\n async submit(\n action: ActionDescriptor,\n input: SubmitActionInput,\n ): Promise<ActionRequest> {\n const idempotencyKey = input.idempotencyKey ?? mintIdempotencyKey()\n return this.client.request<ActionRequest>(\n `${BASE}/actions/${encodeURIComponent(action.apiName)}/requests`,\n {\n method: 'POST',\n headers: { 'idempotency-key': idempotencyKey },\n // Wrapped, and the wrapper is EXACT: the route accepts a body whose\n // keys are precisely `['invocation']` and refuses anything else with\n // \"Governed Action HTTP command body is invalid.\" — a message that\n // names the body rather than the field, so sending the envelope at the\n // top level reads like a malformed invocation instead of a missing\n // wrapper.\n body: { invocation: buildInvocation(action, input) },\n },\n )\n }\n\n requests(lifecycle?: readonly ActionRequestLifecycle[]): Promise<ActionRequest[]> {\n const refused = this.membersOnly(\n 'Listing Action requests',\n 'Keep the id `submit` returns and read that request with `request(id)` or `useActionRequest`.',\n )\n if (refused) return Promise.reject(refused)\n return this.client.request<ActionRequest[]>(`${BASE}/requests`, {\n query: lifecycle?.length ? { lifecycle: lifecycle.join(',') } : undefined,\n })\n }\n\n request(requestId: string): Promise<ActionRequest> {\n return this.client.request<ActionRequest>(`${BASE}/requests/${encodeURIComponent(requestId)}`)\n }\n\n /**\n * Approve or reject. Separate from `submit` because it is a different act by\n * a different person — an Action with separation of duties refuses a decision\n * from whoever submitted it.\n */\n decide(requestId: string, decision: 'approve' | 'reject', reason: string): Promise<ActionRequest> {\n const refused = this.membersOnly(\n 'Approving or rejecting an Action request',\n 'A Frontera member with approval permission decides it in Frontera.',\n )\n if (refused) return Promise.reject(refused)\n return this.client.request<ActionRequest>(\n `${BASE}/requests/${encodeURIComponent(requestId)}/approvals`,\n { method: 'POST', body: { decision, reason } },\n )\n }\n\n /**\n * Cancel takes NO body. It reads the request and the caller from the URL and\n * the credential; a `reason` sent here is refused as an invalid command body\n * rather than ignored, because the route accepts an exact key set.\n */\n cancel(requestId: string): Promise<ActionRequest> {\n const refused = this.membersOnly(\n 'Cancelling an Action request',\n 'A Frontera member with permission to cancel requests can cancel it in Frontera.',\n )\n if (refused) return Promise.reject(refused)\n return this.client.request<ActionRequest>(\n `${BASE}/requests/${encodeURIComponent(requestId)}/cancel`,\n { method: 'POST' },\n )\n }\n}\n\n/**\n * The wire envelope, assembled from the flat input a caller actually has.\n *\n * Exported for the tests: this is the part with a trap in it, and the trap is\n * silent — a wrong shape comes back as \"Action invocation is invalid\" with no\n * field named.\n */\nexport function buildInvocation(\n action: ActionDescriptor,\n input: SubmitActionInput,\n): Record<string, unknown> {\n const invocation: Record<string, unknown> = { input: { ...input.input } }\n\n if (action.subject.mode === 'existing') {\n if (!input.objectId) {\n throw new Error(\n `\"${action.apiName}\" changes an existing ${action.subject.objectTypeId}, so it needs an objectId.`,\n )\n }\n invocation.subjectRef = {\n objectTypeId: action.subject.objectTypeId,\n objectId: input.objectId,\n }\n if (input.expectedVersion !== undefined) {\n // A STRING here, deliberately: the subject version is an opaque token,\n // while the compare-and-set parameter below is the numeric record\n // version. Same number, two types, two meanings.\n invocation.expectedSubjectVersion = String(input.expectedVersion)\n }\n } else if (input.objectId) {\n throw new Error(`\"${action.apiName}\" creates an object, so it takes no objectId.`)\n }\n\n // Fed to the Action's own compare-and-set parameter when it declares one,\n // and only then — an Action without it would refuse the unknown key.\n const casParameter = compareAndSetParameter(action)\n if (casParameter && input.expectedVersion !== undefined) {\n const parameters = invocation.input as Record<string, unknown>\n if (!(casParameter in parameters)) parameters[casParameter] = Number(input.expectedVersion)\n }\n\n if (input.reason !== undefined) invocation.reason = input.reason\n if (input.correlationId !== undefined) invocation.correlationId = input.correlationId\n return invocation\n}\n\n/**\n * The parameter carrying the record version, read off the published schema\n * rather than assumed by name.\n */\nfunction compareAndSetParameter(action: ActionDescriptor): string | null {\n const input = (action.inputSchema as { properties?: Record<string, unknown> } | undefined)\n ?.properties?.input as { properties?: Record<string, unknown> } | undefined\n const properties = input?.properties\n if (!properties) return null\n return 'expectedVersion' in properties ? 'expectedVersion' : null\n}\n\n/**\n * One key per user intent.\n *\n * The service dedupes by this: the same key with a different invocation is\n * REFUSED, and the same key with the same invocation returns the original\n * Request rather than acting twice. So it must be stable across retries of one\n * intent and different between two intents — which is exactly the lifetime of\n * a single `submit` call, not of a component or a session.\n */\nfunction mintIdempotencyKey(): string {\n const random = globalThis.crypto?.randomUUID?.()\n ?? Math.random().toString(36).slice(2).padEnd(22, '0')\n return `frontera-app-${random}`\n}\n",
|
|
18
|
+
"frontera/blueprint/action-types.ts": "/**\n * Types for the governed write plane.\n *\n * Reads and writes are deliberately separate surfaces. A read is answered from\n * a catalog snapshot; a write is a REQUEST against a durable ledger that may be\n * approved by someone else, dispatched by a background worker minutes later,\n * and reconciled after that. Modelling both as \"call the server\" would hide the\n * one fact a UI has to show: submitting is not the same as done.\n */\n\n/** Where a Request has got to. Only `succeeded` and the failures are terminal. */\nexport type ActionRequestLifecycle =\n | 'ready'\n | 'awaiting_approval'\n | 'dispatching'\n | 'finalizing'\n | 'succeeded'\n | 'failed'\n | 'cancelled'\n | 'rejected'\n | 'awaiting_resolution'\n\n/**\n * What is known about the effect on the target — NOT whether the request is\n * finished. `outcome_unknown` is the honest state after a dispatch whose\n * outcome could not be established, and a UI must not render it as failure:\n * the write may well have landed.\n */\nexport type ActionEffectCertainty =\n | 'not_attempted'\n | 'confirmed_applied'\n | 'confirmed_not_applied'\n | 'outcome_unknown'\n\nconst TERMINAL: ReadonlySet<ActionRequestLifecycle> = new Set([\n 'succeeded', 'failed', 'cancelled', 'rejected',\n])\n\nexport function isTerminalLifecycle(lifecycle: ActionRequestLifecycle): boolean {\n return TERMINAL.has(lifecycle)\n}\n\n/**\n * What to tell the person who pressed the button.\n *\n * Four answers, and they come from the CERTAINTY, not the lifecycle — which is\n * the distinction every app gets wrong, because \"succeeded\" reads like the\n * finish line and is not.\n *\n * A request reaches `confirmed_applied` the moment the write commits, and then\n * spends a while in `finalizing` while the platform verifies its own promise:\n * it re-reads the target, checks the properties the Action declared it would\n * change, evaluates the postconditions. That verification NEVER undoes the\n * write — its worst outcome is `awaiting_resolution`, which still carries\n * `confirmed_applied` and means a human should look at why the proof was\n * inconclusive. So waiting for `succeeded` before telling someone their ticket\n * exists leaves them staring at a spinner over a ticket that already exists.\n *\n * `uncertain` is the one that must not be collapsed into either neighbour. It\n * means a dispatch was attempted and the outcome could not be established —\n * the connection died mid-commit, and the write may well have landed. Rendering\n * it as failure invites a duplicate; rendering it as success invites a lie. Say\n * it is being checked; the platform reconciles it against the target and the\n * answer arrives on its own.\n */\nexport type ActionEffect = 'pending' | 'applied' | 'refused' | 'uncertain'\n\nexport function actionEffectOf(\n request: Pick<ActionRequest, 'lifecycle' | 'effectCertainty'> | null | undefined,\n): ActionEffect {\n if (!request) return 'pending'\n switch (request.effectCertainty) {\n case 'confirmed_applied': return 'applied'\n case 'confirmed_not_applied': return 'refused'\n case 'outcome_unknown': return 'uncertain'\n default: break\n }\n // No certainty reported. A terminal lifecycle still answers the question —\n // a rejected or cancelled Request never reached the target at all — while\n // anything else is genuinely still in flight.\n return request.lifecycle && isTerminalLifecycle(request.lifecycle)\n && request.lifecycle !== 'succeeded'\n ? 'refused'\n : 'pending'\n}\n\n/**\n * The record version to send with an edit, read off an object instance.\n *\n * The version arrives as `_meta.recordVersion`, and only from the INSTANCE\n * route — a list query's rows do not carry it, and only an editable type has\n * one at all. Reaching into `_meta` by hand is how a caller ends up sending\n * `undefined`, which does not fail: the compare-and-set is simply skipped, and\n * two people overwrite each other with no refusal and no evidence.\n *\n * So a control that edits a row from a table fetches the instance first:\n *\n * ```tsx\n * const instance = useObjectInstance('SupportTicket', selectedId)\n * submit.mutate({ …, expectedVersion: recordVersionOf(instance.data) })\n * ```\n *\n * Returns `undefined` for a type with no overlay, which is correct — there is\n * no version to assert, and the write path does not expect one.\n */\nexport function recordVersionOf(instance: unknown): number | undefined {\n if (!instance || typeof instance !== 'object') return undefined\n const meta = (instance as { _meta?: unknown })._meta\n if (!meta || typeof meta !== 'object') return undefined\n const version = (meta as { recordVersion?: unknown }).recordVersion\n return typeof version === 'number' ? version : undefined\n}\n\n/**\n * What a submission of this Action waits for.\n *\n * Any mode but `'none'` means the request may wait for approval. Test\n * `mode !== 'none'`, not `mode === 'required'`: a mode you did not test for\n * would otherwise read as \"no approval\". Under `'chain'` the request passes\n * its steps one after another, and a step whose condition does not hold for\n * it is skipped; read the request's `approval` to see where it stands.\n */\nexport interface ActionApprovalPolicy {\n mode: 'none' | 'required' | 'conditional' | 'chain'\n threshold?: number\n separationOfDuties?: boolean | 'strict'\n /** Under `'chain'`: how long a request may wait for its steps, in hours or days, such as `24h` or `3d`. */\n expiresAfter?: string\n /** Under `'chain'`: the steps, in order. Absent for every other mode. */\n stages?: ActionApprovalChainStep[]\n}\n\n/**\n * One step of an Action's approval chain, as discovery describes it.\n *\n * `conditional` says whether the step applies only to some requests. The\n * condition itself is not told: read a request's `approval` to see which\n * steps applied to it.\n *\n * A step whose `approver` is an App role is decided for requests raised\n * through an App that has that role. When such a step is not `conditional`,\n * every request for the Action must be raised through such an App: one raised\n * with no App is refused when it is submitted.\n */\nexport interface ActionApprovalChainStep {\n key: string\n /** Workspace members who may approve Action Requests, or the people who hold the named App role. */\n approver: 'members' | { role: string }\n /** How many approvals the step needs. */\n quorum: number\n conditional: boolean\n}\n\n/** Who decides a step of a request's approval chain: workspace members who may approve, or the people who hold an App role. */\nexport type ActionApproverRule = { members: true } | { role: string; scope: 'subject' }\n\n/**\n * One Action this caller may invoke.\n *\n * Discovery returns ONLY what the caller is authorized for. An Action absent\n * from this list may be unpublished, undeployed, or simply not permitted to\n * this user — the three are indistinguishable here, by design.\n */\nexport interface ActionDescriptor {\n actionDefinitionId: string\n apiName: string\n displayName: string\n description: string\n contractDigest: string\n activeReleaseId: string\n availability: string\n subject: { objectTypeId: string; mode: 'existing' | 'create' }\n approval: ActionApprovalPolicy\n /** JSON Schema for the whole invocation envelope, not just the inputs. */\n inputSchema: Record<string, unknown>\n}\n\n/**\n * What raised a request, in the only terms a reader can act on.\n *\n * `person` is someone acting for themselves. `agent` is an Agent acting under\n * a person's authority — `agentName` says which Agent, `userId` says whose\n * authority. `automation` is an unattended caller that is not an Agent, so\n * there is no Agent name to show; `userId` still names the person it ran for.\n *\n * The split matters when you build an approval screen. \"Raised by Alia\" and\n * \"raised by the Collections Agent, under Alia's authority\" are different\n * things to approve, and flattening them into one line hides the one an\n * approver most needs to see.\n *\n * `applicationId` names the App a request was raised through: by the person\n * directly, or by a Function they started from that App. It does not change\n * `kind`. Absent from a platform that predates the field.\n *\n * `appUserId` and `appUserName` are set when the person is an App user: someone\n * signed in to an externally hosted App, who is not a workspace member. `kind`\n * is then `person`, `userId` is `null`, and `applicationId` is their App. Show\n * `appUserName` when it is present; it can be `null` when the person's identity\n * provider sent no name. Both are `null` for every other request, and absent\n * from a platform that predates the fields.\n *\n * A reader who is signed in to an externally hosted App is given names and no\n * identifier of anyone else: on a request someone else raised, `userId`,\n * `agentId`, `appUserId` and `applicationId` are all `null`. On a request they\n * raised themselves, `appUserId` and `applicationId` are their own.\n */\nexport interface ActionRequestRequester {\n kind: 'agent' | 'person' | 'automation'\n agentId: string | null\n agentName: string | null\n userId: string | null\n applicationId?: string | null\n appUserId?: string | null\n appUserName?: string | null\n}\n\n/** How to read one submitted parameter value: its label, declared kind and whether it is withheld. */\nexport interface ActionRequestInputField {\n /** The parameter's own name, for a label. */\n displayName: string\n /** The declared kind (`string`, `number`, `geopoint`, …), or `null` when the parameter is no longer published. */\n kind: string | null\n /** The value is withheld from this reader; `input` holds `\"[redacted]\"` in its place. */\n hidden: boolean\n}\n\n/**\n * What a request is asking for, ready to render.\n *\n * A queue row on its own says a request exists and what state it is in. This\n * says what would happen if it were approved: which Action, with which\n * parameter values, for what stated reason, raised by whom. Read-only — none\n * of it is sent back on a decision, so editing it locally changes nothing\n * about what the platform would run.\n *\n * `input` is keyed by the Action's parameter API names, the same names\n * `submit` takes — so a form that collects a value and a screen that reviews\n * it agree on what to call it.\n *\n * A parameter the Action declares as confidential or restricted arrives as the\n * placeholder string `'[redacted]'` with `inputFields[key].hidden` true. Decide\n * by `hidden`, never by matching the text: a submitted string can read\n * `'[redacted]'` too. Show it as withheld rather than as an empty value: the\n * parameter WAS supplied, and the approver is simply not the audience for it.\n */\nexport interface ActionRequestIntent {\n actionApiName: string\n actionDisplayName: string\n /** Submitted values, keyed by parameter API name. */\n input: Record<string, unknown>\n /**\n * How to read each `input` entry, under the same key. Format a value by its\n * declared `kind`, never by its shape (a `json` value can look like a\n * location); `hidden` means the value is withheld from this reader. Absent\n * on replies from a platform release that predates it.\n */\n inputFields?: Record<string, ActionRequestInputField>\n reason: string | null\n correlationId: string | null\n requester: ActionRequestRequester\n}\n\nexport interface ActionRequest {\n id: string\n actionDefinitionId: string\n apiName?: string\n lifecycle: ActionRequestLifecycle\n effectCertainty?: ActionEffectCertainty\n createdAt?: string\n updatedAt?: string\n /**\n * What the request is asking for. Present on reads of a single request and\n * on listed requests; absent on the reply to a write, where the caller\n * already holds what it just sent.\n *\n * `null` is a real state, not a failure to handle with a retry or an error\n * banner: the platform can still say what state the request is in but cannot\n * honestly say what it asks for — usually because the Action's published\n * definition has moved on and there is no longer a name or a parameter list\n * to show it against. Render the row with its lifecycle, say the details are\n * unavailable, and keep the decision controls governed as they already are.\n * Never fall back to a guess.\n */\n intent?: ActionRequestIntent | null\n /**\n * Where the request stands in its Action's approval chain. Present on reads\n * of a request whose Action uses approval mode `chain`; `null` for every\n * other request, and absent from a platform that predates the field.\n */\n approval?: ActionRequestApproval | null\n}\n\n/** One step of a request's approval chain. */\nexport interface ActionRequestApprovalStep {\n /** The step's key, as the Action's approval chain names it. */\n key: string\n /**\n * `open` is the step waiting for decisions now; `pending` waits for the\n * steps before it. `skipped` never applied to this request: its condition\n * did not hold.\n */\n status: 'pending' | 'open' | 'approved' | 'skipped' | 'rejected'\n /**\n * Who decides the step: workspace members who may approve Action Requests,\n * or the people who hold the named role in the App the request was raised\n * through.\n */\n approverRule: ActionApproverRule\n /** How many approvals the step needs. */\n quorum: number\n /**\n * Who decided this step, in the order they decided, by display name.\n * `name` is `null` for someone no longer a member of the organization, and\n * for a person whose App sign-in carried no name.\n */\n decidedBy: Array<{ name: string | null }>\n /** When the step was approved or rejected; `null` while it is not settled. */\n decidedAt: string | null\n}\n\n/**\n * A request's approval chain: every step, the one waiting now, and the\n * rejection that closed the request, if one did.\n */\nexport interface ActionRequestApproval {\n stages: ActionRequestApprovalStep[]\n /** The key of the step waiting for decisions; `null` when none is. */\n currentStage: string | null\n /**\n * The rejection that closed the request. `reason` is what the approver\n * wrote; it is `null` when the stored reason could not be read.\n */\n rejection: { stage: string; reason: string | null; decidedBy: { name: string | null } } | null\n}\n\n/**\n * One page of a request list. `nextCursor` is `null` on the last page;\n * otherwise pass it back as `cursor` to read the next one.\n *\n * `truncated` is only ever true for `awaitingMe()`: the platform looks at a\n * bounded number of the most recent waiting requests for one page, and it\n * stopped there. Requests the person could decide may wait behind them, so a\n * short or empty page with no `nextCursor` is not proof that nothing else\n * waits. Tell the person the list may be incomplete, and narrow it with\n * `actionDefinitionId`.\n */\nexport interface ActionRequestPage {\n requests: ActionRequest[]\n nextCursor: string | null\n truncated: boolean\n}\n\n/** Which requests a list reads, and where in it. */\nexport interface ActionRequestListOptions {\n /** Only requests of this Action. */\n actionDefinitionId?: string\n /** Requests per page: 1 to 200. Left out, 50. */\n limit?: number\n /** `nextCursor` of the page before. */\n cursor?: string\n}\n\n/** A decision as the platform recorded it. */\nexport interface ActionApprovalDecision {\n id: string\n requestId: string\n decision: 'approve' | 'reject'\n /** The approval-chain step decided; `null` for an Action that does not use a chain. */\n stage: string | null\n decidedAt: string\n expiresAt?: string\n policyThreshold?: number\n separationOfDuties?: boolean\n humanApproverId?: string\n humanApproverRole?: string\n}\n\n/**\n * What `decide` resolves to: the request as the decision left it, with the\n * two parts the platform answers a decision with.\n *\n * It IS the request (`id`, `lifecycle` and the rest read as they do on any\n * `ActionRequest`), so code written against the earlier `ActionRequest`\n * return type keeps compiling and now reads real values. `request` is that\n * same request under the name the platform gives it, and `approvalDecision`\n * is the decision just recorded. The platform calls that part `approval`; it\n * has another name here because `approval` on a request already means where\n * the request stands in its approval chain.\n *\n * Like every reply to a write, it carries no `intent` and no chain progress:\n * read the request again for those.\n */\nexport interface ActionDecisionResult extends ActionRequest {\n request: ActionRequest\n approvalDecision: ActionApprovalDecision\n}\n\n/**\n * What a caller supplies to invoke an Action.\n *\n * Deliberately flatter than the wire envelope. The service takes a subject\n * reference, a separate string `expectedSubjectVersion`, an `input` map and a\n * `reason`; and an Action with compare-and-set ALSO takes a numeric version as\n * an ordinary input. Two version fields, one string and one number, meaning\n * related but different things, is the kind of contract a hand-written caller\n * gets wrong once and then debugs for an hour. `submit` assembles it.\n */\nexport interface SubmitActionInput {\n /** Primary key of the object being changed. Omit only for `mode: 'create'`. */\n objectId?: string\n /** Parameter values, keyed by the Action's parameter API names. */\n input: Record<string, unknown>\n /**\n * The version the caller believes it read. Sent as the subject version AND,\n * when the Action declares a compare-and-set parameter, as that parameter —\n * so a stale write is refused rather than clobbering a concurrent one.\n */\n expectedVersion?: number | string\n /** Required when the Action declares `reason: 'required'`. */\n reason?: string\n correlationId?: string\n /**\n * Reused across retries of the SAME intended effect. Omit and the hook mints\n * one per user intent, which is almost always what you want: a retried\n * network call must not become a second escalation.\n */\n idempotencyKey?: string\n}\n",
|
|
19
|
+
"frontera/blueprint/action-client.ts": "import type { FronteraClient } from '@frontera-sdk/core/client'\nimport { FronteraError } from '@frontera-sdk/core/errors'\nimport type {\n ActionApprovalDecision,\n ActionDecisionResult,\n ActionDescriptor,\n ActionRequest,\n ActionRequestLifecycle,\n ActionRequestListOptions,\n ActionRequestPage,\n SubmitActionInput,\n} from './action-types'\n\nconst BASE = '/v1/blueprint/governed-actions'\n\n/** The token of a person signed in to an externally hosted App. */\nconst APP_USER_TOKEN_PREFIX = 'sk-au-'\n\n/**\n * The governed write plane.\n *\n * Authorized against the PERSON, never the app. A hosted app is handed a token\n * carrying the signed-in user's id, and every call is checked against that\n * user's organization role, their workspace membership, and the workspace's\n * grant on the object type. So the same page can offer a button to one\n * colleague and not another, and neither the app nor its author decides which.\n *\n * A workspace key cannot invoke at all — its principal belongs to no\n * organization member — which is why a scaffolded dev host, holding one, will\n * read fine and refuse every write. See `README` on `frontera app init`.\n *\n * A person signed in to an EXTERNALLY HOSTED App is checked against the App's\n * roles instead: `discover` lists the Actions their roles hold, and `request`\n * reads a request they raised. `submit` is accepted only for records that a\n * role listing that Action lets the person read. A role that only reads never\n * widens what they can change. A referenced record need only be readable.\n *\n * Such a person also decides the approval steps their App role is given:\n * `awaitingMe` lists the requests waiting on one, `decide` approves or\n * rejects one, and `request` reads it. Any other request answers as not\n * found, whether or not it exists. `mine` lists the requests they raised, and\n * `cancel` withdraws one of those while it waits. `requests`, every request\n * in the workspace, is for Frontera members and is refused here for such a\n * person, before anything is sent.\n */\nexport class ActionClient {\n constructor(private readonly client: FronteraClient) {}\n\n /**\n * The refusal for a call only a Frontera member can make, when the session\n * belongs to a person signed in to an externally hosted App.\n *\n * The service answers such a call as if no credential had been sent. Left to\n * that, the App would see \"Authentication required\" and the session would\n * sign the person in again over a call that can never succeed. Said here\n * instead, by name.\n */\n private membersOnly(what: string, instead: string): FronteraError | null {\n const credential = this.client.config.credential\n if (credential.kind !== 'token' || !credential.token.startsWith(APP_USER_TOKEN_PREFIX)) return null\n return new FronteraError(\n `${what} is not available to people signed in to an externally hosted App. ${instead}`,\n { code: 'FORBIDDEN', status: 403 },\n )\n }\n\n /**\n * Actions this user may invoke, here, now.\n *\n * An empty list is ambiguous ON PURPOSE — unpublished, undeployed and\n * unpermitted are indistinguishable to a caller, so a probe cannot map what\n * exists. That is right for security and hostile to debugging, so treat an\n * unexpected empty list as a question about the CALLER's permissions first.\n */\n discover(): Promise<ActionDescriptor[]> {\n return this.client.request<ActionDescriptor[]>(`${BASE}/discovery`)\n }\n\n /**\n * Submit one Action. Returns as soon as the Request is recorded — NOT when\n * the effect has landed.\n *\n * The write is durable from this point: it survives a closed tab, a restarted\n * server, and a worker that is not running yet. What it does not do is finish\n * synchronously, so a UI that renders success here is lying. Poll the Request\n * (`useActionRequest`) and show the lifecycle.\n */\n async submit(\n action: ActionDescriptor,\n input: SubmitActionInput,\n ): Promise<ActionRequest> {\n const idempotencyKey = input.idempotencyKey ?? mintIdempotencyKey()\n return this.client.request<ActionRequest>(\n `${BASE}/actions/${encodeURIComponent(action.apiName)}/requests`,\n {\n method: 'POST',\n headers: { 'idempotency-key': idempotencyKey },\n // Wrapped, and the wrapper is EXACT: the route accepts a body whose\n // keys are precisely `['invocation']` and refuses anything else with\n // \"Governed Action HTTP command body is invalid.\" — a message that\n // names the body rather than the field, so sending the envelope at the\n // top level reads like a malformed invocation instead of a missing\n // wrapper.\n body: { invocation: buildInvocation(action, input) },\n },\n )\n }\n\n requests(lifecycle?: readonly ActionRequestLifecycle[]): Promise<ActionRequest[]> {\n const refused = this.membersOnly(\n 'Listing every Action request in the workspace',\n 'Read the requests waiting for their decision with `awaitingMe()` or `useApprovalInbox`, and the requests they raised with `mine()` or `useMyActionRequests`.',\n )\n if (refused) return Promise.reject(refused)\n return this.client.request<ActionRequest[]>(`${BASE}/requests`, {\n query: lifecycle?.length ? { lifecycle: lifecycle.join(',') } : undefined,\n })\n }\n\n /**\n * The waiting requests this person could decide right now, newest first.\n *\n * For a Frontera member: requests waiting on a step workspace members\n * decide, in a workspace where they may approve. For a person signed in to\n * an externally hosted App: requests waiting on a step that names an App\n * role they hold, for a record that role's data scope covers.\n *\n * Never one they raised, one they already decided a step of, or one past\n * its deadline. A request leaves the list as soon as any of that changes, so\n * a list read a moment ago can hold a request that `decide` now refuses.\n * Nothing here says how many other requests are waiting for other people.\n *\n * A page looks at a bounded number of the most recent waiting requests.\n * When it stopped there, the page says `truncated: true`: more may wait\n * behind them.\n */\n awaitingMe(options: ActionRequestListOptions = {}): Promise<ActionRequestPage> {\n return this.page({ awaiting: 'me', ...listQuery(options) })\n }\n\n /**\n * The requests this person raised, newest first, in every state unless\n * `lifecycle` narrows it. Each carries its approval steps, who decided them\n * by name and, for a rejected request, the reason.\n *\n * For a person signed in to an externally hosted App: the requests they\n * raised through this App.\n */\n mine(\n options: ActionRequestListOptions & { lifecycle?: readonly ActionRequestLifecycle[] } = {},\n ): Promise<ActionRequestPage> {\n return this.page({\n mine: 'true',\n ...(options.lifecycle?.length ? { lifecycle: options.lifecycle.join(',') } : {}),\n ...listQuery(options),\n })\n }\n\n /** One page of a named list: the requests, and where the next page starts. */\n private async page(query: Record<string, string>): Promise<ActionRequestPage> {\n const envelope = await this.client.requestEnvelope<{\n data?: ActionRequest[]\n nextCursor?: string | null\n truncated?: boolean\n } | null>(\n `${BASE}/requests`,\n { query },\n )\n return {\n requests: envelope?.data ?? [],\n nextCursor: envelope?.nextCursor ?? null,\n truncated: envelope?.truncated === true,\n }\n }\n\n request(requestId: string): Promise<ActionRequest> {\n return this.client.request<ActionRequest>(`${BASE}/requests/${encodeURIComponent(requestId)}`)\n }\n\n /**\n * Approve or reject. Separate from `submit` because it is a different act by\n * a different person — an Action with separation of duties refuses a decision\n * from whoever submitted it.\n *\n * `stage` names the approval-chain step being decided, as `approval.currentStage`\n * showed it to the person. It is required for a request approved in steps\n * (one whose `approval` is not `null`): without it the decision is refused as\n * invalid, with reason `ACTION_APPROVAL_STEP_REQUIRED`. If another step is\n * waiting by the time the decision arrives, it is refused with reason\n * `ACTION_APPROVAL_STEP_CHANGED` rather than recorded on that step. Leave it\n * out only for a request that has no approval steps, where naming one is\n * refused.\n *\n * A Frontera member decides the requests and steps that name workspace\n * members. A person signed in to an externally hosted App decides a step\n * that names an App role they hold, for a record that role's data scope\n * covers. To that person every other request answers `ACTION_NOT_FOUND`,\n * the same as a request that does not exist: show \"nothing to decide here\",\n * not an error about permissions.\n *\n * Resolves to the request as the decision left it, with `request` (the same\n * request) and `approvalDecision` (the decision recorded) beside it: see\n * `ActionDecisionResult`.\n */\n async decide(\n requestId: string,\n decision: 'approve' | 'reject',\n reason: string,\n stage?: string,\n ): Promise<ActionDecisionResult> {\n const answer = await this.client.request<{ approval: ActionApprovalDecision; request: ActionRequest }>(\n `${BASE}/requests/${encodeURIComponent(requestId)}/approvals`,\n { method: 'POST', body: { decision, reason, ...(stage ? { stage } : {}) } },\n )\n return { ...answer.request, request: answer.request, approvalDecision: answer.approval }\n }\n\n /**\n * Cancel sends no body. The platform reads the request from the URL and the\n * caller from the credential, and ignores anything sent as a body: a\n * `reason` sent here would be dropped, not recorded, so none is taken.\n *\n * A Frontera member with permission to cancel requests cancels a request\n * that waits for approval, or is approved and has not started to run. A person signed in to an externally hosted App withdraws\n * only a request they raised, and only while it waits for approval: any\n * other request answers as not found, and one already approved answers\n * `CONFLICT`.\n */\n cancel(requestId: string): Promise<ActionRequest> {\n return this.client.request<ActionRequest>(\n `${BASE}/requests/${encodeURIComponent(requestId)}/cancel`,\n { method: 'POST' },\n )\n }\n}\n\n/** The query a list sends for its filter and position: only what was given. */\nfunction listQuery(options: ActionRequestListOptions): Record<string, string> {\n return {\n ...(options.actionDefinitionId ? { actionDefinitionId: options.actionDefinitionId } : {}),\n ...(options.limit !== undefined ? { limit: String(options.limit) } : {}),\n ...(options.cursor ? { cursor: options.cursor } : {}),\n }\n}\n\n/**\n * The wire envelope, assembled from the flat input a caller actually has.\n *\n * Exported for the tests: this is the part with a trap in it, and the trap is\n * silent — a wrong shape comes back as \"Action invocation is invalid\" with no\n * field named.\n */\nexport function buildInvocation(\n action: ActionDescriptor,\n input: SubmitActionInput,\n): Record<string, unknown> {\n const invocation: Record<string, unknown> = { input: { ...input.input } }\n\n if (action.subject.mode === 'existing') {\n if (!input.objectId) {\n throw new Error(\n `\"${action.apiName}\" changes an existing ${action.subject.objectTypeId}, so it needs an objectId.`,\n )\n }\n invocation.subjectRef = {\n objectTypeId: action.subject.objectTypeId,\n objectId: input.objectId,\n }\n if (input.expectedVersion !== undefined) {\n // A STRING here, deliberately: the subject version is an opaque token,\n // while the compare-and-set parameter below is the numeric record\n // version. Same number, two types, two meanings.\n invocation.expectedSubjectVersion = String(input.expectedVersion)\n }\n } else if (input.objectId) {\n throw new Error(`\"${action.apiName}\" creates an object, so it takes no objectId.`)\n }\n\n // Fed to the Action's own compare-and-set parameter when it declares one,\n // and only then — an Action without it would refuse the unknown key.\n const casParameter = compareAndSetParameter(action)\n if (casParameter && input.expectedVersion !== undefined) {\n const parameters = invocation.input as Record<string, unknown>\n if (!(casParameter in parameters)) parameters[casParameter] = Number(input.expectedVersion)\n }\n\n if (input.reason !== undefined) invocation.reason = input.reason\n if (input.correlationId !== undefined) invocation.correlationId = input.correlationId\n return invocation\n}\n\n/**\n * The parameter carrying the record version, read off the published schema\n * rather than assumed by name.\n */\nfunction compareAndSetParameter(action: ActionDescriptor): string | null {\n const input = (action.inputSchema as { properties?: Record<string, unknown> } | undefined)\n ?.properties?.input as { properties?: Record<string, unknown> } | undefined\n const properties = input?.properties\n if (!properties) return null\n return 'expectedVersion' in properties ? 'expectedVersion' : null\n}\n\n/**\n * One key per user intent.\n *\n * The service dedupes by this: the same key with a different invocation is\n * REFUSED, and the same key with the same invocation returns the original\n * Request rather than acting twice. So it must be stable across retries of one\n * intent and different between two intents — which is exactly the lifetime of\n * a single `submit` call, not of a component or a session.\n */\nfunction mintIdempotencyKey(): string {\n const random = globalThis.crypto?.randomUUID?.()\n ?? Math.random().toString(36).slice(2).padEnd(22, '0')\n return `frontera-app-${random}`\n}\n",
|
|
20
20
|
"frontera/blueprint/blueprint-client.ts": "import type { FronteraClient } from '@frontera-sdk/core/client'\nimport type {\n AggregateGroupBy,\n AggregateRequest,\n AggregateResponse,\n BlueprintFilterableProperty,\n BlueprintObjectName,\n BlueprintRow,\n MetricQueryRequest,\n ObjectInstance,\n QueryRequest,\n QueryResponse,\n} from './types'\n\n/**\n * Typed client for the Blueprint read API.\n *\n * Every method is workspace-scoped by the credential on the underlying\n * `FronteraClient`. An object type the workspace has not been granted is absent\n * from the server's catalog snapshot, so the query compiler raises\n * `UNKNOWN_OBJECT_TYPE` — grant violations arrive as a 404, not a 403, and that\n * is intentional: the caller cannot distinguish \"does not exist\" from \"not\n * granted\", which is the point.\n */\nexport class BlueprintClient {\n constructor(private readonly client: FronteraClient) {}\n\n query<TRow = Record<string, unknown>>(request: QueryRequest): Promise<QueryResponse<TRow>> {\n return this.client.request<QueryResponse<TRow>>('/v1/blueprint/query', {\n method: 'POST',\n body: request,\n })\n }\n\n queryObjectSet<TRow = Record<string, unknown>>(\n objectSetId: string,\n request: Omit<QueryRequest, 'objectSet'> = {},\n ): Promise<QueryResponse<TRow>> {\n return this.client.request<QueryResponse<TRow>>(\n `/v1/blueprint/object-sets/${encodeURIComponent(objectSetId)}/query`,\n { method: 'POST', body: request },\n )\n }\n\n /**\n * Run an aggregation.\n *\n * `groupBy` is required by the service; it is defaulted here so a grand\n * total reads as `aggregate({ objectSet, aggregations })` rather than\n * forcing every caller to remember an empty array.\n */\n aggregate(\n request: Omit<AggregateRequest, 'groupBy'> & { groupBy?: AggregateGroupBy[] },\n ): Promise<AggregateResponse> {\n return this.client.request<AggregateResponse>('/v1/blueprint/aggregate', {\n method: 'POST',\n // Default AFTER the spread: an explicit `undefined` in the request must\n // not win over the fallback.\n body: { ...request, groupBy: request.groupBy ?? [] },\n })\n }\n\n instance<\n TLegacy extends object = never,\n const TObject extends BlueprintObjectName = BlueprintObjectName,\n >(\n objectType: TObject,\n pk: string,\n ): Promise<ObjectInstance<[TLegacy] extends [never] ? BlueprintRow<TObject> : TLegacy>> {\n return this.client.request<ObjectInstance<[TLegacy] extends [never] ? BlueprintRow<TObject> : TLegacy>>(\n `/v1/blueprint/object-types/${encodeURIComponent(objectType)}/instances/${encodeURIComponent(pk)}`,\n )\n }\n\n /**\n * Distinct values for one property — the source for a filter's options.\n *\n * The service answers `{ kind: 'values', values, truncated }`, not a bare\n * array. This returned the envelope while claiming `string[]`, so every\n * caller that trusted the type got an object where it expected a list — and\n * `.map` on it threw at runtime in code that type-checked.\n *\n * `truncated` is dropped here deliberately: it means the distinct set hit the\n * service's cap, which a filter cannot act on beyond showing what it has.\n * Use `propertyValuesWithTruncation` when it matters.\n */\n async propertyValues<const TObject extends BlueprintObjectName = BlueprintObjectName>(\n objectType: TObject,\n property: BlueprintFilterableProperty<TObject>,\n ): Promise<string[]> {\n return (await this.propertyValuesWithTruncation(objectType, property)).values\n }\n\n /**\n * Narrowed against the committed contract, like `instance` above: a property\n * the workspace does not expose for filtering has no distinct-value endpoint,\n * and asking for one is a 400 that only shows up when a filter is opened.\n * Before generation, `BlueprintFilterableProperty` is `string` and this is the\n * loose signature it always was.\n */\n propertyValuesWithTruncation<const TObject extends BlueprintObjectName = BlueprintObjectName>(\n objectType: TObject,\n property: BlueprintFilterableProperty<TObject>,\n ): Promise<{ values: string[]; truncated: boolean }> {\n return this.client\n .request<{ kind?: string; values?: string[]; truncated?: boolean }>(\n `/v1/blueprint/object-types/${encodeURIComponent(objectType)}/properties/${encodeURIComponent(property)}/values`,\n )\n // `Array.isArray`, not a truthiness check: reading `.values` off an ARRAY\n // returns `Array.prototype.values` — the iterator function — so a payload\n // in the older bare-array shape would hand every caller a function where\n // it expected a list, which is a worse failure than the one being fixed.\n .then((payload) => ({\n values: Array.isArray(payload?.values)\n ? payload.values\n : Array.isArray(payload) ? (payload as string[]) : [],\n truncated: payload?.truncated === true,\n }))\n }\n\n metricQuery(apiName: string, request: MetricQueryRequest = {}): Promise<AggregateResponse> {\n return this.client.request<AggregateResponse>(\n `/v1/blueprint/metrics/${encodeURIComponent(apiName)}/query`,\n { method: 'POST', body: request },\n )\n }\n}\n",
|
|
21
|
-
"frontera/blueprint/action-hooks.ts": "import { createContext, useContext, useEffect } from 'react'\nimport {\n useMutation,\n useQuery,\n useQueryClient,\n type UseMutationResult,\n type UseQueryOptions,\n type UseQueryResult,\n} from '@tanstack/react-query'\n\nimport type { ActionClient } from './action-client'\nimport { blueprintKeys } from './hooks'\nimport {\n actionEffectOf,\n isTerminalLifecycle,\n type ActionDescriptor,\n type ActionRequest,\n type ActionRequestLifecycle,\n type SubmitActionInput,\n} from './action-types'\n\nexport const actionKeys = {\n all: ['blueprint', 'actions'] as const,\n discovery: () => ['blueprint', 'actions', 'discovery'] as const,\n requests: (lifecycle?: readonly ActionRequestLifecycle[]) =>\n ['blueprint', 'actions', 'requests', lifecycle?.join(',') ?? 'all'] as const,\n request: (requestId: string) => ['blueprint', 'actions', 'request', requestId] as const,\n}\n\nexport const FronteraActionContext = createContext<ActionClient | null>(null)\n\nexport function useActionClient(): ActionClient {\n const client = useContext(FronteraActionContext)\n if (!client) {\n throw new Error(\n 'No ActionClient in context. Wrap the tree in FronteraActionContext.Provider — createFronteraApp does this for you in a Frontera app.',\n )\n }\n return client\n}\n\ntype ReadOptions<TData> = Omit<UseQueryOptions<TData, Error>, 'queryKey' | 'queryFn'>\n\n/**\n * Actions the signed-in user may invoke.\n *\n * Render buttons from THIS, never from a hard-coded list: the same page must\n * offer different actions to different colleagues, and only the server knows\n * which. A hard-coded button that 403s on click is a worse experience than one\n * that was never drawn.\n *\n * An empty list during development is much more likely to be permissions than\n * a bug in this hook — an Action is hidden unless it is published, deployed,\n * AND its invoke capability is held by the caller's role.\n */\nexport function useActions(\n options: ReadOptions<ActionDescriptor[]> = {},\n): UseQueryResult<ActionDescriptor[], Error> {\n const client = useActionClient()\n return useQuery({\n queryKey: actionKeys.discovery(),\n queryFn: () => client.discover(),\n ...options,\n })\n}\n\n/**\n * One Action by name, or `null` when this user may not invoke it.\n *\n * `null` rather than a thrown error, because \"you may not do this\" is an\n * ordinary state for a UI to be in — it renders nothing, or a disabled control\n * with an explanation — not an exception.\n */\nexport function useAction(\n apiName: string,\n options: ReadOptions<ActionDescriptor[]> = {},\n): { action: ActionDescriptor | null; isLoading: boolean; error: Error | null } {\n const { data, isLoading, error } = useActions(options)\n return {\n action: data?.find((entry) => entry.apiName === apiName) ?? null,\n isLoading,\n error: error ?? null,\n }\n}\n\n/**\n * Submit an Action.\n *\n * Resolves when the Request is RECORDED, not when the effect has landed —\n * dispatch runs on a background worker. A button whose success toast fires here\n * is claiming something it does not know, so pass the returned request id to\n * `useActionRequest` and let the lifecycle drive what the user sees.\n *\n * The idempotency key is minted per `mutate` call, which is the correct\n * lifetime: React Query retrying a failed network call reuses the same key and\n * cannot double-apply, while a second click is a second intent and gets its\n * own.\n */\nexport function useSubmitAction(\n action: ActionDescriptor | null,\n): UseMutationResult<ActionRequest, Error, SubmitActionInput> {\n const client = useActionClient()\n const queryClient = useQueryClient()\n\n return useMutation<ActionRequest, Error, SubmitActionInput>({\n mutationFn: (input) => {\n if (!action) {\n return Promise.reject(new Error(\n 'This Action is not available to you. Render the control only when `useAction` returns one.',\n ))\n }\n return client.submit(action, input)\n },\n onSuccess: (request) => {\n queryClient.setQueryData(actionKeys.request(request.id), request)\n void queryClient.invalidateQueries({ queryKey: actionKeys.requests() })\n },\n })\n}\n\n/**\n * Follow one Request until it settles.\n *\n * Polls while the lifecycle is non-terminal and stops once it is, so a settled\n * Request costs nothing to keep on screen. The interval is deliberately short:\n * the gap between \"submitted\" and \"applied\" is the part users find alarming,\n * and the cheapest fix is showing it moving.\n */\nexport function useActionRequest(\n requestId: string | null | undefined,\n options: ReadOptions<ActionRequest> & { pollMs?: number } = {},\n): UseQueryResult<ActionRequest, Error> {\n const client = useActionClient()\n const queryClient = useQueryClient()\n const { pollMs = 1_500, ...queryOptions } = options\n\n const result = useQuery({\n queryKey: actionKeys.request(requestId ?? ''),\n queryFn: () => client.request(requestId as string),\n enabled: Boolean(requestId) && queryOptions.enabled !== false,\n refetchInterval: (query) => {\n const lifecycle = query.state.data?.lifecycle\n if (!lifecycle) return pollMs\n return isTerminalLifecycle(lifecycle) ? false : pollMs\n },\n ...queryOptions,\n })\n\n /**\n * Refresh what the app is READING once the write has actually landed.\n *\n * Not on submit: at that point the Request is recorded and the object is\n * unchanged, so refetching returns the old values and caches them as fresh —\n * the table would settle on stale data and stay there.\n *\n * Keyed on the CERTAINTY rather than the lifecycle. `confirmed_applied` is\n * the moment the write commits; `succeeded` comes later, after the platform\n * has verified its own promise, and refreshing only then leaves the table\n * showing yesterday's row for the whole verification pass. Nothing about the\n * data changes in that gap.\n *\n * Deliberately not on a refusal: nothing changed, and a refetch there is a\n * request per failure for no new information.\n */\n const landed = actionEffectOf(result.data) === 'applied'\n useEffect(() => {\n if (!landed) return\n void queryClient.invalidateQueries({ queryKey: blueprintKeys.all })\n }, [landed, queryClient])\n\n return result\n}\n\n/** The queue: Requests in this workspace, optionally narrowed by lifecycle. */\nexport function useActionRequests(\n lifecycle?: readonly ActionRequestLifecycle[],\n options: ReadOptions<ActionRequest[]> = {},\n): UseQueryResult<ActionRequest[], Error> {\n const client = useActionClient()\n return useQuery({\n queryKey: actionKeys.requests(lifecycle),\n queryFn: () => client.requests(lifecycle),\n ...options,\n })\n}\n\n/**\n * Approve or reject a Request awaiting a decision.\n *\n * An Action with separation of duties refuses a decision from whoever\n * submitted it, so this will fail for the requester — correctly. Surface that\n * refusal rather than hiding the control: \"someone else must approve this\" is\n * the information the user needs.\n */\nexport function useDecideActionRequest(): UseMutationResult<\n ActionRequest,\n Error,\n { requestId: string; decision: 'approve' | 'reject'; reason: string }\n> {\n const client = useActionClient()\n const queryClient = useQueryClient()\n\n return useMutation({\n mutationFn: ({ requestId, decision, reason }) => client.decide(requestId, decision, reason),\n onSuccess: (request) => {\n queryClient.setQueryData(actionKeys.request(request.id), request)\n void queryClient.invalidateQueries({ queryKey: actionKeys.requests() })\n },\n })\n}\n",
|
|
21
|
+
"frontera/blueprint/action-hooks.ts": "import { createContext, useContext, useEffect } from 'react'\nimport {\n useInfiniteQuery,\n useMutation,\n useQuery,\n useQueryClient,\n type UseMutationResult,\n type UseQueryOptions,\n type UseQueryResult,\n} from '@tanstack/react-query'\n\nimport type { ActionClient } from './action-client'\nimport { blueprintKeys } from './hooks'\nimport {\n actionEffectOf,\n isTerminalLifecycle,\n type ActionDecisionResult,\n type ActionDescriptor,\n type ActionRequest,\n type ActionRequestLifecycle,\n type ActionRequestPage,\n type SubmitActionInput,\n} from './action-types'\n\n/** Which requests `useApprovalInbox` reads. */\nexport interface ApprovalInboxFilter {\n /** Only requests of this Action. */\n actionDefinitionId?: string\n /** Requests read per page: 1 to 200. Left out, 50. */\n pageSize?: number\n}\n\n/** Which requests `useMyActionRequests` reads. */\nexport interface MyActionRequestsFilter extends ApprovalInboxFilter {\n /** Only requests in these states. Left out, every state. */\n lifecycle?: readonly ActionRequestLifecycle[]\n}\n\nexport const actionKeys = {\n all: ['blueprint', 'actions'] as const,\n discovery: () => ['blueprint', 'actions', 'discovery'] as const,\n requests: (lifecycle?: readonly ActionRequestLifecycle[]) =>\n ['blueprint', 'actions', 'requests', lifecycle?.join(',') ?? 'all'] as const,\n request: (requestId: string) => ['blueprint', 'actions', 'request', requestId] as const,\n /** With no filter: the prefix every approval inbox is cached under. */\n awaitingMe: (filter?: ApprovalInboxFilter) => (filter\n ? ['blueprint', 'actions', 'awaiting-me', filter.actionDefinitionId ?? 'all', filter.pageSize ?? 'default'] as const\n : ['blueprint', 'actions', 'awaiting-me'] as const),\n /** With no filter: the prefix every list of one's own requests is cached under. */\n mine: (filter?: MyActionRequestsFilter) => (filter\n ? [\n 'blueprint', 'actions', 'mine',\n filter.lifecycle?.join(',') ?? 'all',\n filter.actionDefinitionId ?? 'all',\n filter.pageSize ?? 'default',\n ] as const\n : ['blueprint', 'actions', 'mine'] as const),\n}\n\nexport const FronteraActionContext = createContext<ActionClient | null>(null)\n\nexport function useActionClient(): ActionClient {\n const client = useContext(FronteraActionContext)\n if (!client) {\n throw new Error(\n 'No ActionClient in context. Wrap the tree in FronteraActionContext.Provider — createFronteraApp does this for you in a Frontera app.',\n )\n }\n return client\n}\n\ntype ReadOptions<TData> = Omit<UseQueryOptions<TData, Error>, 'queryKey' | 'queryFn'>\n\n/**\n * Actions the signed-in user may invoke.\n *\n * Render buttons from THIS, never from a hard-coded list: the same page must\n * offer different actions to different colleagues, and only the server knows\n * which. A hard-coded button that 403s on click is a worse experience than one\n * that was never drawn.\n *\n * An empty list during development is much more likely to be permissions than\n * a bug in this hook — an Action is hidden unless it is published, deployed,\n * AND its invoke capability is held by the caller's role.\n */\nexport function useActions(\n options: ReadOptions<ActionDescriptor[]> = {},\n): UseQueryResult<ActionDescriptor[], Error> {\n const client = useActionClient()\n return useQuery({\n queryKey: actionKeys.discovery(),\n queryFn: () => client.discover(),\n ...options,\n })\n}\n\n/**\n * One Action by name, or `null` when this user may not invoke it.\n *\n * `null` rather than a thrown error, because \"you may not do this\" is an\n * ordinary state for a UI to be in — it renders nothing, or a disabled control\n * with an explanation — not an exception.\n */\nexport function useAction(\n apiName: string,\n options: ReadOptions<ActionDescriptor[]> = {},\n): { action: ActionDescriptor | null; isLoading: boolean; error: Error | null } {\n const { data, isLoading, error } = useActions(options)\n return {\n action: data?.find((entry) => entry.apiName === apiName) ?? null,\n isLoading,\n error: error ?? null,\n }\n}\n\n/**\n * Submit an Action.\n *\n * Resolves when the Request is RECORDED, not when the effect has landed —\n * dispatch runs on a background worker. A button whose success toast fires here\n * is claiming something it does not know, so pass the returned request id to\n * `useActionRequest` and let the lifecycle drive what the user sees.\n *\n * The idempotency key is minted per `mutate` call, which is the correct\n * lifetime: React Query retrying a failed network call reuses the same key and\n * cannot double-apply, while a second click is a second intent and gets its\n * own.\n */\nexport function useSubmitAction(\n action: ActionDescriptor | null,\n): UseMutationResult<ActionRequest, Error, SubmitActionInput> {\n const client = useActionClient()\n const queryClient = useQueryClient()\n\n return useMutation<ActionRequest, Error, SubmitActionInput>({\n mutationFn: (input) => {\n if (!action) {\n return Promise.reject(new Error(\n 'This Action is not available to you. Render the control only when `useAction` returns one.',\n ))\n }\n return client.submit(action, input)\n },\n onSuccess: (request) => {\n queryClient.setQueryData(actionKeys.request(request.id), request)\n void queryClient.invalidateQueries({ queryKey: actionKeys.requests() })\n // A request they raised: their own list has one more.\n void queryClient.invalidateQueries({ queryKey: actionKeys.mine() })\n },\n })\n}\n\n/**\n * Follow one Request until it settles.\n *\n * Polls while the lifecycle is non-terminal and stops once it is, so a settled\n * Request costs nothing to keep on screen. The interval is deliberately short:\n * the gap between \"submitted\" and \"applied\" is the part users find alarming,\n * and the cheapest fix is showing it moving.\n */\nexport function useActionRequest(\n requestId: string | null | undefined,\n options: ReadOptions<ActionRequest> & { pollMs?: number } = {},\n): UseQueryResult<ActionRequest, Error> {\n const client = useActionClient()\n const queryClient = useQueryClient()\n const { pollMs = 1_500, ...queryOptions } = options\n\n const result = useQuery({\n queryKey: actionKeys.request(requestId ?? ''),\n queryFn: () => client.request(requestId as string),\n enabled: Boolean(requestId) && queryOptions.enabled !== false,\n refetchInterval: (query) => {\n const lifecycle = query.state.data?.lifecycle\n if (!lifecycle) return pollMs\n return isTerminalLifecycle(lifecycle) ? false : pollMs\n },\n ...queryOptions,\n })\n\n /**\n * Refresh what the app is READING once the write has actually landed.\n *\n * Not on submit: at that point the Request is recorded and the object is\n * unchanged, so refetching returns the old values and caches them as fresh —\n * the table would settle on stale data and stay there.\n *\n * Keyed on the CERTAINTY rather than the lifecycle. `confirmed_applied` is\n * the moment the write commits; `succeeded` comes later, after the platform\n * has verified its own promise, and refreshing only then leaves the table\n * showing yesterday's row for the whole verification pass. Nothing about the\n * data changes in that gap.\n *\n * Deliberately not on a refusal: nothing changed, and a refetch there is a\n * request per failure for no new information.\n */\n const landed = actionEffectOf(result.data) === 'applied'\n useEffect(() => {\n if (!landed) return\n void queryClient.invalidateQueries({ queryKey: blueprintKeys.all })\n }, [landed, queryClient])\n\n return result\n}\n\n/** The queue: Requests in this workspace, optionally narrowed by lifecycle. */\nexport function useActionRequests(\n lifecycle?: readonly ActionRequestLifecycle[],\n options: ReadOptions<ActionRequest[]> = {},\n): UseQueryResult<ActionRequest[], Error> {\n const client = useActionClient()\n return useQuery({\n queryKey: actionKeys.requests(lifecycle),\n queryFn: () => client.requests(lifecycle),\n ...options,\n })\n}\n\n/** One named list of requests, read page by page. */\nexport interface ActionRequestList {\n /** Every request read so far, newest first. */\n requests: ActionRequest[]\n /** True until the first page has arrived. */\n isLoading: boolean\n /** True while any page is being read, the first or a later one. */\n isFetching: boolean\n error: Error | null\n /** Whether the platform has at least one more request past what was read. */\n hasMore: boolean\n /**\n * The approval inbox only: a page stopped at the platform's limit before it\n * had looked at every waiting request, so the list may be incomplete even\n * when `hasMore` is false. Always false for `useMyActionRequests`.\n */\n truncated: boolean\n /** Read the next page. Does nothing when there is none, or one is on its way. */\n loadMore(): Promise<void>\n /** Read the list again from the top. */\n refetch(): Promise<void>\n}\n\ntype ListReadOptions = {\n /** Set false to read nothing until it is true. */\n enabled?: boolean\n /** Read the list again this often, in milliseconds. Left out, only on `refetch` and after a decision. */\n refetchInterval?: number | false\n}\n\n/** A named list as React Query reads it: one page per cursor. */\nexport interface ActionRequestListQuery {\n queryKey: readonly unknown[]\n queryFn(context: { pageParam: string | null }): Promise<ActionRequestPage>\n initialPageParam: string | null\n getNextPageParam(last: ActionRequestPage): string | null\n}\n\n/**\n * The query behind `useApprovalInbox`: its cache key and how it reads a page.\n * Exported for an App that prefetches or drives the cache itself.\n */\nexport function approvalInboxQuery(client: ActionClient, filter: ApprovalInboxFilter = {}): ActionRequestListQuery {\n return {\n queryKey: actionKeys.awaitingMe(filter),\n queryFn: ({ pageParam }) => client.awaitingMe({\n ...(filter.actionDefinitionId ? { actionDefinitionId: filter.actionDefinitionId } : {}),\n ...(filter.pageSize ? { limit: filter.pageSize } : {}),\n ...(pageParam ? { cursor: pageParam } : {}),\n }),\n initialPageParam: null,\n getNextPageParam: (last) => last.nextCursor,\n }\n}\n\n/** The query behind `useMyActionRequests`. */\nexport function myActionRequestsQuery(client: ActionClient, filter: MyActionRequestsFilter = {}): ActionRequestListQuery {\n return {\n queryKey: actionKeys.mine(filter),\n queryFn: ({ pageParam }) => client.mine({\n ...(filter.lifecycle?.length ? { lifecycle: filter.lifecycle } : {}),\n ...(filter.actionDefinitionId ? { actionDefinitionId: filter.actionDefinitionId } : {}),\n ...(filter.pageSize ? { limit: filter.pageSize } : {}),\n ...(pageParam ? { cursor: pageParam } : {}),\n }),\n initialPageParam: null,\n getNextPageParam: (last) => last.nextCursor,\n }\n}\n\n/**\n * The pages read so far as one list. A request that moved between two reads\n * can be on two pages; it is listed once, where it first appears.\n */\nexport function requestsOfPages(pages: readonly ActionRequestPage[] | undefined): ActionRequest[] {\n const seen = new Set<string>()\n const requests: ActionRequest[] = []\n for (const page of pages ?? []) {\n for (const request of page.requests) {\n if (seen.has(request.id)) continue\n seen.add(request.id)\n requests.push(request)\n }\n }\n return requests\n}\n\n/**\n * Whether any page read so far stopped at the platform's limit: the list may\n * then be incomplete, whatever its last page says about a next one.\n */\nexport function pagesTruncated(pages: readonly ActionRequestPage[] | undefined): boolean {\n return (pages ?? []).some((page) => page.truncated)\n}\n\nfunction useRequestList(query: ActionRequestListQuery, options: ListReadOptions): ActionRequestList {\n const result = useInfiniteQuery({\n ...query,\n ...(options.enabled === undefined ? {} : { enabled: options.enabled }),\n ...(options.refetchInterval === undefined ? {} : { refetchInterval: options.refetchInterval }),\n })\n return {\n requests: requestsOfPages(result.data?.pages),\n isLoading: result.isLoading,\n isFetching: result.isFetching,\n error: result.error ?? null,\n hasMore: result.hasNextPage,\n truncated: pagesTruncated(result.data?.pages),\n loadMore: async () => {\n if (result.hasNextPage && !result.isFetchingNextPage) await result.fetchNextPage()\n },\n refetch: async () => {\n await result.refetch()\n },\n }\n}\n\n/**\n * What awaits this person's approval: the waiting requests they could decide\n * right now, newest first.\n *\n * Works for a Frontera member and for a person signed in to an externally\n * hosted App, each under their own rules (`ActionClient.awaitingMe`). Render\n * the review screen from `requests`: each carries `intent` (what is asked,\n * and by whom) and `approval` (the steps, and the one waiting). Decide with\n * `useDecideActionRequest`, passing `approval.currentStage` as `stage`; the\n * list is read again after every decision made through that hook.\n *\n * `truncated` is true when a page stopped at the platform's limit of waiting\n * requests looked at: say that the list may be incomplete, and narrow it with\n * `actionDefinitionId`.\n *\n * The list is a moment's answer. Someone else may decide a request, or the\n * record may leave the person's data, between the read and the decision; the\n * decision then answers as it would for a request that is not there. Call\n * `refetch`, or set `refetchInterval`, to keep a screen left open current.\n */\nexport function useApprovalInbox(options: ApprovalInboxFilter & ListReadOptions = {}): ActionRequestList {\n const client = useActionClient()\n const { enabled, refetchInterval, ...filter } = options\n return useRequestList(approvalInboxQuery(client, filter), { enabled, refetchInterval })\n}\n\n/**\n * The requests this person raised, newest first: what became of each, the\n * approval steps it passed, who decided them by name and, for a rejected\n * request, the reason given.\n *\n * For a person signed in to an externally hosted App: the requests they\n * raised through this App. Read again after `useSubmitAction`,\n * `useDecideActionRequest` and `useCancelActionRequest` succeed.\n */\nexport function useMyActionRequests(options: MyActionRequestsFilter & ListReadOptions = {}): ActionRequestList {\n const client = useActionClient()\n const { enabled, refetchInterval, ...filter } = options\n return useRequestList(myActionRequestsQuery(client, filter), { enabled, refetchInterval })\n}\n\n/**\n * Approve or reject a Request awaiting a decision.\n *\n * An Action with separation of duties refuses a decision from whoever\n * submitted it, so this will fail for the requester — correctly. Surface that\n * refusal rather than hiding the control: \"someone else must approve this\" is\n * the information the user needs.\n *\n * Works with the session of a person signed in to an externally hosted App,\n * for a step their App role decides.\n *\n * For a request approved in steps, `stage` is required: pass\n * `request.approval.currentStage` from the request the person is looking at,\n * so a decision is never recorded on a step they did not read. The hook does\n * not fill it in from its cache: the cache may have been read again since the\n * screen was drawn, and only your component knows which step it showed. A\n * decision without it is refused (`ACTION_APPROVAL_STEP_REQUIRED`). Leave it\n * out for a request whose `approval` is `null`.\n *\n * Resolves to the request as the decision left it (`ActionDecisionResult`).\n * The request, the approval inbox and the lists of requests are read again.\n */\nexport function useDecideActionRequest(): UseMutationResult<\n ActionDecisionResult,\n Error,\n { requestId: string; decision: 'approve' | 'reject'; reason: string; stage?: string }\n> {\n const client = useActionClient()\n const queryClient = useQueryClient()\n\n return useMutation({\n mutationFn: ({ requestId, decision, reason, stage }) => client.decide(requestId, decision, reason, stage),\n onSuccess: (decided) => refreshAfterWrite(queryClient, decided.id),\n })\n}\n\n/**\n * Withdraw a Request that is still waiting.\n *\n * A person signed in to an externally hosted App withdraws only a request\n * they raised, while it waits for approval. A Frontera member needs the\n * permission to cancel requests. The request and the lists are read again.\n */\nexport function useCancelActionRequest(): UseMutationResult<ActionRequest, Error, string> {\n const client = useActionClient()\n const queryClient = useQueryClient()\n\n return useMutation({\n mutationFn: (requestId) => client.cancel(requestId),\n onSuccess: (cancelled) => refreshAfterWrite(queryClient, cancelled.id),\n })\n}\n\n/**\n * After a decision or a cancellation: read the request again rather than\n * cache the reply, which carries no `intent` and no approval steps, and read\n * every list it may have left or changed in.\n */\nexport function refreshAfterWrite(\n queryClient: { invalidateQueries(filters: { queryKey: readonly unknown[] }): Promise<void> },\n requestId: string,\n): void {\n for (const queryKey of [\n actionKeys.request(requestId),\n actionKeys.requests(),\n actionKeys.awaitingMe(),\n actionKeys.mine(),\n ]) void queryClient.invalidateQueries({ queryKey })\n}\n",
|
|
22
22
|
"frontera/blueprint/types.ts": "/**\n * Request and response types for the Blueprint read API.\n *\n * `ObjectSetExpr` is deliberately loose. The service validates the expression\n * tree in `query/validate.ts` with error messages far better than a structural\n * type could produce, and mirroring that grammar here would mean shipping an\n * SDK release every time the service gained a node kind. The common shapes are\n * named so callers get completion for what they actually write.\n */\n/**\n * Condition operators, mirroring the service's `ConditionOp`.\n *\n * Filtering belongs in the object-set expression, NOT in the app: a client-side\n * `.filter()` over a fetched page silently reduces \"8,961 cancelled shipments\"\n * to \"the cancelled ones that happened to be in the last 200 rows\", and the\n * counts stop matching the table.\n */\nexport type ConditionOp =\n | 'eq' | 'ne' | 'in' | 'notIn' | 'contains' | 'startsWith'\n | 'isNull' | 'isNotNull'\n | 'gt' | 'gte' | 'lt' | 'lte' | 'between'\n | 'dateRange'\n /** Some element of an array property satisfies every `elementWhere` condition. */\n | 'arrayAny'\n /** A location within `radiusMeters` of the point in `value`. */\n | 'nearby'\n /** A location inside the box in `value`. */\n | 'withinBbox'\n\n/** The operators a location property takes, and the only ones it takes. */\nexport type LocationConditionOp = 'nearby' | 'withinBbox' | 'isNull' | 'isNotNull'\n\n/** Every operator except the location-only ones. */\nexport type ScalarConditionOp = Exclude<ConditionOp, 'nearby' | 'withinBbox'>\n\n/**\n * A WGS 84 location in decimal degrees: `lat` in -90..90, `lon` in -180..180.\n * Always named, never a `[lat, lon]` pair. This is the value a location\n * property reads as, and the centre of a `nearby` condition.\n */\nexport interface BlueprintGeoPoint {\n lat: number\n lon: number\n}\n\n/**\n * A latitude/longitude box with inclusive edges. `west > east` means the box\n * crosses the 180° meridian (for example `west: 170, east: -170`). A box that\n * should contain a pole must span every longitude (`west: -180, east: 180`).\n */\nexport interface BlueprintGeoBbox {\n west: number\n south: number\n east: number\n north: number\n}\n\n/** One condition on an element of an array property (`arrayAny`). */\nexport interface ArrayElementCondition {\n /** Field of a struct element; omit when the elements are scalars. */\n field?: string\n op: 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte'\n value: unknown\n}\n\n/**\n * The row key `nearest` adds: the distance in metres from `nearest.from`.\n * Property names start with a lowercase letter, so it never collides with one.\n */\nexport const DISTANCE_ROW_KEY = '_distanceMeters'\n\n/** Order rows by distance from `from`, closest first. */\nexport interface NearestRequest<TProperty extends string = string> {\n property: TProperty\n from: BlueprintGeoPoint\n}\n\nexport type DatePreset =\n | 'TODAY' | 'YESTERDAY' | 'LAST_7_DAYS' | 'LAST_30_DAYS' | 'LAST_90_DAYS'\n | 'THIS_MONTH' | 'LAST_MONTH' | 'THIS_QUARTER' | 'THIS_YEAR'\n\n/**\n * Workspace-specific object types are added here by the App-local generated\n * file. An empty registry deliberately degrades to the existing string-keyed\n * SDK so Apps do not need code generation to remain compatible.\n */\nexport interface BlueprintRegistry {}\n\nexport interface BlueprintObjectSchema<\n TRow,\n TFilterable extends keyof TRow & string,\n TSortable extends keyof TRow & string,\n> {\n row: TRow\n filterable: TFilterable\n sortable: TSortable\n}\n\ntype RegisteredObjectName = Extract<keyof BlueprintRegistry, string>\n\nexport type BlueprintObjectName =\n [RegisteredObjectName] extends [never] ? string : RegisteredObjectName\n\nexport type BlueprintRow<TObject extends BlueprintObjectName> =\n TObject extends keyof BlueprintRegistry\n ? BlueprintRegistry[TObject] extends BlueprintObjectSchema<infer TRow, any, any>\n ? TRow\n : never\n : Record<string, unknown>\n\nexport type BlueprintFilterableProperty<TObject extends BlueprintObjectName> =\n TObject extends keyof BlueprintRegistry\n ? BlueprintRegistry[TObject] extends BlueprintObjectSchema<infer _TRow, infer TFilterable, infer _TSortable>\n ? TFilterable\n : never\n : string\n\nexport type BlueprintSortableProperty<TObject extends BlueprintObjectName> =\n TObject extends keyof BlueprintRegistry\n ? BlueprintRegistry[TObject] extends BlueprintObjectSchema<infer _TRow, infer _TFilterable, infer TSortable>\n ? TSortable\n : never\n : string\n\ntype PropertyValue<TRow, TProperty extends string> =\n TProperty extends keyof TRow ? TRow[TProperty] : unknown\n\n/**\n * The properties of a row that read as a location. Derived from the row type\n * (a generated row types a location as `BlueprintGeoPoint | null`), so it needs\n * nothing from the registry.\n */\nexport type BlueprintLocationProperty<TRow> = {\n [K in Extract<keyof TRow, string>]-?: [NonNullable<TRow[K]>] extends [never]\n ? never\n : NonNullable<TRow[K]> extends BlueprintGeoPoint ? K : never\n}[Extract<keyof TRow, string>]\n\nexport type TypedPropertyCondition<TRow, TProperty extends string> =\n TProperty extends unknown\n ? {\n property: TProperty\n // An untyped (string-keyed) object type takes every operator; a\n // registered one keeps the location operators for its locations.\n op: string extends TProperty ? ConditionOp : ScalarConditionOp\n value?: PropertyValue<TRow, TProperty>\n values?: Array<PropertyValue<TRow, TProperty>>\n preset?: DatePreset\n timezone?: string\n elementWhere?: ArrayElementCondition[]\n radiusMeters?: number\n }\n : never\n\n/** A condition on a location property: geo operators and presence only. */\nexport type TypedLocationCondition<TProperty extends string> =\n TProperty extends unknown\n ?\n | { property: TProperty; op: 'nearby'; value: BlueprintGeoPoint; radiusMeters: number }\n | { property: TProperty; op: 'withinBbox'; value: BlueprintGeoBbox }\n | { property: TProperty; op: 'isNull' | 'isNotNull' }\n : never\n\nexport type TypedWhereNode<TRow, TProperty extends string> =\n | TypedPropertyCondition<TRow, TProperty>\n | TypedLocationCondition<BlueprintLocationProperty<TRow>>\n | { and: Array<TypedWhereNode<TRow, TProperty>> }\n | { or: Array<TypedWhereNode<TRow, TProperty>> }\n | { not: TypedWhereNode<TRow, TProperty> }\n\nexport type BlueprintWhereNode<TObject extends BlueprintObjectName> = TypedWhereNode<\n BlueprintRow<TObject>,\n BlueprintFilterableProperty<TObject>\n>\n\nexport interface PropertyCondition {\n property: string\n op: ConditionOp\n value?: unknown\n values?: unknown[]\n /** `dateRange` only — resolved to [start, end) server-side. */\n preset?: DatePreset\n timezone?: string\n /** `arrayAny` only — conditions one element must satisfy together. */\n elementWhere?: ArrayElementCondition[]\n /**\n * `nearby` only — the radius in metres around the point in `value`, measured\n * over the Earth's surface; objects exactly on the circle match.\n */\n radiusMeters?: number\n}\n\nexport type WhereNode =\n | PropertyCondition\n | { and: WhereNode[] }\n | { or: WhereNode[] }\n | { not: WhereNode }\n\nexport type ObjectSetExpr =\n | { type: 'base'; objectType: string }\n | { type: 'filter'; objectSet: ObjectSetExpr; where: WhereNode }\n | { type: 'searchAround'; objectSet: ObjectSetExpr; link: string }\n | { type: 'union' | 'intersect' | 'subtract'; objectSets: ObjectSetExpr[] }\n | { type: 'reference'; objectSetId: string }\n | { type: 'static'; objectType: string; pks: Array<string | number> }\n | { type: string; [key: string]: unknown }\n\nexport interface OrderBy {\n property: string\n dir: 'asc' | 'desc'\n}\n\nexport interface QueryRequest {\n objectSet: ObjectSetExpr\n select?: string[]\n orderBy?: OrderBy[]\n pageSize?: number\n /**\n * Cursor for the next page — the previous response's `nextPageToken`.\n *\n * There is no `page`. Offset paging was removed because it has no defined\n * meaning over an unordered scan, and the service now REFUSES a request\n * carrying it (`INVALID_PAGE`) rather than quietly serving page 1 forever.\n * This SDK declared `page` for a while after that, so every caller following\n * the types sent a parameter guaranteed to fail.\n */\n pageToken?: string\n /**\n * Order by distance from `nearest.from` to the location `nearest.property`,\n * closest first, and add `_distanceMeters` to every row. The object set must\n * also filter that location with `nearby` or `withinBbox` (outside any `or`\n * or `not`), and `orderBy` must be omitted.\n */\n nearest?: NearestRequest\n}\n\n/**\n * `properties` is the FULL projection for the object type regardless of what\n * `select` asked for — filter display columns client-side rather than assuming\n * this list matches `select`.\n */\nexport interface QueryResponse<TRow = Record<string, unknown>> {\n rows: TRow[]\n properties: Array<{\n apiName: string\n displayName?: string\n propertyType?: string\n dataType?: string\n }>\n objectType: string\n pageSize: number\n /** Whether another page exists. `nextPageToken` is present exactly when this is true. */\n hasMore: boolean\n /**\n * Pass back as `pageToken`. Its ABSENCE is how a scan learns it has finished\n * — there is no total, so a caller that waits for one waits forever.\n */\n nextPageToken?: string\n}\n\nexport const AGGREGATE_FUNCTIONS = [\n 'count',\n 'sum',\n 'avg',\n 'min',\n 'max',\n 'countDistinct',\n] as const\n\nexport type AggregateFunction = (typeof AGGREGATE_FUNCTIONS)[number]\n\nexport type TimeGrain = 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'\n\nexport interface AggregateSpec {\n /**\n * Result column name.\n *\n * `alias`, not `name` — this mirrors the service's `aggregateBody` schema\n * exactly (blueprint-query-router.ts). Getting it wrong produces a 400 that\n * says only \"Expected required property\", which is a miserable thing to\n * debug from inside an app.\n */\n alias: string\n fn: AggregateFunction\n property?: string\n /** Per-aggregation filter expression, validated server-side. */\n filters?: unknown\n}\n\nexport interface AggregateGroupBy {\n property: string\n /** Set for a temporal property to bucket by grain; the service aliases the\n * result column as `${property}_${grain}`. */\n bucket?: TimeGrain\n}\n\nexport interface AggregateRequest {\n objectSet: ObjectSetExpr\n aggregations: AggregateSpec[]\n /** REQUIRED by the service — pass `[]` for a grand total. */\n groupBy: AggregateGroupBy[]\n having?: unknown\n sort?: Array<{ alias: string; dir: 'asc' | 'desc' }>\n limit?: number\n}\n\nexport interface AggregateResponse {\n rows: Array<Record<string, unknown>>\n}\n\nexport interface MetricQueryRequest {\n measures?: string[]\n dimensions?: string[]\n grain?: TimeGrain\n limit?: number\n}\n\n/**\n * One object, as the service returns it: the property bag itself.\n *\n * NOT wrapped in `{ objectType, primaryKey, properties }` — the instance route\n * responds with the properties flat under the envelope's `data`, so a wrapper\n * type here would make `.properties` permanently undefined and render an empty\n * detail panel with no error to explain it.\n */\nexport type ObjectInstance<TProps = Record<string, unknown>> = TProps\n\n/** Wrap an object set in a filter. `where` undefined returns the set unchanged. */\nexport function filtered(objectSet: ObjectSetExpr, where?: WhereNode): ObjectSetExpr {\n return where ? { type: 'filter', objectSet, where } : objectSet\n}\n\n/** The common case: one object type, optionally filtered. */\nexport function objectsOf(objectType: string, where?: WhereNode): ObjectSetExpr {\n return filtered({ type: 'base', objectType }, where)\n}\n",
|
|
23
23
|
"frontera/blueprint/hooks.ts": "import { createContext, useContext } from 'react'\nimport { useQuery, type UseQueryOptions, type UseQueryResult } from '@tanstack/react-query'\n\nimport type { BlueprintClient } from './blueprint-client'\nimport { DISTANCE_ROW_KEY, objectsOf } from './types'\nimport type {\n AggregateRequest,\n AggregateResponse,\n BlueprintFilterableProperty,\n BlueprintLocationProperty,\n BlueprintObjectName,\n BlueprintRow,\n BlueprintSortableProperty,\n MetricQueryRequest,\n NearestRequest,\n ObjectInstance,\n QueryRequest,\n QueryResponse,\n TypedWhereNode,\n WhereNode,\n} from './types'\n\n/**\n * Query-key factory.\n *\n * Requests are serialised into the key so two structurally equal requests share\n * a cache entry. Key order therefore matters: callers must build request\n * objects consistently, which they do because these hooks construct them.\n */\nexport const blueprintKeys = {\n all: ['blueprint'] as const,\n query: (request: QueryRequest) => ['blueprint', 'query', JSON.stringify(request)] as const,\n aggregate: (request: AggregateRequest) =>\n ['blueprint', 'aggregate', JSON.stringify(request)] as const,\n instance: (objectType: string, pk: string) =>\n ['blueprint', 'instance', objectType, pk] as const,\n metric: (apiName: string, request: MetricQueryRequest) =>\n ['blueprint', 'metric', apiName, JSON.stringify(request)] as const,\n}\n\nexport const FronteraBlueprintContext = createContext<BlueprintClient | null>(null)\n\nexport function useBlueprintClient(): BlueprintClient {\n const client = useContext(FronteraBlueprintContext)\n if (!client) {\n throw new Error(\n 'No BlueprintClient in context. Wrap the tree in FronteraBlueprintContext.Provider — createFronteraApp does this for you in a Frontera app.',\n )\n }\n return client\n}\n\ntype ReadOptions<TData> = Omit<UseQueryOptions<TData, Error>, 'queryKey' | 'queryFn' | 'select'>\n\n/** Run an arbitrary object-set query. */\nexport function useObjectQuery<TRow = Record<string, unknown>>(\n request: QueryRequest,\n options: ReadOptions<QueryResponse<TRow>> = {},\n): UseQueryResult<QueryResponse<TRow>, Error> {\n const client = useBlueprintClient()\n return useQuery({\n queryKey: blueprintKeys.query(request),\n queryFn: () => client.query<TRow>(request),\n ...options,\n })\n}\n\n/**\n * One object type, optionally filtered.\n *\n * `where` compiles into the object-set expression, so the SERVER filters and\n * pages. Filtering the returned rows in the component instead is the classic\n * mistake: it narrows only the page you happened to fetch, so a facet showing\n * 8,961 matches renders 5 rows and claims \"Page 1 of 1\".\n *\n * Paging is a CURSOR, not a page number. Hold the token from the previous\n * response and pass it back; `hasMore` is false and `nextPageToken` absent on\n * the last page. There is no total and no page count — an unordered scan cannot\n * produce one, which is exactly why offset paging was removed rather than left\n * to mislead.\n *\n * ```tsx\n * const [token, setToken] = useState<string | undefined>()\n * const page = useObjects<Ticket>('SupportTicket', { pageSize: 25, pageToken: token })\n * // next: setToken(page.data?.nextPageToken)\n * // restart: setToken(undefined)\n * ```\n */\ntype EffectiveRow<TLegacy, TObject extends BlueprintObjectName> =\n [TLegacy] extends [never] ? BlueprintRow<TObject> : TLegacy\n\ntype EffectiveFilterable<TLegacy, TObject extends BlueprintObjectName> =\n [TLegacy] extends [never]\n ? BlueprintFilterableProperty<TObject>\n : Extract<keyof TLegacy, string>\n\ntype EffectiveSortable<TLegacy, TObject extends BlueprintObjectName> =\n [TLegacy] extends [never]\n ? BlueprintSortableProperty<TObject>\n : Extract<keyof TLegacy, string>\n\n/** A location property of the row, or any name for an untyped object type. */\ntype EffectiveLocation<TLegacy, TObject extends BlueprintObjectName> =\n string extends EffectiveFilterable<TLegacy, TObject>\n ? string\n : BlueprintLocationProperty<EffectiveRow<TLegacy, TObject>>\n\ntype SelectedRow<TRow, TSelect> =\n undefined extends TSelect\n ? TRow\n : TSelect extends readonly (Extract<keyof TRow, string>)[]\n ? Pick<TRow, TSelect[number]>\n : TRow\n\n/** `nearest` adds the distance to every row, whatever `select` narrowed. */\ntype WithDistance<TRow, TNearest> =\n TNearest extends NearestRequest ? TRow & { [DISTANCE_ROW_KEY]: number } : TRow\n\ntype ObjectsRow<\n TLegacy,\n TObject extends BlueprintObjectName,\n TSelect,\n TNearest,\n> = WithDistance<SelectedRow<EffectiveRow<TLegacy, TObject>, TSelect>, TNearest>\n\ntype ObjectsOptions<\n TLegacy,\n TObject extends BlueprintObjectName,\n TSelect extends readonly Extract<keyof EffectiveRow<TLegacy, TObject>, string>[] | undefined,\n TNearest extends NearestRequest<EffectiveLocation<TLegacy, TObject>> | undefined,\n> = ReadOptions<QueryResponse<ObjectsRow<TLegacy, TObject, TSelect, TNearest>>> &\n Omit<QueryRequest, 'objectSet' | 'select' | 'orderBy' | 'nearest'> & {\n select?: TSelect\n orderBy?: Array<{ property: EffectiveSortable<TLegacy, TObject>; dir: 'asc' | 'desc' }>\n where?: TypedWhereNode<EffectiveRow<TLegacy, TObject>, EffectiveFilterable<TLegacy, TObject>>\n nearest?: TNearest\n }\n\nexport function useObjects<\n TLegacy extends object = never,\n const TObject extends BlueprintObjectName = BlueprintObjectName,\n const TSelect extends readonly Extract<keyof EffectiveRow<TLegacy, TObject>, string>[] | undefined =\n readonly Extract<keyof EffectiveRow<TLegacy, TObject>, string>[] | undefined,\n const TNearest extends NearestRequest<EffectiveLocation<TLegacy, TObject>> | undefined = undefined,\n>(\n objectType: TObject,\n options: ObjectsOptions<NoInfer<TLegacy>, TObject, TSelect, TNearest> = {},\n): UseQueryResult<QueryResponse<ObjectsRow<TLegacy, TObject, TSelect, TNearest>>, Error> {\n const { select, orderBy, pageSize, pageToken, where, nearest, ...queryOptions } = options\n return useObjectQuery<ObjectsRow<TLegacy, TObject, TSelect, TNearest>>(\n {\n objectSet: objectsOf(objectType, where as WhereNode | undefined),\n select: select ? [...select] : undefined,\n orderBy,\n pageSize,\n pageToken,\n ...(nearest ? { nearest } : {}),\n },\n queryOptions,\n )\n}\n\nexport function useAggregate(\n request: AggregateRequest,\n options: ReadOptions<AggregateResponse> = {},\n): UseQueryResult<AggregateResponse, Error> {\n const client = useBlueprintClient()\n return useQuery({\n queryKey: blueprintKeys.aggregate(request),\n queryFn: () => client.aggregate(request),\n ...options,\n })\n}\n\nexport function useObjectInstance<\n TLegacy extends object = never,\n const TObject extends BlueprintObjectName = BlueprintObjectName,\n>(\n objectType: TObject,\n pk: string | null | undefined,\n options: ReadOptions<ObjectInstance<EffectiveRow<TLegacy, TObject>>> = {},\n): UseQueryResult<ObjectInstance<EffectiveRow<TLegacy, TObject>>, Error> {\n const client = useBlueprintClient()\n return useQuery({\n queryKey: blueprintKeys.instance(objectType, pk ?? ''),\n queryFn: () => client.instance<TLegacy, TObject>(objectType, pk as string),\n enabled: Boolean(pk) && options.enabled !== false,\n ...options,\n })\n}\n\n/**\n * One metric the organization has already defined.\n *\n * A metric exists so every reader computes it the same way. Deriving the same\n * figure from raw columns in a component is how two dashboards end up\n * disagreeing about one number, and it is where the arithmetic bugs live — one\n * app shipped a share ratio whose numerator omitted the filter its denominator\n * applied, reading 551.7%, next to a defined metric that had been there all\n * along.\n *\n * This hook existed only as `client.metricQuery` for a while, so apps that\n * wanted a metric hand-rolled a hook around `useBlueprintClient` — the exact\n * detour the guidance tells authors not to take. Reach for this instead;\n * `useAggregate` is for figures nobody has defined yet.\n */\nexport function useMetric(\n apiName: string,\n request: MetricQueryRequest = {},\n options: ReadOptions<AggregateResponse> = {},\n): UseQueryResult<AggregateResponse, Error> {\n const client = useBlueprintClient()\n return useQuery({\n queryKey: blueprintKeys.metric(apiName, request),\n queryFn: () => client.metricQuery(apiName, request),\n ...options,\n })\n}\n",
|
|
24
24
|
"frontera/blueprint/provider.tsx": "import type { ReactNode } from 'react'\nimport type { FronteraClient } from '@frontera-sdk/core/client'\n\nimport { ActionClient } from './action-client'\nimport { FronteraActionContext } from './action-hooks'\nimport { BlueprintClient } from './blueprint-client'\nimport { FronteraBlueprintContext } from './hooks'\n\n/**\n * Plug Blueprint reads AND governed writes into `createFronteraApp`.\n *\n * The dependency runs one way — `@frontera-sdk/blueprint` knows about\n * `@frontera-sdk/core`, never the reverse — so the app entry point composes the\n * two rather than sdk-core importing a domain package it should not know exists.\n * That is why this is a callback the entry passes in:\n *\n * ```tsx\n * createFronteraApp(<App />, { providers: [blueprintProvider] })\n * ```\n *\n * Both clients come from ONE provider deliberately. Splitting them would mean\n * an app that reads compiles and an app that acts throws at runtime with\n * \"no ActionClient in context\" — a failure no type checks and every author\n * hits exactly once, on the line where they added their first button.\n *\n * Both are rebuilt whenever the host rotates the credential, because the\n * `FronteraClient` they wrap is replaced rather than mutated. That matters more\n * for writes than reads: a submit carrying a stale token is refused after the\n * user has already confirmed the thing they wanted to happen.\n */\nexport function blueprintProvider(\n value: { client: FronteraClient },\n children: ReactNode,\n): ReactNode {\n return (\n <FronteraBlueprintContext.Provider value={new BlueprintClient(value.client)}>\n <FronteraActionContext.Provider value={new ActionClient(value.client)}>\n {children}\n </FronteraActionContext.Provider>\n </FronteraBlueprintContext.Provider>\n )\n}\n",
|