diff --git a/bun.lock b/bun.lock index 5b891508..92c5ebd6 100644 --- a/bun.lock +++ b/bun.lock @@ -235,7 +235,7 @@ }, "packages/auth": { "name": "@wrnexus/auth", - "version": "0.8.26", + "version": "0.8.29", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/captcha": "workspace:*", @@ -277,7 +277,7 @@ }, "packages/captcha": { "name": "@wrnexus/captcha", - "version": "0.8.14", + "version": "0.8.16", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", @@ -292,7 +292,7 @@ }, "packages/cli": { "name": "@wrnexus/cli", - "version": "0.8.64", + "version": "0.8.65", "bin": { "wrnexus": "src/index.ts", }, @@ -342,14 +342,14 @@ }, "packages/csr": { "name": "@wrnexus/csr", - "version": "0.8.35", + "version": "0.8.38", "dependencies": { "@wrnexus/core": "workspace:*", }, }, "packages/db": { "name": "@wrnexus/db", - "version": "0.8.20", + "version": "0.8.21", "devDependencies": { "@types/bun": "^1.3.14", "typescript": "^6.0.3", @@ -357,7 +357,7 @@ }, "packages/dev-server": { "name": "@wrnexus/dev-server", - "version": "0.8.59", + "version": "0.8.61", "dependencies": { "@wrnexus/authz": "workspace:*", "@wrnexus/cache": "workspace:*", @@ -386,7 +386,7 @@ }, "packages/dev-toolbar": { "name": "@wrnexus/dev-toolbar", - "version": "0.8.16", + "version": "0.8.18", "devDependencies": { "@types/bun": "^1.3.14", "typescript": "^6.0.3", @@ -688,7 +688,7 @@ }, "packages/ui": { "name": "@wrnexus/ui", - "version": "0.8.70", + "version": "0.8.76", "dependencies": { "@wrnexus/core": "workspace:*", }, @@ -709,7 +709,7 @@ }, "packages/validation": { "name": "@wrnexus/validation", - "version": "0.8.11", + "version": "0.8.13", "dependencies": { "@wrnexus/core": "workspace:*", "@wrnexus/plugin": "workspace:*", diff --git a/packages/auth/components/InvitationAccept.wrn b/packages/auth/components/InvitationAccept.wrn index b6681299..0c58244e 100644 --- a/packages/auth/components/InvitationAccept.wrn +++ b/packages/auth/components/InvitationAccept.wrn @@ -7,6 +7,10 @@ component InvitationAccept { token = "" redirect = "/account" organization = "your workspace" + workspace = "" + team = "" + role = "" + expires = "" inviter = "A workspace administrator" title = "Accept your invitation" description = "Create your password to join the workspace." @@ -18,10 +22,17 @@ component InvitationAccept { } view {
- +

{title}

{description}

-

{organization}

Invited by {inviter}

+
+

{organization}

+ {#if workspace}

Workspace: {workspace}

{/if} + {#if team}

Team: {team}

{/if} + {#if role}

Role: {role}

{/if} +

Invited by: {inviter}

+ {#if expires}

Invitation expires {expires}

{/if} +
diff --git a/packages/auth/package.json b/packages/auth/package.json index d4218185..aa08a0c7 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/auth", - "version": "0.8.26", + "version": "0.8.29", "description": "Complete authentication, account security, MFA, passkeys, recovery, devices, risk, and audit system for WRNexusJS.", "type": "module", "sideEffects": false, diff --git a/packages/auth/src/engine.ts b/packages/auth/src/engine.ts index 09f726ae..fd39c4b0 100644 --- a/packages/auth/src/engine.ts +++ b/packages/auth/src/engine.ts @@ -98,6 +98,9 @@ export interface AuthEngine { ttlMs?: number; baseUrl?: string; }): Promise<{ token: string; user: ReturnType }>; + inspectInvitation( + token: string, + ): Promise<{ target?: string; expiresAt: number; metadata: Record } | undefined>; acceptInvitation( token: string, input: { password?: string; displayName?: string }, @@ -1483,6 +1486,16 @@ export function createAuthEngine(options: AuthEngineOptions): AuthEngine { return { token, user: publicUser(user) }; }, + async inspectInvitation(raw) { + const token = await inspectToken(raw, "invite"); + if (!token) return undefined; + return { + target: token.target, + expiresAt: token.expiresAt, + metadata: { ...(token.metadata ?? {}) }, + }; + }, + async acceptInvitation(raw, input) { const token = await inspectToken(raw, "invite"); if (!token) diff --git a/packages/auth/test/engine.test.ts b/packages/auth/test/engine.test.ts index 93622a7d..231c8d21 100644 --- a/packages/auth/test/engine.test.ts +++ b/packages/auth/test/engine.test.ts @@ -388,9 +388,14 @@ describe("authentication engine", () => { displayName: "Invited User", roles: ["member"], invitedBy: "admin-user", + metadata: { organizationName: "Acme", workspaceName: "Product" }, baseUrl: "https://example.test", }); expect(messages.at(-1)?.template).toBe("invitation"); + expect(await engine.inspectInvitation(invitation.token)).toMatchObject({ + target: "invited@example.com", + metadata: { organizationName: "Acme", workspaceName: "Product" }, + }); const accepted = await engine.acceptInvitation(invitation.token, { password: "StrongPassword123", }); @@ -399,6 +404,7 @@ describe("authentication engine", () => { expect( (await engine.acceptInvitation(invitation.token, { password: "StrongPassword123" })).ok, ).toBe(false); + expect(await engine.inspectInvitation(invitation.token)).toBeUndefined(); }); test("customizes delivered token links without application-owned auth APIs", async () => { diff --git a/packages/captcha/assets/client/captcha.js b/packages/captcha/assets/client/captcha.js index 3af77e4a..a33005ca 100644 --- a/packages/captcha/assets/client/captcha.js +++ b/packages/captcha/assets/client/captcha.js @@ -1,4 +1,4 @@ -/* global Audio, CSS, CustomEvent, Element, HTMLElement, MutationObserver, URL, clearInterval, clearTimeout, document, fetch, window */ +/* global Audio, CSS, CustomEvent, Element, Event, HTMLElement, MutationObserver, URL, clearInterval, clearTimeout, document, fetch, window */ (function () { "use strict"; @@ -149,6 +149,11 @@ if (input) { input.name = state.config.responseField; input.value = state.responseToken; + // Keep the serialized attribute in sync for enhanced forms and DOM + // serializers that read attributes rather than the live value property. + input.setAttribute("value", state.responseToken); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); } } diff --git a/packages/captcha/package.json b/packages/captcha/package.json index 4764668c..abe3c270 100644 --- a/packages/captcha/package.json +++ b/packages/captcha/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/captcha", - "version": "0.8.14", + "version": "0.8.16", "description": "First-class CAPTCHA challenges, providers, verification guards, page gates, and WRNexusJS UI.", "type": "module", "sideEffects": false, diff --git a/packages/captcha/test/component.test.ts b/packages/captcha/test/component.test.ts index 844ee0f4..7af5d20f 100644 --- a/packages/captcha/test/component.test.ts +++ b/packages/captcha/test/component.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { parse } from "@wrnexus/syntax"; const componentPath = join(import.meta.dir, "../components/Captcha.wrn"); +const clientPath = join(import.meta.dir, "../assets/client/captcha.js"); test("Captcha.wrn parses and exposes the full public contract", async () => { const source = await readFile(componentPath, "utf8"); @@ -70,3 +71,10 @@ test("Captcha.wrn parses and exposes the full public contract", async () => { expect(source).not.toContain("captcha.js"); expect(source).not.toContain("lifecycle {"); }); + +test("CAPTCHA response tokens remain visible to enhanced form serializers", async () => { + const source = await readFile(clientPath, "utf8"); + expect(source).toContain('input.setAttribute("value", state.responseToken)'); + expect(source).toContain('new Event("input", { bubbles: true })'); + expect(source).toContain('new Event("change", { bubbles: true })'); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 67bec01b..d71cf6f9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/cli", - "version": "0.8.64", + "version": "0.8.65", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/csr/package.json b/packages/csr/package.json index 7edb733d..4fcc1bcf 100644 --- a/packages/csr/package.json +++ b/packages/csr/package.json @@ -1,12 +1,12 @@ { "name": "@wrnexus/csr", - "version": "0.8.35", + "version": "0.8.38", "type": "module", "main": "src/index.ts", "exports": { ".": "./src/index.ts" }, "dependencies": { - "@wrnexus/core": "workspace:*" + "@wrnexus/core": "^0.8.12" } } diff --git a/packages/csr/src/realtime-runtime.ts b/packages/csr/src/realtime-runtime.ts index 0e4cfc6e..1d944b24 100644 --- a/packages/csr/src/realtime-runtime.ts +++ b/packages/csr/src/realtime-runtime.ts @@ -24,6 +24,7 @@ export const REALTIME_RUNTIME = String.raw` var wrn = (window.wrn = window.wrn || {}); if (wrn.room) return; // already installed var open = {}; // normalized room+query key -> room connection + var refreshTimer = null; function normalizedQuery(query) { var params = new URLSearchParams(String(query || "").replace(/^\?/, "")); @@ -165,6 +166,16 @@ export const REALTIME_RUNTIME = String.raw` if (msg.type === "__open") return setStatus("connected", "is-connected"); if (msg.type === "__close") return setStatus("disconnected", "is-disconnected"); if (msg.type === "__error") return setStatus("error", "is-error"); + window.dispatchEvent(new window.CustomEvent("wrnexus:realtime", { + detail: { room: name, message: msg } + })); + if (el.hasAttribute("data-room-refresh")) { + clearTimeout(refreshTimer); + refreshTimer = setTimeout(function () { + if (typeof window.__wrnexusRefresh === "function") window.__wrnexusRefresh(); + else location.reload(); + }, Math.max(50, Number(el.getAttribute("data-room-refresh-delay") || 250))); + } if (!log) return; var tpl = templates[msg.type]; if (tpl == null) tpl = templates[""]; diff --git a/packages/csr/test/realtime.test.ts b/packages/csr/test/realtime.test.ts index e787b1fd..3413c1ec 100644 --- a/packages/csr/test/realtime.test.ts +++ b/packages/csr/test/realtime.test.ts @@ -108,6 +108,20 @@ test("submitting [data-room-send] sends JSON and clears reset fields", () => { expect((inputs[1] as unknown as HTMLInputElement).value).toBe(""); }); +test("data-room-refresh softly refreshes and emits a browser event", async () => { + const win = boot(`
`) as unknown as Window & { + __wrnexusRefresh: () => void; + }; + let events = 0; + let refreshes = 0; + win.addEventListener("wrnexus:realtime", () => events++); + win.__wrnexusRefresh = () => refreshes++; + sockets[0]!.fireMessage({ type: "invalidate", resource: "messages" }); + await Bun.sleep(300); + expect(events).toBe(1); + expect(refreshes).toBe(1); +}); + test("programmatic wrn.room() sends and receives", () => { const win = boot(`
`) as unknown as Window & { wrn: { diff --git a/packages/db/package.json b/packages/db/package.json index cec0b53e..987d1abd 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/db", - "version": "0.8.20", + "version": "0.8.21", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/dev-server/package.json b/packages/dev-server/package.json index 1e26657c..9f33099c 100644 --- a/packages/dev-server/package.json +++ b/packages/dev-server/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-server", - "version": "0.8.59", + "version": "0.8.61", "type": "module", "main": "src/index.ts", "exports": { diff --git a/packages/dev-toolbar/package.json b/packages/dev-toolbar/package.json index ad4283f5..27c3e4ae 100644 --- a/packages/dev-toolbar/package.json +++ b/packages/dev-toolbar/package.json @@ -1,6 +1,6 @@ { "name": "@wrnexus/dev-toolbar", - "version": "0.8.16", + "version": "0.8.18", "private": true, "type": "module", "sideEffects": false, diff --git a/packages/dev-toolbar/src/client/runtime.ts b/packages/dev-toolbar/src/client/runtime.ts index ac9718b7..65684994 100644 --- a/packages/dev-toolbar/src/client/runtime.ts +++ b/packages/dev-toolbar/src/client/runtime.ts @@ -73,7 +73,7 @@ export const DEV_TOOLBAR_RUNTIME = String.raw`(() => { addEventListener("wrnexus:diagnostic",event=>{const detail=event.detail||{};const code=detail.code||"WRN-RUNTIME";const category=String(code).includes("HYDRATE")?"runtime":String(code).includes("ROUTE")?"routing":"compiler";const title=String(code).includes("HYDRATE")?"Hydration diagnostic":"WRNexus diagnostic";const x=issue(String(code),category,"error",title,detail.message||"Framework diagnostic",null,"Open the source location and resolve the reported framework contract.","high",detail);state.issues=[x,...state.issues.filter(i=>i.fingerprint!==x.fingerprint)];render();}); addEventListener("wrnexus:store-mutation",event=>{const mutation=event.detail||{};const x=issue("stores/mutation","stores","info","Store action: "+(mutation.store||"unknown")+"."+(mutation.action||"direct"),"Changed fields: "+((mutation.changed||[]).join(", ")||"none"),null,"Use the Stores panel to inspect safe client state.","high",mutation);state.issues=[x,...state.issues.filter(i=>i.fingerprint!==x.fingerprint)];render();}); addEventListener("wrnexus:hydrated",event=>{state.runtimeMetrics.hydrationCount+=1;state.runtimeMetrics.hydrationMs+=Number(event.detail?.durationMs||0);}); - try{new PerformanceObserver(list=>{for(const entry of list.getEntries()){if(entry.duration>50)state.runtimeMetrics.longTasks.push(entry.duration);}state.runtimeMetrics.longTasks=state.runtimeMetrics.longTasks.slice(-100);}).observe({type:"longtask",buffered:true});}catch{} + try{if(PerformanceObserver.supportedEntryTypes?.includes("longtask")){new PerformanceObserver(list=>{for(const entry of list.getEntries()){if(entry.duration>50)state.runtimeMetrics.longTasks.push(entry.duration);}state.runtimeMetrics.longTasks=state.runtimeMetrics.longTasks.slice(-100);}).observe({type:"longtask",buffered:true});}}catch{} fetch("/__wrnexus/dev-toolbar/platform").then(r=>r.ok?r.json():null).then(data=>{if(!data)return;state.platform=data.platform;state.panels=data.panels||[];const apps=root.querySelector(".wrn-apps");const existing=new Set([...apps.querySelectorAll("[data-category]")].map(button=>button.dataset.category));for(const app of state.panels){if(!existing.has(app.id)){const button=document.createElement("button");button.className="wrn-small";button.dataset.category=app.id;button.textContent=app.title+(app.badge!==undefined?" ("+app.badge+")":"");button.title=app.description||app.title;apps.appendChild(button);}if(app.data!==undefined||app.issues?.length){const payload=app.data!==undefined?app.data:app.issues;state.issues.push(issue("plugin/"+app.id,app.id,"info",app.title,JSON.stringify(payload,null,2),null,app.description||"Plugin-provided development information.","high",{panel:app.id,data:payload}));}}render();}).catch(()=>{}); window.__wrnexusDevToolbar={open(){state.open=true;panel.classList.add("open")},close(){state.open=false;panel.classList.remove("open")},toggle(){state.open=!state.open;panel.classList.toggle("open",state.open)},scan,clear(){state.issues=[];render()},report(){return state.report},highlight,configure(config){state.config=Object.assign(state.config,config||{});shell.dataset.position=state.config.position;try{localStorage.setItem(KEY,JSON.stringify(state.config))}catch{}}}; const observer=new MutationObserver(()=>{clearTimeout(observer.timer);observer.timer=setTimeout(()=>{if(!state.open)return;scan()},400)});observer.observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:["class","style","src","href","alt","aria-label"]}); diff --git a/packages/dev-toolbar/test/runtime.test.ts b/packages/dev-toolbar/test/runtime.test.ts new file mode 100644 index 00000000..d285e7d7 --- /dev/null +++ b/packages/dev-toolbar/test/runtime.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from "bun:test"; +import { DEV_TOOLBAR_RUNTIME } from "../src/client/runtime.ts"; + +test("long-task observation is enabled only in browsers that support it", () => { + expect(DEV_TOOLBAR_RUNTIME).toContain( + 'PerformanceObserver.supportedEntryTypes?.includes("longtask")', + ); +}); diff --git a/packages/syntax/test/formatter.test.ts b/packages/syntax/test/formatter.test.ts index 957147be..7d3866ac 100644 --- a/packages/syntax/test/formatter.test.ts +++ b/packages/syntax/test/formatter.test.ts @@ -93,7 +93,9 @@ test("formats large typed JSON prop defaults and remains idempotent", () => { }`; const formatted = formatWrn(source, options); - expect(formatted).toContain(`categories: unknown[] = [\n {\n "label": "Platform & Core"`); + expect(formatted).toContain( + `categories: unknown[] = [\n {\n "label": "Platform & Core"`, + ); expect(formatted).toContain(`"apps": [\n {`); expect(formatWrn(formatted, options)).toBe(formatted); expect(() => parse(formatted)).not.toThrow(); diff --git a/packages/ui/components/AdvancedSelect.wrn b/packages/ui/components/AdvancedSelect.wrn index efb5b41a..394ddc38 100644 --- a/packages/ui/components/AdvancedSelect.wrn +++ b/packages/ui/components/AdvancedSelect.wrn @@ -344,7 +344,7 @@ component AdvancedSelect { {#if searchable}