@lotics/app-sdk 0.55.3 → 0.56.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/rpc.js CHANGED
@@ -1,4 +1,3 @@
1
- import { promptForPassword } from "./password_gate.js";
2
1
  import { runUploadPipeline } from "./upload/pipeline.js";
3
2
  import { parseSearch, serializeMerge, } from "./url_params.js";
4
3
  /**
@@ -346,44 +345,23 @@ let sessionToken = null;
346
345
  * `APP_PUBLIC_SESSION_HEADER`; both sides are pinned by tests.
347
346
  */
348
347
  export const APP_PUBLIC_SESSION_HEADER = "x-lotics-app-session";
349
- function sessionStorageKey(appId) {
350
- return `lotics_app_session:${appId}`;
351
- }
352
- function readStoredSession(appId) {
353
- try {
354
- const raw = window.localStorage.getItem(sessionStorageKey(appId));
355
- if (!raw)
356
- return null;
357
- const parsed = JSON.parse(raw);
358
- if (!parsed.token || !parsed.expires_at)
359
- return null;
360
- if (Date.parse(parsed.expires_at) <= Date.now()) {
361
- window.localStorage.removeItem(sessionStorageKey(appId));
362
- return null;
348
+ /**
349
+ * The cookie the app-host Worker sets after the visitor clears the password
350
+ * gate. Readable by design — the SDK forwards its value as the session header
351
+ * on every data call. Same exposure as the `localStorage` token it replaced,
352
+ * minus the password prompt the SDK no longer owns.
353
+ */
354
+ const SESSION_COOKIE = "lotics_app_session";
355
+ function readSessionCookie() {
356
+ for (const part of document.cookie.split(";")) {
357
+ const eq = part.indexOf("=");
358
+ if (eq < 0)
359
+ continue;
360
+ if (part.slice(0, eq).trim() === SESSION_COOKIE) {
361
+ return decodeURIComponent(part.slice(eq + 1).trim()) || null;
363
362
  }
364
- return parsed.token;
365
- }
366
- catch {
367
- return null;
368
- }
369
- }
370
- function writeStoredSession(appId, token, expiresAt) {
371
- try {
372
- window.localStorage.setItem(sessionStorageKey(appId), JSON.stringify({ token, expires_at: expiresAt }));
373
- }
374
- catch {
375
- // localStorage unavailable (Safari private mode, quota): the token still
376
- // lives in memory for this page lifetime — that's good enough for a single
377
- // session, the next reload simply re-prompts.
378
- }
379
- }
380
- function clearStoredSession(appId) {
381
- try {
382
- window.localStorage.removeItem(sessionStorageKey(appId));
383
- }
384
- catch {
385
- // ignore
386
363
  }
364
+ return null;
387
365
  }
388
366
  /**
389
367
  * Resolve the app's identity + analytics config from its own subdomain. Shared
@@ -412,13 +390,12 @@ async function boot() {
412
390
  const attempt = (async () => {
413
391
  const info = await resolveAppInfo();
414
392
  if (info.requires_password) {
415
- const stored = readStoredSession(info.app_id);
416
- if (stored) {
417
- sessionToken = stored;
418
- }
419
- else {
420
- await acquireSessionToken(info.app_id);
421
- }
393
+ // The visitor already passed the gate — the app-host Worker refuses to
394
+ // serve a single byte of this bundle otherwise — and handed us the session
395
+ // in a cookie on this origin. The SDK does not prompt: a password screen
396
+ // is the serving layer's job, which is why it can be one styled, localized
397
+ // page for every public app instead of hand-rolled DOM in a data library.
398
+ sessionToken = readSessionCookie();
422
399
  }
423
400
  return info;
424
401
  })();
@@ -429,23 +406,6 @@ async function boot() {
429
406
  bootPromise = attempt;
430
407
  return attempt;
431
408
  }
432
- async function acquireSessionToken(appId) {
433
- await promptForPassword({
434
- authenticate: async (password, controller) => {
435
- try {
436
- const res = (await apiCall("POST", `/v1/apps/${appId}/public/authenticate`, { password }, { skipAuth: true }));
437
- sessionToken = res.session_token;
438
- writeStoredSession(appId, res.session_token, res.expires_at);
439
- return { ok: true };
440
- }
441
- catch (err) {
442
- const message = err instanceof Error && err.message ? err.message : "Incorrect password.";
443
- controller.showError(message);
444
- return { ok: false };
445
- }
446
- },
447
- });
448
- }
449
409
  /**
450
410
  * A user-facing message for a transport/gateway failure — derived from the HTTP
451
411
  * status, never from the response body. A 524 (Cloudflare edge timeout on a long
@@ -528,15 +488,21 @@ async function apiCall(method, path, body, opts) {
528
488
  }
529
489
  if (!res.ok) {
530
490
  const errorBody = parsed;
531
- // Password gate (re-)prompt: the stored token went stale because the owner
532
- // rotated or cleared the password, or it was never present. Drop the
533
- // current token, ask the visitor again, and retry the original call once.
491
+ // The session went stale — the owner rotated or cleared the password, so the
492
+ // token's fingerprint no longer matches. Reloading is the whole recovery:
493
+ // the app-host Worker re-checks its own cookie and, if that is stale too,
494
+ // serves the gate. The SDK deliberately owns no re-prompt — the gate is a
495
+ // page at the serving layer, not a modal inside the data layer.
534
496
  if (res.status === 401 && errorBody?.error_code === PASSWORD_REQUIRED_CODE && !opts?.skipAuth) {
535
- const appId = opts?.appId ?? (await boot()).app_id;
536
497
  sessionToken = null;
537
- clearStoredSession(appId);
538
- await acquireSessionToken(appId);
539
- return apiCall(method, path, body, { ...opts, appId });
498
+ // `/__gate`, not a plain reload: the edge cookie outlives the API session
499
+ // (rotation kills the session immediately, the cookie only on expiry), so
500
+ // reloading would be served the bundle again and loop forever. This route
501
+ // clears both cookies and re-presents the gate.
502
+ window.location.assign("/__gate");
503
+ // Never resolves — navigation replaces the document. Returning would let
504
+ // callers render an error state during teardown.
505
+ return new Promise(() => { });
540
506
  }
541
507
  // Never surface a non-JSON body (a gateway HTML error page) or a 5xx body as
542
508
  // the message — emit a body-free, status-derived message instead.
package/docs/runtime.md CHANGED
@@ -129,20 +129,16 @@ fails. Dev apps are always bridged; standalone mode exists only on the deployed
129
129
  On first data call the SDK resolves the app's identity from its own subdomain
130
130
  (one shared `GET /v1/apps/by-subdomain/{slug}` fetch — concurrent first calls
131
131
  coalesce; a transient failure isn't cached, the next call retries). If the app
132
- is password-protected, the SDK renders its own full-screen password overlay
133
- (plain DOM, so it works before React data arrives), exchanges the password for a
134
- session token, and stores it in `localStorage` under
135
- `lotics_app_session:<app_id>` with its expiry. The token rides on subsequent
136
- calls in its own `X-Lotics-App-Session` header deliberately not
137
- `Authorization`, which carries API keys and OAuth bearers: this token
138
- authenticates nobody, it proves the visitor knows the shared password, and
139
- sharing the credential header made the auth layer reject it before the password
140
- gate could read it. A `401` with error code `PASSWORD_REQUIRED` (the
141
- owner rotated or cleared the password) drops the stored token, re-prompts, and
142
- retries the original call once. There is no cancel button — the visitor enters
143
- the password or leaves. When `localStorage` is unavailable (private browsing,
144
- quota) the token lives in memory for the page's lifetime and the next reload
145
- re-prompts. What the password does and doesn't protect: [security](./security.md).
132
+ is password-protected, the visitor never reaches the app at all until they
133
+ clear the gate: `<slug>.lotics.app` is served by the app-host Worker, which
134
+ withholds every byte of the bundle and serves a password page instead. On
135
+ success it sets `lotics_app_session` on the app's origin; the SDK reads that
136
+ cookie and forwards it as `X-Lotics-App-Session` on data calls. The SDK owns no
137
+ password UI a gate is a property of *serving* an app, not of fetching data,
138
+ so it lives at the serving layer where one styled, localized page covers every
139
+ public app. A `401` with error code `PASSWORD_REQUIRED` (the owner rotated or
140
+ cleared the password) reloads the page, which puts the visitor back through the
141
+ gate. What the password does and doesn't protect: [security](./security.md).
146
142
 
147
143
  ### Transport error semantics
148
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.55.3",
3
+ "version": "0.56.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,29 +0,0 @@
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 {};
@@ -1,142 +0,0 @@
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
- }