lua-cli 3.44.0 → 3.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-exports.d.ts +633 -501
- package/dist/api-exports.js +1231 -262
- package/dist/api-exports.js.map +1 -1
- package/dist/index.js +2708 -1214
- package/dist/index.js.map +1 -1
- package/dist/voice/test/index.d.ts +462 -462
- package/dist/workflow-builder.d.ts +5 -1
- package/dist/workflow-builder.js +893 -49
- package/dist/workflow-builder.js.map +1 -1
- package/docs/API_REFERENCE.md +1 -1
- package/docs/README.md +1 -1
- package/docs/api/Agents.md +22 -10
- package/docs/api/Baskets.md +11 -1
- package/docs/api/Workflows.md +12 -4
- package/docs/workflows/approvals.md +2 -0
- package/docs/workflows/workspaces-and-long-steps.md +2 -0
- package/package.json +2 -2
- package/template/package.json +1 -1
- package/template-web-app/web/package.json +1 -0
- package/template-web-app/web/src/App.tsx +1 -1
- package/template-web-app/web/src/main.tsx +1 -1
- package/template-web-app/web/src/lua/client.ts +0 -264
package/docs/API_REFERENCE.md
CHANGED
|
@@ -717,7 +717,7 @@ if (Lua.request.webhook) {
|
|
|
717
717
|
|
|
718
718
|
**Properties:**
|
|
719
719
|
|
|
720
|
-
- `Lua.request.channel` - Channel type (`'whatsapp'`, `'web'
|
|
720
|
+
- `Lua.request.channel` - Channel type (`'whatsapp'`, `'pop'` for the embeddable chat widget, `'phone'` for voice, `'web'` for the Lua web workspace (not the widget), `'api'`, and others; unrecognised channels arrive as plain strings)
|
|
721
721
|
- `Lua.request.webhook` - Webhook info (if running in webhook)
|
|
722
722
|
- `Lua.request.agentId` - The agent handling this turn
|
|
723
723
|
- `Lua.request.orgId` - The agent's organization, on deployed runtimes that know it (absent offline in `lua test`
|
package/docs/README.md
CHANGED
package/docs/api/Agents.md
CHANGED
|
@@ -40,7 +40,7 @@ Supervision lets one agent observe, preview or release another agent in the same
|
|
|
40
40
|
|
|
41
41
|
| Action | Allows |
|
|
42
42
|
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
43
|
-
| `observe` | `logs` (without message bodies), `runs`, `data` (only the collections named on the grant), `versions.list/get/diff`, `persona.versions`, `source`, `env.keys`, `workflows.get`
|
|
43
|
+
| `observe` | `logs` (without message bodies), `runs`, `data` (only the collections named on the grant), `versions.list/get/diff`, `persona.versions`, `source`, `env.keys`, `workflows.get`, `deployed` |
|
|
44
44
|
| `observe-content` | Log message bodies in `logs` |
|
|
45
45
|
| `preview` | `versions.preview`: chat a staged version |
|
|
46
46
|
| `release` | `versions.promote`, `persona.activate`, `versions.delete` (a `superseded` version only), `workflows.create` (an inert, inactive workflow with no human author, so it cannot carry a schedule), `workflows.push` (a new, never-activated version of an existing workflow) and `workflows.deploy`. Promote, activate and deploy always need `expectActive`. Every release op runs only from a live workflow step of the supervisor whose workflow the grant names (`releaseWorkflowIds`). |
|
|
@@ -55,6 +55,10 @@ const errors = await Agents.logs('base_agent-tp', { level: 'error', limit: 20 })
|
|
|
55
55
|
const runs = await Agents.runs('base_agent-tp').list({ status: 'failed' });
|
|
56
56
|
const state = await Agents.data('base_agent-tp').get('pilot_state', 'config'); // collection named on the grant
|
|
57
57
|
const keys = await Agents.env('base_agent-tp').keys(); // names only, never values
|
|
58
|
+
// What it serves right now, even with no promoted agent version (activeAgentVersion: null): live skills and tools,
|
|
59
|
+
// the published persona and processors, each with the version id an agent version snapshot would pin, and the
|
|
60
|
+
// live model (live.model; compare with a version's snapshot.model, since a promote applies it).
|
|
61
|
+
const live = await Agents.deployed('base_agent-tp');
|
|
58
62
|
|
|
59
63
|
const versions = await Agents.versions('base_agent-tp').list();
|
|
60
64
|
const diff = await Agents.versions('base_agent-tp').diff(7, 8);
|
|
@@ -71,12 +75,16 @@ await Agents.workflows('base_agent-tp').push('wf_123', definition); // the body
|
|
|
71
75
|
// A workflow the target does not have yet: create it (inert), push, then deploy with expectActive: null.
|
|
72
76
|
// const { workflowId } = await Agents.workflows('base_agent-tp').create('nightly-digest');
|
|
73
77
|
await Agents.workflows('base_agent-tp').deploy('wf_123', '1.4.0', { expectActive: wf.activeVersionId });
|
|
78
|
+
// Optionally name the source commit the version was built from: deploy(wf, v, { expectActive, commitHash? })
|
|
74
79
|
|
|
75
80
|
// Clean up: only a superseded version can be deleted.
|
|
76
81
|
await Agents.versions('base_agent-tp').delete(5);
|
|
77
82
|
```
|
|
78
83
|
|
|
84
|
+
> **Experimental.** `Agents.jobs(agentId).register({ versionId, jobId })` registers job `jobId`, defined in the staged version `versionId`, on the target. It is available in a deployed agent only once the platform serves `jobs.register`, and needs the `supervision:register-job` grant (given per supervisor and target by an org owner or admin, scoped to jobs defined in the staged `versionId`). Without it the call is refused with `supervision_not_granted`. Under `lua test` / `lua dev` it is always refused with `supervision_release_offline_refused`, with no network call. An empty `versionId` or `jobId` is refused with `supervision_bad_args`, and an empty agent id with `supervision_no_target`.
|
|
85
|
+
|
|
79
86
|
- A workflow deploy also records a new active agent version. Read `versions.list` again before a later `promote`.
|
|
87
|
+
- `commitHash` on `workflows.deploy` is optional and forwarded unchanged. A call without it behaves as before, and the platform may fill it from the Job workspace.
|
|
80
88
|
|
|
81
89
|
### Platform switches
|
|
82
90
|
|
|
@@ -88,13 +96,14 @@ await Agents.versions('base_agent-tp').delete(5);
|
|
|
88
96
|
|
|
89
97
|
### What a supervisor can and cannot see
|
|
90
98
|
|
|
91
|
-
| Surface
|
|
92
|
-
|
|
|
93
|
-
| Logs
|
|
94
|
-
| Runs
|
|
95
|
-
| Data
|
|
96
|
-
| Env
|
|
97
|
-
|
|
|
99
|
+
| Surface | What is returned |
|
|
100
|
+
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
101
|
+
| Logs | Timestamp, level, log source and primitive or tool name. No user ids. The window is at most 7 days and at most 100 rows. The message (truncated to 2,000 characters) is included only with `observe-content`. |
|
|
102
|
+
| Runs | Metadata only: id, workflow, status, reason, trigger and times. No inputs or outputs. |
|
|
103
|
+
| Data | Read-only. Only the collections the org admin named on the grant (`dataCollections`). |
|
|
104
|
+
| Env | Variable names only. |
|
|
105
|
+
| Deployed | Live skills (tool names, bundle hashes, whether source is attached), the published persona (id, version, content hash) and processors, with version ids. No code, tool source or persona text. |
|
|
106
|
+
| Source | The files in the target's source backup, each up to 512 KB. Backup exclusions and credential-like paths are never listed or returned: dotfiles, `.env*`, `*.pem`, `*.key`, `id_rsa*`, `.npmrc`, `credentials*`, `*secret*`. |
|
|
98
107
|
|
|
99
108
|
### Guarantees
|
|
100
109
|
|
|
@@ -108,14 +117,17 @@ await Agents.versions('base_agent-tp').delete(5);
|
|
|
108
117
|
### Offline (`lua test`, `lua dev`)
|
|
109
118
|
|
|
110
119
|
- **Read members** call the developer API with **your** credential and mirror the projections above: env keys only, and logs without user ids or message bodies.
|
|
111
|
-
- **Refused offline:** `versions.promote`, `persona.activate`, `versions.delete`, `workflows.create`, `workflows.push`, `workflows.deploy`, `versions.preview` and `data`. A local test never changes a live agent, and readable collections live on the grant.
|
|
112
|
-
- **Not available offline:** `Agents.list`, `Agents.get` and `
|
|
120
|
+
- **Refused offline:** `versions.promote`, `persona.activate`, `versions.delete`, `workflows.create`, `workflows.push`, `workflows.deploy`, `jobs.register` (experimental), `versions.preview` and `data`. A local test never changes a live agent, and readable collections live on the grant.
|
|
121
|
+
- **Not available offline:** `Agents.list`, `Agents.get`, `source.get` and `Agents.deployed` (`supervision_offline_unsupported`).
|
|
113
122
|
|
|
114
123
|
### Errors
|
|
115
124
|
|
|
116
125
|
Every error is a `SupervisionError` with a `code`:
|
|
117
126
|
|
|
118
127
|
- `supervision_not_granted`
|
|
128
|
+
- `supervision_release_offline_refused`, for a release op (or the experimental `jobs.register`) run offline
|
|
129
|
+
- `supervision_bad_args`, a local check of the experimental `jobs.register`: an empty `versionId` or `jobId`
|
|
130
|
+
- `supervision_no_target`, a local check: no agent id is given
|
|
119
131
|
- `supervision_not_found`, for another org or an unknown agent
|
|
120
132
|
- `supervision_self_refused`
|
|
121
133
|
- `supervision_release_binding_required` and `supervision_release_binding_refused`
|
package/docs/api/Baskets.md
CHANGED
|
@@ -32,10 +32,13 @@ The Baskets API enables:
|
|
|
32
32
|
enum BasketStatus {
|
|
33
33
|
ACTIVE = 'active',
|
|
34
34
|
CHECKED_OUT = 'checked_out',
|
|
35
|
-
ABANDONED = 'abandoned'
|
|
35
|
+
ABANDONED = 'abandoned',
|
|
36
|
+
EXPIRED = 'expired'
|
|
36
37
|
}
|
|
37
38
|
```
|
|
38
39
|
|
|
40
|
+
`Baskets.get(status)` and `basket.updateStatus(status)` reject any other value (including a different letter case) with HTTP 400 `Invalid basket status '<value>'. Allowed: active, checked_out, abandoned, expired`, before anything is read or persisted. A missing basket is HTTP 404 and a failure inside the commerce service is a generic HTTP 500 (503 when it is unreachable). The SDK surfaces each as a thrown `Error`; there is no `success: false` body to branch on.
|
|
41
|
+
|
|
39
42
|
## BasketInstance Methods
|
|
40
43
|
|
|
41
44
|
Once you have a basket instance, you can call these methods:
|
|
@@ -112,6 +115,13 @@ Updates the basket's status.
|
|
|
112
115
|
|
|
113
116
|
**Returns:** `Promise<Basket>` - Updated basket data
|
|
114
117
|
|
|
118
|
+
**Errors:**
|
|
119
|
+
- An unknown status is rejected with HTTP 400 `Invalid basket status '<value>'. Allowed: active, checked_out, abandoned, expired`; nothing is persisted.
|
|
120
|
+
- A missing basket is HTTP 404.
|
|
121
|
+
- A failure inside the commerce service is a generic HTTP 500 (503 when the commerce service itself is down or unreachable).
|
|
122
|
+
|
|
123
|
+
The SDK surfaces each of these as a thrown `Error`; there is no `success: false` body to branch on.
|
|
124
|
+
|
|
115
125
|
**Example:**
|
|
116
126
|
```typescript
|
|
117
127
|
await basket.updateStatus(BasketStatus.ABANDONED);
|
package/docs/api/Workflows.md
CHANGED
|
@@ -76,7 +76,7 @@ Returns the run summary (status, counts, gate, lineage, `output` when readable).
|
|
|
76
76
|
|
|
77
77
|
### `Workflows.list(opts?)`
|
|
78
78
|
|
|
79
|
-
`opts`: `{ limit?, status?, workflow?, correlationKey?, tags?, sort? }` — `tags` are AND-ed; `sort` is one of `-createdAt | createdAt | -durationMs | durationMs`.
|
|
79
|
+
`opts`: `{ limit?, status?, workflow?, correlationKey?, tags?, sort? }` — `tags` are AND-ed; `sort` is one of `-createdAt | createdAt | -durationMs | durationMs`. `workflow` is a name or an id; an id (UUID, `wf_…`, `wfc_…`) is queried as-is. Under `lua test` a name that matches no workflow definition returns `[]`, and a failed definition lookup throws a typed `WorkflowApiError`; deployed, the bridge matches runs by recorded name or id. A `workflow` filter never widens to every run in either runtime.
|
|
80
80
|
|
|
81
81
|
### `Workflows.cancel(runId, opts?)`
|
|
82
82
|
|
|
@@ -90,6 +90,12 @@ Resumes a step suspended with `ctx.suspend(...)`. The loser of a resume race rec
|
|
|
90
90
|
|
|
91
91
|
Delivers a named signal to a run waiting on `waitForSignal(name)`. `opts.dedupeKey` makes a re-send a no-op (`{ accepted: true, duplicate: true }`). Returns `{ accepted, reason?: 'source_not_accepted' | 'duplicate' | 'parked' }`. The waiting step completes with `{ payload, source: { kind, id, principalId? }, signalId, receivedAt }` — read the payload as `stepResults.<id>.payload`.
|
|
92
92
|
|
|
93
|
+
### `Workflows.raiseBudget(runId, patch)`
|
|
94
|
+
|
|
95
|
+
Raises the caps of one of the calling agent's runs. `patch` is `{ maxCredits?, maxSteps?, maxJobSeconds?, maxDurationSeconds?, note? }` — at least one cap, increases only, each at or under the org cap. Returns `{ raised: true, budget, runStatus, resumed }`, or the idempotent `{ raised: false, reason: 'already_raised' }` when the same raise was already applied.
|
|
96
|
+
|
|
97
|
+
Only a run parked on its budget gate, or still queued/running, is raisable; any other state (including a terminal run, or a run past its per-run raise limit) throws `BUDGET_NOT_RAISABLE` (409). Only the run's creator/holder or an org admin may raise it (`NOT_RUN_CREATOR`, 403 — so a call from a job or workflow step with no user fails closed). A run that is not the calling agent's throws `RUN_NOT_FOUND` (404). An over-cap value throws `CAP_EXCEEDED` (400, with `cap`, `value`, `ceiling`); a malformed patch throws `VALIDATION_FAILED` (400). Behaviour is the same under `lua test` and deployed.
|
|
98
|
+
|
|
93
99
|
#### Reserved payload keys
|
|
94
100
|
|
|
95
101
|
The run ledger redacts by **key name**, not by value: a payload member whose key is one of
|
|
@@ -100,14 +106,16 @@ is persisted as `[REDACTED]` whatever it holds. A key matches when one of those
|
|
|
100
106
|
|
|
101
107
|
This is by design (a credential must never land in a run), so the placeholder is what `stepResults.<id>.payload`, the run output, `lua workflows status --steps`, exports and the chat run card show — a workflow cannot read a data field with one of these names back. Name data fields otherwise (`ticketRef`, `handle`, `code`, `verifier`). The same rule applies to every step output and suspend payload. A `waitForSignal` whose `schema` declares a property with a reserved name draws the non-blocking `signal-reserved-key` warning at push / compose (the property, the placeholder it would read as, the rename), and `lua workflows signal` warns before sending a payload that carries a reserved key (the signal is still delivered).
|
|
102
108
|
|
|
103
|
-
###
|
|
109
|
+
### Other members
|
|
104
110
|
|
|
105
|
-
`startBatch`, `signalByKey`, `
|
|
111
|
+
`startBatch`, `signalByKey`, `setGoal` and `goals.{list,get,pause,resume,close}` are part of the API surface (the member list is frozen). For `startBatch`: from an agent turn, prefer the `startWorkflowRunBatch` tool (one consent card for the whole batch, D23-r5).
|
|
106
112
|
|
|
107
113
|
## Errors
|
|
108
114
|
|
|
109
115
|
All failures throw `WorkflowApiError` with `code` (the server discriminator — `RUNS_IN_FLIGHT`, `SUSPENDED_CAP`, `WORKFLOW_NOT_FOUND`, `CONTROL_UNAVAILABLE`, …), `statusCode`, and any extra fields the server returned (for example `blockingRunId`).
|
|
110
116
|
|
|
117
|
+
- `WORKFLOW_INACTIVE` (409): the workflow was deactivated (`lua workflows deactivate`), so `Workflows.start` by name or by id starts no new run. Runs already going keep going; activate the workflow to start again.
|
|
118
|
+
|
|
111
119
|
```typescript
|
|
112
120
|
try {
|
|
113
121
|
await Workflows.start('nightly-sync', {});
|
|
@@ -130,7 +138,7 @@ lua test workflow outreach --input @leads.json \
|
|
|
130
138
|
|
|
131
139
|
- `--step-output <id>=@file|<json>` completes a step without running it (validated against its `outputSchema`) — this is how a predicate over an agent output gets both truth values under the default `--agents fake`.
|
|
132
140
|
- `--approve <id>[=@payload]` / `--deny <id>[=@reason]` pre-answer approvals; `--signal <name>=<json>` pre-supplies `waitForSignal` payloads in order.
|
|
133
|
-
- `--fixtures <dir>` replays recorded
|
|
141
|
+
- `--fixtures <dir>` replays recorded fixtures (`--record <dir>` writes each live agent reply — requires `--agents live`; tool steps never run offline, so nothing else is recorded); a missing fixture exits **5** — never a silent fake.
|
|
134
142
|
- `--from-run <runId>` seeds every completed step from a real run and restarts at the first non-completed one.
|
|
135
143
|
- `--ledger-out <file>` writes the in-memory ledger; `lua workflows replay <runId> --local` re-evaluates a real run's predicates and mappings against your compiled artifact and reports `LEDGER_DIVERGENCE`.
|
|
136
144
|
- `--park <id>` simulates a platform-fault park of a `sideEffects:'external'` step (retry / skip / complete / fail, same verbs as production); `--fast-retries` collapses retry backoff to 0; `--real-time` actually waits instead of fast-forwarding the virtual clock; `--artefacts-dir <dir>` backs `ctx.artefacts.*` on disk.
|
|
@@ -46,6 +46,8 @@ Offline, `lua workflows run --approve <id>` completes the node with `decidedBy:
|
|
|
46
46
|
|
|
47
47
|
`timeoutHours` runs on **business time** when `businessHours: { tz, calendar }` is set. `onTimeout` is a chain: each `{ escalateTo, timeoutHours }` hop re-prompts the next audience; the terminal `'deny'` (or `'cancel-run'`/`'continue'`) completes the node — e.g. `{approved:false, timedOut:true, escalations:2}`.
|
|
48
48
|
|
|
49
|
+
A numeric `timeoutHours` (on `approval` and `waitForSignal`) must be in 1..720: outside that range `lua compile` and `lua push` refuse it as `timeout-hours-out-of-range`. A `waitForSignal` signal name must match `[a-zA-Z0-9_.-]{1,64}` or it is refused as `signal-name-invalid`. The run's own wall (7 days by default) still caps the live deadline, so a window longer than the run wall is clamped at runtime (the `deadline-clamped` warning).
|
|
50
|
+
|
|
49
51
|
## Reminders
|
|
50
52
|
|
|
51
53
|
`remindAfterHours: 24` re-sends the approval push to the approvers who still have to decide, every 24 hours while the approval is pending: at most **3 reminders** per ask (an escalation hop is a new ask, so its count starts again), and never at or after the deadline. The reminder replaces the earlier notification in place (same collapse key) and reads "Reminder: …". It takes a plain number of hours in `1..720` (no binding); anything else is refused as `remind-after-invalid`, and a value at or above the first deadline pushes with the `remind-after-exceeds-timeout` warning, because it can never fire. The first deadline is the first escalation hop's `timeoutHours` when `onTimeout` starts with a hop, otherwise `timeoutHours` (168 hours when you leave it out). Reminders ride the approval **push**, so they reach the approvers an agent's approval routing names (shared-agent approvers); an approval with no routing gets the inbox row and the channel fallback but no push, and so no reminder. They are sent on the platform's sweep cadence, so a reminder can arrive a few minutes after it is due, and quiet hours and do-not-disturb drop it like any other push.
|
|
@@ -14,6 +14,8 @@ _Source of truth: workflows-spec 05 §5.17 (D19-r1/r2). This page is the develop
|
|
|
14
14
|
|
|
15
15
|
**Suspension does not release the workspace.** When your run waits for a person or a signal, its volume is left exactly as it was — it keeps holding one of the org's `maxWorkspacesPerOrg` slots until a terminal reason releases it, or, for a kept `empty` workspace, `ttlHours` expires (168 h at most once the ceiling is enabled). Releasing a workspace at suspension is not implemented yet (tracked as SA-134); until then, commit before a step suspends so your work is durable on the run branch `lua/wf-<lineageId>` independent of the volume.
|
|
16
16
|
|
|
17
|
+
**Storage is metered.** A Job-tier workspace volume, and every worktree-arm clone volume, is metered as GiB-hours (`sizeGb` × hours held, summed over the clones) on a `workflow:storage` usage record written when the volume is deleted; a held volume counts until its TTL expiry. The record is attributed to your organization and is for visibility only: it does not charge credits or touch the wallet, and whether storage is billed is a separate platform decision.
|
|
18
|
+
|
|
17
19
|
**Worktree arms:** parallel arms never share a filesystem; each gets its own clone and branch and the `merge` step integrates them — put `verify` on the workspace so an agent-resolved conflict must pass your tests. `onConflict:'agent'` lets one resolver turn fix conflicts.
|
|
18
20
|
|
|
19
21
|
## Commands from a code step — `ctx.exec` / `ctx.$`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lua-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.46.0",
|
|
4
4
|
"description": "Build, test, and deploy AI agents with custom tools, webhooks, and scheduled jobs. Features LuaAgent unified configuration, streaming chat, and batch deployment.",
|
|
5
5
|
"readmeFilename": "README.md",
|
|
6
6
|
"main": "dist/api-exports.js",
|
|
@@ -87,7 +87,7 @@
|
|
|
87
87
|
"dotenv": "^17.2.3",
|
|
88
88
|
"esbuild": "^0.28.1",
|
|
89
89
|
"inquirer": "^12.9.6",
|
|
90
|
-
"js-yaml": "^4.
|
|
90
|
+
"js-yaml": "^4.3.2",
|
|
91
91
|
"open": "^10.1.0",
|
|
92
92
|
"posthog-node": "^5.26.0",
|
|
93
93
|
"ts-evaluator": "^2.0.0",
|
package/template/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { Button } from '@lua-ai-global/ui/button';
|
|
3
3
|
import { Card } from '@lua-ai-global/ui/card';
|
|
4
|
-
import { lua } from '
|
|
4
|
+
import { lua } from '@lua-ai-global/app-client';
|
|
5
5
|
|
|
6
6
|
export function App() {
|
|
7
7
|
const [message, setMessage] = useState<string>();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { StrictMode } from 'react';
|
|
2
2
|
import { createRoot } from 'react-dom/client';
|
|
3
3
|
import { App } from './App';
|
|
4
|
-
import { lua } from '
|
|
4
|
+
import { lua } from '@lua-ai-global/app-client';
|
|
5
5
|
import './index.css';
|
|
6
6
|
|
|
7
7
|
// Theme follows the shell: first from the bootstrap, then from the host's `init`.
|
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Lua app client — the page side of a Lua web app.
|
|
3
|
-
*
|
|
4
|
-
* - Session: the shell opens this app in an iframe with a single-use handoff
|
|
5
|
-
* code in the URL fragment (`#code=…`). The client exchanges it with lua-auth
|
|
6
|
-
* for a Firebase custom token, signs in (identitytoolkit) and keeps the ID
|
|
7
|
-
* token in memory only, refreshing it (securetoken) before it expires. No
|
|
8
|
-
* cookie, no localStorage: a reload asks the shell for a new code.
|
|
9
|
-
* - Bridge (protocol v1): posts `lua:ready` once loaded, takes `lua:init`
|
|
10
|
-
* (theme, density, locale, path) from the host, posts `lua:auth:expired`
|
|
11
|
-
* when the session cannot be renewed so the shell re-issues a code. Messages
|
|
12
|
-
* go only to, and are only accepted from, the shells the gateway lists in
|
|
13
|
-
* the bootstrap (`hostOrigins`, the same list as the CSP's frame-ancestors).
|
|
14
|
-
* - `api(method, path, body?)`: calls a route of this app through the gateway
|
|
15
|
-
* (`/_lua/api/<path>`) with the session. A 401 renews the token once and
|
|
16
|
-
* retries; only a second 401 ends the session.
|
|
17
|
-
*
|
|
18
|
-
* Under `lua apps dev` the bootstrap says `dev: true`: there is no handoff and
|
|
19
|
-
* the dev server runs routes as the developer.
|
|
20
|
-
*
|
|
21
|
-
* Generated by `lua apps new`; yours to edit. (A packaged `@lua-ai-global/app-client`
|
|
22
|
-
* replaces this file later.)
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
export interface LuaBootstrap {
|
|
26
|
-
v: 1;
|
|
27
|
-
app: { id: string; name: string; version: string; deployment: number; origin: string };
|
|
28
|
-
apiBase: string;
|
|
29
|
-
manifest: string;
|
|
30
|
-
auth: { origin: string; exchangePath: string; audience: string; firebaseApiKey?: string };
|
|
31
|
-
embedded: boolean;
|
|
32
|
-
/** The shells allowed to frame this app; the bridge talks to these only. */
|
|
33
|
-
hostOrigins?: string[];
|
|
34
|
-
theme: 'light' | 'dark';
|
|
35
|
-
density: 'compact' | 'comfortable';
|
|
36
|
-
preview: boolean;
|
|
37
|
-
/** Set by `lua apps dev` only. */
|
|
38
|
-
dev?: boolean;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface LuaInit {
|
|
42
|
-
v: 1;
|
|
43
|
-
theme?: 'light' | 'dark';
|
|
44
|
-
density?: 'compact' | 'comfortable';
|
|
45
|
-
locale?: string;
|
|
46
|
-
path?: string;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
declare global {
|
|
50
|
-
interface Window {
|
|
51
|
-
__LUA__?: LuaBootstrap;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const BRIDGE_VERSION = 1;
|
|
56
|
-
const IDENTITY = 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken';
|
|
57
|
-
const SECURE_TOKEN = 'https://securetoken.googleapis.com/v1/token';
|
|
58
|
-
/** Renew this long before the ID token expires. */
|
|
59
|
-
const REFRESH_MARGIN_MS = 5 * 60_000;
|
|
60
|
-
|
|
61
|
-
export class LuaApiError extends Error {
|
|
62
|
-
constructor(
|
|
63
|
-
readonly status: number,
|
|
64
|
-
readonly code: string | undefined,
|
|
65
|
-
message: string,
|
|
66
|
-
readonly executionId?: string
|
|
67
|
-
) {
|
|
68
|
-
super(message);
|
|
69
|
-
this.name = 'LuaApiError';
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
interface Tokens {
|
|
74
|
-
idToken: string;
|
|
75
|
-
refreshToken: string;
|
|
76
|
-
expiresInS: number;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const nonEmpty = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
|
|
80
|
-
|
|
81
|
-
/** A token response is only a session when every field is there; anything else is "no session". */
|
|
82
|
-
function tokensFrom(idToken: unknown, refreshToken: unknown, expiresIn: unknown): Tokens | null {
|
|
83
|
-
const expiresInS = Number(expiresIn);
|
|
84
|
-
if (!nonEmpty(idToken) || !nonEmpty(refreshToken) || !Number.isFinite(expiresInS) || expiresInS <= 0) return null;
|
|
85
|
-
return { idToken, refreshToken, expiresInS };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async function jsonOrNull(response: Response | undefined): Promise<Record<string, unknown> | null> {
|
|
89
|
-
if (!response?.ok) return null;
|
|
90
|
-
try {
|
|
91
|
-
const body = await response.json();
|
|
92
|
-
return body && typeof body === 'object' ? (body as Record<string, unknown>) : null;
|
|
93
|
-
} catch {
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
class LuaAppClient {
|
|
99
|
-
private readonly boot: LuaBootstrap | undefined = window.__LUA__;
|
|
100
|
-
private idToken: string | undefined;
|
|
101
|
-
private refreshToken: string | undefined;
|
|
102
|
-
private refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
|
103
|
-
private renewing: Promise<boolean> | undefined;
|
|
104
|
-
private session: Promise<void> | undefined;
|
|
105
|
-
private readonly initListeners: Array<(init: LuaInit) => void> = [];
|
|
106
|
-
|
|
107
|
-
get appName(): string {
|
|
108
|
-
return this.boot?.app.name ?? document.title;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
get theme(): 'light' | 'dark' {
|
|
112
|
-
return this.boot?.theme ?? 'dark';
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
onInit(listener: (init: LuaInit) => void): void {
|
|
116
|
-
this.initListeners.push(listener);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Take the handoff code, open the session, tell the host we are ready. */
|
|
120
|
-
start(): Promise<void> {
|
|
121
|
-
if (!this.session) {
|
|
122
|
-
window.addEventListener('message', (event) => {
|
|
123
|
-
// Only the shell that framed us, from an origin the gateway allows, speaks the bridge.
|
|
124
|
-
if (event.source !== window.parent || !this.hostOrigins().includes(event.origin)) return;
|
|
125
|
-
if (event.data?.type !== 'lua:init') return;
|
|
126
|
-
for (const listener of this.initListeners) listener(event.data as LuaInit);
|
|
127
|
-
});
|
|
128
|
-
this.session = this.openSession().finally(() => this.post({ type: 'lua:ready', v: BRIDGE_VERSION }));
|
|
129
|
-
}
|
|
130
|
-
return this.session;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async api<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
134
|
-
await this.start();
|
|
135
|
-
let response = await this.send(method, path, body);
|
|
136
|
-
// A 401 usually means the ID token lapsed between refreshes: renew once and retry once.
|
|
137
|
-
if (response.status === 401 && (await this.renew())) response = await this.send(method, path, body);
|
|
138
|
-
if (response.status === 401) this.expired();
|
|
139
|
-
const text = await response.text();
|
|
140
|
-
let data: any;
|
|
141
|
-
try {
|
|
142
|
-
data = text ? JSON.parse(text) : undefined;
|
|
143
|
-
} catch {
|
|
144
|
-
data = text;
|
|
145
|
-
}
|
|
146
|
-
if (!response.ok) {
|
|
147
|
-
throw new LuaApiError(
|
|
148
|
-
response.status,
|
|
149
|
-
data?.code,
|
|
150
|
-
data?.message ?? response.statusText,
|
|
151
|
-
response.headers.get('x-lua-execution-id') ?? undefined
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
return data as T;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
private send(method: string, path: string, body?: unknown): Promise<Response> {
|
|
158
|
-
const base = this.boot?.apiBase ?? '/_lua/api';
|
|
159
|
-
const headers: Record<string, string> = { accept: 'application/json' };
|
|
160
|
-
if (this.idToken) headers.authorization = `Bearer ${this.idToken}`;
|
|
161
|
-
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
162
|
-
return fetch(`${base}${path.startsWith('/') ? path : `/${path}`}`, {
|
|
163
|
-
method,
|
|
164
|
-
headers,
|
|
165
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
private async openSession(): Promise<void> {
|
|
170
|
-
const boot = this.boot;
|
|
171
|
-
if (!boot || boot.dev) return; // `lua apps dev`: the dev server is the caller's session.
|
|
172
|
-
const code = new URLSearchParams(location.hash.slice(1)).get('code');
|
|
173
|
-
// The code is single-use: drop it from the URL before anything can copy it.
|
|
174
|
-
if (code) history.replaceState(null, '', location.pathname + location.search);
|
|
175
|
-
if (!code || !boot.auth.firebaseApiKey) return this.expired();
|
|
176
|
-
|
|
177
|
-
const exchanged = await jsonOrNull(
|
|
178
|
-
await fetch(`${boot.auth.origin}${boot.auth.exchangePath}`, {
|
|
179
|
-
method: 'POST',
|
|
180
|
-
headers: { 'content-type': 'application/json' },
|
|
181
|
-
body: JSON.stringify({ code, audience: boot.auth.audience }),
|
|
182
|
-
}).catch(() => undefined)
|
|
183
|
-
);
|
|
184
|
-
if (!nonEmpty(exchanged?.token)) return this.expired();
|
|
185
|
-
|
|
186
|
-
const signedIn = await jsonOrNull(
|
|
187
|
-
await fetch(`${IDENTITY}?key=${encodeURIComponent(boot.auth.firebaseApiKey)}`, {
|
|
188
|
-
method: 'POST',
|
|
189
|
-
headers: { 'content-type': 'application/json' },
|
|
190
|
-
body: JSON.stringify({ token: exchanged!.token, returnSecureToken: true }),
|
|
191
|
-
}).catch(() => undefined)
|
|
192
|
-
);
|
|
193
|
-
const tokens = signedIn && tokensFrom(signedIn.idToken, signedIn.refreshToken, signedIn.expiresIn);
|
|
194
|
-
if (!tokens) return this.expired();
|
|
195
|
-
this.keep(tokens);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
private keep(tokens: Tokens): void {
|
|
199
|
-
this.idToken = tokens.idToken;
|
|
200
|
-
this.refreshToken = tokens.refreshToken;
|
|
201
|
-
clearTimeout(this.refreshTimer);
|
|
202
|
-
this.refreshTimer = setTimeout(
|
|
203
|
-
() => void this.renew().then((ok) => ok || this.expired()),
|
|
204
|
-
Math.max(tokens.expiresInS * 1000 - REFRESH_MARGIN_MS, 30_000)
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** Swap the refresh token for a new ID token. One renewal at a time; `false` when it cannot be done. */
|
|
209
|
-
private renew(): Promise<boolean> {
|
|
210
|
-
if (!this.renewing) {
|
|
211
|
-
this.renewing = this.refreshOnce().finally(() => (this.renewing = undefined));
|
|
212
|
-
}
|
|
213
|
-
return this.renewing;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
private async refreshOnce(): Promise<boolean> {
|
|
217
|
-
const key = this.boot?.auth.firebaseApiKey;
|
|
218
|
-
if (!key || !this.refreshToken) return false;
|
|
219
|
-
const r = await jsonOrNull(
|
|
220
|
-
await fetch(`${SECURE_TOKEN}?key=${encodeURIComponent(key)}`, {
|
|
221
|
-
method: 'POST',
|
|
222
|
-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
223
|
-
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: this.refreshToken }),
|
|
224
|
-
}).catch(() => undefined)
|
|
225
|
-
);
|
|
226
|
-
const tokens = r && tokensFrom(r.id_token, r.refresh_token, r.expires_in);
|
|
227
|
-
if (!tokens) return false;
|
|
228
|
-
this.keep(tokens);
|
|
229
|
-
return true;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/** The session is gone: the shell answers with a fresh handoff code. */
|
|
233
|
-
private expired(): void {
|
|
234
|
-
this.idToken = undefined;
|
|
235
|
-
this.refreshToken = undefined;
|
|
236
|
-
clearTimeout(this.refreshTimer);
|
|
237
|
-
this.post({ type: 'lua:auth:expired', v: BRIDGE_VERSION });
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
private hostOrigins(): string[] {
|
|
241
|
-
return this.boot?.hostOrigins ?? [];
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* One post per allowed shell origin: the browser delivers it only when the
|
|
246
|
-
* framing window really has that origin, so no other page ever receives it.
|
|
247
|
-
* A browser refuses some shell origins as a target outright (a web browser
|
|
248
|
-
* throws on the desktop app's `tauri://localhost`), so each post stands
|
|
249
|
-
* alone: one origin this browser cannot address must not stop the others,
|
|
250
|
-
* or fail the session that posts `lua:ready` when it opens.
|
|
251
|
-
*/
|
|
252
|
-
private post(message: { type: string; v: number }): void {
|
|
253
|
-
if (window.parent === window) return;
|
|
254
|
-
for (const origin of this.hostOrigins()) {
|
|
255
|
-
try {
|
|
256
|
-
window.parent.postMessage(message, origin);
|
|
257
|
-
} catch {
|
|
258
|
-
// Not addressable from this browser; the shell that framed us has one of the others.
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
export const lua = new LuaAppClient();
|