@nanobpm/urban 0.75.1 → 0.77.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/runtime/core/modules/runtime.gen.js +1 -1
- package/dist/runtime/core/modules/surfaces.js +12 -7
- package/package.json +1 -1
- package/src/runtime/browser/runtime.browser.js +62 -11
- package/src/runtime/browser/runtime.browser.test.ts +105 -1
- package/src/runtime/core/modules/runtime.gen.ts +1 -1
- package/src/runtime/core/modules/surfaces.test.ts +78 -7
- package/src/runtime/core/modules/surfaces.ts +12 -7
|
@@ -47,7 +47,7 @@ function api(path,opts){return fetch(BASE+path,opts).then(r=>{if(!r.ok)return r.
|
|
|
47
47
|
function loadTasks(){
|
|
48
48
|
formEl.replaceChildren();
|
|
49
49
|
tasksEl.replaceChildren(el('li',{},'loading…'));
|
|
50
|
-
return api('/api/tasks').then(ts=>{
|
|
50
|
+
return api('/api/tasks'+location.search).then(ts=>{
|
|
51
51
|
tasksEl.replaceChildren();
|
|
52
52
|
if(!ts.length){tasksEl.appendChild(el('li',{},'No open tasks.'));return;}
|
|
53
53
|
for(const t of ts){
|
|
@@ -202,13 +202,18 @@ export function mountSurfaces(ctx, app) {
|
|
|
202
202
|
source: "surface:taskInbox",
|
|
203
203
|
handler: async (req) => {
|
|
204
204
|
const pik = req.query.get("processInstanceKey") ?? undefined;
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
//
|
|
208
|
-
// "
|
|
209
|
-
|
|
210
|
-
|
|
205
|
+
const assignee = req.query.get("assignee") ?? undefined;
|
|
206
|
+
const candidateGroup = req.query.get("candidateGroup") ?? undefined;
|
|
207
|
+
// Constrain the inbox to open (answerable) tasks via openUserTasks — the accessor that
|
|
208
|
+
// pins state="CREATED" for us. A bare searchUserTasks returns tasks in every state
|
|
209
|
+
// (CREATED/COMPLETED/CANCELED/...), so already-answered or withdrawn tasks would surface
|
|
210
|
+
// as if actionable — completing one then fails with "User task ... is not active".
|
|
211
|
+
// Deriving the pin from openUserTasks (rather than re-pinning state here) keeps the
|
|
212
|
+
// definition of "open" single-sourced. See nanobpm/nano-ide#248.
|
|
213
|
+
const tasks = await app.engine.openUserTasks({
|
|
211
214
|
...(pik ? { processInstanceKey: pik } : {}),
|
|
215
|
+
...(assignee ? { assignee } : {}),
|
|
216
|
+
...(candidateGroup ? { candidateGroup } : {}),
|
|
212
217
|
});
|
|
213
218
|
return json(tasks);
|
|
214
219
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -2385,32 +2385,58 @@ function renderDataGrid(node) {
|
|
|
2385
2385
|
}
|
|
2386
2386
|
|
|
2387
2387
|
// A navigation node — a horizontal menu bar (variant "bar", default) or a
|
|
2388
|
-
// vertical side rail (variant "rail"). Each item links
|
|
2389
|
-
//
|
|
2390
|
-
//
|
|
2391
|
-
//
|
|
2392
|
-
//
|
|
2393
|
-
//
|
|
2394
|
-
//
|
|
2388
|
+
// vertical side rail (variant "rail"). Each item links to one of three targets,
|
|
2389
|
+
// in precedence order:
|
|
2390
|
+
// 1. { label, page } → an in-app #/<page> hash link within THIS (pages)
|
|
2391
|
+
// surface, highlighted when it's the current page.
|
|
2392
|
+
// 2. { label, path } → a same-origin, same-tab link to another mounted
|
|
2393
|
+
// surface by relative path (e.g. "/tasks" → the taskInbox surface). The
|
|
2394
|
+
// path is resolved against the app's mount root via baseRelativeUrl, so a
|
|
2395
|
+
// root-absolute "/tasks" survives the Nano console's path-prefixed App-View
|
|
2396
|
+
// proxy (/console/app-view/<name>/tasks) instead of escaping to the console
|
|
2397
|
+
// origin. Only same-origin values are honoured here — a scheme
|
|
2398
|
+
// (http(s)/javascript:/…) or protocol-relative "//host" is rejected (""),
|
|
2399
|
+
// falling through to the {href} case, so `path` can never smuggle an
|
|
2400
|
+
// external or executable-scheme target into a same-tab link. This is the
|
|
2401
|
+
// only link form that composes multi-surface apps into a coherent,
|
|
2402
|
+
// proxy-safe navigable whole (nanobpm/nano-ide#436).
|
|
2403
|
+
// 3. { label, href } → a hardened external new-tab link; only http(s) is
|
|
2404
|
+
// honoured.
|
|
2405
|
+
// With items:"auto" (or omitted) the nav lists every page from the /app/pages
|
|
2406
|
+
// index, using each page's title. An item may also carry a `badge` ({ source,
|
|
2407
|
+
// table, filter, tone, refreshMs, hideWhenZero }) that renders a live count pill
|
|
2408
|
+
// from the /app/data gateway.
|
|
2395
2409
|
/** @param {any} item
|
|
2396
2410
|
* @returns {HTMLAnchorElement|null}
|
|
2397
2411
|
*/
|
|
2398
2412
|
function navLink(item) {
|
|
2399
2413
|
const isPage = safePageId(item.page);
|
|
2400
|
-
|
|
2401
|
-
|
|
2414
|
+
// A same-origin relative path to another mounted surface (e.g. "/tasks"),
|
|
2415
|
+
// rebased onto the app mount root so it survives the console proxy prefix.
|
|
2416
|
+
const rel = isPage ? "" : sameOriginPath(item.path);
|
|
2417
|
+
const isRel = !isPage && rel !== "";
|
|
2418
|
+
const isExt = !isPage && !isRel && typeof item.href === "string" && /^https?:\/\//i.test(item.href);
|
|
2419
|
+
if (!isPage && !isRel && !isExt) return null;
|
|
2402
2420
|
/** @type {Record<string, any>} */
|
|
2403
2421
|
const attrs = { class: "pc-nav-link" };
|
|
2404
2422
|
if (isPage) {
|
|
2405
2423
|
attrs.href = "#/" + encodeURIComponent(item.page);
|
|
2406
2424
|
if (item.page === CURRENT) { attrs.class += " active"; attrs["aria-current"] = "page"; }
|
|
2425
|
+
} else if (isRel) {
|
|
2426
|
+
// Same-origin, same-tab: no target=_blank — this is in-app cross-surface
|
|
2427
|
+
// navigation, not an external hand-off.
|
|
2428
|
+
attrs.href = rel;
|
|
2407
2429
|
} else {
|
|
2408
2430
|
attrs.href = item.href; attrs.target = "_blank"; attrs.rel = "noopener noreferrer";
|
|
2409
2431
|
}
|
|
2410
2432
|
/** @type {Array<Node|string>} */
|
|
2411
2433
|
const kids = [];
|
|
2412
2434
|
if (item.icon != null) kids.push(el("span", { class: "pc-nav-icon" }, String(item.icon)));
|
|
2413
|
-
|
|
2435
|
+
// Default the label from the target actually used, not a fixed page>path>href
|
|
2436
|
+
// fallback: when `path` is rejected and we fell through to `href`, the label
|
|
2437
|
+
// must reflect the href we navigate to, never the discarded `path`.
|
|
2438
|
+
const defaultLabel = isPage ? item.page : (isRel ? item.path : item.href);
|
|
2439
|
+
const labelText = String(item.label != null ? item.label : defaultLabel);
|
|
2414
2440
|
kids.push(el("span", { class: "pc-nav-label" }, labelText));
|
|
2415
2441
|
const link = el("a", attrs, ...kids);
|
|
2416
2442
|
// A nav item may carry a live count badge sourced from a datasource (e.g. the
|
|
@@ -2560,6 +2586,31 @@ function baseRelativeUrl(url) {
|
|
|
2560
2586
|
return cleaned; // already relative
|
|
2561
2587
|
}
|
|
2562
2588
|
|
|
2589
|
+
// Resolve a same-origin, in-app relative path (e.g. a nav item's { path: "/tasks" }
|
|
2590
|
+
// targeting another mounted surface) onto the app's mount root, so it survives the
|
|
2591
|
+
// console App-View proxy prefix — the SAME rebasing rule as baseRelativeUrl (the
|
|
2592
|
+
// single source of truth for "strip one leading slash to rebase onto the mount
|
|
2593
|
+
// root"). Unlike baseRelativeUrl (whose output is an <iframe> src that may legally
|
|
2594
|
+
// be an http(s) URL), a cross-surface nav link must stay SAME-ORIGIN and same-tab:
|
|
2595
|
+
// a scheme (http(s):, and — defence in depth — javascript:/data:/… ) or a
|
|
2596
|
+
// protocol-relative "//host" is NOT an in-app path, so it is rejected ("") and the
|
|
2597
|
+
// caller falls through to the hardened new-tab {href} handling. Whitespace is
|
|
2598
|
+
// stripped/trimmed FIRST (browsers strip ASCII tab/newline before parsing a URL, so
|
|
2599
|
+
// `/ta\tsks` must be judged on its cleaned form) so the scheme gate can't be
|
|
2600
|
+
// bypassed by embedded whitespace.
|
|
2601
|
+
/** @param {any} path */
|
|
2602
|
+
function sameOriginPath(path) {
|
|
2603
|
+
if (typeof path !== "string") return "";
|
|
2604
|
+
// Browsers normalize backslashes to `/` in URLs, so `\\evil.example/tasks` (or
|
|
2605
|
+
// `/\evil.example`) is parsed as protocol-relative `//evil.example/…`. Fold `\` → `/`
|
|
2606
|
+
// before the scheme/`//` gates so a backslash can't smuggle a cross-origin target past them.
|
|
2607
|
+
const cleaned = path.replace(/[\t\n\r]/g, "").replace(/\\/g, "/").trim();
|
|
2608
|
+
if (cleaned === "") return "";
|
|
2609
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return ""; // any scheme → not a same-origin in-app path
|
|
2610
|
+
if (cleaned.startsWith("//")) return ""; // protocol-relative → different origin
|
|
2611
|
+
return baseRelativeUrl(cleaned); // rebase root-absolute onto the mount root (shared rule)
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2563
2614
|
// `appView` (ADR 0057 / #416): mount an embedded App View — an <iframe> hosting a
|
|
2564
2615
|
// self-contained view document (e.g. the agent cockpit's embed.html) served
|
|
2565
2616
|
// alongside the page. This is the runtime counterpart of the console's App-View
|
|
@@ -2798,4 +2849,4 @@ function boot() {
|
|
|
2798
2849
|
// renderer functions / RENDERERS registry can be imported directly.
|
|
2799
2850
|
if (typeof document !== "undefined" && document.getElementById("page")) boot();
|
|
2800
2851
|
|
|
2801
|
-
export { RENDERERS, renderText, renderButton, renderProse, renderNav, renderAppView, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };
|
|
2852
|
+
export { RENDERERS, renderText, renderButton, renderProse, renderNav, renderAppView, navLink, sameOriginPath, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
import assert from "node:assert/strict";
|
|
9
9
|
import { PAGE_NODE_TYPES } from "../core/page-nodes.ts";
|
|
10
|
-
import { RENDERERS, renderText, renderAppView, navLink, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell } from "./runtime.browser.js";
|
|
10
|
+
import { RENDERERS, renderText, renderAppView, navLink, sameOriginPath, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell } from "./runtime.browser.js";
|
|
11
11
|
|
|
12
12
|
// ── Minimal fake DOM ────────────────────────────────────────────────────────
|
|
13
13
|
// Just enough of the Element/Document surface that el()/renderText touch:
|
|
@@ -348,6 +348,110 @@ test("#338: navLink badge degrades to no pill (never a broken nav) when the fetc
|
|
|
348
348
|
assert.equal(pill.textContent, "");
|
|
349
349
|
});
|
|
350
350
|
|
|
351
|
+
// ── #436: same-origin cross-surface nav (a { path } item) ───────────────────
|
|
352
|
+
// A pages-surface nav item can now point at another mounted surface by a relative
|
|
353
|
+
// same-origin path (e.g. "/tasks" → the taskInbox surface): an in-page, same-tab
|
|
354
|
+
// link, rebased onto the app mount root so it survives the console App-View proxy
|
|
355
|
+
// prefix. This is distinct from { page } (an in-app #/<page> hash) and { href }
|
|
356
|
+
// (an external new-tab http(s) link).
|
|
357
|
+
|
|
358
|
+
test("#436: navLink({ path }) renders a same-origin, same-tab link rebased onto the mount root", (t) => {
|
|
359
|
+
t.after(installFakeDom());
|
|
360
|
+
const link = navLink({ label: "Tasks", path: "/tasks" });
|
|
361
|
+
assert.ok(link, "expected a nav link for a { path } item");
|
|
362
|
+
assert.equal(link.tagName, "A");
|
|
363
|
+
// Root-absolute "/tasks" is rebased to a document-relative "tasks" so it inherits
|
|
364
|
+
// the mount root (and thus the console proxy prefix) rather than escaping to origin.
|
|
365
|
+
assert.equal(link.getAttribute("href"), "tasks");
|
|
366
|
+
// Same-tab, same-origin: NOT an external hand-off — no new-tab / noopener chrome.
|
|
367
|
+
assert.equal(link.getAttribute("target"), null, "cross-surface nav stays in the same tab");
|
|
368
|
+
assert.equal(link.getAttribute("rel"), null, "no rel=noopener on a same-origin link");
|
|
369
|
+
const label = findByClass(link, "pc-nav-label");
|
|
370
|
+
assert.ok(label && label.textContent === "Tasks");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("#436: navLink({ path }) falls back to the path as its label when none is given", (t) => {
|
|
374
|
+
t.after(installFakeDom());
|
|
375
|
+
const link = navLink({ path: "/tasks" });
|
|
376
|
+
assert.ok(link);
|
|
377
|
+
const label = findByClass(link, "pc-nav-label");
|
|
378
|
+
assert.ok(label && label.textContent === "/tasks", "label defaults to the raw path");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("#436: a { page } item still wins over a { path } (in-app hash route has precedence)", (t) => {
|
|
382
|
+
t.after(installFakeDom());
|
|
383
|
+
const link = navLink({ label: "Home", page: "home", path: "/tasks" });
|
|
384
|
+
assert.ok(link);
|
|
385
|
+
assert.equal(link.getAttribute("href"), "#/home", "page hash route takes precedence over path");
|
|
386
|
+
assert.equal(link.getAttribute("target"), null);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("#436: a same-origin { path } wins over an external { href } (in-app link preferred)", (t) => {
|
|
390
|
+
t.after(installFakeDom());
|
|
391
|
+
const link = navLink({ label: "Tasks", path: "/tasks", href: "https://example.test/tasks" });
|
|
392
|
+
assert.ok(link);
|
|
393
|
+
assert.equal(link.getAttribute("href"), "tasks", "the same-origin path is used, not the external href");
|
|
394
|
+
assert.equal(link.getAttribute("target"), null, "so it stays same-tab, not a new-tab external link");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("#436: a scheme/protocol-relative { path } is rejected and falls through to the { href } handling", (t) => {
|
|
398
|
+
t.after(installFakeDom());
|
|
399
|
+
// A hostile/mistaken path carrying a scheme must NOT become a same-tab link — it
|
|
400
|
+
// is rejected as not-same-origin and the item falls through to the hardened
|
|
401
|
+
// new-tab external-href branch.
|
|
402
|
+
const link = navLink({ label: "Evil", path: "javascript:alert(1)", href: "https://example.test/ok" });
|
|
403
|
+
assert.ok(link);
|
|
404
|
+
assert.equal(link.getAttribute("href"), "https://example.test/ok");
|
|
405
|
+
assert.equal(link.getAttribute("target"), "_blank");
|
|
406
|
+
assert.equal(link.getAttribute("rel"), "noopener noreferrer");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("#436: with no label and a rejected { path }, the default label matches the { href } actually used", (t) => {
|
|
410
|
+
t.after(installFakeDom());
|
|
411
|
+
// No explicit label: the path is rejected (unsafe scheme) and we fall through to
|
|
412
|
+
// the external href. The default label MUST reflect the href we navigate to, never
|
|
413
|
+
// the discarded path — otherwise the visible text lies about the link target.
|
|
414
|
+
const link = navLink({ path: "javascript:alert(1)", href: "https://example.test/ok" });
|
|
415
|
+
assert.ok(link);
|
|
416
|
+
assert.equal(link.getAttribute("href"), "https://example.test/ok");
|
|
417
|
+
const label = findByClass(link, "pc-nav-label");
|
|
418
|
+
assert.ok(label && label.textContent === "https://example.test/ok", "label defaults to the href actually used, not the rejected path");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("#436: a { path } item with only an unsafe scheme and no href renders nothing", (t) => {
|
|
422
|
+
t.after(installFakeDom());
|
|
423
|
+
assert.equal(navLink({ label: "Evil", path: "javascript:alert(1)" }), null);
|
|
424
|
+
assert.equal(navLink({ label: "Ext", path: "//evil.example/tasks" }), null);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("#436: sameOriginPath rebases in-app paths and rejects non-same-origin targets", () => {
|
|
428
|
+
// Root-absolute → rebased onto the mount root (survives the console proxy prefix).
|
|
429
|
+
assert.equal(sameOriginPath("/tasks"), "tasks");
|
|
430
|
+
assert.equal(sameOriginPath("/tasks/inbox"), "tasks/inbox");
|
|
431
|
+
// Already document-relative → left as-is.
|
|
432
|
+
assert.equal(sameOriginPath("tasks"), "tasks");
|
|
433
|
+
assert.equal(sameOriginPath("./tasks"), "./tasks");
|
|
434
|
+
// Embedded ASCII tab/newline is stripped before the scheme gate (browsers strip
|
|
435
|
+
// it before parsing), so `java\tscript:` can't sneak past as a "relative" path.
|
|
436
|
+
assert.equal(sameOriginPath("java\tscript:alert(1)"), "");
|
|
437
|
+
// Any scheme / protocol-relative host is not a same-origin in-app path.
|
|
438
|
+
assert.equal(sameOriginPath("https://example.test/tasks"), "");
|
|
439
|
+
assert.equal(sameOriginPath("http://example.test/tasks"), "");
|
|
440
|
+
assert.equal(sameOriginPath("javascript:alert(1)"), "");
|
|
441
|
+
assert.equal(sameOriginPath("data:text/html,x"), "");
|
|
442
|
+
assert.equal(sameOriginPath("//evil.example/tasks"), "");
|
|
443
|
+
// Backslashes are folded to `/` before the gates (browsers normalize them), so a
|
|
444
|
+
// `\\host` or `/\host` value can't smuggle a protocol-relative cross-origin target past.
|
|
445
|
+
assert.equal(sameOriginPath("\\\\evil.example/tasks"), "");
|
|
446
|
+
assert.equal(sameOriginPath("/\\evil.example/tasks"), "");
|
|
447
|
+
assert.equal(sameOriginPath("\\/evil.example/tasks"), "");
|
|
448
|
+
// Non-strings / blanks → "".
|
|
449
|
+
assert.equal(sameOriginPath(""), "");
|
|
450
|
+
assert.equal(sameOriginPath(" "), "");
|
|
451
|
+
assert.equal(sameOriginPath(null), "");
|
|
452
|
+
assert.equal(sameOriginPath(42), "");
|
|
453
|
+
});
|
|
454
|
+
|
|
351
455
|
test("#338: applyNavBadge honours hideWhenZero and mirrors the count into the accessible name", (t) => {
|
|
352
456
|
const restore = installFakeDom();
|
|
353
457
|
t.after(restore);
|