lua-cli 3.41.0 → 3.42.0-beta.2
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 +2425 -126
- package/dist/api-exports.js +17963 -11393
- package/dist/api-exports.js.map +1 -1
- package/dist/index.js +11147 -1071
- package/dist/index.js.map +1 -1
- package/dist/voice/test/index.d.ts +72 -72
- package/dist/workflow-builder.d.ts +16 -2
- package/dist/workflow-builder.js +5666 -317
- package/dist/workflow-builder.js.map +1 -1
- package/docs/API_REFERENCE.md +7 -1
- package/docs/api/AI.md +3 -1
- package/docs/api/Agents.md +197 -12
- package/docs/api/Data.md +192 -61
- package/docs/api/Jobs.md +2 -0
- package/docs/api/LuaWebhook.md +128 -85
- package/docs/api/LuaWorkflow.md +15 -15
- package/docs/api/Orders.md +7 -5
- package/docs/api/Products.md +1 -1
- package/docs/api/Progress.md +57 -0
- package/docs/api/Threads.md +122 -0
- package/docs/api/User.md +2 -2
- package/docs/api/Workflows.md +22 -1
- package/docs/workflows/approvals.md +4 -0
- package/docs/workflows/coding-harness.md +27 -1
- package/docs/workflows/reply-channels.md +2 -2
- package/docs/workflows/workspaces-and-long-steps.md +3 -1
- package/package.json +2 -1
- package/scripts/generate-catalog.lib.ts +813 -0
- package/scripts/generate-catalog.ts +94 -0
- package/template/package.json +1 -1
- package/template/tsconfig.json +1 -1
- package/template-web-app/app.ts +29 -0
- package/template-web-app/web/index.html +12 -0
- package/template-web-app/web/package.json +27 -0
- package/template-web-app/web/src/App.tsx +28 -0
- package/template-web-app/web/src/index.css +3 -0
- package/template-web-app/web/src/lua/client.ts +264 -0
- package/template-web-app/web/src/main.tsx +16 -0
- package/template-web-app/web/tsconfig.json +14 -0
- package/template-web-app/web/vite.config.ts +9 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* U34 (LUA-1008) — generate the machine-readable platform catalogue (`catalog.json`) for a lua-cli release,
|
|
3
|
+
* and optionally diff it against the previous release's catalogue.
|
|
4
|
+
*
|
|
5
|
+
* Run from the repo root after `pnpm run build:shared` (an unbuilt shared dist fails the run on purpose —
|
|
6
|
+
* otherwise every imported SDK type would read as `any`):
|
|
7
|
+
*
|
|
8
|
+
* node_modules/.bin/tsx packages/lua-cli/scripts/generate-catalog.ts \
|
|
9
|
+
* --out packages/lua-cli/temp/catalog/catalog.json \
|
|
10
|
+
* [--previous <previous catalog.json>] \
|
|
11
|
+
* [--diff-out packages/lua-cli/temp/catalog/catalog-diff.json] \
|
|
12
|
+
* [--summary <markdown file to append the diff to, e.g. $GITHUB_STEP_SUMMARY>]
|
|
13
|
+
*
|
|
14
|
+
* `--previous` pointing at a missing file is not an error: the diff then lists everything as added.
|
|
15
|
+
* The logic lives in `generate-catalog.lib.ts` (fixture-tested); this file only wires argv and files.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
20
|
+
import { WAVE2_FLAGS, WAVE2_REUSED_FLAGS } from '@lua/shared-types';
|
|
21
|
+
import type { PlatformCatalog } from '@lua/shared-types';
|
|
22
|
+
import {
|
|
23
|
+
CatalogError,
|
|
24
|
+
diffCatalogs,
|
|
25
|
+
generateCatalog,
|
|
26
|
+
renderDiffMarkdown,
|
|
27
|
+
serializeCatalog,
|
|
28
|
+
type CatalogStatusManifest,
|
|
29
|
+
} from './generate-catalog.lib.js';
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv: string[]): Record<string, string> {
|
|
32
|
+
const out: Record<string, string> = {};
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
const arg = argv[i];
|
|
35
|
+
if (!arg.startsWith('--')) throw new CatalogError(`unexpected argument '${arg}'`);
|
|
36
|
+
const value = argv[i + 1];
|
|
37
|
+
if (value === undefined || value.startsWith('--')) throw new CatalogError(`${arg} needs a value`);
|
|
38
|
+
out[arg.slice(2)] = value;
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function writeFile(file: string, content: string): void {
|
|
45
|
+
fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
|
|
46
|
+
fs.writeFileSync(file, content);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function main(): void {
|
|
50
|
+
const args = parseArgs(process.argv.slice(2));
|
|
51
|
+
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
52
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')) as { version: string };
|
|
53
|
+
const manifest = JSON.parse(
|
|
54
|
+
fs.readFileSync(path.join(packageDir, 'catalog-status.json'), 'utf8')
|
|
55
|
+
) as CatalogStatusManifest;
|
|
56
|
+
|
|
57
|
+
const { catalog, warnings } = generateCatalog({
|
|
58
|
+
packageDir,
|
|
59
|
+
manifest,
|
|
60
|
+
knownFlags: [...Object.keys(WAVE2_FLAGS), ...WAVE2_REUSED_FLAGS],
|
|
61
|
+
cliVersion: pkg.version,
|
|
62
|
+
now: new Date(),
|
|
63
|
+
});
|
|
64
|
+
for (const w of warnings) console.warn(`warning: ${w}`);
|
|
65
|
+
|
|
66
|
+
const out = args.out ?? path.join(packageDir, 'temp', 'catalog', 'catalog.json');
|
|
67
|
+
writeFile(out, serializeCatalog(catalog));
|
|
68
|
+
console.log(
|
|
69
|
+
`catalog.json v${catalog.cliVersion}: ${catalog.sdk.length} SDK exports, ${catalog.commands.length} commands, ` +
|
|
70
|
+
`${catalog.primitives.length} primitives, ${catalog.limits.length} limits, ${catalog.features.length} features → ${out}`
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (args.previous !== undefined || args['diff-out'] !== undefined || args.summary !== undefined) {
|
|
74
|
+
const previous =
|
|
75
|
+
args.previous && fs.existsSync(args.previous)
|
|
76
|
+
? (JSON.parse(fs.readFileSync(args.previous, 'utf8')) as PlatformCatalog)
|
|
77
|
+
: null;
|
|
78
|
+
if (!previous) console.log('no previous catalogue: every entry counts as added');
|
|
79
|
+
const diff = diffCatalogs(previous, catalog);
|
|
80
|
+
if (args['diff-out']) writeFile(args['diff-out'], serializeCatalog(diff));
|
|
81
|
+
const markdown = renderDiffMarkdown(diff);
|
|
82
|
+
if (args.summary) fs.appendFileSync(args.summary, markdown);
|
|
83
|
+
console.log(
|
|
84
|
+
`diff vs ${diff.fromVersion || '(none)'}: +${diff.added.length} -${diff.removed.length} ~${diff.changed.length}`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
main();
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.error(err instanceof CatalogError ? `generate-catalog: ${err.message}` : err);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
package/template/package.json
CHANGED
package/template/tsconfig.json
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineWebApp, route } from 'lua-cli';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* __APP_NAME__ — a Lua web app.
|
|
6
|
+
*
|
|
7
|
+
* Pages: the Vite project in ./web (runs in the browser, inside Lua Workspace
|
|
8
|
+
* and the admin console). Routes: the handlers below (run in the Lua sandbox
|
|
9
|
+
* with the signed-in person as `auth`; Data, env() and fetch are available).
|
|
10
|
+
*
|
|
11
|
+
* lua apps dev __APP_NAME__ pages with hot reload, routes run locally as you
|
|
12
|
+
* lua test webapp --name __APP_NAME__ --route 'GET /hello'
|
|
13
|
+
* lua push webapp --name __APP_NAME__
|
|
14
|
+
*/
|
|
15
|
+
export default defineWebApp({
|
|
16
|
+
name: '__APP_NAME__',
|
|
17
|
+
description: 'A Lua web app',
|
|
18
|
+
pages: {
|
|
19
|
+
root: './web',
|
|
20
|
+
nav: [{ path: '/', label: 'Overview' }],
|
|
21
|
+
},
|
|
22
|
+
routes: {
|
|
23
|
+
'GET /hello': route({
|
|
24
|
+
description: 'Greets the signed-in person',
|
|
25
|
+
responses: { 200: z.object({ message: z.string() }) },
|
|
26
|
+
handler: async ({ auth }) => ({ message: `Hello, ${auth.name ?? auth.email ?? auth.userId}` }),
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>__APP_NAME__</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__APP_NAME__-web",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"build": "vite build",
|
|
7
|
+
"typecheck": "tsc --noEmit"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@base-ui/react": "~1.8.0",
|
|
11
|
+
"@lua-ai-global/ui": "~0.2.0",
|
|
12
|
+
"@phosphor-icons/react": "~2.1.10",
|
|
13
|
+
"react": "~19.3.0",
|
|
14
|
+
"react-dom": "~19.3.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@tailwindcss/vite": "~4.3.3",
|
|
18
|
+
"@types/react": "~19.3.0",
|
|
19
|
+
"@types/react-dom": "~19.3.0",
|
|
20
|
+
"@vitejs/plugin-react": "~6.1.1",
|
|
21
|
+
"shadcn": "~3.8.5",
|
|
22
|
+
"tailwindcss": "~4.3.3",
|
|
23
|
+
"tw-animate-css": "~1.4.0",
|
|
24
|
+
"typescript": "~5.9.3",
|
|
25
|
+
"vite": "~8.3.1"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { Button } from '@lua-ai-global/ui/button';
|
|
3
|
+
import { Card } from '@lua-ai-global/ui/card';
|
|
4
|
+
import { lua } from './lua/client';
|
|
5
|
+
|
|
6
|
+
export function App() {
|
|
7
|
+
const [message, setMessage] = useState<string>();
|
|
8
|
+
const [error, setError] = useState<string>();
|
|
9
|
+
|
|
10
|
+
const load = () => {
|
|
11
|
+
setError(undefined);
|
|
12
|
+
lua
|
|
13
|
+
.api<{ message: string }>('GET', '/hello')
|
|
14
|
+
.then((r) => setMessage(r.message))
|
|
15
|
+
.catch((e: Error) => setError(e.message));
|
|
16
|
+
};
|
|
17
|
+
useEffect(load, []);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<main className="min-h-screen bg-background p-6 text-foreground">
|
|
21
|
+
<Card className="mx-auto max-w-md space-y-4 p-6">
|
|
22
|
+
<h1 className="text-lg font-semibold">{lua.appName}</h1>
|
|
23
|
+
<p className="text-muted-foreground">{error ?? message ?? 'Loading…'}</p>
|
|
24
|
+
<Button onClick={load}>Say hello again</Button>
|
|
25
|
+
</Card>
|
|
26
|
+
</main>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
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();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { App } from './App';
|
|
4
|
+
import { lua } from './lua/client';
|
|
5
|
+
import './index.css';
|
|
6
|
+
|
|
7
|
+
// Theme follows the shell: first from the bootstrap, then from the host's `init`.
|
|
8
|
+
document.documentElement.classList.toggle('dark', lua.theme === 'dark');
|
|
9
|
+
lua.onInit((init) => document.documentElement.classList.toggle('dark', init.theme === 'dark'));
|
|
10
|
+
|
|
11
|
+
createRoot(document.getElementById('root')!).render(
|
|
12
|
+
<StrictMode>
|
|
13
|
+
<App />
|
|
14
|
+
</StrictMode>
|
|
15
|
+
);
|
|
16
|
+
void lua.start();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"isolatedModules": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineConfig } from 'vite';
|
|
2
|
+
import react from '@vitejs/plugin-react';
|
|
3
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
4
|
+
|
|
5
|
+
// `lua push webapp` builds with this config and pins only what the Lua gateway
|
|
6
|
+
// needs on top of it (base '/', assets under assets/, the CSP nonce).
|
|
7
|
+
export default defineConfig({
|
|
8
|
+
plugins: [react(), tailwindcss()],
|
|
9
|
+
});
|