@lotics/app-sdk 0.7.0 → 0.8.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/src/hooks.js +18 -4
- package/dist/src/index.d.ts +2 -0
- package/dist/src/mock.d.ts +45 -0
- package/dist/src/mock.js +65 -0
- package/dist/src/mount.d.ts +26 -2
- package/dist/src/mount.js +3 -1
- package/dist/src/password_gate.d.ts +29 -0
- package/dist/src/password_gate.js +142 -0
- package/dist/src/rpc.js +125 -54
- package/package.json +1 -1
package/dist/src/hooks.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
import { useCallback, useEffect, useState } from "react";
|
|
17
17
|
import { rpc } from "./rpc.js";
|
|
18
|
+
import { getMockRows } from "./mock.js";
|
|
18
19
|
export function useWorkflow(alias) {
|
|
19
20
|
return useCallback((inputs) => rpc("workflow", { alias, inputs: inputs ?? {} }), [alias]);
|
|
20
21
|
}
|
|
@@ -26,12 +27,25 @@ export function useQuery(alias, params) {
|
|
|
26
27
|
// refetch callback mutates only this counter; state transitions still
|
|
27
28
|
// happen inside the effect so loading / error / rows flow consistently.
|
|
28
29
|
const [refetchToken, setRefetchToken] = useState(0);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
// Initialize from the fixture when mock mode is on and the app registered
|
|
31
|
+
// rows for this alias — avoids a flash of `loading: true` on first paint.
|
|
32
|
+
// Computed lazily so subsequent renders don't re-read `window.location`.
|
|
33
|
+
const [state, setState] = useState(() => {
|
|
34
|
+
const mockRows = getMockRows(alias);
|
|
35
|
+
if (mockRows)
|
|
36
|
+
return { rows: mockRows, loading: false, error: null };
|
|
37
|
+
return { rows: [], loading: true, error: null };
|
|
33
38
|
});
|
|
34
39
|
useEffect(() => {
|
|
40
|
+
// Re-check on every effect run so HMR updates to the fixture (mount-time
|
|
41
|
+
// re-registration) propagate without a hard reload. When the fixture has
|
|
42
|
+
// an entry we short-circuit the RPC — partial mocking still works because
|
|
43
|
+
// aliases without fixture entries fall through to the live path below.
|
|
44
|
+
const mockRows = getMockRows(alias);
|
|
45
|
+
if (mockRows) {
|
|
46
|
+
setState({ rows: mockRows, loading: false, error: null });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
35
49
|
let cancelled = false;
|
|
36
50
|
setState((s) => ({ rows: s.rows, loading: true, error: null }));
|
|
37
51
|
rpc("query", { alias, params: params ?? {} })
|
package/dist/src/index.d.ts
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
* depending on packages/ui's React Native Web setup.
|
|
11
11
|
*/
|
|
12
12
|
export { mount } from "./mount.js";
|
|
13
|
+
export type { MountOptions } from "./mount.js";
|
|
13
14
|
export { useWorkflow, useQuery, useFileUpload } from "./hooks.js";
|
|
14
15
|
export type { UploadedFile } from "./hooks.js";
|
|
15
16
|
export { rpc } from "./rpc.js";
|
|
16
17
|
export type { RpcOp } from "./rpc.js";
|
|
18
|
+
export type { AppFixture } from "./mock.js";
|
|
17
19
|
export type { AppWorkflows, AppQueries } from "./types.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo / design-time fixture support for `useQuery`.
|
|
3
|
+
*
|
|
4
|
+
* Activation contract:
|
|
5
|
+
*
|
|
6
|
+
* 1. App passes `{ fixture }` to `mount(<App />, { fixture })`. The fixture
|
|
7
|
+
* is a `{ queries: { alias: Row[] } }` map keyed by the same aliases the
|
|
8
|
+
* app declared in `package.json#lotics.queries`.
|
|
9
|
+
* 2. At runtime, the user (or a screenshot script) loads the app with the
|
|
10
|
+
* `?__mock=1` URL search param. Without that param the SDK ignores the
|
|
11
|
+
* fixture entirely and `useQuery` flows through the RPC bridge as usual.
|
|
12
|
+
*
|
|
13
|
+
* The two-step gate keeps demo data shipping in the bundle from leaking into
|
|
14
|
+
* normal traffic — the param namespace (`__mock` prefix) is reserved and
|
|
15
|
+
* unlikely to collide with app-side query state. Apps that don't pass a
|
|
16
|
+
* fixture pay nothing: `getMockRows` returns `null` for every alias and the
|
|
17
|
+
* hook path is unchanged.
|
|
18
|
+
*
|
|
19
|
+
* What's deliberately *not* mocked yet:
|
|
20
|
+
* - `useWorkflow` — mutations have side effects (notifications, audit
|
|
21
|
+
* trail). A workflow mock would have to also produce realistic followup
|
|
22
|
+
* state, which is more design than this iteration warrants.
|
|
23
|
+
* - `useFileUpload` — same reason.
|
|
24
|
+
*
|
|
25
|
+
* If those become needed, extend `AppFixture` with `workflows`/`uploads` and
|
|
26
|
+
* route from each hook.
|
|
27
|
+
*/
|
|
28
|
+
export interface AppFixture {
|
|
29
|
+
/** Map of query alias → rows the hook should return when mock mode is on. */
|
|
30
|
+
queries?: Record<string, Array<Record<string, unknown>>>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Called by `mount({ fixture })`. Module-level state because the SDK has no
|
|
34
|
+
* React context boundary around the iframe — every hook resolves against the
|
|
35
|
+
* same registration. Calling twice replaces (last-write-wins) which is fine
|
|
36
|
+
* for HMR.
|
|
37
|
+
*/
|
|
38
|
+
export declare function registerMockFixture(fixture: AppFixture | undefined): void;
|
|
39
|
+
/**
|
|
40
|
+
* Returns the fixture rows for an alias when mock mode is active *and* the
|
|
41
|
+
* fixture has an entry for that alias. Otherwise null — the hook falls
|
|
42
|
+
* through to the real RPC path. The distinction matters: an app may mock
|
|
43
|
+
* only some queries and let the rest flow through to real data.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getMockRows(alias: string): Array<Record<string, unknown>> | null;
|
package/dist/src/mock.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demo / design-time fixture support for `useQuery`.
|
|
3
|
+
*
|
|
4
|
+
* Activation contract:
|
|
5
|
+
*
|
|
6
|
+
* 1. App passes `{ fixture }` to `mount(<App />, { fixture })`. The fixture
|
|
7
|
+
* is a `{ queries: { alias: Row[] } }` map keyed by the same aliases the
|
|
8
|
+
* app declared in `package.json#lotics.queries`.
|
|
9
|
+
* 2. At runtime, the user (or a screenshot script) loads the app with the
|
|
10
|
+
* `?__mock=1` URL search param. Without that param the SDK ignores the
|
|
11
|
+
* fixture entirely and `useQuery` flows through the RPC bridge as usual.
|
|
12
|
+
*
|
|
13
|
+
* The two-step gate keeps demo data shipping in the bundle from leaking into
|
|
14
|
+
* normal traffic — the param namespace (`__mock` prefix) is reserved and
|
|
15
|
+
* unlikely to collide with app-side query state. Apps that don't pass a
|
|
16
|
+
* fixture pay nothing: `getMockRows` returns `null` for every alias and the
|
|
17
|
+
* hook path is unchanged.
|
|
18
|
+
*
|
|
19
|
+
* What's deliberately *not* mocked yet:
|
|
20
|
+
* - `useWorkflow` — mutations have side effects (notifications, audit
|
|
21
|
+
* trail). A workflow mock would have to also produce realistic followup
|
|
22
|
+
* state, which is more design than this iteration warrants.
|
|
23
|
+
* - `useFileUpload` — same reason.
|
|
24
|
+
*
|
|
25
|
+
* If those become needed, extend `AppFixture` with `workflows`/`uploads` and
|
|
26
|
+
* route from each hook.
|
|
27
|
+
*/
|
|
28
|
+
let registeredFixture;
|
|
29
|
+
/**
|
|
30
|
+
* Called by `mount({ fixture })`. Module-level state because the SDK has no
|
|
31
|
+
* React context boundary around the iframe — every hook resolves against the
|
|
32
|
+
* same registration. Calling twice replaces (last-write-wins) which is fine
|
|
33
|
+
* for HMR.
|
|
34
|
+
*/
|
|
35
|
+
export function registerMockFixture(fixture) {
|
|
36
|
+
registeredFixture = fixture;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* True iff the iframe URL carries the `__mock=1` activation flag AND a
|
|
40
|
+
* fixture was registered. Safe to call before mount — returns false when no
|
|
41
|
+
* fixture exists. Throws never; a malformed URL or missing `window` (SSR /
|
|
42
|
+
* jsdom without location) silently returns false.
|
|
43
|
+
*/
|
|
44
|
+
function isMockMode() {
|
|
45
|
+
if (!registeredFixture)
|
|
46
|
+
return false;
|
|
47
|
+
try {
|
|
48
|
+
return new URLSearchParams(window.location.search).get("__mock") === "1";
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Returns the fixture rows for an alias when mock mode is active *and* the
|
|
56
|
+
* fixture has an entry for that alias. Otherwise null — the hook falls
|
|
57
|
+
* through to the real RPC path. The distinction matters: an app may mock
|
|
58
|
+
* only some queries and let the rest flow through to real data.
|
|
59
|
+
*/
|
|
60
|
+
export function getMockRows(alias) {
|
|
61
|
+
if (!isMockMode())
|
|
62
|
+
return null;
|
|
63
|
+
const rows = registeredFixture?.queries?.[alias];
|
|
64
|
+
return rows ?? null;
|
|
65
|
+
}
|
package/dist/src/mount.d.ts
CHANGED
|
@@ -10,9 +10,23 @@
|
|
|
10
10
|
* mount(<App />);
|
|
11
11
|
* ```
|
|
12
12
|
*
|
|
13
|
+
* Optional second argument registers a demo/design-time fixture. When the
|
|
14
|
+
* iframe is loaded with `?__mock=1`, `useQuery` returns rows from the
|
|
15
|
+
* fixture instead of hitting the RPC bridge. Without the URL flag the
|
|
16
|
+
* fixture is inert — useful for taking screenshots or iterating on
|
|
17
|
+
* dashboard layouts before real data exists.
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* mount(<App />, {
|
|
21
|
+
* fixture: {
|
|
22
|
+
* queries: { customers: MOCK_CUSTOMERS, deals: MOCK_DEALS },
|
|
23
|
+
* },
|
|
24
|
+
* });
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
13
27
|
* `mount` wires up React 19's createRoot against `#root` in the iframe shell,
|
|
14
28
|
* sets up window.onerror/unhandledrejection forwarding so runtime crashes
|
|
15
|
-
* surface in the parent's debug pane
|
|
29
|
+
* surface in the parent's debug pane, registers the fixture (if any), and
|
|
16
30
|
* renders the user's tree.
|
|
17
31
|
*
|
|
18
32
|
* If the bundler doesn't ship #root in the user's `index.html`, we create it
|
|
@@ -20,4 +34,14 @@
|
|
|
20
34
|
* mount resilient.
|
|
21
35
|
*/
|
|
22
36
|
import type { ReactNode } from "react";
|
|
23
|
-
|
|
37
|
+
import { type AppFixture } from "./mock.js";
|
|
38
|
+
export interface MountOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Optional demo fixture. When the iframe URL contains `?__mock=1`,
|
|
41
|
+
* `useQuery(alias)` returns `fixture.queries[alias]` instead of calling
|
|
42
|
+
* the RPC bridge. Aliases not present in the fixture still flow through
|
|
43
|
+
* the real path — partial mocking is supported.
|
|
44
|
+
*/
|
|
45
|
+
fixture?: AppFixture;
|
|
46
|
+
}
|
|
47
|
+
export declare function mount(element: ReactNode, options?: MountOptions): void;
|
package/dist/src/mount.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createRoot } from "react-dom/client";
|
|
2
|
-
|
|
2
|
+
import { registerMockFixture } from "./mock.js";
|
|
3
|
+
export function mount(element, options = {}) {
|
|
4
|
+
registerMockFixture(options.fixture);
|
|
3
5
|
let container = document.getElementById("root");
|
|
4
6
|
if (!container) {
|
|
5
7
|
container = document.createElement("div");
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanilla DOM password overlay for password-protected public apps.
|
|
3
|
+
*
|
|
4
|
+
* The SDK can't assume the host app's React tree has mounted yet (this fires
|
|
5
|
+
* before any data call resolves), so the overlay is built with plain DOM
|
|
6
|
+
* APIs and self-contained inline styles. It rejects the boot promise only
|
|
7
|
+
* on `cancel()` — which is currently never wired to a button; the visitor
|
|
8
|
+
* either enters the correct password or leaves the page.
|
|
9
|
+
*
|
|
10
|
+
* One overlay at a time. Concurrent calls reuse the in-flight promise so a
|
|
11
|
+
* burst of failing API calls produces a single modal, not several stacked.
|
|
12
|
+
*/
|
|
13
|
+
interface OverlayController {
|
|
14
|
+
showError(message: string): void;
|
|
15
|
+
setSubmitting(submitting: boolean): void;
|
|
16
|
+
}
|
|
17
|
+
export interface PasswordPromptArgs {
|
|
18
|
+
/**
|
|
19
|
+
* Validates the entered password. Resolve to keep the overlay open on a
|
|
20
|
+
* wrong password (the SDK calls `controller.showError(...)` afterwards),
|
|
21
|
+
* or `accept()` to dismiss the overlay and resolve the prompt with the
|
|
22
|
+
* password the visitor typed.
|
|
23
|
+
*/
|
|
24
|
+
authenticate: (password: string, controller: OverlayController) => Promise<{
|
|
25
|
+
ok: boolean;
|
|
26
|
+
}>;
|
|
27
|
+
}
|
|
28
|
+
export declare function promptForPassword(args: PasswordPromptArgs): Promise<string>;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanilla DOM password overlay for password-protected public apps.
|
|
3
|
+
*
|
|
4
|
+
* The SDK can't assume the host app's React tree has mounted yet (this fires
|
|
5
|
+
* before any data call resolves), so the overlay is built with plain DOM
|
|
6
|
+
* APIs and self-contained inline styles. It rejects the boot promise only
|
|
7
|
+
* on `cancel()` — which is currently never wired to a button; the visitor
|
|
8
|
+
* either enters the correct password or leaves the page.
|
|
9
|
+
*
|
|
10
|
+
* One overlay at a time. Concurrent calls reuse the in-flight promise so a
|
|
11
|
+
* burst of failing API calls produces a single modal, not several stacked.
|
|
12
|
+
*/
|
|
13
|
+
let activePrompt = null;
|
|
14
|
+
export function promptForPassword(args) {
|
|
15
|
+
if (activePrompt)
|
|
16
|
+
return activePrompt;
|
|
17
|
+
activePrompt = new Promise((resolve) => {
|
|
18
|
+
const root = document.createElement("div");
|
|
19
|
+
root.dataset.loticsAppPasswordGate = "true";
|
|
20
|
+
root.style.cssText = [
|
|
21
|
+
"position:fixed",
|
|
22
|
+
"inset:0",
|
|
23
|
+
"z-index:2147483647",
|
|
24
|
+
"background:rgba(15,23,42,0.55)",
|
|
25
|
+
"backdrop-filter:blur(8px)",
|
|
26
|
+
"-webkit-backdrop-filter:blur(8px)",
|
|
27
|
+
"display:flex",
|
|
28
|
+
"align-items:center",
|
|
29
|
+
"justify-content:center",
|
|
30
|
+
"padding:24px",
|
|
31
|
+
"font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Inter,sans-serif",
|
|
32
|
+
"color:#0f172a",
|
|
33
|
+
].join(";");
|
|
34
|
+
const card = document.createElement("form");
|
|
35
|
+
card.setAttribute("role", "dialog");
|
|
36
|
+
card.setAttribute("aria-modal", "true");
|
|
37
|
+
card.setAttribute("aria-labelledby", "lotics-app-password-title");
|
|
38
|
+
card.style.cssText = [
|
|
39
|
+
"background:#fff",
|
|
40
|
+
"border-radius:14px",
|
|
41
|
+
"padding:28px",
|
|
42
|
+
"max-width:380px",
|
|
43
|
+
"width:100%",
|
|
44
|
+
"box-shadow:0 25px 50px -12px rgba(0,0,0,0.25)",
|
|
45
|
+
"display:flex",
|
|
46
|
+
"flex-direction:column",
|
|
47
|
+
"gap:16px",
|
|
48
|
+
].join(";");
|
|
49
|
+
const title = document.createElement("h2");
|
|
50
|
+
title.id = "lotics-app-password-title";
|
|
51
|
+
title.textContent = "Password required";
|
|
52
|
+
title.style.cssText = "margin:0;font-size:18px;font-weight:600;letter-spacing:-0.01em;";
|
|
53
|
+
const subtitle = document.createElement("p");
|
|
54
|
+
subtitle.textContent = "Enter the password to access this app.";
|
|
55
|
+
subtitle.style.cssText = "margin:0;color:#475569;font-size:13.5px;";
|
|
56
|
+
const input = document.createElement("input");
|
|
57
|
+
input.type = "password";
|
|
58
|
+
input.autocomplete = "current-password";
|
|
59
|
+
input.required = true;
|
|
60
|
+
input.placeholder = "Password";
|
|
61
|
+
input.setAttribute("aria-label", "Password");
|
|
62
|
+
input.style.cssText = [
|
|
63
|
+
"width:100%",
|
|
64
|
+
"box-sizing:border-box",
|
|
65
|
+
"padding:10px 12px",
|
|
66
|
+
"border:1px solid #cbd5e1",
|
|
67
|
+
"border-radius:8px",
|
|
68
|
+
"font-size:14px",
|
|
69
|
+
"outline:none",
|
|
70
|
+
"transition:border-color 0.15s,box-shadow 0.15s",
|
|
71
|
+
].join(";");
|
|
72
|
+
input.addEventListener("focus", () => {
|
|
73
|
+
input.style.borderColor = "#2563eb";
|
|
74
|
+
input.style.boxShadow = "0 0 0 3px rgba(37,99,235,0.18)";
|
|
75
|
+
});
|
|
76
|
+
input.addEventListener("blur", () => {
|
|
77
|
+
input.style.borderColor = "#cbd5e1";
|
|
78
|
+
input.style.boxShadow = "none";
|
|
79
|
+
});
|
|
80
|
+
const error = document.createElement("div");
|
|
81
|
+
error.setAttribute("role", "alert");
|
|
82
|
+
error.style.cssText = "color:#dc2626;font-size:13px;min-height:18px;";
|
|
83
|
+
const submit = document.createElement("button");
|
|
84
|
+
submit.type = "submit";
|
|
85
|
+
submit.textContent = "Continue";
|
|
86
|
+
submit.style.cssText = [
|
|
87
|
+
"padding:10px 12px",
|
|
88
|
+
"background:#0f172a",
|
|
89
|
+
"color:#fff",
|
|
90
|
+
"border:none",
|
|
91
|
+
"border-radius:8px",
|
|
92
|
+
"font-size:14px",
|
|
93
|
+
"font-weight:500",
|
|
94
|
+
"cursor:pointer",
|
|
95
|
+
"transition:background 0.15s,opacity 0.15s",
|
|
96
|
+
].join(";");
|
|
97
|
+
const setSubmitting = (s) => {
|
|
98
|
+
submit.disabled = s;
|
|
99
|
+
input.disabled = s;
|
|
100
|
+
submit.textContent = s ? "Checking…" : "Continue";
|
|
101
|
+
submit.style.opacity = s ? "0.7" : "1";
|
|
102
|
+
submit.style.cursor = s ? "wait" : "pointer";
|
|
103
|
+
};
|
|
104
|
+
const showError = (message) => {
|
|
105
|
+
error.textContent = message;
|
|
106
|
+
};
|
|
107
|
+
card.append(title, subtitle, input, error, submit);
|
|
108
|
+
root.append(card);
|
|
109
|
+
document.body.append(root);
|
|
110
|
+
// Defer focus until the element is in layout — some browsers ignore focus
|
|
111
|
+
// on freshly-attached inputs without a microtask break.
|
|
112
|
+
queueMicrotask(() => input.focus());
|
|
113
|
+
card.addEventListener("submit", (e) => {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
const value = input.value;
|
|
116
|
+
if (!value) {
|
|
117
|
+
showError("Enter a password.");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
showError("");
|
|
121
|
+
setSubmitting(true);
|
|
122
|
+
args
|
|
123
|
+
.authenticate(value, { showError, setSubmitting })
|
|
124
|
+
.then((res) => {
|
|
125
|
+
if (res.ok) {
|
|
126
|
+
root.remove();
|
|
127
|
+
activePrompt = null;
|
|
128
|
+
resolve(value);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
setSubmitting(false);
|
|
132
|
+
input.select();
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
.catch((err) => {
|
|
136
|
+
setSubmitting(false);
|
|
137
|
+
showError(err instanceof Error ? err.message : "Something went wrong.");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
return activePrompt;
|
|
142
|
+
}
|
package/dist/src/rpc.js
CHANGED
|
@@ -1,23 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* RPC bridge for a custom-code app's data operations.
|
|
3
|
-
*
|
|
4
|
-
* An app reaches the Lotics API one of two ways, chosen automatically:
|
|
5
|
-
*
|
|
6
|
-
* - **Bridged** — the app is embedded by a Lotics host (the authenticated
|
|
7
|
-
* product, or `lotics app dev`), which passes its origin via the
|
|
8
|
-
* `?lotics_host=` query param. The host holds the session; the app posts
|
|
9
|
-
* ops over postMessage and the host makes the API call. Used by internal
|
|
10
|
-
* apps — the member's credentials must never reach the app.
|
|
11
|
-
*
|
|
12
|
-
* - **Standalone** — the app is served on its own at `<slug>.lotics.app`
|
|
13
|
-
* with no host. It calls the public `/v1/apps/{id}/*` endpoints directly;
|
|
14
|
-
* those are anonymous-accessible for a publicly-shared app. The app holds
|
|
15
|
-
* no credentials, so there is nothing to protect.
|
|
16
|
-
*
|
|
17
|
-
* Wire protocol (bridged — must match `app_iframe_host.tsx`):
|
|
18
|
-
* app → host: { id, op, payload }
|
|
19
|
-
* host → app: { id, type: "result", data } | { id, type: "error", message }
|
|
20
|
-
*/
|
|
1
|
+
import { promptForPassword } from "./password_gate.js";
|
|
21
2
|
/** The embedding Lotics host's origin — present iff the app is bridged. */
|
|
22
3
|
const hostOrigin = new URLSearchParams(window.location.search).get("lotics_host");
|
|
23
4
|
export function rpc(op, payload) {
|
|
@@ -63,41 +44,134 @@ function rpcBridged(op, payload, host) {
|
|
|
63
44
|
// Standalone apps run only at `<slug>.lotics.app` (production); dev apps are
|
|
64
45
|
// always bridged by the `lotics app dev` wrapper.
|
|
65
46
|
const API_BASE = "https://api.lotics.ai";
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
47
|
+
const PASSWORD_REQUIRED_CODE = "PASSWORD_REQUIRED";
|
|
48
|
+
/**
|
|
49
|
+
* Boot-time resolution result. Promise is shared so concurrent first calls
|
|
50
|
+
* (e.g. two `useQuery` hooks rendering simultaneously) coalesce into one
|
|
51
|
+
* `/by-subdomain` fetch and at most one password prompt.
|
|
52
|
+
*/
|
|
53
|
+
let bootPromise = null;
|
|
54
|
+
let sessionToken = null;
|
|
55
|
+
function sessionStorageKey(appId) {
|
|
56
|
+
return `lotics_app_session:${appId}`;
|
|
57
|
+
}
|
|
58
|
+
function readStoredSession(appId) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = window.localStorage.getItem(sessionStorageKey(appId));
|
|
61
|
+
if (!raw)
|
|
62
|
+
return null;
|
|
63
|
+
const parsed = JSON.parse(raw);
|
|
64
|
+
if (!parsed.token || !parsed.expires_at)
|
|
65
|
+
return null;
|
|
66
|
+
if (Date.parse(parsed.expires_at) <= Date.now()) {
|
|
67
|
+
window.localStorage.removeItem(sessionStorageKey(appId));
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return parsed.token;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function writeStoredSession(appId, token, expiresAt) {
|
|
77
|
+
try {
|
|
78
|
+
window.localStorage.setItem(sessionStorageKey(appId), JSON.stringify({ token, expires_at: expiresAt }));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// localStorage unavailable (Safari private mode, quota): the token still
|
|
82
|
+
// lives in memory for this page lifetime — that's good enough for a single
|
|
83
|
+
// session, the next reload simply re-prompts.
|
|
80
84
|
}
|
|
81
|
-
return appIdPromise;
|
|
82
85
|
}
|
|
83
|
-
|
|
86
|
+
function clearStoredSession(appId) {
|
|
87
|
+
try {
|
|
88
|
+
window.localStorage.removeItem(sessionStorageKey(appId));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// ignore
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function boot() {
|
|
95
|
+
if (bootPromise)
|
|
96
|
+
return bootPromise;
|
|
97
|
+
const slug = window.location.hostname.split(".")[0];
|
|
98
|
+
const attempt = (async () => {
|
|
99
|
+
const info = (await apiCall("GET", `/v1/apps/by-subdomain/${encodeURIComponent(slug)}`));
|
|
100
|
+
if (info.requires_password) {
|
|
101
|
+
const stored = readStoredSession(info.app_id);
|
|
102
|
+
if (stored) {
|
|
103
|
+
sessionToken = stored;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
await acquireSessionToken(info.app_id);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return info;
|
|
110
|
+
})();
|
|
111
|
+
// Don't cache a rejection — a transient failure on first load would brick
|
|
112
|
+
// every later data call. Clear the slot so the next call retries.
|
|
113
|
+
attempt.catch(() => {
|
|
114
|
+
if (bootPromise === attempt)
|
|
115
|
+
bootPromise = null;
|
|
116
|
+
});
|
|
117
|
+
bootPromise = attempt;
|
|
118
|
+
return attempt;
|
|
119
|
+
}
|
|
120
|
+
async function acquireSessionToken(appId) {
|
|
121
|
+
await promptForPassword({
|
|
122
|
+
authenticate: async (password, controller) => {
|
|
123
|
+
try {
|
|
124
|
+
const res = (await apiCall("POST", `/v1/apps/${appId}/public/authenticate`, { password }, { skipAuth: true }));
|
|
125
|
+
sessionToken = res.session_token;
|
|
126
|
+
writeStoredSession(appId, res.session_token, res.expires_at);
|
|
127
|
+
return { ok: true };
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
const message = err instanceof Error && err.message ? err.message : "Incorrect password.";
|
|
131
|
+
controller.showError(message);
|
|
132
|
+
return { ok: false };
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
async function apiCall(method, path, body, opts) {
|
|
138
|
+
const headers = {};
|
|
139
|
+
if (body)
|
|
140
|
+
headers["content-type"] = "application/json";
|
|
141
|
+
if (sessionToken && !opts?.skipAuth) {
|
|
142
|
+
headers["authorization"] = `Bearer ${sessionToken}`;
|
|
143
|
+
}
|
|
84
144
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
85
145
|
method,
|
|
86
|
-
headers
|
|
146
|
+
headers,
|
|
87
147
|
body: body ? JSON.stringify(body) : undefined,
|
|
88
148
|
});
|
|
89
149
|
const text = await res.text();
|
|
90
|
-
|
|
91
|
-
|
|
150
|
+
let parsed = null;
|
|
151
|
+
if (text) {
|
|
92
152
|
try {
|
|
93
|
-
|
|
153
|
+
parsed = JSON.parse(text);
|
|
94
154
|
}
|
|
95
155
|
catch {
|
|
96
|
-
//
|
|
156
|
+
// not JSON — body left as raw text
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
const errorBody = parsed;
|
|
161
|
+
// Password gate (re-)prompt: the stored token went stale because the owner
|
|
162
|
+
// rotated or cleared the password, or it was never present. Drop the
|
|
163
|
+
// current token, ask the visitor again, and retry the original call once.
|
|
164
|
+
if (res.status === 401 && errorBody?.error_code === PASSWORD_REQUIRED_CODE && !opts?.skipAuth) {
|
|
165
|
+
const appId = opts?.appId ?? (await boot()).app_id;
|
|
166
|
+
sessionToken = null;
|
|
167
|
+
clearStoredSession(appId);
|
|
168
|
+
await acquireSessionToken(appId);
|
|
169
|
+
return apiCall(method, path, body, { ...opts, appId });
|
|
97
170
|
}
|
|
171
|
+
const message = errorBody?.message ?? text;
|
|
98
172
|
throw new Error(message || `HTTP ${res.status}`);
|
|
99
173
|
}
|
|
100
|
-
return text ?
|
|
174
|
+
return parsed ?? (text ? text : {});
|
|
101
175
|
}
|
|
102
176
|
function rpcStandalone(op, payload) {
|
|
103
177
|
switch (op) {
|
|
@@ -110,27 +184,24 @@ function rpcStandalone(op, payload) {
|
|
|
110
184
|
}
|
|
111
185
|
}
|
|
112
186
|
async function standaloneQuery(p) {
|
|
113
|
-
const
|
|
114
|
-
const r = (await apiCall("POST", `/v1/apps/${
|
|
115
|
-
alias: p.alias,
|
|
116
|
-
params: p.params,
|
|
117
|
-
}));
|
|
187
|
+
const { app_id } = await boot();
|
|
188
|
+
const r = (await apiCall("POST", `/v1/apps/${app_id}/query`, { alias: p.alias, params: p.params }, { appId: app_id }));
|
|
118
189
|
return { rows: r.rows ?? [] };
|
|
119
190
|
}
|
|
120
191
|
async function standaloneWorkflow(p) {
|
|
121
|
-
const
|
|
122
|
-
return apiCall("POST", `/v1/apps/${
|
|
192
|
+
const { app_id } = await boot();
|
|
193
|
+
return apiCall("POST", `/v1/apps/${app_id}/workflows/${encodeURIComponent(p.alias)}/execute`, { inputs: p.inputs }, { appId: app_id });
|
|
123
194
|
}
|
|
124
195
|
async function standaloneUpload(file) {
|
|
125
196
|
if (!(file instanceof File)) {
|
|
126
197
|
throw new Error("upload payload must include a File");
|
|
127
198
|
}
|
|
128
|
-
const
|
|
129
|
-
const init = (await apiCall("POST", `/v1/apps/${
|
|
199
|
+
const { app_id } = await boot();
|
|
200
|
+
const init = (await apiCall("POST", `/v1/apps/${app_id}/files/upload-url`, {
|
|
130
201
|
filename: file.name,
|
|
131
202
|
mime_type: file.type,
|
|
132
203
|
file_size: file.size,
|
|
133
|
-
}));
|
|
204
|
+
}, { appId: app_id }));
|
|
134
205
|
const put = await fetch(init.upload_url, {
|
|
135
206
|
method: "PUT",
|
|
136
207
|
body: file,
|
|
@@ -139,10 +210,10 @@ async function standaloneUpload(file) {
|
|
|
139
210
|
if (!put.ok) {
|
|
140
211
|
throw new Error(`Storage upload failed (${put.status})`);
|
|
141
212
|
}
|
|
142
|
-
const done = (await apiCall("POST", `/v1/apps/${
|
|
213
|
+
const done = (await apiCall("POST", `/v1/apps/${app_id}/files/complete`, {
|
|
143
214
|
file_id: init.file_id,
|
|
144
215
|
file_storage_key: init.file_storage_key,
|
|
145
216
|
filename: file.name,
|
|
146
|
-
}));
|
|
217
|
+
}, { appId: app_id }));
|
|
147
218
|
return done.file;
|
|
148
219
|
}
|