diff --git a/.chachalog/client-callable-actions.md b/.chachalog/client-callable-actions.md new file mode 100644 index 00000000..0f8e0158 --- /dev/null +++ b/.chachalog/client-callable-actions.md @@ -0,0 +1,10 @@ +--- +# Allowed version bumps: patch, minor, major +javascript-modules: minor +--- + +New: actions — server functions callable from client components as plain async calls. Export a function from a `.action.ts` file, import it in an island, and call it: arguments and results are serialized automatically (devalue), with optional input validation via any Standard Schema compatible library (`action(schema, fn)`). + +New: `registerNodeLegacyAction` registers Jahia's node-bound `.do` action endpoints from JavaScript. + +The vite plugin's server bundle input now also includes action files (`actions.inputGlob` option, default `**/*.action.{js,ts}`); the same files are replaced by fetch stubs in the client bundle. diff --git a/.chachalog/js-server-extension-points.md b/.chachalog/js-server-extension-points.md new file mode 100644 index 00000000..e3705cc9 --- /dev/null +++ b/.chachalog/js-server-extension-points.md @@ -0,0 +1,8 @@ +--- +# Allowed version bumps: patch, minor, major +javascript-modules: minor +--- + +JavaScript modules can now declare choicelist initializers, server-side node validators and actions — extension points that previously required a Java module. Use the new `registerChoiceListInitializer`, `registerNodeValidator`, `registerNodeLegacyAction` and `registerRenderFilter` functions from `@jahia/javascript-modules-library`. + +Note for existing modules using `server.registry.add("render-filter", …)`: a declared `priority` is now honored (it was previously ignored and forced to 0), which may reorder such filters in the render chain. diff --git a/.chachalog/js-server-extension-sdk.md b/.chachalog/js-server-extension-sdk.md new file mode 100644 index 00000000..63d7e8c9 --- /dev/null +++ b/.chachalog/js-server-extension-sdk.md @@ -0,0 +1,6 @@ +--- +# Allowed version bumps: patch, minor, major +javascript-modules: minor +--- + +Java modules can now consume JavaScript-declared server extensions through the new `JSServerExtensionInvoker` OSGi service. A module can define its own extension type, let JavaScript modules contribute entries via `server.registry.add`, and invoke their callbacks from Java without depending on GraalVM APIs. This enables, for example, form-field validators written in JavaScript to run during server-side form submission processing. diff --git a/ACTIONS-IMPROVEMENTS-PLAN.md b/ACTIONS-IMPROVEMENTS-PLAN.md new file mode 100644 index 00000000..c3ad7174 --- /dev/null +++ b/ACTIONS-IMPROVEMENTS-PLAN.md @@ -0,0 +1,119 @@ +# Actions framework improvements plan + +Improvements to the `feature/js-server-extensions` branch (PR #687) driven by the first external +consumer of the actions framework and the `JSServerExtensionInvoker` SDK: **formidable** (PR +Jahia/formidable#164 + the `fmdb:callServerAction` bridge built on top of it). No code here yet — +this file is the agreed plan. Items 1–3 target the current branch or an immediate follow-up; item +4 is explicitly a separate, dedicated PR. + +Context: formidable evaluated replacing its `formidable-form-action` registry type with the +generic actions framework and concluded (correctly) that the two solve different problems — form +actions need host-object context (action node, session, request, files) and HTTP-status failure +semantics that the devalue RPC transport doesn't carry. Formidable keeps its own registry type +consumed through the SDK, and instead **bridges** generic actions as one selectable form action +(`fmdb:callServerAction`). That bridge is what surfaces the gaps below. + +## 1. Action metadata (`label`, `description`, `tags`) + +**Problem.** Registry entries of type `action` carry only `key` (`/`), +`type`, `bundleKey`, and the opaque `execute`. Any consumer that wants to *enumerate* actions — +formidable's "Call Server Action" picker, a future admin UI, docs tooling — has no human label and, +worse, no way to distinguish actions meant for such reuse from internal client RPCs. Formidable +defined the consuming convention already (entries tagged `form-action` are listed/invoked; see +`formidable-elements/src/server/actions/callServerAction.server.ts`), but nothing can produce the +tag yet. + +**Design.** + +- Metadata is attached to the exported function itself, so it survives the vite plugin's lexical + export discovery without new syntax: + - `action(schema, fn, meta?)` — third optional argument on the safe wrapper. + - `withActionMeta(fn, meta)` — helper for raw (schema-less) exports. + - Both store the object under a well-known symbol/property (e.g. `fn[ACTION_META]`). +- `registerActionsModule` reads the property and spreads it as **flat fields** on the registry + entry: `label: string`, `description?: string`, `tags?: string[]`. Flat because `Registry.find` + filters on top-level fields only, and because consumers read entries as plain maps through the + SDK (`forEach`) where nested structures add noise. `execute` semantics are unchanged. +- Reserved/known tag values are documented, starting with `form-action` (formidable's contract: + the action accepts a single `{formId, locale, parameters}` argument; return value ignored). + Tags are otherwise free-form. +- Guard: metadata keys are whitelisted (`label`, `description`, `tags`) so authors can't shadow + `execute`/`key`/`type`/`bundleKey`. + +**Touch points.** `javascript-modules-library/src/framework/actions/action.ts`, +`registerActionsModule.ts`, types, the "Actions" guide, one Cypress assertion enumerating the +test-module action with metadata. No engine-java change. + +## 2. SDK `Invoker.call` settles thenables + +**Problem.** `JSServerExtensionInvoker.Invoker.call` converts the JS return value to plain Java +immediately, so a JS extension returning a promise is unusable through the SDK. The engine solved +this for its own endpoint with `JSPromise.settle` (microtask drain on host return), but SDK +consumers can't reach it. Formidable had to build a two-phase workaround (`execute` returns +`{pending: true}`; a second `collect` call, made after the first host return drained the microtask +queue, reads the captured outcome — see `JsFormActionDispatcher` + `registerFormAction`). + +**Design.** + +- In `JSServerExtensionInvokerImpl`, before `convert(...)`: if the result is thenable, settle it + with `JSPromise.settle` (move/share the class — it currently lives in `actions/`); convert the + fulfilled value, or throw a `RuntimeException` carrying the rejection reason. Never-settling + promises (timer/I-O-dependent) fail with the same explicit message as the endpoint. +- Javadoc the contract on `Invoker.call`; add GraalJS-backed unit tests mirroring the existing + `JSPromise` tests, but through the SDK surface. +- Backward compatible: sync results behave exactly as before. + +**Follow-up in formidable once released:** delete the `pending`/`collect` protocol on both sides +(TS adapter + dispatcher) — the wrapper just returns the handler's promise. + +## 3. Export the registrar SPI + +**Problem.** `Registrar` is already a whiteboard (`JavascriptModuleListener` binds +`Registrar` services with dynamic/multiple cardinality), so third-party bundles *could* plug into +JS-module lifecycle — but the `...engine.registrars` package is not exported; only `...engine.sdk` +is. Consumers that want to publish JS registry entries as their own OSGi services (the +`AbstractServiceRegistrar` pattern used by choicelists/render-filters/legacy actions) must instead +re-resolve entries per call, and re-implement matching/fallback logic. + +**Design.** + +- Promote a consumer-facing SPI into the `sdk` package (keeping the internal registrars where they + are): `JSExtensionRegistrar` (the `register(Bundle)`/`unregister(Bundle)` pair) and an exported + abstract base equivalent to `AbstractServiceRegistrar` (service class + registry type + + `createBridge`), documented with the same invariants (bridges re-resolve entries inside + `doWithContext` per invocation; per-entry failure isolation). +- Internal registrars migrate to the exported base at leisure; no behavior change. + +**Payoff for formidable:** `JsFormActionDispatcher`, the optional-reference plumbing in +`FormSubmitServlet`, and the Java-vs-JS precedence special-casing in `FormSubmissionPipeline` +collapse into one registrar that publishes each `formidable-form-action` entry as a regular +`FormAction` OSGi service. + +## 4. Java-native `invokeAction` API — separate, dedicated PR (after 1–3) + +**Problem.** Generic actions can only be invoked first-class over HTTP. Java code (or SDK +consumers like formidable's bridge, currently doing this from TS instead) must hand-build a +devalue-serialized args string and parse the devalue result — the wire format leaks into every +caller. + +**Design sketch (to refine in its own PR).** + +- New SDK method, e.g. `ActionInvoker.invoke(String name, Object... args)`: + - resolves the `action` entry, serializes args and deserializes the result **by delegating to a + JS-side adapter** (the library owns devalue, per ADR-0008's "the JS adapter owns all + serialization" — Java must not re-implement devalue), + - settles promises (depends on item 2), + - maps `{message, issues?}` rejections to a typed `ActionInvocationException`. +- Open questions for that PR: which Java types are devalue-encodable (align with island props), + whether host objects should be allowed as args (probably not — keeps the "actions are + context-free" model honest), and whether invocation should honor `tags` (e.g. refuse untagged + internal actions when called by third parties). + +## Suggested sequencing + +1. Item 1 (metadata) — small, unblocks formidable's picker end-to-end; can land in PR #687 or as + the first follow-up. +2. Item 2 (promise settling) — engine-java only, well-testable; unblocks async simplification for + every SDK consumer. +3. Item 3 (registrar SPI) — API-design-heavy; needs maintainer alignment on the exported surface. +4. Item 4 (invokeAction) — dedicated PR after the above, since it builds on items 1–2. diff --git a/docs/2-guides/4-legacy-node-actions/README.md b/docs/2-guides/4-legacy-node-actions/README.md new file mode 100644 index 00000000..2c5f0dd9 --- /dev/null +++ b/docs/2-guides/4-legacy-node-actions/README.md @@ -0,0 +1,89 @@ +--- +page: + $path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/legacy-node-actions + jcr:title: Declaring Legacy Node Actions + j:templateName: documentation +content: + $subpath: document-area/content +--- + +Legacy node actions are HTTP endpoints bound to content nodes: appending `..do` to a node URL invokes the action against that node. They expose the classic Jahia `org.jahia.bin.Action` mechanism to JavaScript modules, for parity with Java modules — useful for plain HTML form submissions and for interoperating with existing `.do`-based integrations. + +> To call server code from client components (islands), prefer [actions](../7-actions/README.md): typed, client-callable functions with automatic serialization. + +## Declaring a legacy node action + +Call `registerNodeLegacyAction` at the top level of a server file (it registers the action as a side effect at module startup, like `jahiaComponent`): + +```ts +import { registerNodeLegacyAction } from "@jahia/javascript-modules-library"; + +registerNodeLegacyAction( + { name: "myModuleGreet", requiredMethods: ["GET"], requireAuthenticatedUser: false }, + ({ parameters, resource }) => ({ + json: { + greeting: `Hello ${parameters.who?.[0] ?? "world"}`, + path: resource.getNode().getPath(), + }, + }), +); +``` + +The action is then reachable on any node URL: + +``` +GET /cms/render/live/en/sites/mysite/home.myModuleGreet.do?who=Jahia +Accept: application/json +→ 200 {"greeting": "Hello Jahia", "path": "/sites/mysite/home"} +``` + +Note that Jahia's render servlet only writes the JSON body when the request declares it accepts JSON — send an `Accept: application/json` header (browsers submitting forms get the redirect/status behavior instead). + +## Declaration options + +| Option | Description | +| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | The URL-visible action name. Names are platform-wide (shared with Java modules, last registration wins) — prefix them with your module name. | +| `requiredMethods` | Allowed HTTP methods, e.g. `["POST"]`. Defaults to Jahia's default (GET and POST). | +| `requireAuthenticatedUser` | Defaults to **`true`** (Jahia's default): guests get a 401. Set to `false` explicitly for public actions. | +| `requiredPermission` | Permission required on the target node, e.g. `"jcr:write"`. | +| `requiredWorkspace` | Restrict to `"default"` or `"live"`. | + +## The handler + +The handler receives a context object: + +- `parameters` — merged query-string and form parameters, as `Record`, +- `resource` / `renderContext` / `session` — the target resource, render context and user JCR session, +- `request` — escape hatch: the raw `HttpServletRequest` (headers, cookies, body), +- `urlResolver` — escape hatch: the Jahia URL resolver. + +And returns (possibly asynchronously — `async` handlers are supported, limited to microtask-based work: the server runtime has no timers or async I/O): + +- `json` — an object serialized as the JSON response body, +- `statusCode` — HTTP status, default 200, +- `redirect` (+ `absoluteRedirect`) — redirect the client instead of returning a body. + +Returning nothing sends an empty 200. + +Do not combine `redirect` with a `statusCode`: the platform picks the redirect status itself, and a +3xx `statusCode` makes Jahia answer `sendError()` instead of redirecting. For actions answering a +plain browser form POST, always return a `redirect` — a bare `json` result leaves the visitor on a +blank page (the JSON body is only written for requests that ask for JSON). + +## CSRF protection for POST actions + +POST, PUT and DELETE requests to `.do` URLs — and GET requests made with an authenticated session — are blocked by Jahia's CSRF guard unless the URL is whitelisted. **This is your module's responsibility**: ship an OSGi configuration file in your module's `settings/configurations/` folder: + +```properties +# settings/configurations/org.jahia.modules.jahiacsrfguard-mymodule.cfg +whitelist = *.myModuleSubmit.do,*.myModuleOther.do +``` + +Whitelisting disables CSRF protection for those URLs, so only do it for actions designed to be called without a CSRF token (e.g. public form submissions), and keep the patterns as narrow as possible. Without this file, POST calls to your action fail with a 403. + +## Good to know + +- **Keep handlers fast and non-blocking** — they run on a request thread. +- **Content modifications**: use the provided `session` to read/write JCR content as the calling user; standard permissions apply, plus `requiredPermission` if you set it. +- **Errors**: an exception thrown by the handler results in an error response; validate input and return explicit `statusCode` values (e.g. 400) for expected failures. diff --git a/docs/2-guides/5-choicelist-initializers/README.md b/docs/2-guides/5-choicelist-initializers/README.md new file mode 100644 index 00000000..58fc5917 --- /dev/null +++ b/docs/2-guides/5-choicelist-initializers/README.md @@ -0,0 +1,66 @@ +--- +page: + $path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/choicelist-initializers + jcr:title: Declaring Choicelist Initializers + j:templateName: documentation +content: + $subpath: document-area/content +--- + +Choicelist initializers populate the dropdown lists offered to editors in Content Editor. Out of the box, Jahia provides initializers such as `resourceBundle` or `nodes`; with JavaScript modules you can declare your own initializers in JavaScript, without writing a Java module. + +## Declaring an initializer + +Call `registerChoiceListInitializer` at the top level of a server file (it registers the initializer as a side effect at module startup, like `jahiaComponent`): + +```ts +import { registerChoiceListInitializer } from "@jahia/javascript-modules-library"; + +registerChoiceListInitializer({ key: "myModuleColors" }, ({ locale }) => [ + { label: locale.startsWith("fr") ? "Rouge" : "Red", value: "red" }, + { label: locale.startsWith("fr") ? "Vert" : "Green", value: "green" }, +]); +``` + +Then reference the initializer's key from a property definition in your CND file: + +```cnd +[mymodule:myComponent] > jnt:content, mix:title + - color (string, choicelist[myModuleColors]) +``` + +The callback returns the list of choices as `{ label, value, properties? }` objects: + +- `label` is the text shown to the editor, +- `value` is the string persisted in the JCR, +- `properties` is optional metadata interpreted by the editing UI, e.g. `{ defaultProperty: true }` to preselect a choice, or `{ image: "/path.png" }` to display a thumbnail. + +## The initializer context + +The callback receives a context object: + +| Property | Description | +| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `param` | The parameter from the CND declaration: `choicelist[myModuleColors='myParam']` passes `"myParam"`. Empty string when absent. | +| `locale` | BCP-47 language tag to localize labels for (e.g. `"en"`, `"fr"`). Whether the platform forwards the content language or the editor's UI language varies across Jahia versions. | +| `values` | Choices accumulated by previous initializers when several are chained in the CND declaration (e.g. `choicelist[resourceBundle,myModuleColors]`). Include them in your result to keep them. | +| `node` | The node being edited, when it exists (it does not on creation forms). | +| `java` | Escape hatch: the raw Java objects received by the underlying `ModuleChoiceListInitializer` — `propertyDefinition` (`ExtendedPropertyDefinition`), `locale` (`java.util.Locale`), `values`, `context`. | + +For example, an initializer that lists values differently per property and honors a parameter: + +```ts +registerChoiceListInitializer({ key: "myModuleSizes" }, ({ param, values, java }) => { + const sizes = [...values, { label: "Small", value: "s" }, { label: "Medium", value: "m" }]; + if (param === "extended") { + sizes.push({ label: `Large (${java.propertyDefinition.getName()})`, value: "l" }); + } + return sizes; +}); +``` + +## Good to know + +- **Keys are platform-wide.** Initializer keys live in a single namespace shared with Java modules; the last registration wins. Prefix your keys with your module name (`myModuleColors`, not `colors`). +- **Keep callbacks fast.** The callback runs synchronously every time an editor form displays the choicelist. +- **Labels are your responsibility.** Unlike `choicelist[resourceBundle]`, labels are not resolved from resource bundles automatically — return localized labels using the `locale` from the context (you can use your module's i18n setup or any custom logic). diff --git a/docs/2-guides/7-actions/README.md b/docs/2-guides/7-actions/README.md new file mode 100644 index 00000000..6e870a6c --- /dev/null +++ b/docs/2-guides/7-actions/README.md @@ -0,0 +1,77 @@ +--- +page: + $path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/actions + jcr:title: Actions + j:templateName: documentation +content: + $subpath: document-area/content +--- + +Actions are server functions callable from client components (islands) as if they were local async functions. You write the function once; the build compiles it twice — the real implementation for the server, a typed network stub for the client — so calling server code from the browser is a plain `await`. + +## Writing an action + +Any file ending in `.action.ts` (or `.action.js`) exports actions. The file can be named anything and placed anywhere under `src/`: + +```ts +// src/actions/rates.action.ts +export const getExchangeRate = async (currency: string) => { + // this code only ever runs on the server + return lookupRate(currency); +}; +``` + +## Calling an action from the client + +Import the function from a client component and call it: + +```tsx +// src/components/Rate.client.tsx +import { useState } from "react"; +import { getExchangeRate } from "../actions/rates.action"; + +export default function Rate({ initialValue }: { initialValue: number }) { + const [rate, setRate] = useState(initialValue); + return ( + + ); +} +``` + +As far as TypeScript is concerned this is a local function call; at runtime the client performs a network request. Arguments and return values are serialized with [devalue](https://github.com/sveltejs/devalue), so `Date`, `Map`, `Set`, `RegExp`, cyclic structures etc. survive the round trip — but functions, class instances and other non-serializable values do not. + +A thrown (or rejected) server error rejects the client call with an `Error`. Only deliberate error types carry their message to the caller: throw `ActionError` (exported by the library) for user-facing failures like `throw new ActionError("Out of stock")`. Any other exception is logged on the server and replaced by a generic message in the response — actions are guest-callable, and unexpected error messages can leak implementation details. + +## Safe actions (input validation) + +Wrap the implementation with `action` and any [Standard Schema](https://standardschema.dev) compatible schema (zod, valibot, arktype, …). The implementation only runs on valid input, and its parameter type is inferred from the schema: + +```ts +// src/actions/rates.action.ts +import { action } from "@jahia/javascript-modules-library"; +import { z } from "zod"; + +export const getExchangeRate = action(z.object({ currency: z.string() }), ({ currency }) => { + return lookupRate(currency); +}); +``` + +On invalid input the client call rejects with an error carrying the validation `issues`. + +## How it works, and its limits + +- Each export becomes a callable endpoint named `/`. Export names must be unique across all the `.action.ts` files of your module (duplicates fail at module startup). +- Only top-level `export const = …` and `export function ` declarations are picked up; `export { … }` lists, `export default` and re-exports are not supported in action files. +- Actions run synchronously on a server thread. `async`/`await` over synchronous work is fully supported, but there are no timers and no asynchronous I/O in the server runtime — a promise that relies on them never settles and the call fails. +- The client stub POSTs to the current page URL (`.jsAction.do`); calls execute with the visitor's session and permissions. **Guests can call your actions**: treat inputs as untrusted and enforce your own permission checks inside the function. +- Requests carry a mandatory `X-JS-Action` header, which protects the endpoint against classic CSRF (HTML forms cannot set headers; cross-origin scripts would need a CORS preflight that Jahia does not grant). + +## When to use what + +| Need | Use | +| -------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| Call server code from an island | **Actions** (this page) | +| Expose a `.do` HTTP endpoint on a content node (plain form POST, external integration) | [Legacy node actions](../4-legacy-node-actions/README.md) | +| Expose data in the content graph | GraphQL extensions | diff --git a/docs/3-reference/1-cnd-format/README.md b/docs/3-reference/1-cnd-format/README.md index a8959910..137ab11e 100644 --- a/docs/3-reference/1-cnd-format/README.md +++ b/docs/3-reference/1-cnd-format/README.md @@ -124,6 +124,8 @@ Speaking of the UI, let's list all possible visual editor hints for string prope This hint will display a dropdown list in the UI. The list of choices is defined in the constraints, as a list of strings. +You can also populate the dropdown from JavaScript by declaring your own initializer key with `registerChoiceListInitializer` and referencing it as `choicelist[myKey]` — see the [choicelist initializers guide](../../2-guides/5-choicelist-initializers/README.md). + #### `choicelist[componentTypes='']` The dropdown list will be populated with a list of coma-separated component types or mixins. diff --git a/docs/3-reference/3-node-validators/README.md b/docs/3-reference/3-node-validators/README.md new file mode 100644 index 00000000..f8172a18 --- /dev/null +++ b/docs/3-reference/3-node-validators/README.md @@ -0,0 +1,61 @@ +--- +page: + $path: /sites/academy/home/documentation/jahia/8_2/developer/javascript-module-development/node-validators + jcr:title: Server-Side Node Validators + j:templateName: documentation +content: + $subpath: document-area/content +--- + +Node validators run on the server every time a JCR session saves a node of a given type. Returning violations rejects the save and surfaces error messages in Content Editor — attached to a specific field or to the whole node. With JavaScript modules you can declare validators in JavaScript, without writing a Java module. + +## Declaring a validator + +Call `registerNodeValidator` at the top level of a server file (it registers the validator as a side effect at module startup, like `jahiaComponent`): + +```ts +import { registerNodeValidator } from "@jahia/javascript-modules-library"; + +registerNodeValidator({ nodeType: "mymodule:article" }, (node) => { + const email = node.getPropertyAsString("email"); + if (email && !email.includes("@")) { + return { message: "Please provide a valid email address", propertyName: "email" }; + } +}); +``` + +The callback receives the `JCRNodeWrapper` being saved and returns: + +- **nothing** — the node is valid, +- **one violation** or **an array of violations** — the save is rejected. + +A violation is `{ message, propertyName? }`: with `propertyName`, the message is shown on that field in Content Editor; without it, it is shown as a node-level error. + +## Declaration options + +| Option | Description | +| -------------- | --------------------------------------------------------------------------------------------------------- | +| `nodeType` | Node type (primary or mixin) the validator applies to, matched with `isNodeType()`. | +| `name` | Distinguishes several validators on the same node type in one module. Default `"default"`. | +| `skipOnImport` | Skip this validator during content imports. Default `false`. | +| `advanced` | Run in the advanced phase, which only runs once **all** default-phase validators passed. Default `false`. | + +The two phases mirror Jahia's Java validator groups: default-phase violations suppress the advanced phase entirely (advanced checks can assume basic integrity). + +## Localizing messages + +Messages of the form `{my.bundle.key}` (the whole message being a single `{…}` reference) are resolved by Jahia against the deployed resource bundles, in the editor's UI locale — the same mechanism Java validators use. Ship the keys in your module's `settings/resources/*.properties` bundles: + +```ts +return { message: "{mymodule.validation.email.invalid}", propertyName: "email" }; +``` + +Any other message is displayed verbatim. Alternatively, resolve the text yourself in the callback using `context.locale` (the saving session's locale as a BCP-47 tag, possibly null). + +## Good to know + +- **Never call `session.save()` inside a validator** — it would recurse into validation. +- **Keep validators fast**: they run on every matching session save (editing, APIs, publication-driven saves). They may be `async`, limited to microtask-based work (the server runtime has no timers or async I/O). +- **i18n properties**: Jahia silently drops violations attached to internationalized properties when the saving session has no locale; return a node-level violation as a fallback if that matters for your check. +- **Failure policy**: a validator that throws fails the save with a generic node-level message (fail closed) and logs the error with the validator key; a returned violation without a string `message` is logged and ignored. +- **GraphQL/API saves** are validated too — the violation messages appear in the mutation errors. diff --git a/docs/adr/0001-javascript-server-extension-points.md b/docs/adr/0001-javascript-server-extension-points.md new file mode 100644 index 00000000..f582e1a4 --- /dev/null +++ b/docs/adr/0001-javascript-server-extension-points.md @@ -0,0 +1,63 @@ +# Bridge JavaScript-declared server extension points through per-type registrars + +- Status: accepted +- Date: 2026-07-21 + +## Context and Problem Statement + +JavaScript modules can only contribute views/templates (and untyped render filters) today. Every other Jahia extension point — choicelist initializers, server-side node validators, actions, etc. — requires a Java OSGi module. We want JavaScript modules to declare these extension points directly, with a mechanism that makes adding _future_ extension points cheap. + +A proof of concept existed in the pre-rename engine ([npm-modules-engine#125](https://github.com/Jahia/npm-modules-engine/pull/125)): a single `ServicesRegistrar` reading registry entries of `type='service'` and dispatching to per-type mapper classes held in a static map. + +## Decision Drivers + +- Adding a new extension point later must be a small, local change. +- Bridges must survive GraalVM context pooling: contexts are recycled and version-invalidated on every module (un)deploy, so JS function handles cannot be cached. +- Jahia core already consumes `Action`, `ModuleChoiceListInitializer`, `RenderFilter`, … as OSGi services (whiteboard-tracked by core's `OSGIRegistry` and piped into `TemplatePackageRegistry`) — we should ride that supported surface rather than reach into core internals. +- Registrars need per-type Jahia service dependencies (for collision warnings, etc.) that are naturally expressed as Declarative Services references. + +## Considered Options + +1. **One `Registrar` component per extension type, over a shared abstract base class** (`AbstractServiceRegistrar`). +2. Single generic registrar with a static map of per-type mappers (the POC design). +3. Fully independent per-type registrars with duplicated bookkeeping (the pre-existing `RenderFilterRegistrar` pattern, copy-pasted). + +## Decision Outcome + +Chosen option: **1 — per-type registrar components over a shared base class.** + +- Each extension point is a `@Component(service = Registrar.class)` extending `AbstractServiceRegistrar`, which owns the generic flow: find registry entries for the bundle (`{type, bundleKey}`), build a bridge per entry (`createBridge`), publish it as an OSGi service, track per-bundle `ServiceRegistration`s, and unregister them on bundle stop — with per-entry error isolation. +- `JavascriptModuleListener` already discovers `Registrar` services dynamically (`@Reference(MULTIPLE, DYNAMIC, GREEDY)`) and replays already-started JS bundles to late-arriving registrars. A new extension point is therefore one new subclass — no central wiring to touch. +- Bridges **never cache JS function handles**. On every invocation they re-resolve the registry entry inside `GraalVMEngine.doWithContext(cp -> cp.getRegistry().get(type, key))`. If the entry is gone (module stopped mid-flight), they log and return a benign default instead of failing. +- Per-type DS components keep Jahia service dependencies (`@Reference`) local to the type that needs them, and a registrar that fails to activate does not take the others down. + +Note on Declarative Services inheritance: bnd does not process DS annotations on inherited members, so the base class holds plain `protected` fields and every concrete registrar declares its own `@Reference`/`@Activate` methods. + +### Whiteboard pattern alignment + +The mechanism is deliberately whiteboard-shaped at both OSGi seams: + +- **Registrars** are whiteboard participants: publish a `Registrar` service and `JavascriptModuleListener` picks it up. +- **Bridges** are whiteboard participants toward core: we publish plain `Action` / `ModuleChoiceListInitializer` / `RenderFilter` services and core's `OSGIRegistry` tracks them — the engine never calls core registration APIs for these. + +The JS-side `server.registry` is _not_ a whiteboard — GraalVM code cannot publish OSGi services, and the registry is per-pooled-context. It acts as a staging registry that registrars mirror onto the OSGi whiteboard once per bundle start. (Node validators deviate from the whiteboard for correctness reasons — see [ADR-0005](0005-js-node-validators-single-bean-validation-bridge.md).) + +### Consequences + +- Good: new extension points are one subclass + one library wrapper; no dispatch table, no central registry of mappers. +- Good: unregistration and error isolation are written once, in the base class. +- Bad: one DS component per type (slightly more boilerplate than a static map) — accepted for testability and failure isolation. +- Neutral: every bridge invocation borrows a pooled GraalVM context (or reuses the current thread's); this is the same cost profile as the pre-existing render filters and views. + +## Pros and Cons of the Options + +### Option 2 — single registrar + static mapper map (POC) + +- Good: one component. +- Bad: per-type Jahia dependencies pile into one class or mappers do raw service lookups outside DS. +- Bad: static map is not injectable/mockable; a broken mapper risks the whole dispatch. + +### Option 3 — independent copy-pasted registrars + +- Good: no abstraction to design. +- Bad: per-bundle bookkeeping and error handling duplicated (and already inconsistent: the legacy `RenderFilterRegistrar` lacked null-guards); every future type pays the full cost again. diff --git a/docs/adr/0002-first-class-registry-types.md b/docs/adr/0002-first-class-registry-types.md new file mode 100644 index 00000000..24d980bd --- /dev/null +++ b/docs/adr/0002-first-class-registry-types.md @@ -0,0 +1,30 @@ +# Use first-class registry types for each extension point + +- Status: accepted +- Date: 2026-07-21 + +## Context and Problem Statement + +JS modules declare objects in the engine registry (`server.registry.add(type, key, ...)`). The registry namespaces entries by `type + "-" + key`. The POC ([npm-modules-engine#125](https://github.com/Jahia/npm-modules-engine/pull/125)) registered all extension points under a single umbrella `type: 'service'` with a `serviceType` property as a second-level discriminator. How should the new extension points be keyed? + +## Considered Options + +1. **First-class registry types**: `action`, `choicelist-initializer`, `node-validator` (kebab-case, like the existing `view`, `viewRenderer`, `render-filter`, `bundleInitializer`). +2. POC style: `type: 'service'` + `serviceType` discriminator. + +## Decision Outcome + +Chosen option: **1 — first-class registry types.** + +- The registry keys entries as `type-key`. With a shared `service` type, an action named `foo` and a choicelist initializer named `foo` collide and `Registry.add` throws. For actions and choicelists the key _is_ the platform-visible name, so cross-kind collisions are a real hazard, not a theoretical one. +- Registrar discovery uses `Registry.find({type, bundleKey})` — exactly the pattern `ViewsRegistrar` and `RenderFilterRegistrar` already use. A second-level discriminator would add filtering logic for no benefit. +- It matches how the platform is described ("registry of views, of render filters, of actions") and the registry conventions developers already know from jContent. +- Nothing in the POC exploited the `service` umbrella; its own TODOs pointed at dedicated typed registration functions. + +The existing `render-filter` type string is kept as-is for backward compatibility with modules that call `server.registry.add('render-filter', ...)` directly. + +### Consequences + +- Good: no cross-kind key collisions; discovery stays a single exact-match filter. +- Good: each type can evolve its entry shape independently. +- Bad: one more type string to know per extension point — mitigated by the typed wrappers ([ADR-0003](0003-typed-registration-wrappers.md)), which make the type string an implementation detail most developers never see. diff --git a/docs/adr/0003-typed-registration-wrappers.md b/docs/adr/0003-typed-registration-wrappers.md new file mode 100644 index 00000000..5d93af17 --- /dev/null +++ b/docs/adr/0003-typed-registration-wrappers.md @@ -0,0 +1,38 @@ +# Idiomatic TypeScript registration wrappers with a raw Java escape hatch + +- Status: accepted +- Date: 2026-07-21 + +## Context and Problem Statement + +The POC passed raw Java objects (`HttpServletRequest`, `ExtendedPropertyDefinition`, `JCRSessionWrapper`, `Locale`, …) straight into JS callbacks and expected Java-shaped return values. That is fast to build but couples module code to Java APIs, is hard to type, and leaks polyglot conversion pitfalls (e.g. nested JS objects converted with `Value.as(Map.class)`) into user code. How should the developer-facing API look? + +## Decision Drivers + +- Developer experience consistent with `jahiaComponent`: one exported function, TypeScript-typed, registration as a module-init side effect. +- Advanced use cases must stay possible: the underlying Java objects carry capabilities we cannot re-expose exhaustively. +- Polyglot value conversion must be controlled in one place, not in every module. + +## Considered Options + +1. **Idiomatic TS-first signatures with a `java` escape hatch, adaptation done in the library (TS side).** +2. Raw Java objects everywhere (POC style). +3. Fully abstracted JS API with no Java access. + +## Decision Outcome + +Chosen option: **1.** + +- The library exports one registration function per extension point (`registerChoiceListInitializer`, `registerNodeLegacyAction`, `registerNodeValidator`, `registerRenderFilter`), siblings of `jahiaComponent` in `javascript-modules-library/src/framework/`. +- Each wrapper stores an _adapter_ function in the registry: the Java bridge always calls a stable, raw-shaped function; the TS adapter converts to/from the idiomatic shapes before invoking the user callback. **Java stays dumb and stable; the adaptation lives in TS**, where it is cheap to evolve and unit-test. +- Idiomatic context objects expose converted values (e.g. `locale` as a BCP-47 language tag via `Locale.toLanguageTag()`, parameters as `Record`) and keep the raw Java objects under a `java` property (or as documented raw fields such as the `JCRNodeWrapper` itself, which is already the library's public node surface). +- Structured return values that must cross the boundary as JSON (action results) are **pre-stringified with `JSON.stringify` in the adapter** and parsed with `new JSONObject(String)` on the Java side. This sidesteps polyglot deep-conversion issues with nested objects/arrays that broke the POC's `new JSONObject(value.as(Map.class))` approach. +- Java types referenced in public signatures are provided by the existing java-ts-bind generation; types not yet generated (`ExtendedPropertyDefinition`, `URLResolver`, `Locale.toLanguageTag`) are added to the bind configuration with narrow method whitelists. +- Handlers may be `async`: every bridge settles returned promises through `JSPromise.settleOrThrow` (microtask-only — the server runtime has no timers or async I/O; rejections behave like synchronous throws). See [#688](https://github.com/Jahia/javascript-modules/issues/688). + +### Consequences + +- Good: typed, documented, discoverable API; conversion bugs are fixed once in the library. +- Good: no capability loss — the escape hatch keeps the full Java surface reachable. +- Bad: two representations of some values (idiomatic + raw) can confuse; mitigated by docs marking the `java` property as the escape hatch. +- Bad: the registry entry shape becomes a library↔engine contract that must be kept in sync (documented in both the wrapper and the bridge). diff --git a/docs/adr/0004-csrf-whitelisting-for-js-actions.md b/docs/adr/0004-csrf-whitelisting-for-js-actions.md new file mode 100644 index 00000000..22e5a8bf --- /dev/null +++ b/docs/adr/0004-csrf-whitelisting-for-js-actions.md @@ -0,0 +1,42 @@ +# CSRF whitelisting of JavaScript actions is the module author's responsibility + +- Status: accepted +- Date: 2026-07-21 + +## Context and Problem Statement + +Actions are invoked via `..do`. Unsafe HTTP methods (POST/PUT/DELETE) on `.do` URLs are blocked by Jahia's CSRF guard module unless the URL pattern is whitelisted through its OSGi configuration factory (PID `org.jahia.modules.jahiacsrfguard`, property `whitelist = *..do`). Java module authors ship such a config file with their module today. What should the engine do for JS-declared actions? + +## Considered Options + +1. **Document-only**: the JS module ships its own `settings/configurations/org.jahia.modules.jahiacsrfguard-.cfg`, exactly like Java modules. +2. Engine auto-registers a ConfigAdmin factory instance covering each JS action's URL on deploy. +3. Opt-in flag on the action declaration that triggers auto-registration. + +## Decision Outcome + +Chosen option: **1 — document-only.** + +JS modules already ship OSGi configs: the bundle transformer maps `settings/**` into `META-INF/**`, and Jahia deploys `META-INF/configurations/*.cfg` on module install. So the recipe is one file in the module: + +```properties +# settings/configurations/org.jahia.modules.jahiacsrfguard-mymodule.cfg +whitelist = *.myAction.do +``` + +- Security posture: opting out of CSRF protection stays an **explicit, auditable, per-module act** that reviewers and operators can see in the module source and in the deployed configuration — never a side effect of declaring an action. +- Parity: identical mental model and operational behavior as Java modules. +- Zero engine code, zero new lifecycle to maintain. + +The main cost — a developer forgetting the file and getting an opaque 403 on POST — is mitigated by a prominent section in the actions guide and by the test module exercising the recipe end-to-end. + +### Rejected alternative: ConfigAdmin auto-registration (options 2 and 3) + +The engine would create/update a `jahiacsrfguard` factory configuration per JS bundle (marker property for ownership, deleted on undeploy). Rejected because: + +- **Silently weakens CSRF protection** (option 2): developers never see the opt-out happen; a compromised or careless module opens POST endpoints without any reviewable artifact. +- **Persistence hazards**: ConfigAdmin configurations survive crashes and uninstall-without-stop, requiring marker-based reconciliation logic to avoid orphaned whitelist entries. +- **Cluster semantics are unclear**: Jahia's configuration management synchronizes file-based configs; programmatically created factory instances may not propagate, or may fight with operator-managed `.cfg` files for the same factory. +- Undefined behavior when the CSRF-guard module is absent or disabled. + +Option 3 (explicit `csrfWhitelisted: true` flag) fixes the visibility objection but keeps all the lifecycle/cluster hazards; it can be revisited if the documented recipe proves to be a recurring support burden. diff --git a/docs/adr/0005-js-node-validators-single-bean-validation-bridge.md b/docs/adr/0005-js-node-validators-single-bean-validation-bridge.md new file mode 100644 index 00000000..851c024c --- /dev/null +++ b/docs/adr/0005-js-node-validators-single-bean-validation-bridge.md @@ -0,0 +1,68 @@ +# Bridge JS node validators through a single Bean Validation bean registered for `nt:base` + +- Status: accepted +- Date: 2026-07-21 + +## Context and Problem Statement + +Jahia's server-side content validation is class-and-annotation based, not functional: + +- Validators are registered per node type in a **global** map (`JCRStoreService.addValidator(nodeType, Class)`) — one validator class per node type platform-wide, last registration wins, `removeValidator(nodeType)` removes unconditionally. +- The class must implement `JCRNodeValidator` and expose a public `(JCRNodeWrapper)` constructor. On every session save, core instantiates it for each changed node matching the map key (`node.isNodeType(key)`) and runs Bean Validation (Hibernate Validator via Spring's `LocalValidatorFactoryBean`) over it, in two phases driven by validation groups: `(Default, DefaultSkipOnImportGroup)` then — only if clean — `(AdvancedGroup, AdvancedSkipOnImportGroup)`; imports omit the SkipOnImport groups. +- Violations map to editor errors through the violation's property path: a path resolving to a property definition produces a field-level `PropertyConstraintViolationException` (Content Editor field error); a blank path produces a node-level `NodeConstraintViolationException`. + +JS validators are dynamic functions declared at module init. We need a bridge from this functional model onto the static class/annotation model, with correct field-level error mapping and import semantics. + +## Decision Drivers + +- **Correctness under multi-type matching**: core instantiates and validates the bean once _per matching map entry_. Naively registering one bridge class under each declared node type makes a node matching K entries produce K duplicate violation sets (Hibernate Validator's violation dedup compares `rootBean` by `equals()`, and fresh bridge instances are never equal). +- Never clobber Java-module validators: the global map is last-wins, and removal is by node type only. +- JS functions must be re-resolved from the pooled GraalVM context registry per invocation. +- Messages come from user code and become Hibernate Validator message _templates_. + +## Considered Options + +1. **One engine-owned bean class registered under the single sentinel key `nt:base`; all node-type matching done engine/JS-side.** +2. Register the bridge class under each JS-declared node type. +3. Generate a distinct annotated validator class per JS registration (bytecode generation). +4. Ride the `JCRNodeValidatorDefinition` bean path like Java modules. +5. JCR interceptors/listeners throwing on save. + +## Decision Outcome + +Chosen option: **1 — single bean under sentinel `nt:base`.** + +- `JSNodeValidator implements JCRNodeValidator` carries four repeatable class-level `@JSValidation(mode=…, groups=…)` constraint annotations — one per Jahia phase combination (default / default-skip-on-import / advanced / advanced-skip-on-import). The `mode` attribute tells the shared `ConstraintValidator` which phase invoked it (the Bean Validation API does not expose active groups to `isValid`). +- Since every node `isNodeType("nt:base")` and the map holds exactly one JS entry, core instantiates the bean **exactly once per changed node per save** — every JS validator runs exactly once per phase, by construction. No dedup logic exists because none is needed. +- The `ConstraintValidator` obtains the engine registrar via OSGi lookup (`BundleUtils.getOsgiService`, the repo's established pattern; `null` → no-op) and dispatches: a fast volatile snapshot gate (`Mode → Set`, checked with `node.isNodeType`) avoids entering GraalVM for unaffected nodes; matching entries are then re-resolved from the live context registry and executed inside `doWithContext`. +- Violations are built programmatically: `disableDefaultConstraintViolation()` + `buildConstraintViolationWithTemplate(escapedMessage)`, with `.addPropertyNode(propertyName)` for field-level errors or none for node-level (blank path). The annotation deliberately has **no `propertyName()` attribute**, so core falls back to the per-violation property path. +- **Message semantics**: Jahia's JCR validator factory (`applicationcontext-jcr.xml`) replaces Hibernate Validator's standard interpolation with `JahiaMessageInterpolator`, which performs **no EL and no `{…}` parameter parsing**. It strips the first and last character of the template and looks the remainder up as a resource-bundle key (ValidationMessages, then every deployed module's bundle, then Jahia internal messages, in the current UI locale); unresolved templates are returned **verbatim**. Consequently: no escaping is applied (it would leak backslashes); a message of exactly `{my.bundle.key}` form is localized through resource bundles — the same i18n mechanism Java validators use — and any other message is displayed as-is. The bridge guards one interpolator edge case: messages shorter than 2 characters (which would crash `substring(1, length-1)`) are replaced by a generic fallback. +- **Lifecycle**: the registrar ref-counts declared validators across JS bundles; it calls `addValidator("nt:base", JSNodeValidator.class)` on 0→1 and `removeValidator("nt:base")` on 1→0 **only after verifying the registered constructor's declaring class is ours** (never delete a foreign validator; WARN in both collision directions). +- **Error policy**: a _throwing_ JS validator fails the save with a generic node-level violation (fail-closed, mirroring Java validator behavior; loud ERROR log with the validator key). A _malformed returned violation_ (missing/non-string message) is logged and skipped — a shape bug must not brick every content save on the platform. + +### Consequences + +- Good: exactly-once execution semantics; no interference with Java-module validators; zero overhead when no JS validator is registered (the bridge is not in the map at all). +- Neutral: while any JS validator exists, every changed node pays one reflective constructor + up to four gated `isValid` calls (no GraalVM entry unless a declared type matches) — cheaper than core's own per-node mandatory-property loop. +- Neutral/documented: a failing default-phase JS validator suppresses the advanced phase for _all_ JS validators on that node (per-bean group orchestration — same behavior as a single Java validator class). +- Documented platform caveats: violations on i18n properties are silently dropped by core when the session locale is null; never call `session.save()` inside a validator. +- This is a deliberate deviation from the whiteboard pattern of [ADR-0001](0001-javascript-server-extension-points.md): core's validator consumption is a keyed map with unconditional removal, so precise ref-counted lifecycle control matters more than whiteboard purity here. + +## Pros and Cons of the Options + +### Option 2 — register under each declared node type + +- Bad: K-fold duplicate violations for nodes matching several entries; dedup would hinge on Hibernate-Validator-internal `equals` semantics. +- Bad: last-wins clobbering of Java validators on common node types; our removal could delete theirs. + +### Option 3 — bytecode generation per registration + +- Bad: ASM/ByteBuddy dependency, per-redeploy classloader and validator-metadata leaks, and it _still_ ends in the same one-class-per-node-type map with the same collision hazards. + +### Option 4 — `JCRNodeValidatorDefinition` path + +- Bad: designed for module Spring contexts, which JS modules do not have; read once at bean (un)registration so dynamic add/remove per JS deploy does not propagate; inherits the same map-collision and duplicate-instantiation issues. + +### Option 5 — JCR interceptors/listeners + +- Bad: wrong lifecycle (no validation phases or import semantics), and no `CompositeConstraintViolationException` integration — field-level Content Editor errors are lost. diff --git a/docs/adr/0007-action-naming.md b/docs/adr/0007-action-naming.md new file mode 100644 index 00000000..7dc7874d --- /dev/null +++ b/docs/adr/0007-action-naming.md @@ -0,0 +1,30 @@ +# Reserve "action" for client-callable server functions; rename the platform bridge to "legacy node actions" + +- Status: accepted +- Date: 2026-07-22 + +## Context and Problem Statement + +Two distinct features both naturally claim the name "action": + +1. The bridge to Jahia's classic `org.jahia.bin.Action` platform extension point — HTTP endpoints bound to content nodes (`..do`), initially shipped as `registerAction()`. +2. The client-callable server functions of [#588](https://github.com/Jahia/javascript-modules/issues/588) — `.action.ts` files compiled into typed RPC stubs, the forward-looking developer experience. + +Shipping both under one word would permanently confuse documentation, support and code search. The API is unreleased, so a rename is still free. + +## Decision Outcome + +- **The plain name goes to the modern feature**: `.action.ts` files, the `action()` safe wrapper, and the docs page titled "Actions" all belong to the #588 implementation. It is the API developers should reach for by default. +- **The platform bridge becomes `registerNodeLegacyAction`** (registry type `node-legacy-action`, Java class `NodeLegacyActionRegistrar`, guide "Legacy Node Actions"). The name states both what it binds to (a content node) and its lineage (the legacy `.do` mechanism kept for Java parity and existing integrations). + +### Considered alternatives + +- `registerAction` for the bridge (status quo) — rejected: collides with the #588 vocabulary. +- `registerJCRAction` — rejected: `org.jahia.bin.Action` is a render/HTTP-layer concept, not a repository one; "JCR" suggests observation/listener semantics it does not have. +- `registerNodeAction` (without "legacy") — rejected by the maintainer in favor of an explicit legacy marker, steering new code toward `.action.ts` actions unless node-bound `.do` semantics are specifically needed. + +### Consequences + +- Good: one obvious default ("use actions"), one clearly-marked escape hatch for `.do` parity. +- Neutral: the docs must keep a "when to use what" table (done in both guides) since the two features overlap on "call the server over HTTP". +- Done pre-release; no compatibility shim exists or is needed. diff --git a/docs/adr/0008-client-callable-actions.md b/docs/adr/0008-client-callable-actions.md new file mode 100644 index 00000000..a2a91cbc --- /dev/null +++ b/docs/adr/0008-client-callable-actions.md @@ -0,0 +1,61 @@ +# Client-callable actions: dual-compiled `.action.ts` files over a single dispatch endpoint + +- Status: accepted +- Date: 2026-07-22 + +## Context and Problem Statement + +[#588](https://github.com/Jahia/javascript-modules/issues/588) specifies actions as typed server functions callable from client islands — inspired by SvelteKit remote functions: a `.action.ts` file is compiled once for the server (real implementation) and once for the client (network stub), with devalue serialization and optional Standard Schema input validation. How do we implement the build-time split, the server endpoint, and the wire protocol on top of the existing engine? + +## Decision Drivers + +- Zero boilerplate for module authors: export a function, import it from the client, call it. +- Ride proven infrastructure: the engine's registry/`doWithContext` model, the Render servlet's auth valves, devalue (already used for island props). +- CSRF safety without per-module configuration. +- The server JS runtime has no event loop: asynchronicity is microtask-only. + +## Decision Outcome + +### Build (vite-plugin) + +`.action.{ts,js}` files (default glob `**/*.action.{js,ts}`) are compiled twice: + +- **Server bundle**: the file is included as-is and a `__registerActionsModule({ …exports }, "")` call is appended (underscore-marked internal: the engine resolves the library as one shared module at runtime, so a separate subpath entry point is not resolvable there). Each exported function is registered in the engine registry under type `action`, key `/` (module name read from `package.json` at build time — the same value the stubs embed, so no runtime agreement on bundle symbolic names is needed). Duplicate keys fail at module startup via the registry's add semantics. +- **Client bundle**: the module is replaced wholesale by generated stubs — one `async` function per export that POSTs `devalue.stringify(args)` and parses the response. The server implementation never reaches the client bundle, and imports it made (including `@jahia/javascript-modules-library`) disappear with it. + +Export discovery is a deliberate v1 simplification: only top-level `export const = …` / `export function ` declarations, extracted lexically (works identically on TS and JS, no parser dependency, no plugin-phase sensitivity). Other export forms emit a build warning and are ignored. + +### Endpoint and wire protocol + +One engine-owned platform action, `jsAction` (`GenericActionEndpoint`), dispatches to all registered actions: + +- URL: `.jsAction.do?name=/` — riding the Render servlet keeps Jahia's authentication valves (calls execute as the visitor, guest included) and requires no new servlet/HTTP-whiteboard surface. +- Request body: devalue-serialized arguments array. Response envelope: `{"data": ""}` on success, `{"error": "...", "issues": [...]?}` on failure — always on HTTP 200, because the render servlet only writes JSON bodies for 2xx action results; the stub discriminates on the envelope. +- The JS adapter (library) owns all serialization: the Java endpoint pipes opaque strings and never converts structured polyglot values. + +### CSRF + +The engine ships a single reviewable CSRF-guard whitelist entry (`*.jsAction.do`) in its own configuration. Actual protection is the mandatory **`X-JS-Action` request header**: HTML forms cannot set custom headers, and cross-origin scripts cannot send one without a CORS preflight that Jahia does not grant. This does not contradict [ADR-0004](0004-csrf-whitelisting-for-js-actions.md): that decision rejected _silent per-module_ whitelist generation for developer-shaped `.do` endpoints; here the endpoint is engine-owned, single, and header-protected by construction. + +### Asynchronicity + +Handlers may be `async`. The endpoint settles returned promises through `JSPromise.settle`: GraalJS drains the microtask queue when the last JavaScript frame returns to the host, so any composition of `async`/`await`/`then` over synchronous work settles before the endpoint reads the outcome (validated by unit tests against real GraalJS). Promises depending on timers or async I/O — which do not exist in the server runtime — are detected as never-settling and fail the call with an explicit message. + +### Safe actions + +`action(schema, implementation)` accepts any [Standard Schema](https://standardschema.dev) v1 schema (interface vendored — no validation-library dependency) and validates the single client-supplied argument. Validation failures reject with `ActionValidationError`; the adapter ships the issues (pre-stringified JSON) through the error envelope, and the client stub re-attaches them to the thrown `Error`. + +## Considered Alternatives + +- **Dedicated OSGi HTTP-whiteboard servlet** — rejected for v1: leaves Jahia's authentication valve chain and the `/modules/*` URL space with unclear interactions; the Render servlet gives auth, sessions and URL resolution for free. +- **Per-action platform Actions** (one `org.jahia.bin.Action` per export) — rejected: floods the global action-name map, requires per-module CSRF whitelists (the exact DX problem ADR-0004 chose not to solve silently), and gains nothing over a single dispatcher. +- **JSON wire format** — rejected: loses `Date`/`Map`/`Set`/cycles; devalue is already in the stack for island props. +- **AST-based export discovery** — deferred: a real parser (oxc/es-module-lexer) can replace the lexical extraction later without changing any contract. + +## Consequences + +- Good: end-to-end typed calls with one import; guests can call actions (public-site islands), with the explicit documented duty to validate inputs and enforce permissions in the function. +- Good: no per-module CSRF or servlet configuration. +- Limitation (documented): `location.pathname`-based stub URLs assume the island is served from a page render URL; the `.html` template extension is stripped, other extensions are passed through. +- Limitation (documented): microtask-only asynchronicity; no `export { }` lists/`default` in action files (v1). +- Intended evolution ([#690](https://github.com/Jahia/javascript-modules/issues/690)): swap the transport to a dedicated servlet (module-scoped URLs, real HTTP status codes, no CSRF-guard entry). Both wire ends are platform-generated, so the swap is invisible to module authors. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..b564e622 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,17 @@ +# Architecture Decision Records + +This directory contains the Architecture Decision Records (ADRs) for the JavaScript Modules project, in [MADR](https://adr.github.io/madr/) style. An ADR captures a single architecturally significant decision: its context, the options considered, and the consequences we accept. + +ADRs are numbered in the order they were accepted and are never rewritten once accepted — a superseding decision gets a new ADR that links back. + +## Index + +| ADR | Title | Status | +| ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------- | +| [0001](0001-javascript-server-extension-points.md) | Bridge JavaScript-declared server extension points through per-type registrars | accepted | +| [0002](0002-first-class-registry-types.md) | Use first-class registry types for each extension point | accepted | +| [0003](0003-typed-registration-wrappers.md) | Idiomatic TypeScript registration wrappers with a raw Java escape hatch | accepted | +| [0004](0004-csrf-whitelisting-for-js-actions.md) | CSRF whitelisting of JavaScript actions is the module author's responsibility | accepted | +| [0005](0005-js-node-validators-single-bean-validation-bridge.md) | Bridge JS node validators through a single Bean Validation bean registered for `nt:base` | accepted | +| [0007](0007-action-naming.md) | Reserve "action" for client-callable server functions; rename the platform bridge to "legacy node actions" | accepted | +| [0008](0008-client-callable-actions.md) | Client-callable actions: dual-compiled `.action.ts` files over a single dispatch endpoint | accepted | diff --git a/jahia-test-module/settings/configurations/org.jahia.modules.jahiacsrfguard-jsmtest.cfg b/jahia-test-module/settings/configurations/org.jahia.modules.jahiacsrfguard-jsmtest.cfg new file mode 100644 index 00000000..a646327e --- /dev/null +++ b/jahia-test-module/settings/configurations/org.jahia.modules.jahiacsrfguard-jsmtest.cfg @@ -0,0 +1,5 @@ +# Whitelists the POST test action in Jahia's CSRF guard, following the documented recipe +# for JS modules exposing actions to unsafe HTTP methods (see docs/2-guides/4-legacy-node-actions). +# testJsActionAuth is a GET, but the guard also challenges authenticated GET .do requests, +# which is how the e2e suite calls it. +whitelist = *.testJsActionPost.do,*.testJsActionAuth.do diff --git a/jahia-test-module/settings/definitions.cnd b/jahia-test-module/settings/definitions.cnd index 11fcce95..3e96988d 100644 --- a/jahia-test-module/settings/definitions.cnd +++ b/jahia-test-module/settings/definitions.cnd @@ -155,3 +155,14 @@ [javascriptExample:testVirtualNodeSample] > jnt:content, javascriptExampleMix:javascriptExampleComponent, mix:title - myProperty (string) + +[javascriptExample:testChoicelistInitializer] > jnt:content, javascriptExampleMix:javascriptExampleComponent, mix:title + - color (string, choicelist[testColorsInitializer]) + - colorWithParam (string, choicelist[testColorsInitializer='warm']) + +[javascriptExample:testValidation] > jnt:content, javascriptExampleMix:javascriptExampleComponent, mix:title + - email (string) + - score (long) + - i18nText (string) internationalized + +[javascriptExample:testGenericAction] > jnt:content, javascriptExampleMix:javascriptExampleComponent, mix:title diff --git a/jahia-test-module/src/actions/calculator.action.ts b/jahia-test-module/src/actions/calculator.action.ts new file mode 100644 index 00000000..65dbde63 --- /dev/null +++ b/jahia-test-module/src/actions/calculator.action.ts @@ -0,0 +1,45 @@ +import { action, ActionError, type StandardSchemaV1 } from "@jahia/javascript-modules-library"; + +/** + * Test fixtures for actions (.action.ts files): functions executed on the server, callable from the + * client through generated fetch stubs. + */ + +export const add = async (a: number, b: number) => a + b; + +// exercises devalue-only types across the wire (Date, Map, Set) +export const echoKinds = (input: { date: Date; map: Map; set: Set }) => ({ + ...input, + dateType: input.date instanceof Date, + mapSize: input.map.size, + setHas: input.set.has("present"), +}); + +// an ActionError is deliberate: its message travels to the caller +export const failOnPurpose = () => { + throw new ActionError("Intentional failure"); +}; + +// any other exception is masked with a generic message (actions are guest-callable) +export const failInternally = () => { + throw new Error("secret implementation detail"); +}; + +// depends on an event that never happens: the runtime must detect it instead of hanging +export const neverSettles = () => new Promise(() => {}); + +/** Hand-rolled Standard Schema, to avoid pulling a validation library into the test module. */ +const positiveNumberInput: StandardSchemaV1<{ n: number }> = { + "~standard": { + version: 1, + vendor: "javascript-modules-test", + validate: (value) => { + const n = (value as { n?: unknown } | null)?.n; + return typeof n === "number" && n > 0 + ? { value: { n } } + : { issues: [{ message: "n must be a positive number" }] }; + }, + }, +}; + +export const safeDouble = action(positiveNumberInput, ({ n }) => n * 2); diff --git a/jahia-test-module/src/client/components/SampleGenericAction.tsx b/jahia-test-module/src/client/components/SampleGenericAction.tsx new file mode 100644 index 00000000..843a77aa --- /dev/null +++ b/jahia-test-module/src/client/components/SampleGenericAction.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; +import { add, failOnPurpose, safeDouble } from "../../actions/calculator.action"; + +/** + * Exercises the client side of actions: the imports above resolve to generated fetch stubs, not to + * the server implementation. + */ +export default function SampleGenericAction() { + const [result, setResult] = useState(""); + + return ( +
+ + + +

{result}

+
+ ); +} diff --git a/jahia-test-module/src/react/server/extensions/actions.ts b/jahia-test-module/src/react/server/extensions/actions.ts new file mode 100644 index 00000000..df6742b3 --- /dev/null +++ b/jahia-test-module/src/react/server/extensions/actions.ts @@ -0,0 +1,43 @@ +import { registerNodeLegacyAction } from "@jahia/javascript-modules-library"; + +/** + * Test fixtures for JS-declared actions, invoked via ..do URLs. + * + * Exercises: GET with query parameters and node access, POST (CSRF-whitelisted via + * settings/configurations/org.jahia.modules.jahiacsrfguard-jsmtest.cfg), authentication + * requirement, redirects, and method restrictions. + */ + +registerNodeLegacyAction( + { name: "testJsActionGet", requiredMethods: ["GET"], requireAuthenticatedUser: false }, + // async on purpose: exercises promise settling in the legacy action bridge + async ({ parameters, resource }) => ({ + json: { + echo: parameters.echo?.[0] ?? null, + path: resource.getNode().getPath(), + }, + }), +); + +registerNodeLegacyAction( + { name: "testJsActionPost", requiredMethods: ["POST"], requireAuthenticatedUser: false }, + ({ parameters }) => ({ + statusCode: 201, + json: { received: parameters.payload?.[0] ?? null }, + }), +); + +// requireAuthenticatedUser defaults to true: guests get a 401 +registerNodeLegacyAction( + { name: "testJsActionAuth", requiredMethods: ["GET"] }, + ({ renderContext }) => ({ + json: { user: renderContext.getUser().getUsername() }, + }), +); + +registerNodeLegacyAction( + { name: "testJsActionRedirect", requiredMethods: ["GET"], requireAuthenticatedUser: false }, + // No statusCode: the platform picks the redirect status itself (303 unless the request asks for + // another one). Returning a 3xx here would make Jahia answer sendError() instead of redirecting. + () => ({ redirect: "/redirected-target" }), +); diff --git a/jahia-test-module/src/react/server/extensions/choicelists.ts b/jahia-test-module/src/react/server/extensions/choicelists.ts new file mode 100644 index 00000000..bc5b14b1 --- /dev/null +++ b/jahia-test-module/src/react/server/extensions/choicelists.ts @@ -0,0 +1,30 @@ +import { + registerChoiceListInitializer, + type ChoiceListValue, +} from "@jahia/javascript-modules-library"; + +/** + * Test fixture for JS-declared choicelist initializers, referenced from settings/definitions.cnd as + * choicelist[testColorsInitializer]. + * + * Exercises: localized labels, properties (defaultProperty), the CND parameter + * (choicelist[testColorsInitializer='warm']), previous values passthrough, and the raw Java escape + * hatch (property definition name). + */ +registerChoiceListInitializer( + { key: "testColorsInitializer" }, + // async on purpose: exercises promise settling in the choicelist bridge + async ({ param, locale, values, java }) => { + const choices: ChoiceListValue[] = [ + ...values, + { label: locale.startsWith("fr") ? "Rouge" : "Red", value: "red" }, + { label: "Green", value: "green", properties: { defaultProperty: true } }, + // escape hatch probe: label derived from the raw ExtendedPropertyDefinition + { label: `prop:${java.propertyDefinition.getName()}`, value: "propName" }, + ]; + if (param === "warm") { + choices.push({ label: "Orange", value: "orange" }); + } + return choices; + }, +); diff --git a/jahia-test-module/src/react/server/extensions/validators.ts b/jahia-test-module/src/react/server/extensions/validators.ts new file mode 100644 index 00000000..5ddce6e6 --- /dev/null +++ b/jahia-test-module/src/react/server/extensions/validators.ts @@ -0,0 +1,54 @@ +import { registerNodeValidator } from "@jahia/javascript-modules-library"; + +/** + * Test fixtures for JS-declared node validators on javascriptExample:testValidation. + * + * Exercises: field-level violations, node-level violations, message pass-through with special + * characters, the advanced phase (only runs when the default phase passes), and skipOnImport. + */ + +const NODE_TYPE = "javascriptExample:testValidation"; + +// default phase: email format (field-level), node-level probe, special-characters probe +registerNodeValidator({ nodeType: NODE_TYPE }, (node) => { + const email = node.getPropertyAsString("email"); + if (!email) return undefined; + + if (email === "node-level-probe") { + return { message: "This content is inconsistent (node-level probe)" }; + } + if (email === "escaping-probe") { + // must survive verbatim: braces, EL-lookalike, backslash + return { message: "lone { brace, ${7*7}, back\\slash and {jcr:title}", propertyName: "email" }; + } + if (!email.includes("@")) { + return { message: "Please provide a valid email address", propertyName: "email" }; + } + return undefined; +}); + +// advanced phase: only runs once the default phase passed +// async on purpose: exercises promise settling in the validator bridge +registerNodeValidator( + { nodeType: NODE_TYPE, name: "score-range", advanced: true }, + async (node) => { + if (node.hasProperty("score") && node.getProperty("score").getLong() > 100) { + return { message: "Score must be at most 100 (advanced phase)", propertyName: "score" }; + } + return undefined; + }, +); + +// skipped during content imports +registerNodeValidator( + { nodeType: NODE_TYPE, name: "skip-on-import", skipOnImport: true }, + (node) => { + if (node.getPropertyAsString("email") === "import-probe") { + return { + message: "Rejected outside of imports (skip-on-import probe)", + propertyName: "email", + }; + } + return undefined; + }, +); diff --git a/jahia-test-module/src/react/server/views/testGenericAction/TestGenericAction.tsx b/jahia-test-module/src/react/server/views/testGenericAction/TestGenericAction.tsx new file mode 100644 index 00000000..1a2ed012 --- /dev/null +++ b/jahia-test-module/src/react/server/views/testGenericAction/TestGenericAction.tsx @@ -0,0 +1,16 @@ +import { Island, jahiaComponent } from "@jahia/javascript-modules-library"; +import SampleGenericAction from "$client/components/SampleGenericAction"; + +jahiaComponent( + { + id: "test_generic_action", + nodeType: "javascriptExample:testGenericAction", + componentType: "view", + }, + () => ( + <> +

Actions (.action.ts) called from a client island:

+ + + ), +); diff --git a/javascript-create-module/templates/module/vite.config.mjs b/javascript-create-module/templates/module/vite.config.mjs index 91dbaa52..334c778c 100644 --- a/javascript-create-module/templates/module/vite.config.mjs +++ b/javascript-create-module/templates/module/vite.config.mjs @@ -14,7 +14,7 @@ export default defineConfig({ // outputDir: "client", // }, // server: { - // inputGlob: "**/*.server.{jsx,tsx}", + // inputGlob: "**/*.server.{js,jsx,ts,tsx}", // outputFile: "server/index.js", // }, diff --git a/javascript-modules-engine-java/.java-ts-bind/package.json b/javascript-modules-engine-java/.java-ts-bind/package.json index 1fd7be06..0b8e10fe 100644 --- a/javascript-modules-engine-java/.java-ts-bind/package.json +++ b/javascript-modules-engine-java/.java-ts-bind/package.json @@ -29,7 +29,9 @@ "org.jahia.modules.javascript.modules.engine.js.server.RenderHelper", "org.jahia.services.render.RenderContext", "org.jahia.services.render.Resource", - "org.jahia.services.content.JCRNodeWrapper" + "org.jahia.services.content.JCRNodeWrapper", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition", + "org.jahia.services.render.URLResolver" ], "include": [ "java.io.BufferedReader", @@ -92,11 +94,14 @@ "org.jahia.services.content.QueryManagerWrapper", "org.jahia.services.content.decorator.JCRNodeDecorator", "org.jahia.services.content.decorator.JCRSiteNode", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition", "org.jahia.services.query.QueryResultWrapper", "org.jahia.services.query.QueryWrapper", "org.jahia.services.render.RenderContext", "org.jahia.services.render.Resource", "org.jahia.services.render.URLGenerator", + "org.jahia.services.render.URLResolver", "org.jahia.services.sites.JahiaSite", "org.jahia.services.usermanager.JahiaPrincipal", "org.jahia.services.usermanager.JahiaUser", @@ -146,6 +151,7 @@ "java.util.List.iterator", "java.util.List.size", "java.util.Locale.get.*", + "java.util.Locale.toLanguageTag", "java.util.Locale.toString", "java.util.Map.containsKey", "java.util.Map.get.*", @@ -161,6 +167,9 @@ "javax.jcr.Binary.is.*", "javax.jcr.Item.get.*", "javax.jcr.Item.is.*", + "javax.jcr.Item.remove", + "javax.jcr.Node.addMixin", + "javax.jcr.Node.addNode.*", "javax.jcr.Node.get.*AsDate", "javax.jcr.Node.get.*Url.*", "javax.jcr.Node.get.*User", @@ -186,12 +195,19 @@ "javax.jcr.Node.getWeakReferences.*", "javax.jcr.Node.has.*", "javax.jcr.Node.is.*", + "javax.jcr.Node.orderBefore", + "javax.jcr.Node.remove", + "javax.jcr.Node.removeMixin", + "javax.jcr.Node.setPrimaryType", + "javax.jcr.Node.setProperty.*", "javax.jcr.NodeIterator.getSize", "javax.jcr.NodeIterator.hasNext", "javax.jcr.NodeIterator.nextNode", "javax.jcr.Property.get.*", "javax.jcr.Property.has.*", "javax.jcr.Property.is.*", + "javax.jcr.Property.remove", + "javax.jcr.Property.setValue.*", "javax.jcr.PropertyIterator.getSize", "javax.jcr.PropertyIterator.hasNext", "javax.jcr.PropertyIterator.nextProperty", @@ -238,19 +254,25 @@ "org.jahia.services.content.JCRCallback.*", "org.jahia.services.content.JCRItemWrapper.get.*", "org.jahia.services.content.JCRItemWrapper.is.*", + "org.jahia.services.content.JCRItemWrapper.remove", "org.jahia.services.content.JCRNodeIteratorWrapper.getPosition", "org.jahia.services.content.JCRNodeIteratorWrapper.getSize", "org.jahia.services.content.JCRNodeIteratorWrapper.hasNext", "org.jahia.services.content.JCRNodeIteratorWrapper.nextNode", + "org.jahia.services.content.JCRNodeWrapper.addMixin", + "org.jahia.services.content.JCRNodeWrapper.addNode.*", + "org.jahia.services.content.JCRNodeWrapper.denyRoles", "org.jahia.services.content.JCRNodeWrapper.get.*AsDate", "org.jahia.services.content.JCRNodeWrapper.get.*Url.*", "org.jahia.services.content.JCRNodeWrapper.get.*User", "org.jahia.services.content.JCRNodeWrapper.getAncestor.*", + "org.jahia.services.content.JCRNodeWrapper.getApplicablePropertyDefinition", "org.jahia.services.content.JCRNodeWrapper.getCanonicalPath", "org.jahia.services.content.JCRNodeWrapper.getDisplayableName", "org.jahia.services.content.JCRNodeWrapper.getExistingLocales", "org.jahia.services.content.JCRNodeWrapper.getI18N", "org.jahia.services.content.JCRNodeWrapper.getI18Ns", + "org.jahia.services.content.JCRNodeWrapper.getOrCreateI18N", "org.jahia.services.content.JCRNodeWrapper.getIdentifier", "org.jahia.services.content.JCRNodeWrapper.getLanguage", "org.jahia.services.content.JCRNodeWrapper.getMixinNodeTypes", @@ -260,6 +282,7 @@ "org.jahia.services.content.JCRNodeWrapper.getParent", "org.jahia.services.content.JCRNodeWrapper.getPath.*", "org.jahia.services.content.JCRNodeWrapper.getPrimaryNodeTypeName", + "org.jahia.services.content.JCRNodeWrapper.getRealNode", "org.jahia.services.content.JCRNodeWrapper.getProperties.*", "org.jahia.services.content.JCRNodeWrapper.getProperty.*", "org.jahia.services.content.JCRNodeWrapper.getReferences.*", @@ -267,10 +290,25 @@ "org.jahia.services.content.JCRNodeWrapper.getSession", "org.jahia.services.content.JCRNodeWrapper.getUUID", "org.jahia.services.content.JCRNodeWrapper.getWeakReferences.*", + "org.jahia.services.content.JCRNodeWrapper.grantRoles", "org.jahia.services.content.JCRNodeWrapper.has.*", "org.jahia.services.content.JCRNodeWrapper.is.*", + "org.jahia.services.content.JCRNodeWrapper.markForDeletion.*", + "org.jahia.services.content.JCRNodeWrapper.orderBefore", + "org.jahia.services.content.JCRNodeWrapper.remove", + "org.jahia.services.content.JCRNodeWrapper.removeMixin", + "org.jahia.services.content.JCRNodeWrapper.rename", + "org.jahia.services.content.JCRNodeWrapper.revokeRolesForPrincipal", + "org.jahia.services.content.JCRNodeWrapper.setAclInheritanceBreak", + "org.jahia.services.content.JCRNodeWrapper.setPrimaryType", + "org.jahia.services.content.JCRNodeWrapper.setProperty.*", + "org.jahia.services.content.JCRNodeWrapper.unmarkForDeletion", + "org.jahia.services.content.JCRPropertyWrapper.addValue.*", "org.jahia.services.content.JCRPropertyWrapper.get.*", "org.jahia.services.content.JCRPropertyWrapper.is.*", + "org.jahia.services.content.JCRPropertyWrapper.remove", + "org.jahia.services.content.JCRPropertyWrapper.removeValue.*", + "org.jahia.services.content.JCRPropertyWrapper.setValue.*", "org.jahia.services.content.JCRSessionWrapper.getAliasedUser", "org.jahia.services.content.JCRSessionWrapper.getAttribute.*", "org.jahia.services.content.JCRSessionWrapper.getFallbackLocale", @@ -282,13 +320,20 @@ "org.jahia.services.content.JCRSessionWrapper.getRootNode", "org.jahia.services.content.JCRSessionWrapper.getUser.*", "org.jahia.services.content.JCRSessionWrapper.getWorkspace", + "org.jahia.services.content.JCRSessionWrapper.itemExists", + "org.jahia.services.content.JCRSessionWrapper.move.*", "org.jahia.services.content.JCRSessionWrapper.nodeExists", "org.jahia.services.content.JCRSessionWrapper.propertyExists", + "org.jahia.services.content.JCRSessionWrapper.refresh", + "org.jahia.services.content.JCRSessionWrapper.save", "org.jahia.services.content.JCRValueWrapper.get.*", "org.jahia.services.content.JCRValueWrapper.is.*", + "org.jahia.services.content.JCRWorkspaceWrapper.clone", + "org.jahia.services.content.JCRWorkspaceWrapper.copy.*", "org.jahia.services.content.JCRWorkspaceWrapper.getName", "org.jahia.services.content.JCRWorkspaceWrapper.getQueryManager", "org.jahia.services.content.JCRWorkspaceWrapper.getSession", + "org.jahia.services.content.JCRWorkspaceWrapper.move.*", "org.jahia.services.content.QueryManagerWrapper.createQuery", "org.jahia.services.content.decorator.JCRSiteNode.get.*AsDate", "org.jahia.services.content.decorator.JCRSiteNode.get.*InstalledModules.*", @@ -321,6 +366,18 @@ "org.jahia.services.content.decorator.JCRSiteNode.getUUID", "org.jahia.services.content.decorator.JCRSiteNode.getWeakReferences.*", "org.jahia.services.content.decorator.JCRSiteNode.is.*", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition.getName", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition.getSelectorOptions", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition.isHidden", + "org.jahia.services.content.nodetypes.ExtendedItemDefinition.isMandatory", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.getName", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.getRequiredType", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.getSelectorOptions", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.getValueConstraints", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.isHidden", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.isInternationalized", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.isMandatory", + "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition.isMultiple", "org.jahia.services.query.QueryResultWrapper.getApproxCount", "org.jahia.services.query.QueryResultWrapper.getNodes", "org.jahia.services.query.QueryWrapper.bindValue", @@ -336,6 +393,11 @@ "org.jahia.services.render.Resource.is.*", "org.jahia.services.render.URLGenerator.get.*", "org.jahia.services.render.URLGenerator.is.*", + "org.jahia.services.render.URLResolver.getLocale", + "org.jahia.services.render.URLResolver.getPath", + "org.jahia.services.render.URLResolver.getSiteKey", + "org.jahia.services.render.URLResolver.getUrlPathInfo", + "org.jahia.services.render.URLResolver.getWorkspace", "org.jahia.services.usermanager.JahiaUser.get.*", "org.jahia.services.usermanager.JahiaUser.is.*", "org.osgi.framework.Bundle.get.*", @@ -450,7 +512,6 @@ "org.jahia.services.content.decorator.JCRPlaceholderNode", "org.jahia.services.content.decorator.JCRUserNode", "org.jahia.services.content.nodetypes.ExtendedNodeDefinition", - "org.jahia.services.content.nodetypes.ExtendedPropertyDefinition", "org.jahia.services.content.nodetypes.NodeTypeWrapper", "org.jahia.services.pwd.PasswordService", "org.jahia.services.query.QueryResultAdapter", diff --git a/javascript-modules-engine-java/pom.xml b/javascript-modules-engine-java/pom.xml index 6727ba77..edc8b507 100644 --- a/javascript-modules-engine-java/pom.xml +++ b/javascript-modules-engine-java/pom.xml @@ -109,6 +109,20 @@ jdom2 provided + + + org.json + json + 20231013 + provided + + + + javax.validation + validation-api + 2.0.1.Final + provided + org.jahia.server jahia-impl @@ -208,6 +222,38 @@ JUnitParams test + + org.mockito + mockito-core + 4.11.0 + test + + + + org.graalvm.js + js + test + + + + commons-lang + commons-lang + 2.6 + test + + + + org.hibernate.validator + hibernate-validator + 6.2.0.Final + test + + + org.glassfish + javax.el + 3.0.0 + test + @@ -316,6 +362,13 @@ io.github.bensku:java-ts-bind + + org.graalvm.js:js + + commons-lang:commons-lang + + org.hibernate.validator:hibernate-validator + org.glassfish:javax.el org.apache.jackrabbit:jackrabbit-spi-commons org.jboss.spec.javax.servlet:jboss-servlet-api_3.1_spec diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/actions/GenericActionEndpoint.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/actions/GenericActionEndpoint.java new file mode 100644 index 00000000..d2b7c667 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/actions/GenericActionEndpoint.java @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.actions; + +import org.apache.commons.io.IOUtils; +import org.graalvm.polyglot.Value; +import org.jahia.bin.Action; +import org.jahia.bin.ActionResult; +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.jahia.modules.javascript.modules.engine.jsengine.JSPromise; +import org.jahia.services.content.JCRSessionWrapper; +import org.jahia.services.render.RenderContext; +import org.jahia.services.render.Resource; +import org.jahia.services.render.URLResolver; +import org.json.JSONArray; +import org.json.JSONObject; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.List; +import java.util.Map; + +/** + * Single HTTP endpoint dispatching to all JavaScript-declared actions (registry type {@code action}, + * produced by {@code .action.ts} files). + * + *

Exposed as the platform action {@code jsAction}: client stubs POST a devalue-serialized arguments + * array to {@code .jsAction.do?name=/} and receive an envelope + * {@code {"data": ""}} or {@code {"error": "...", "issues": [...]?}}. The envelope always + * travels on HTTP 200 because the render servlet only writes JSON bodies for 2xx action results. + * + *

The {@code X-JS-Action} header is required: HTML forms cannot set custom headers and cross-origin + * scripts would need a CORS preflight that Jahia does not grant, so the endpoint is not exploitable as + * a classic CSRF target even though its URL pattern is whitelisted in Jahia's CSRF guard (the engine + * ships that whitelist entry). + * + *

The dispatcher itself allows guest calls (islands run on public pages); the executed function can + * inspect the current user through its own means, and modules needing protection can check and throw. + */ +@Component(service = Action.class, immediate = true) +public class GenericActionEndpoint extends Action { + + public static final String ACTION_NAME = "jsAction"; + public static final String REGISTRY_TYPE = "action"; + public static final String REQUIRED_HEADER = "X-JS-Action"; + + private static final Logger logger = LoggerFactory.getLogger(GenericActionEndpoint.class); + + private GraalVMEngine graalVMEngine; + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + public void setGraalVMEngine(GraalVMEngine graalVMEngine) { + this.graalVMEngine = graalVMEngine; + } + + @Activate + public void activate() { + setName(ACTION_NAME); + setRequireAuthenticatedUser(false); + setRequiredMethods("POST"); + } + + @Override + public ActionResult doExecute(HttpServletRequest request, RenderContext renderContext, Resource resource, + JCRSessionWrapper session, Map> parameters, URLResolver urlResolver) + throws Exception { + if (request.getHeader(REQUIRED_HEADER) == null) { + return error("Missing " + REQUIRED_HEADER + " header"); + } + String name = getParameter(parameters, "name"); + if (name == null) { + return error("Missing action name"); + } + String body = IOUtils.toString(request.getReader()); + + return graalVMEngine.doWithContext(contextProvider -> { + Map entry = contextProvider.getRegistry().get(REGISTRY_TYPE, name); + if (entry == null || entry.get("execute") == null) { + return error("Unknown action: " + name); + } + JSPromise.Outcome outcome; + try { + outcome = JSPromise.settle(Value.asValue(entry.get("execute")).execute(body)); + } catch (Exception e) { + logger.error("JS action '{}' failed to execute", name, e); + return error("Action execution failed"); + } + if (!outcome.isSettled()) { + logger.error("JS action '{}' returned a promise that did not settle; only microtask-based " + + "asynchronicity is supported on the server (no timers or async I/O)", name); + return error("Action did not settle synchronously"); + } + if (outcome.isRejected()) { + return error(readMessage(outcome.getError()), readIssues(outcome.getError())); + } + Value data = outcome.getValue(); + if (data == null || data.isNull() || !data.isString()) { + logger.error("JS action '{}' adapter did not return a serialized string", name); + return error("Action returned an unexpected result"); + } + return new ActionResult(HttpServletResponse.SC_OK, null, new JSONObject().put("data", data.asString())); + }); + } + + private static String readMessage(Value errorValue) { + if (errorValue != null && errorValue.hasMembers() && errorValue.hasMember("message")) { + Value message = errorValue.getMember("message"); + if (message != null && message.isString()) { + return message.asString(); + } + } + return "Action failed"; + } + + /** The JS adapter pre-stringifies validation issues as a JSON array. */ + private static JSONArray readIssues(Value errorValue) { + if (errorValue != null && errorValue.hasMembers() && errorValue.hasMember("issues")) { + Value issues = errorValue.getMember("issues"); + if (issues != null && issues.isString()) { + try { + return new JSONArray(issues.asString()); + } catch (Exception e) { + logger.warn("Ignoring malformed validation issues payload", e); + } + } + } + return null; + } + + private static ActionResult error(String message) { + return error(message, null); + } + + private static ActionResult error(String message, JSONArray issues) { + JSONObject json = new JSONObject().put("error", message); + if (issues != null) { + json.put("issues", issues); + } + return new ActionResult(HttpServletResponse.SC_OK, null, json); + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/JcrHelper.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/JcrHelper.java index 7a8cb719..b2eeb8c7 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/JcrHelper.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/js/server/JcrHelper.java @@ -5,6 +5,7 @@ import org.jahia.services.content.JCRCallback; import org.jahia.services.content.JCRTemplate; import org.jahia.services.usermanager.JahiaUserManagerService; +import org.jahia.utils.LanguageCodeConverters; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -44,4 +45,31 @@ public Object doExecuteAsGuest(JCRCallback callback, Locale locale, Stri } return result; } + + /** + * Execute JCR operations on a system session (root privileges) on the given workspace and locale. + * This is intended for server-side code that must write to the repository, such as content patch + * scripts. + * + *

Unlike {@link #doExecuteAsGuest}, errors are NOT swallowed: they are rethrown to the caller, + * because callers like the content patch runner must detect failures. + * + * @param callback the callback to execute using the JCR session + * @param language the session language code (e.g. "en"), or null for a non-localized session + * (translation nodes are then visible as plain subnodes, which is usually what + * content patches want) + * @param workspace the workspace to open the session on ("default" or "live") + * @return the result of the callback + */ + public Object doExecuteAsSystem(JCRCallback callback, String language, String workspace) { + Locale locale = language != null ? LanguageCodeConverters.languageCodeToLocale(language) : null; + try { + return JCRTemplate.getInstance().doExecuteWithSystemSessionAsUser(null, workspace, locale, callback); + } catch (Exception e) { + // include the class name: many JCR exceptions (e.g. UnsupportedRepositoryOperationException) + // carry a null message, and the polyglot boundary hides the Java cause chain from JS + throw new IllegalStateException("Error while executing callback as system: " + + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : ""), e); + } + } } diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/jsengine/JSPromise.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/jsengine/JSPromise.java new file mode 100644 index 00000000..e5cc5539 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/jsengine/JSPromise.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.jsengine; + +import org.graalvm.polyglot.Value; +import org.graalvm.polyglot.proxy.ProxyExecutable; + +/** + * Settles a JS value that may be a promise, synchronously. + * + *

The server-side JS runtime has no event loop, timers or asynchronous I/O: every promise either + * settles through the microtask queue — which GraalJS drains when the last JavaScript frame returns to + * the host — or never settles at all. Attaching the {@code then} handlers is itself a polyglot call, so + * by the time it returns, microtask-resolvable chains (any composition of {@code async}/{@code await} + * and {@code Promise.resolve}/{@code then} over synchronous work) have run to completion. + * + *

Nested-invocation limitation: GraalJS only drains the microtask queue when the + * last JavaScript frame leaves the stack. When a callback is invoked from a nested host + * boundary (host → JS → host → JS, e.g. a render filter reached through {@code } inside a view, + * or a node validator triggered by a {@code session.save()} made from JS), outer JS frames are still on + * the stack, the queue is not processed, and even a trivial {@code async () => value} cannot settle. + * Async callbacks are therefore only supported on host-initiated invocations; nested invocations must + * use synchronous callbacks. + */ +public final class JSPromise { + + private JSPromise() { + } + + /** Outcome of settling a JS value. */ + public static final class Outcome { + private final Value value; + private final Value error; + private final boolean settled; + + private Outcome(Value value, Value error, boolean settled) { + this.value = value; + this.error = error; + this.settled = settled; + } + + public boolean isSettled() { + return settled; + } + + public boolean isRejected() { + return error != null; + } + + public Value getValue() { + return value; + } + + public Value getError() { + return error; + } + } + + public static Outcome settle(Value result) { + if (result == null || !isThenable(result)) { + return new Outcome(result, null, true); + } + final Value[] outcome = new Value[2]; + final boolean[] done = new boolean[1]; + result.invokeMember("then", + (ProxyExecutable) arguments -> { + outcome[0] = arguments.length > 0 ? arguments[0] : null; + done[0] = true; + return null; + }, + (ProxyExecutable) arguments -> { + // a bare reject() carries no reason; substitute a readable one + outcome[1] = arguments.length > 0 ? arguments[0] : Value.asValue("unknown error"); + done[0] = true; + return null; + }); + // the microtask queue is drained when invokeMember returns to the host + return new Outcome(outcome[0], outcome[1], done[0]); + } + + /** + * Settles a JS value and returns the fulfilled result, converting the two failure modes into a + * {@link GraalVMException}: a rejection behaves like a synchronous throw (same as a non-async + * callback throwing), and a never-settling promise fails with an explicit explanation. + * + * @param result the value returned by a JS callback (plain value or promise) + * @param what describes the callback for error messages, e.g. {@code "JS render filter 'x'"} + */ + public static Value settleOrThrow(Value result, String what) { + Outcome outcome = settle(result); + if (!outcome.isSettled()) { + throw new GraalVMException(what + " returned a promise that did not settle. Either it relies on " + + "timers or async I/O (unsupported on the server), or it was invoked from inside a running " + + "JS execution (e.g. a nested render or a JS-triggered save), where the microtask queue " + + "cannot be drained — use a synchronous callback there"); + } + if (outcome.isRejected()) { + throw new GraalVMException(what + " failed: " + messageOf(outcome.getError())); + } + return outcome.getValue(); + } + + private static String messageOf(Value errorValue) { + if (errorValue == null) { + return "unknown error"; + } + if (errorValue.hasMembers() && errorValue.hasMember("message")) { + Value message = errorValue.getMember("message"); + if (message != null && message.isString()) { + return message.asString(); + } + } + return errorValue.toString(); + } + + private static boolean isThenable(Value value) { + return value.hasMembers() && value.hasMember("then") && value.getMember("then").canExecute(); + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/AbstractServiceRegistrar.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/AbstractServiceRegistrar.java new file mode 100644 index 00000000..9edf683b --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/AbstractServiceRegistrar.java @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars; + +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceRegistration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collection; +import java.util.Dictionary; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Base class for registrars that expose JavaScript registry entries of a given type as OSGi services + * implementing a Jahia extension interface. + * + *

For each bundle, {@link #register(Bundle)} finds the registry entries matching the registrar's type, + * wraps each of them in a bridge built by {@link #createBridge(Map)} and publishes the bridge as an OSGi + * service of the registrar's service class. Registrations are tracked per bundle and released in + * {@link #unregister(Bundle)}. A failure on one entry never prevents the other entries from being processed. + * + *

Bridges must never capture JS function handles: GraalVM contexts are pooled and invalidated on every + * module (un)deploy, so a bridge must re-resolve its registry entry inside + * {@link GraalVMEngine#doWithContext} on every invocation. + * + *

Note that Declarative Services annotations are not processed on inherited members, so concrete + * subclasses must declare their own {@code @Component}, {@code @Reference} and {@code @Activate} members + * and assign the {@link #graalVMEngine} and {@link #bundleContext} fields. + */ +public abstract class AbstractServiceRegistrar implements Registrar { + + private static final Logger logger = LoggerFactory.getLogger(AbstractServiceRegistrar.class); + + private final Class serviceClass; + private final String registryType; + private final Map>> registrations = new ConcurrentHashMap<>(); + + protected GraalVMEngine graalVMEngine; + protected BundleContext bundleContext; + + protected AbstractServiceRegistrar(Class serviceClass, String registryType) { + this.serviceClass = serviceClass; + this.registryType = registryType; + } + + /** + * Builds the OSGi service bridge for a single registry entry. The returned object is published as a + * service of the registrar's service class. + */ + protected abstract S createBridge(Map registryEntry); + + /** + * Hook invoked before a bridge is created and registered, e.g. to emit key-collision warnings. + */ + protected void beforeRegister(Bundle bundle, Map registryEntry) { + // no-op by default + } + + /** + * Hook providing the OSGi service properties for a registry entry. + */ + protected Dictionary getServiceProperties(Map registryEntry) { + return new Hashtable<>(); + } + + @Override + public void register(Bundle bundle) { + List> entries = graalVMEngine.doWithContext(contextProvider -> { + Map filter = new HashMap<>(); + filter.put("type", registryType); + filter.put("bundleKey", bundle.getSymbolicName()); + return contextProvider.getRegistry().find(filter); + }); + + Collection> set = registrations.computeIfAbsent(bundle, b -> ConcurrentHashMap.newKeySet()); + for (Map entry : entries) { + try { + beforeRegister(bundle, entry); + set.add(bundleContext.registerService(serviceClass, createBridge(entry), getServiceProperties(entry))); + } catch (Exception e) { + logger.error("Unable to register {} '{}' from bundle {}", registryType, entry.get("key"), + bundle.getSymbolicName(), e); + } + } + } + + @Override + public void unregister(Bundle bundle) { + Collection> set = registrations.remove(bundle); + if (set != null) { + for (ServiceRegistration registration : set) { + try { + registration.unregister(); + } catch (Exception e) { + logger.warn("Error unregistering a {} service of bundle {}", registryType, + bundle.getSymbolicName(), e); + } + } + } + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ChoiceListInitializerRegistrar.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ChoiceListInitializerRegistrar.java new file mode 100644 index 00000000..9c7eee87 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/ChoiceListInitializerRegistrar.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars; + +import org.apache.jackrabbit.value.StringValue; +import org.graalvm.polyglot.Value; +import org.jahia.modules.javascript.modules.engine.jsengine.ContextProvider; +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.jahia.modules.javascript.modules.engine.jsengine.JSPromise; +import org.jahia.services.content.nodetypes.ExtendedPropertyDefinition; +import org.jahia.services.content.nodetypes.initializers.ChoiceListInitializerService; +import org.jahia.services.content.nodetypes.initializers.ChoiceListValue; +import org.jahia.services.content.nodetypes.initializers.ModuleChoiceListInitializer; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Exposes JavaScript registry entries of type {@code choicelist-initializer} as + * {@link ModuleChoiceListInitializer} OSGi services, consumed by Jahia core and usable from CND definitions + * as {@code choicelist[key]} selector options. + */ +@Component(service = Registrar.class, immediate = true) +public class ChoiceListInitializerRegistrar extends AbstractServiceRegistrar { + + public static final String REGISTRY_TYPE = "choicelist-initializer"; + + private static final Logger logger = LoggerFactory.getLogger(ChoiceListInitializerRegistrar.class); + + private ChoiceListInitializerService choiceListInitializerService; + + public ChoiceListInitializerRegistrar() { + super(ModuleChoiceListInitializer.class, REGISTRY_TYPE); + } + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + public void setGraalVMEngine(GraalVMEngine graalVMEngine) { + this.graalVMEngine = graalVMEngine; + } + + @Reference + public void setChoiceListInitializerService(ChoiceListInitializerService choiceListInitializerService) { + this.choiceListInitializerService = choiceListInitializerService; + } + + @Activate + public void activate(BundleContext bundleContext) { + this.bundleContext = bundleContext; + } + + @Override + protected void beforeRegister(Bundle bundle, Map registryEntry) { + Object key = registryEntry.get("key"); + if (key != null && choiceListInitializerService.getInitializers().containsKey(key.toString())) { + logger.warn("A choicelist initializer with key '{}' is already registered on this platform; " + + "the one declared by bundle {} will take precedence (last registration wins). " + + "Consider prefixing initializer keys with the module name.", key, bundle.getSymbolicName()); + } + } + + @Override + protected ModuleChoiceListInitializer createBridge(Map registryEntry) { + return new ChoiceListInitializerBridge(registryEntry, graalVMEngine); + } + + public static class ChoiceListInitializerBridge implements ModuleChoiceListInitializer { + + private final GraalVMEngine engine; + private String key; + + public ChoiceListInitializerBridge(Map registryEntry, GraalVMEngine engine) { + this.engine = engine; + this.key = (String) registryEntry.get("key"); + } + + @Override + public void setKey(String key) { + this.key = key; + } + + @Override + public String getKey() { + return key; + } + + @Override + public List getChoiceListValues(ExtendedPropertyDefinition epd, String param, + List values, Locale locale, Map context) { + return engine.doWithContext(contextProvider -> { + Map entry = getJsInitializer(contextProvider); + if (entry == null || entry.get("getChoiceListValues") == null) { + logger.warn("JS choicelist initializer '{}' is no longer available in the registry, " + + "passing previous values through", key); + // in a chained declaration, wiping the accumulated list would drop the other + // initializers' choices during a redeploy window + return values != null ? values : Collections.emptyList(); + } + Value result = JSPromise.settleOrThrow( + Value.asValue(entry.get("getChoiceListValues")).execute(epd, param, values, locale, context), + "JS choicelist initializer '" + key + "'"); + return convertValues(result, key); + }); + } + + private Map getJsInitializer(ContextProvider contextProvider) { + return contextProvider.getRegistry().get(REGISTRY_TYPE, key); + } + + /** + * Converts a JS array of {@code {label, value, properties?}} objects into Jahia + * {@link ChoiceListValue} instances. Malformed items are logged and skipped. + */ + static List convertValues(Value result, String key) { + if (result == null || result.isNull() || !result.hasArrayElements()) { + return Collections.emptyList(); + } + List choiceListValues = new ArrayList<>(); + for (long i = 0; i < result.getArraySize(); i++) { + Value item = result.getArrayElement(i); + Value label = item.getMember("label"); + Value value = item.getMember("value"); + if (label == null || label.isNull() || value == null || value.isNull()) { + logger.warn("JS choicelist initializer '{}' returned an item without label or value " + + "at index {}, skipping it", key, i); + continue; + } + Value properties = item.getMember("properties"); + if (properties != null && !properties.isNull() && properties.hasMembers()) { + Map propertiesMap = new HashMap<>(); + for (String memberKey : properties.getMemberKeys()) { + propertiesMap.put(memberKey, properties.getMember(memberKey).as(Object.class)); + } + choiceListValues.add(new ChoiceListValue(label.asString(), propertiesMap, + new StringValue(value.asString()))); + } else { + choiceListValues.add(new ChoiceListValue(label.asString(), value.asString())); + } + } + return choiceListValues; + } + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/NodeLegacyActionRegistrar.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/NodeLegacyActionRegistrar.java new file mode 100644 index 00000000..5fcb30bd --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/NodeLegacyActionRegistrar.java @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars; + +import org.graalvm.polyglot.Value; +import org.jahia.bin.Action; +import org.jahia.bin.ActionResult; +import org.jahia.modules.javascript.modules.engine.jsengine.ContextProvider; +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.jahia.modules.javascript.modules.engine.jsengine.JSPromise; +import org.jahia.services.content.JCRSessionWrapper; +import org.jahia.services.render.RenderContext; +import org.jahia.services.render.Resource; +import org.jahia.services.render.URLResolver; +import org.jahia.services.templates.JahiaTemplateManagerService; +import org.json.JSONObject; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.util.List; +import java.util.Map; + +/** + * Exposes JavaScript registry entries of type {@code node-legacy-action} as {@link Action} OSGi services, consumed by + * Jahia core and invoked through {@code ..do} URLs. + */ +@Component(service = Registrar.class, immediate = true) +public class NodeLegacyActionRegistrar extends AbstractServiceRegistrar { + + public static final String REGISTRY_TYPE = "node-legacy-action"; + + private static final Logger logger = LoggerFactory.getLogger(NodeLegacyActionRegistrar.class); + + private JahiaTemplateManagerService templateManagerService; + + public NodeLegacyActionRegistrar() { + super(Action.class, REGISTRY_TYPE); + } + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + public void setGraalVMEngine(GraalVMEngine graalVMEngine) { + this.graalVMEngine = graalVMEngine; + } + + @Reference + public void setTemplateManagerService(JahiaTemplateManagerService templateManagerService) { + this.templateManagerService = templateManagerService; + } + + @Activate + public void activate(BundleContext bundleContext) { + this.bundleContext = bundleContext; + } + + @Override + protected void beforeRegister(Bundle bundle, Map registryEntry) { + Object key = registryEntry.get("key"); + if ("jsAction".equals(key)) { + // the engine's generic action endpoint: shadowing it would break every module's client + // action stubs AND inherit its platform-wide CSRF-guard whitelist (*.jsAction.do) + throw new IllegalArgumentException("'jsAction' is reserved for the engine's generic action " + + "endpoint and cannot be registered by a module"); + } + if (key != null + && templateManagerService.getTemplatePackageRegistry().getActions().containsKey(key.toString())) { + logger.warn("An action named '{}' is already registered on this platform; the one declared by " + + "bundle {} will take precedence (last registration wins). " + + "Consider prefixing action names with the module name.", key, bundle.getSymbolicName()); + } + } + + @Override + protected Action createBridge(Map registryEntry) { + return new ActionBridge(registryEntry, graalVMEngine); + } + + public static class ActionBridge extends Action { + + private final GraalVMEngine engine; + + public ActionBridge(Map registryEntry, GraalVMEngine engine) { + this.engine = engine; + setName((String) registryEntry.get("key")); + if (registryEntry.containsKey("requiredMethods")) { + setRequiredMethods(registryEntry.get("requiredMethods").toString()); + } + if (registryEntry.containsKey("requireAuthenticatedUser")) { + setRequireAuthenticatedUser((Boolean) registryEntry.get("requireAuthenticatedUser")); + } + if (registryEntry.containsKey("requiredPermission")) { + setRequiredPermission(registryEntry.get("requiredPermission").toString()); + } + if (registryEntry.containsKey("requiredWorkspace")) { + setRequiredWorkspace(registryEntry.get("requiredWorkspace").toString()); + } + } + + @Override + public ActionResult doExecute(HttpServletRequest request, RenderContext renderContext, Resource resource, + JCRSessionWrapper session, Map> parameters, URLResolver urlResolver) + throws Exception { + return engine.doWithContext(contextProvider -> { + Map entry = getJsAction(contextProvider); + if (entry == null || entry.get("doExecute") == null) { + logger.warn("JS action '{}' is no longer available in the registry", getName()); + return ActionResult.SERVICE_UNAVAILABLE; + } + Value result = JSPromise.settleOrThrow( + Value.asValue(entry.get("doExecute")) + .execute(request, renderContext, resource, session, parameters, urlResolver), + "JS legacy node action '" + getName() + "'"); + return convertResult(result); + }); + } + + private Map getJsAction(ContextProvider contextProvider) { + return contextProvider.getRegistry().get(REGISTRY_TYPE, getName()); + } + + /** + * Converts the JS adapter result ({@code {statusCode?, json?: string, redirect?, absoluteRedirect?}}, + * with {@code json} pre-stringified on the JS side to avoid polyglot deep-conversion pitfalls) into an + * {@link ActionResult}. + */ + static ActionResult convertResult(Value result) { + if (result == null || result.isNull()) { + return new ActionResult(HttpServletResponse.SC_OK); + } + int statusCode = result.hasMember("statusCode") && !result.getMember("statusCode").isNull() + ? result.getMember("statusCode").asInt() + : HttpServletResponse.SC_OK; + String redirect = result.hasMember("redirect") && !result.getMember("redirect").isNull() + ? result.getMember("redirect").asString() + : null; + boolean absoluteRedirect = result.hasMember("absoluteRedirect") + && !result.getMember("absoluteRedirect").isNull() + && result.getMember("absoluteRedirect").asBoolean(); + JSONObject json = null; + if (result.hasMember("json") && !result.getMember("json").isNull()) { + json = new JSONObject(result.getMember("json").asString()); + } + return new ActionResult(statusCode, redirect, absoluteRedirect, json); + } + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/RenderFilterRegistrar.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/RenderFilterRegistrar.java index 6a522109..42385c44 100644 --- a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/RenderFilterRegistrar.java +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/RenderFilterRegistrar.java @@ -18,31 +18,37 @@ import org.graalvm.polyglot.Value; import org.jahia.modules.javascript.modules.engine.jsengine.ContextProvider; import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.jahia.modules.javascript.modules.engine.jsengine.JSPromise; import org.jahia.services.render.RenderContext; import org.jahia.services.render.RenderService; import org.jahia.services.render.Resource; import org.jahia.services.render.filter.AbstractFilter; import org.jahia.services.render.filter.RenderChain; import org.jahia.services.render.filter.RenderFilter; -import org.osgi.framework.Bundle; import org.osgi.framework.BundleContext; -import org.osgi.framework.ServiceRegistration; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; import org.osgi.service.component.annotations.ReferenceCardinality; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import java.util.*; +import java.util.Map; +/** + * Exposes JavaScript registry entries of type {@code render-filter} as {@link RenderFilter} OSGi + * services, participating in Jahia's render chain like Java {@link AbstractFilter} implementations. + */ @Component(service = Registrar.class, immediate = true) -public class RenderFilterRegistrar implements Registrar { +public class RenderFilterRegistrar extends AbstractServiceRegistrar { - private RenderService renderService; - private BundleContext bundleContext; + public static final String REGISTRY_TYPE = "render-filter"; - private GraalVMEngine graalVMEngine; + private RenderService renderService; - private final Map>> registrations = new HashMap<>(); + public RenderFilterRegistrar() { + super(RenderFilter.class, REGISTRY_TYPE); + } @Reference public void setRenderService(RenderService renderService) { @@ -60,81 +66,87 @@ public void activate(BundleContext bundleContext) { } @Override - public void register(Bundle bundle) { - List> renderFilters = graalVMEngine.doWithContext(contextProvider -> { - Map filter = new HashMap<>(); - filter.put("type", "render-filter"); - filter.put("bundleKey", bundle.getSymbolicName()); - return contextProvider.getRegistry().find(filter); - }); - - Set> set = new HashSet<>(); - registrations.put(bundle, set); - for (Map renderFilter : renderFilters) { - RenderFilterBridge renderFilterImpl = new RenderFilterBridge(renderFilter, graalVMEngine); - renderFilterImpl.setRenderService(renderService); - renderFilterImpl.setPriority(0); - - set.add(bundleContext.registerService(RenderFilter.class, renderFilterImpl, new Hashtable<>())); - } - } - - @Override - public void unregister(Bundle bundle) { - Collection> set = registrations.remove(bundle); - if (set != null) { - for (ServiceRegistration registration : set) { - registration.unregister(); - } - } + protected RenderFilter createBridge(Map registryEntry) { + RenderFilterBridge bridge = new RenderFilterBridge(registryEntry, graalVMEngine); + bridge.setRenderService(renderService); + return bridge; } public static class RenderFilterBridge extends AbstractFilter { + + private static final Logger logger = LoggerFactory.getLogger(RenderFilterBridge.class); + private final GraalVMEngine engine; private final String key; - public RenderFilterBridge(Map value, GraalVMEngine engine) { + public RenderFilterBridge(Map registryEntry, GraalVMEngine engine) { this.engine = engine; - this.key = (String) value.get("key"); - if (value.containsKey("priority")) { - setPriority(Integer.parseInt(value.get("priority").toString())); + this.key = (String) registryEntry.get("key"); + if (registryEntry.containsKey("priority")) { + setPriority(Float.parseFloat(registryEntry.get("priority").toString())); + } else { + setPriority(0); } - if (value.containsKey("description")) { - setDescription(value.get("description").toString()); + if (registryEntry.containsKey("description")) { + setDescription(registryEntry.get("description").toString()); } - if (value.containsKey("applyOnConfigurations")) { - this.setApplyOnConfigurations(value.get("applyOnConfigurations").toString()); + if (registryEntry.containsKey("applyOnConfigurations")) { + setApplyOnConfigurations(registryEntry.get("applyOnConfigurations").toString()); } - if (value.containsKey("applyOnModes")) { - this.setApplyOnModes(value.get("applyOnModes").toString()); + if (registryEntry.containsKey("applyOnModes")) { + setApplyOnModes(registryEntry.get("applyOnModes").toString()); } - if (value.containsKey("applyOnNodeTypes")) { - this.setApplyOnNodeTypes(value.get("applyOnNodeTypes").toString()); + if (registryEntry.containsKey("applyOnNodeTypes")) { + setApplyOnNodeTypes(registryEntry.get("applyOnNodeTypes").toString()); } - if (value.containsKey("applyOnTemplates")) { - this.setApplyOnTemplates(value.get("applyOnTemplates").toString()); + if (registryEntry.containsKey("applyOnTemplates")) { + setApplyOnTemplates(registryEntry.get("applyOnTemplates").toString()); } - if (value.containsKey("applyOnTemplateTypes")) { - this.setApplyOnTemplateTypes(value.get("applyOnTemplateTypes").toString()); + if (registryEntry.containsKey("applyOnTemplateTypes")) { + setApplyOnTemplateTypes(registryEntry.get("applyOnTemplateTypes").toString()); } } @Override - public String execute(String s, RenderContext renderContext, Resource resource, RenderChain renderChain) throws Exception { + public String execute(String previousOut, RenderContext renderContext, Resource resource, RenderChain renderChain) throws Exception { return engine.doWithContext(contextProvider -> { - return Value.asValue(getJsFilter(contextProvider).get("execute")).execute(s, renderContext, resource, renderChain).asString(); + Map jsFilter = getJsFilter(contextProvider); + if (jsFilter == null) { + logger.warn("JS render filter '{}' is no longer available in the registry, skipping execute", key); + return previousOut; + } + if (jsFilter.get("execute") == null) { + // both callbacks are optional: a prepare-only filter is a no-op here + return previousOut; + } + Value result = JSPromise.settleOrThrow( + Value.asValue(jsFilter.get("execute")).execute(previousOut, renderContext, resource, renderChain), + "JS render filter '" + key + "' execute"); + return result == null || result.isNull() ? previousOut : result.asString(); }); } @Override public String prepare(RenderContext renderContext, Resource resource, RenderChain renderChain) throws Exception { return engine.doWithContext(contextProvider -> { - return Value.asValue(getJsFilter(contextProvider).get("prepare")).execute(renderContext, resource, renderChain).asString(); + Map jsFilter = getJsFilter(contextProvider); + if (jsFilter == null) { + logger.warn("JS render filter '{}' is no longer available in the registry, skipping prepare", key); + return null; + } + if (jsFilter.get("prepare") == null) { + // both callbacks are optional: an execute-only filter is a no-op here + return null; + } + Value result = JSPromise.settleOrThrow( + Value.asValue(jsFilter.get("prepare")).execute(renderContext, resource, renderChain), + "JS render filter '" + key + "' prepare"); + return result == null || result.isNull() ? null : result.asString(); }); } private Map getJsFilter(ContextProvider contextProvider) { - return contextProvider.getRegistry().get("render-filter", key); + return contextProvider.getRegistry().get(REGISTRY_TYPE, key); } } } diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/package-info.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/package-info.java new file mode 100644 index 00000000..43644b3a --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/package-info.java @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Registrars bridge JavaScript registry entries to Jahia extension points, driven per JS bundle by + * {@code JavascriptModuleListener} through the {@link org.jahia.modules.javascript.modules.engine.registrars.Registrar} + * whiteboard (see ADR-0001 in docs/adr). + * + *

Package layout rule: registrars whose bridge fits in a single class live flat in this package + * (e.g. choicelists, render filters, legacy node actions, over + * {@link org.jahia.modules.javascript.modules.engine.registrars.AbstractServiceRegistrar}); verticals + * needing several collaborating classes get a subpackage (e.g. {@code validation}). + * Non-registrar surfaces live outside: the actions dispatch endpoint in {@code ..engine.actions}, the + * exported third-party facade in {@code ..engine.sdk}, and engine plumbing in {@code ..engine.jsengine}. + */ +package org.jahia.modules.javascript.modules.engine.registrars; diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSNodeValidator.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSNodeValidator.java new file mode 100644 index 00000000..8e8e2f4d --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSNodeValidator.java @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.validation; + +import org.jahia.services.content.JCRNodeWrapper; +import org.jahia.services.content.decorator.validation.AdvancedGroup; +import org.jahia.services.content.decorator.validation.AdvancedSkipOnImportGroup; +import org.jahia.services.content.decorator.validation.DefaultSkipOnImportGroup; +import org.jahia.services.content.decorator.validation.JCRNodeValidator; + +/** + * The single Bean Validation bean bridging all JavaScript-declared node validators. + * + *

It is registered once, for the sentinel node type {@code nt:base} (see + * {@link NodeValidatorRegistrar}), so Jahia core instantiates and validates it exactly once per changed + * node per save — node-type matching and dispatch to the JS validators happen in + * {@link JSValidationConstraintValidator}/{@link NodeValidatorRegistrar}. Registering it under each + * JS-declared node type instead would run every matching JS validator once per matching node type, + * producing duplicate violations. + * + *

The four repeated class-level constraints mirror Jahia's validation phases: on a normal save, core + * validates with groups (Default, DefaultSkipOnImportGroup) and then — only if that passed — with + * (AdvancedGroup, AdvancedSkipOnImportGroup); during imports, the SkipOnImport groups are omitted. + */ +@JSValidation(mode = JSValidation.Mode.DEFAULT) +@JSValidation(mode = JSValidation.Mode.DEFAULT_SKIP_ON_IMPORT, groups = DefaultSkipOnImportGroup.class) +@JSValidation(mode = JSValidation.Mode.ADVANCED, groups = AdvancedGroup.class) +@JSValidation(mode = JSValidation.Mode.ADVANCED_SKIP_ON_IMPORT, groups = AdvancedSkipOnImportGroup.class) +public class JSNodeValidator implements JCRNodeValidator { + + private final JCRNodeWrapper node; + + public JSNodeValidator(JCRNodeWrapper node) { + this.node = node; + } + + public JCRNodeWrapper getNode() { + return node; + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSValidation.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSValidation.java new file mode 100644 index 00000000..5a8247f5 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSValidation.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.validation; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Class-level constraint carried by {@link JSNodeValidator}, dispatching to JavaScript-declared node + * validators. One annotation instance exists per Jahia validation phase combination (see {@link Mode}), + * with matching Bean Validation groups, so that Jahia's group orchestration during session save applies + * to JS validators exactly as it does to Java ones. + * + *

This annotation intentionally has no {@code propertyName()} attribute: Jahia core then derives the + * property from each violation's property path, which the constraint validator sets per violation. + */ +@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Repeatable(JSValidation.List.class) +@Constraint(validatedBy = JSValidationConstraintValidator.class) +public @interface JSValidation { + + String message() default ""; + + Class[] groups() default {}; + + Class[] payload() default {}; + + /** The Jahia validation phase this constraint instance covers. */ + Mode mode(); + + enum Mode { + /** First validation phase, also enforced during imports. */ + DEFAULT, + /** First validation phase, skipped during imports. */ + DEFAULT_SKIP_ON_IMPORT, + /** Second validation phase (runs only if the first one passed), also enforced during imports. */ + ADVANCED, + /** Second validation phase, skipped during imports. */ + ADVANCED_SKIP_ON_IMPORT + } + + @Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) + @Retention(RetentionPolicy.RUNTIME) + @interface List { + JSValidation[] value(); + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSValidationConstraintValidator.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSValidationConstraintValidator.java new file mode 100644 index 00000000..39246d5e --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSValidationConstraintValidator.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.validation; + +import org.apache.commons.lang3.StringUtils; +import org.jahia.osgi.BundleUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; +import java.util.List; +import java.util.function.Supplier; + +/** + * Dispatches {@link JSValidation} constraints to the JavaScript node validators registered for the + * current validation phase, and reports their violations programmatically — with a property node for + * field-level errors (Jahia core maps a resolvable property path to a field error in the editing UI, and + * a blank path to a node-level error). + * + *

Message templates are handed to Jahia's {@code JahiaMessageInterpolator}, which resolves messages of + * the form {resource.bundle.key} against deployed resource bundles and returns any other + * message verbatim (no EL, no parameter interpolation). Messages shorter than 2 characters would crash + * that interpolator and are replaced by a generic fallback. + */ +public class JSValidationConstraintValidator implements ConstraintValidator { + + private static final Logger logger = LoggerFactory.getLogger(JSValidationConstraintValidator.class); + + /** + * Test seam; the production default resolves the registrar service per call, which is cheap at + * validation frequency and stays correct across engine redeploys. + */ + static Supplier registrarSupplier = + () -> BundleUtils.getOsgiService(NodeValidatorRegistrar.class, null); + + private JSValidation.Mode mode; + + @Override + public void initialize(JSValidation constraintAnnotation) { + this.mode = constraintAnnotation.mode(); + } + + @Override + public boolean isValid(JSNodeValidator bean, ConstraintValidatorContext context) { + NodeValidatorRegistrar registrar; + try { + registrar = registrarSupplier.get(); + } catch (Exception e) { + logger.debug("JS node validator registrar is not available, skipping JS validation", e); + return true; + } + if (registrar == null) { + // engine stopped or redeploying: nothing to validate against + return true; + } + + List violations = registrar.collectViolations(bean.getNode(), mode); + if (violations.isEmpty()) { + return true; + } + + context.disableDefaultConstraintViolation(); + for (JSViolation violation : violations) { + ConstraintValidatorContext.ConstraintViolationBuilder builder = context + .buildConstraintViolationWithTemplate(sanitizeMessage(violation.getMessage(), violation.getValidatorKey())); + if (StringUtils.isNotBlank(violation.getPropertyName())) { + // property-level path -> field-level error in the editing UI + builder.addPropertyNode(violation.getPropertyName()).addConstraintViolation(); + } else { + // class-level violation -> blank path -> node-level error + builder.addConstraintViolation(); + } + } + return false; + } + + static String sanitizeMessage(String message, String validatorKey) { + if (message == null || message.trim().length() < 2) { + logger.warn("JS node validator '{}' returned a blank or too-short violation message (the platform interpolator requires at least 2 characters), using a generic one", + validatorKey); + return "Invalid content"; + } + return message; + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSViolation.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSViolation.java new file mode 100644 index 00000000..c80f8054 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSViolation.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.validation; + +/** + * A violation reported by a JavaScript node validator: a message, an optional property name (for + * field-level errors in the editing UI) and the key of the reporting validator. + */ +public final class JSViolation { + + private final String message; + private final String propertyName; + private final String validatorKey; + + public JSViolation(String message, String propertyName, String validatorKey) { + this.message = message; + this.propertyName = propertyName; + this.validatorKey = validatorKey; + } + + public String getMessage() { + return message; + } + + public String getPropertyName() { + return propertyName; + } + + public String getValidatorKey() { + return validatorKey; + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/NodeValidatorRegistrar.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/NodeValidatorRegistrar.java new file mode 100644 index 00000000..a06686e6 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/registrars/validation/NodeValidatorRegistrar.java @@ -0,0 +1,270 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.validation; + +import org.graalvm.polyglot.Value; +import org.graalvm.polyglot.proxy.ProxyObject; +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.jahia.modules.javascript.modules.engine.jsengine.JSPromise; +import org.jahia.modules.javascript.modules.engine.registrars.Registrar; +import org.jahia.services.content.JCRNodeWrapper; +import org.jahia.services.content.JCRStoreService; +import org.osgi.framework.Bundle; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.jcr.RepositoryException; +import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Bridges JavaScript registry entries of type {@code node-validator} to Jahia's JCR save validation. + * + *

Unlike the OSGi-service-publishing registrars, Jahia consumes node validators through a global + * {@code nodeType -> validator class} map ({@link JCRStoreService#addValidator}) that allows a single + * validator class per node type, removed unconditionally by node type. This registrar therefore + * registers the single {@link JSNodeValidator} bridge class under the sentinel node type + * {@code nt:base} while any JS validator exists (so core instantiates it exactly once per changed node + * per save), performs all node-type matching itself, and removes the bridge — after an ownership check — + * only when the last JS validator is gone. + */ +@Component(service = {Registrar.class, NodeValidatorRegistrar.class}, immediate = true) +public class NodeValidatorRegistrar implements Registrar { + + public static final String REGISTRY_TYPE = "node-validator"; + static final String SENTINEL_NODE_TYPE = "nt:base"; + + private static final Logger logger = LoggerFactory.getLogger(NodeValidatorRegistrar.class); + + private GraalVMEngine graalVMEngine; + + /** Declared validators per bundle; guarded by {@code this}. */ + private final Map> declaredByBundle = new HashMap<>(); + /** Immutable fast gate read by validation threads without locking. */ + private volatile Map> declaredNodeTypesByMode = Collections.emptyMap(); + /** Guarded by {@code this}. */ + private boolean bridgeRegistered; + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + public void setGraalVMEngine(GraalVMEngine graalVMEngine) { + this.graalVMEngine = graalVMEngine; + } + + @Override + public void register(Bundle bundle) { + List> entries = graalVMEngine.doWithContext(contextProvider -> { + Map filter = new HashMap<>(); + filter.put("type", REGISTRY_TYPE); + filter.put("bundleKey", bundle.getSymbolicName()); + return contextProvider.getRegistry().find(filter); + }); + List declared = new ArrayList<>(); + for (Map entry : entries) { + Object nodeType = entry.get("nodeType"); + if (nodeType == null) { + logger.warn("Ignoring JS node validator '{}' of bundle {}: no nodeType declared", + entry.get("key"), bundle.getSymbolicName()); + continue; + } + declared.add(new DeclaredValidator(nodeType.toString(), modeOf(entry))); + } + synchronized (this) { + declaredByBundle.put(bundle, declared); + rebuildSnapshotAndBridge(); + } + } + + @Override + public void unregister(Bundle bundle) { + synchronized (this) { + declaredByBundle.remove(bundle); + rebuildSnapshotAndBridge(); + } + } + + @Deactivate + public void deactivate() { + synchronized (this) { + declaredByBundle.clear(); + rebuildSnapshotAndBridge(); + } + } + + /** Called under lock. */ + private void rebuildSnapshotAndBridge() { + Map> snapshot = new EnumMap<>(JSValidation.Mode.class); + for (List declared : declaredByBundle.values()) { + for (DeclaredValidator validator : declared) { + snapshot.computeIfAbsent(validator.mode, mode -> new HashSet<>()).add(validator.nodeType); + } + } + declaredNodeTypesByMode = Collections.unmodifiableMap(snapshot); + + boolean needed = !snapshot.isEmpty(); + if (needed && !bridgeRegistered) { + Constructor existing = getRegisteredPlatformValidator(); + if (existing != null && !JSNodeValidator.class.equals(existing.getDeclaringClass())) { + logger.warn("A validator ({}) is already registered for node type {}; it will be replaced " + + "by the JavaScript modules validator bridge (the platform allows a single validator " + + "class per node type)", existing.getDeclaringClass().getName(), SENTINEL_NODE_TYPE); + } + addPlatformValidator(); + bridgeRegistered = true; + } else if (!needed && bridgeRegistered) { + Constructor current = getRegisteredPlatformValidator(); + if (current != null && JSNodeValidator.class.equals(current.getDeclaringClass())) { + removePlatformValidator(); + } else if (current != null) { + logger.warn("Not removing the validator registered for node type {}: it is owned by {}", + SENTINEL_NODE_TYPE, current.getDeclaringClass().getName()); + } + bridgeRegistered = false; + } + } + + // JCRStoreService interactions isolated as seams for unit tests + + protected Constructor getRegisteredPlatformValidator() { + return JCRStoreService.getInstance().getValidators().get(SENTINEL_NODE_TYPE); + } + + protected void addPlatformValidator() { + JCRStoreService.getInstance().addValidator(SENTINEL_NODE_TYPE, JSNodeValidator.class); + } + + protected void removePlatformValidator() { + JCRStoreService.getInstance().removeValidator(SENTINEL_NODE_TYPE); + } + + /** + * Runs the JS validators declared for the given phase against the node and returns their violations. + * Called by {@link JSValidationConstraintValidator} on every session save of any node; the volatile + * snapshot gate avoids entering GraalVM when no declared node type matches. + */ + public List collectViolations(JCRNodeWrapper node, JSValidation.Mode mode) { + if (node == null) { + return Collections.emptyList(); + } + Set candidateTypes = declaredNodeTypesByMode.getOrDefault(mode, Collections.emptySet()); + if (candidateTypes.isEmpty() || candidateTypes.stream().noneMatch(type -> isNodeTypeSafe(node, type))) { + return Collections.emptyList(); + } + + return graalVMEngine.doWithContext(contextProvider -> { + List violations = new ArrayList<>(); + Map filter = new HashMap<>(); + filter.put("type", REGISTRY_TYPE); + for (Map entry : contextProvider.getRegistry().find(filter)) { + if (modeOf(entry) != mode) { + continue; + } + Object nodeType = entry.get("nodeType"); + if (nodeType == null || !isNodeTypeSafe(node, nodeType.toString())) { + continue; + } + String key = String.valueOf(entry.get("key")); + try { + Map jsContext = new HashMap<>(); + jsContext.put("locale", getSessionLocale(node)); + // settleOrThrow supports async validators; rejections land in the catch below + Value result = JSPromise.settleOrThrow( + Value.asValue(entry.get("validate")).execute(node, ProxyObject.fromMap(jsContext)), + "JS node validator '" + key + "'"); + appendViolations(violations, result, key); + } catch (Exception e) { + // fail closed: a broken validator must not let invalid content through + logger.error("JS node validator '{}' failed to execute", key, e); + violations.add(new JSViolation("The content could not be validated (" + key + ")", null, key)); + } + } + return violations; + }); + } + + static JSValidation.Mode modeOf(Map entry) { + boolean advanced = Boolean.TRUE.equals(entry.get("advanced")); + boolean skipOnImport = Boolean.TRUE.equals(entry.get("skipOnImport")); + if (advanced) { + return skipOnImport ? JSValidation.Mode.ADVANCED_SKIP_ON_IMPORT : JSValidation.Mode.ADVANCED; + } + return skipOnImport ? JSValidation.Mode.DEFAULT_SKIP_ON_IMPORT : JSValidation.Mode.DEFAULT; + } + + /** Accepts undefined/null (no violations), a single violation object, or an array of them. */ + static void appendViolations(List violations, Value result, String key) { + if (result == null || result.isNull()) { + return; + } + if (result.hasArrayElements()) { + for (long i = 0; i < result.getArraySize(); i++) { + appendViolation(violations, result.getArrayElement(i), key); + } + } else { + appendViolation(violations, result, key); + } + } + + private static void appendViolation(List violations, Value item, String key) { + Value message = item.hasMembers() ? item.getMember("message") : null; + if (message == null || message.isNull() || !message.isString()) { + logger.warn("JS node validator '{}' returned a violation without a string message, skipping it", key); + return; + } + Value propertyName = item.getMember("propertyName"); + violations.add(new JSViolation(message.asString(), + propertyName != null && propertyName.isString() ? propertyName.asString() : null, key)); + } + + private static Locale getSessionLocale(JCRNodeWrapper node) { + try { + return node.getSession().getLocale(); + } catch (RepositoryException e) { + logger.debug("Unable to read the session locale for validation", e); + return null; + } + } + + private static boolean isNodeTypeSafe(JCRNodeWrapper node, String nodeType) { + try { + return node.isNodeType(nodeType); + } catch (RepositoryException e) { + logger.warn("Unable to check node type {} during JS validation", nodeType, e); + return false; + } + } + + private static final class DeclaredValidator { + private final String nodeType; + private final JSValidation.Mode mode; + + private DeclaredValidator(String nodeType, JSValidation.Mode mode) { + this.nodeType = nodeType; + this.mode = mode; + } + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvoker.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvoker.java new file mode 100644 index 00000000..88d42316 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvoker.java @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.sdk; + +import java.util.List; +import java.util.Map; + +/** + * Public SDK entry point letting other OSGi bundles consume JavaScript-declared server + * extensions registered via {@code server.registry.add(type, key, entry)} in JS modules — without any + * dependency on GraalVM/polyglot types or on the engine internals. + * + *

This is the supported extension surface for modules (such as Formidable) that define their own + * server-side extension type and need to run the JS callbacks contributed against it. It complements the + * built-in registrars ({@code node-validator}, {@code action}, …), which wire JS entries to Jahia's own + * extension points; here the consumer owns the extension point. + * + *

All work happens inside a single pooled GraalVM context for the duration of one {@link #forEach} + * call. GraalVM values (the JS callables stored in entries) are only valid during that call, so callables + * must be invoked through the {@link Invoker} passed to the handler, never captured for later use. + */ +public interface JSServerExtensionInvoker { + + /** + * Iterates all registry entries of {@code registryType} (across every deployed JS module) within one + * JS context, invoking {@code handler} for each. Results that are non-null are collected and returned + * in registry order. + * + *

The handler receives the entry as a plain {@code Map} (scalar fields such as + * {@code nodeType} are plain Java; function fields are opaque handles to pass to the {@link Invoker}). + * A handler exception propagates to the caller — callers that need fail-closed semantics should catch + * their own errors inside the handler and translate them into a result. + * + * @param registryType the JS registry type to look up (e.g. {@code "formidable-field-validator"}) + * @param handler invoked once per matching entry; return {@code null} to skip an entry + * @param the result type accumulated across entries + * @return the non-null handler results, in registry order (never {@code null}) + */ + List forEach(String registryType, ExtensionHandler handler); + + /** Handles a single registry entry, optionally invoking its JS callables through {@code invoker}. */ + @FunctionalInterface + interface ExtensionHandler { + T handle(Map entry, Invoker invoker); + } + + /** Invokes a JS callable stored in a registry entry and converts its result to plain Java. */ + @FunctionalInterface + interface Invoker { + /** + * Executes {@code callable} (a function field read from an entry) with {@code args} and returns + * the result converted to plain Java: {@code null}, {@link Boolean}, {@link Long}/{@link Double}, + * {@link String}, {@link List}, or {@link Map}. Host objects passed as arguments (e.g. a + * {@code JCRNodeWrapper}) are forwarded to JS as-is. + * + *

Async callables are settled synchronously: a returned promise resolves through the microtask + * queue (any composition of {@code async}/{@code await} over synchronous work — no timers or async + * I/O), its rejection surfaces as a {@link RuntimeException}, exactly like a synchronous throw. + * This requires the {@link #forEach} call to be host-initiated: invoked from inside a running JS + * execution (a view render, a JS-triggered save), a promise cannot settle and the call fails. + * + * @throws RuntimeException if the callable is not executable, throws, or returns a promise that + * cannot settle + */ + Object call(Object callable, Object... args); + } +} diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvokerImpl.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvokerImpl.java new file mode 100644 index 00000000..66f817d5 --- /dev/null +++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvokerImpl.java @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.sdk; + +import org.graalvm.polyglot.Value; +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.jahia.modules.javascript.modules.engine.jsengine.JSPromise; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Default {@link JSServerExtensionInvoker}. Runs each {@link #forEach} within one pooled GraalVM context + * (re-resolving the registry inside the context, as GraalVM contexts are recycled on module (un)deploy), + * and converts GraalVM {@link Value} results to plain Java so callers never see polyglot types. + */ +@Component(service = JSServerExtensionInvoker.class, immediate = true) +public class JSServerExtensionInvokerImpl implements JSServerExtensionInvoker { + + private GraalVMEngine graalVMEngine; + + @Reference(cardinality = ReferenceCardinality.MANDATORY) + public void setGraalVMEngine(GraalVMEngine graalVMEngine) { + this.graalVMEngine = graalVMEngine; + } + + @Override + public List forEach(String registryType, ExtensionHandler handler) { + return graalVMEngine.doWithContext(contextProvider -> { + List results = new ArrayList<>(); + Invoker invoker = JSServerExtensionInvokerImpl::invoke; + Map filter = new HashMap<>(); + filter.put("type", registryType); + for (Map entry : contextProvider.getRegistry().find(filter)) { + T result = handler.handle(entry, invoker); + if (result != null) { + results.add(result); + } + } + return results; + }); + } + + /** + * {@link Invoker} implementation: executes the callable, settles a possibly-async result (a + * rejection surfaces as a {@link org.jahia.modules.javascript.modules.engine.jsengine.GraalVMException}), + * then converts it to plain Java. + */ + static Object invoke(Object callable, Object... args) { + return convert(JSPromise.settleOrThrow(Value.asValue(callable).execute(args), "JS extension callable")); + } + + /** Recursively converts a GraalVM value to plain Java ({@code null}/Boolean/Long/Double/String/List/Map). */ + static Object convert(Value value) { + if (value == null || value.isNull()) { + return null; + } + if (value.isBoolean()) { + return value.asBoolean(); + } + if (value.isNumber()) { + return value.fitsInLong() ? (Object) value.asLong() : (Object) value.asDouble(); + } + if (value.isString()) { + return value.asString(); + } + if (value.hasArrayElements()) { + List list = new ArrayList<>((int) value.getArraySize()); + for (long i = 0; i < value.getArraySize(); i++) { + list.add(convert(value.getArrayElement(i))); + } + return list; + } + if (value.hasMembers()) { + Map map = new LinkedHashMap<>(); + for (String key : value.getMemberKeys()) { + map.put(key, convert(value.getMember(key))); + } + return map; + } + return value.toString(); + } +} diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/jsengine/JSPromiseTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/jsengine/JSPromiseTest.java new file mode 100644 index 00000000..7dae4652 --- /dev/null +++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/jsengine/JSPromiseTest.java @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.jsengine; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; +import org.graalvm.polyglot.proxy.ProxyExecutable; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class JSPromiseTest { + + private static Context context; + + @BeforeClass + public static void setUp() { + context = Context.newBuilder("js").build(); + } + + @AfterClass + public static void tearDown() { + context.close(); + } + + private static JSPromise.Outcome run(String jsFunction) { + Value fn = context.eval("js", "(" + jsFunction + ")"); + return JSPromise.settle(fn.execute()); + } + + @Test + public void settlesPlainValues() { + JSPromise.Outcome settled = run("() => 42"); + assertTrue(settled.isSettled()); + assertFalse(settled.isRejected()); + assertEquals(42, settled.getValue().asInt()); + } + + @Test + public void settlesAsyncFunctions() { + JSPromise.Outcome settled = run("async () => 'hello'"); + assertTrue("async function result should settle at the API boundary", settled.isSettled()); + assertEquals("hello", settled.getValue().asString()); + } + + @Test + public void settlesAwaitChains() { + JSPromise.Outcome settled = run( + "async () => { const a = await Promise.resolve(20); const b = await Promise.resolve(22); return a + b; }"); + assertTrue("awaited chains should settle through the microtask queue", settled.isSettled()); + assertEquals(42, settled.getValue().asInt()); + } + + @Test + public void settlesThenChains() { + JSPromise.Outcome settled = run( + "() => Promise.resolve('a').then((v) => v + 'b').then((v) => v + 'c')"); + assertTrue(settled.isSettled()); + assertEquals("abc", settled.getValue().asString()); + } + + @Test + public void capturesRejections() { + JSPromise.Outcome settled = run("async () => { throw new Error('boom'); }"); + assertTrue(settled.isSettled()); + assertTrue(settled.isRejected()); + assertEquals("boom", settled.getError().getMember("message").asString()); + } + + @Test + public void capturesRejectedPlainObjects() { + JSPromise.Outcome settled = run( + "() => Promise.reject({ message: 'invalid', issues: '[{\"message\":\"nope\"}]' })"); + assertTrue(settled.isSettled()); + assertTrue(settled.isRejected()); + assertEquals("invalid", settled.getError().getMember("message").asString()); + assertEquals("[{\"message\":\"nope\"}]", settled.getError().getMember("issues").asString()); + } + + @Test + public void neverSettlingPromisesAreReportedAsNotDone() { + JSPromise.Outcome settled = run("() => new Promise(() => {})"); + assertFalse(settled.isSettled()); + } + + @Test + public void asyncCallbacksCannotSettleAtNestedHostBoundaries() { + // Pins the nested-invocation limitation documented on JSPromise: at a host → JS → host → JS + // boundary, outer JS frames are still on the stack, GraalJS does not drain the microtask + // queue, and even a trivial async callback cannot settle. If a GraalJS upgrade makes this + // test fail, the limitation is gone: relax the docs on JSPromise, registerRenderFilter and + // registerNodeValidator accordingly. + Value asyncFn = context.eval("js", "(async () => 42)"); + boolean[] nestedSettled = { true }; + ProxyExecutable hostCallback = arguments -> + nestedSettled[0] = JSPromise.settle(arguments[0].execute()).isSettled(); + Value outer = context.eval("js", "((host, fn) => host(fn))"); + outer.execute(hostCallback, asyncFn); + assertFalse("async callbacks are expected not to settle at nested host boundaries", nestedSettled[0]); + } + + @Test + public void settleOrThrowReturnsFulfilledValues() { + Value fn = context.eval("js", "(async () => 'done')"); + assertEquals("done", JSPromise.settleOrThrow(fn.execute(), "test callback").asString()); + } + + @Test + public void settleOrThrowConvertsRejectionsIntoGraalVMExceptions() { + Value fn = context.eval("js", "(async () => { throw new Error('kaboom'); })"); + try { + JSPromise.settleOrThrow(fn.execute(), "test callback"); + fail("expected a GraalVMException"); + } catch (GraalVMException e) { + assertTrue(e.getMessage().contains("test callback")); + assertTrue(e.getMessage().contains("kaboom")); + } + } + + @Test + public void settleOrThrowFailsExplicitlyOnNeverSettlingPromises() { + Value fn = context.eval("js", "(() => new Promise(() => {}))"); + try { + JSPromise.settleOrThrow(fn.execute(), "test callback"); + fail("expected a GraalVMException"); + } catch (GraalVMException e) { + assertTrue(e.getMessage().contains("did not settle")); + } + } +} diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/AbstractServiceRegistrarTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/AbstractServiceRegistrarTest.java new file mode 100644 index 00000000..fbbab52e --- /dev/null +++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/AbstractServiceRegistrarTest.java @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars; + +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.junit.Before; +import org.junit.Test; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceRegistration; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class AbstractServiceRegistrarTest { + + private GraalVMEngine engine; + private BundleContext bundleContext; + private Bundle bundle; + private TestRegistrar registrar; + private List> registryEntries; + + /** Minimal concrete registrar bridging entries to plain Runnable services. */ + private static class TestRegistrar extends AbstractServiceRegistrar { + List> beforeRegisterCalls = new ArrayList<>(); + String failingKey; + + TestRegistrar() { + super(Runnable.class, "test-type"); + } + + @Override + protected Runnable createBridge(Map registryEntry) { + if (registryEntry.get("key").equals(failingKey)) { + throw new IllegalStateException("bridge creation failed for " + failingKey); + } + return () -> { + }; + } + + @Override + protected void beforeRegister(Bundle bundle, Map registryEntry) { + beforeRegisterCalls.add(registryEntry); + } + } + + @Before + public void setUp() { + engine = mock(GraalVMEngine.class); + bundleContext = mock(BundleContext.class); + bundle = mock(Bundle.class); + when(bundle.getSymbolicName()).thenReturn("test-bundle"); + + registryEntries = new ArrayList<>(); + // the registrar reads entries through doWithContext; short-circuit the context here + when(engine.doWithContext(any(Function.class))).thenAnswer(invocation -> registryEntries); + + registrar = new TestRegistrar(); + registrar.graalVMEngine = engine; + registrar.bundleContext = bundleContext; + } + + private Map entry(String key) { + Map entry = new HashMap<>(); + entry.put("type", "test-type"); + entry.put("key", key); + entry.put("bundleKey", "test-bundle"); + return entry; + } + + /** Every registerService call returns a fresh mock, collected for later verification. */ + @SuppressWarnings("unchecked") + private List> stubRegistrations() { + List> created = new ArrayList<>(); + when(bundleContext.registerService(eq(Runnable.class), any(Runnable.class), any())).thenAnswer(invocation -> { + ServiceRegistration registration = mock(ServiceRegistration.class); + created.add(registration); + return registration; + }); + return created; + } + + @Test + public void registerPublishesOneServicePerEntryAndUnregisterReleasesThem() { + registryEntries.addAll(Arrays.asList(entry("a"), entry("b"))); + List> registrations = stubRegistrations(); + + registrar.register(bundle); + verify(bundleContext, times(2)).registerService(eq(Runnable.class), any(Runnable.class), any()); + assertEquals(2, registrar.beforeRegisterCalls.size()); + + registrar.unregister(bundle); + assertEquals(2, registrations.size()); + for (ServiceRegistration registration : registrations) { + verify(registration).unregister(); + } + } + + @Test + public void aFailingBridgeDoesNotPreventOtherEntriesFromRegistering() { + registryEntries.addAll(Arrays.asList(entry("a"), entry("broken"), entry("c"))); + registrar.failingKey = "broken"; + stubRegistrations(); + + registrar.register(bundle); + + verify(bundleContext, times(2)).registerService(eq(Runnable.class), any(Runnable.class), any()); + } + + @Test + public void unregisterUnknownBundleIsANoOp() { + registrar.unregister(bundle); + verify(bundleContext, never()).registerService(eq(Runnable.class), any(Runnable.class), any()); + } + + @Test + public void registrarQueriesTheRegistryWithItsTypeAndTheBundleKey() { + registrar.register(bundle); + // the registry read goes through doWithContext with a {type, bundleKey} filter; + // the mocked engine records the interaction + verify(registrar.graalVMEngine).doWithContext(any(Function.class)); + } + + @Test + public void unregisterSurvivesAFailingServiceUnregistration() { + registryEntries.addAll(Arrays.asList(entry("a"), entry("b"))); + List> registrations = stubRegistrations(); + + registrar.register(bundle); + assertEquals(2, registrations.size()); + doThrow(new IllegalStateException("already unregistered")).when(registrations.get(0)).unregister(); + + registrar.unregister(bundle); + + // both unregistrations attempted despite one of them throwing + for (ServiceRegistration registration : registrations) { + verify(registration).unregister(); + } + } + +} diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/ChoiceListInitializerRegistrarTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/ChoiceListInitializerRegistrarTest.java new file mode 100644 index 00000000..584ead91 --- /dev/null +++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/ChoiceListInitializerRegistrarTest.java @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; +import org.jahia.services.content.nodetypes.initializers.ChoiceListValue; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.jcr.RepositoryException; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ChoiceListInitializerRegistrarTest { + + private static Context context; + + @BeforeClass + public static void setUp() { + context = Context.newBuilder("js").build(); + } + + @AfterClass + public static void tearDown() { + context.close(); + } + + private static List convert(String jsExpression) { + Value result = context.eval("js", jsExpression); + return ChoiceListInitializerRegistrar.ChoiceListInitializerBridge.convertValues(result, "test"); + } + + @Test + public void convertsLabelValuePairs() throws RepositoryException { + List values = convert("[{label: 'Red', value: 'red'}, {label: 'Green', value: 'green'}]"); + + assertEquals(2, values.size()); + assertEquals("Red", values.get(0).getDisplayName()); + assertEquals("red", values.get(0).getValue().getString()); + assertNull(values.get(0).getProperties()); + assertEquals("Green", values.get(1).getDisplayName()); + } + + @Test + public void convertsProperties() throws RepositoryException { + List values = convert( + "[{label: 'Blue', value: 'blue', properties: {defaultProperty: true, image: '/img.png'}}]"); + + assertEquals(1, values.size()); + assertEquals("Blue", values.get(0).getDisplayName()); + assertEquals("blue", values.get(0).getValue().getString()); + assertEquals(Boolean.TRUE, values.get(0).getProperties().get("defaultProperty")); + assertEquals("/img.png", values.get(0).getProperties().get("image")); + } + + @Test + public void skipsMalformedItems() { + List values = convert( + "[{label: 'ok', value: 'ok'}, {label: 'missing value'}, {value: 'missing label'}, {}]"); + + assertEquals(1, values.size()); + assertEquals("ok", values.get(0).getDisplayName()); + } + + @Test + public void nonArrayResultsYieldNoValues() { + assertTrue(convert("null").isEmpty()); + assertTrue(convert("undefined").isEmpty()); + assertTrue(convert("({label: 'not-an-array', value: 'x'})").isEmpty()); + assertTrue(convert("[]").isEmpty()); + } +} diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/NodeLegacyActionRegistrarTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/NodeLegacyActionRegistrarTest.java new file mode 100644 index 00000000..3554a9a2 --- /dev/null +++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/NodeLegacyActionRegistrarTest.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; +import org.jahia.bin.ActionResult; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class NodeLegacyActionRegistrarTest { + + private static Context context; + + @BeforeClass + public static void setUp() { + context = Context.newBuilder("js").build(); + } + + @AfterClass + public static void tearDown() { + context.close(); + } + + private static ActionResult convert(String jsExpression) { + Value result = context.eval("js", jsExpression); + return NodeLegacyActionRegistrar.ActionBridge.convertResult(result); + } + + @Test + public void declarationIsMappedOntoTheActionBaseClass() { + Map entry = new HashMap<>(); + entry.put("key", "myAction"); + entry.put("requiredMethods", "GET,POST"); + entry.put("requireAuthenticatedUser", Boolean.TRUE); + entry.put("requiredPermission", "jcr:write"); + entry.put("requiredWorkspace", "live"); + + NodeLegacyActionRegistrar.ActionBridge bridge = new NodeLegacyActionRegistrar.ActionBridge(entry, null); + + assertEquals("myAction", bridge.getName()); + assertTrue(bridge.getRequiredMethods().contains("GET")); + assertTrue(bridge.getRequiredMethods().contains("POST")); + assertTrue(bridge.isRequireAuthenticatedUser()); + assertEquals("jcr:write", bridge.getRequiredPermission()); + assertEquals("live", bridge.getRequiredWorkspace()); + } + + @Test + public void absentDeclarationKeysKeepBaseClassDefaults() { + Map entry = new HashMap<>(); + entry.put("key", "minimalAction"); + + NodeLegacyActionRegistrar.ActionBridge bridge = new NodeLegacyActionRegistrar.ActionBridge(entry, null); + + assertEquals("minimalAction", bridge.getName()); + // Jahia's Action base class requires an authenticated user by default + assertTrue(bridge.isRequireAuthenticatedUser()); + assertNull(bridge.getRequiredPermission()); + } + + @Test + public void convertsAFullResult() { + ActionResult result = convert( + "({statusCode: 201, json: JSON.stringify({message: 'ok', nested: {list: [1, 2]}}), " + + "redirect: '/somewhere', absoluteRedirect: true})"); + + assertEquals(201, result.getResultCode()); + assertEquals("/somewhere", result.getUrl()); + assertTrue(result.isAbsoluteUrl()); + assertEquals("ok", result.getJson().getString("message")); + assertEquals(2, result.getJson().getJSONObject("nested").getJSONArray("list").length()); + } + + @Test + public void defaultsToHttp200() { + ActionResult result = convert("({})"); + assertEquals(200, result.getResultCode()); + assertNull(result.getUrl()); + assertFalse(result.isAbsoluteUrl()); + assertNull(result.getJson()); + } + + @Test + public void nullAndUndefinedResultsYieldAnEmpty200() { + assertEquals(200, convert("null").getResultCode()); + assertEquals(200, convert("undefined").getResultCode()); + } +} diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSNodeValidatorBeanValidationTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSNodeValidatorBeanValidationTest.java new file mode 100644 index 00000000..2b24a546 --- /dev/null +++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/validation/JSNodeValidatorBeanValidationTest.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.validation; + +import org.jahia.services.content.JCRNodeWrapper; +import org.jahia.services.content.decorator.validation.AdvancedGroup; +import org.jahia.services.content.decorator.validation.DefaultSkipOnImportGroup; +import org.junit.After; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import javax.validation.ConstraintViolation; +import javax.validation.MessageInterpolator; +import javax.validation.Validation; +import javax.validation.Validator; +import javax.validation.ValidatorFactory; +import javax.validation.groups.Default; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.function.Supplier; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Exercises the whole Bean Validation chain of the JS validator bridge against a real Hibernate + * Validator (same version as the platform): constraint discovery on {@link JSNodeValidator}, group + * orchestration per {@link JSValidation.Mode}, programmatic violation building (property-level vs + * node-level paths) and message pass-through. + * + *

The interpolator is a pass-through, mimicking Jahia's {@code JahiaMessageInterpolator} behavior for + * messages that do not match a resource bundle key (it returns unresolved templates verbatim and never + * applies EL or parameter interpolation). + */ +public class JSNodeValidatorBeanValidationTest { + + private static ValidatorFactory factory; + private static Validator validator; + + /** Canned violations returned by the fake registrar, per requested mode. */ + private FakeRegistrar fakeRegistrar; + private Supplier previousSupplier; + + private static class FakeRegistrar extends NodeValidatorRegistrar { + private final List defaultViolations = new ArrayList<>(); + private final List defaultSkipOnImportViolations = new ArrayList<>(); + private final List advancedViolations = new ArrayList<>(); + + @Override + public List collectViolations(JCRNodeWrapper node, JSValidation.Mode mode) { + switch (mode) { + case DEFAULT: + return defaultViolations; + case DEFAULT_SKIP_ON_IMPORT: + return defaultSkipOnImportViolations; + case ADVANCED: + return advancedViolations; + default: + return List.of(); + } + } + } + + @BeforeClass + public static void setUpFactory() { + factory = Validation.byDefaultProvider().configure() + .messageInterpolator(new MessageInterpolator() { + @Override + public String interpolate(String messageTemplate, Context context) { + return messageTemplate; + } + + @Override + public String interpolate(String messageTemplate, Context context, Locale locale) { + return messageTemplate; + } + }) + .buildValidatorFactory(); + validator = factory.getValidator(); + } + + @AfterClass + public static void tearDownFactory() { + factory.close(); + } + + @Before + public void setUp() { + fakeRegistrar = new FakeRegistrar(); + previousSupplier = JSValidationConstraintValidator.registrarSupplier; + JSValidationConstraintValidator.registrarSupplier = () -> fakeRegistrar; + } + + @After + public void tearDown() { + JSValidationConstraintValidator.registrarSupplier = previousSupplier; + } + + @Test + public void noViolationsMeansValid() { + assertTrue(validator.validate(new JSNodeValidator(null)).isEmpty()); + } + + @Test + public void propertyLevelViolationCarriesThePropertyPath() { + fakeRegistrar.defaultViolations.add(new JSViolation("Email is invalid", "email", "test")); + + Set> violations = validator.validate(new JSNodeValidator(null)); + + assertEquals(1, violations.size()); + ConstraintViolation violation = violations.iterator().next(); + assertEquals("Email is invalid", violation.getMessage()); + // Jahia core maps a resolvable property path to a field-level error in the editing UI + assertEquals("email", violation.getPropertyPath().toString()); + } + + @Test + public void nodeLevelViolationHasABlankPath() { + fakeRegistrar.defaultViolations.add(new JSViolation("Node is inconsistent", null, "test")); + + Set> violations = validator.validate(new JSNodeValidator(null)); + + assertEquals(1, violations.size()); + // Jahia core maps a blank property path to a node-level error + assertEquals("", violations.iterator().next().getPropertyPath().toString()); + } + + @Test + public void messagesPassThroughVerbatimIncludingSpecialCharacters() { + String nasty = "lone { brace, ${7*7}, back\\slash and {jcr:title}"; + fakeRegistrar.defaultViolations.add(new JSViolation(nasty, "email", "test")); + + Set> violations = validator.validate(new JSNodeValidator(null)); + + assertEquals(nasty, violations.iterator().next().getMessage()); + } + + @Test + public void blankMessagesAreReplacedByAGenericFallback() { + fakeRegistrar.defaultViolations.add(new JSViolation(" ", null, "test")); + + Set> violations = validator.validate(new JSNodeValidator(null)); + + assertEquals("Invalid content", violations.iterator().next().getMessage()); + } + + @Test + public void groupOrchestrationMatchesJahiaPhases() { + fakeRegistrar.defaultViolations.add(new JSViolation("default phase", null, "test")); + fakeRegistrar.defaultSkipOnImportViolations.add(new JSViolation("default skip-on-import phase", null, "test")); + fakeRegistrar.advancedViolations.add(new JSViolation("advanced phase", null, "test")); + + // normal save, first phase: Default + DefaultSkipOnImportGroup (what Jahia core requests) + Set> firstPhase = + validator.validate(new JSNodeValidator(null), Default.class, DefaultSkipOnImportGroup.class); + assertEquals(2, firstPhase.size()); + + // import, first phase: Default only -> the skip-on-import validator does not run + Set> importPhase = + validator.validate(new JSNodeValidator(null), Default.class); + assertEquals(1, importPhase.size()); + assertEquals("default phase", importPhase.iterator().next().getMessage()); + + // second phase: AdvancedGroup + Set> advancedPhase = + validator.validate(new JSNodeValidator(null), AdvancedGroup.class); + assertEquals(1, advancedPhase.size()); + assertEquals("advanced phase", advancedPhase.iterator().next().getMessage()); + } + + @Test + public void missingRegistrarMeansValid() { + JSValidationConstraintValidator.registrarSupplier = () -> null; + fakeRegistrar.defaultViolations.add(new JSViolation("should not surface", null, "test")); + + assertTrue(validator.validate(new JSNodeValidator(null)).isEmpty()); + } +} diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/validation/NodeValidatorRegistrarTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/validation/NodeValidatorRegistrarTest.java new file mode 100644 index 00000000..c742512a --- /dev/null +++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/registrars/validation/NodeValidatorRegistrarTest.java @@ -0,0 +1,211 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.registrars.validation; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine; +import org.junit.AfterClass; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.osgi.framework.Bundle; + +import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class NodeValidatorRegistrarTest { + + private static Context jsContext; + + private TestableRegistrar registrar; + private List> registryEntries; + + /** Registrar with the JCRStoreService interactions replaced by an in-memory state. */ + private static class TestableRegistrar extends NodeValidatorRegistrar { + Constructor platformValidator; + int addCalls; + int removeCalls; + + @Override + protected Constructor getRegisteredPlatformValidator() { + return platformValidator; + } + + @Override + protected void addPlatformValidator() { + addCalls++; + platformValidator = JSNodeValidator.class.getConstructors()[0]; + } + + @Override + protected void removePlatformValidator() { + removeCalls++; + platformValidator = null; + } + } + + @BeforeClass + public static void setUpContext() { + jsContext = Context.newBuilder("js").build(); + } + + @AfterClass + public static void tearDownContext() { + jsContext.close(); + } + + @Before + @SuppressWarnings("unchecked") + public void setUp() { + GraalVMEngine engine = mock(GraalVMEngine.class); + registryEntries = new ArrayList<>(); + when(engine.doWithContext(any(Function.class))).thenAnswer(invocation -> registryEntries); + + registrar = new TestableRegistrar(); + registrar.setGraalVMEngine(engine); + } + + private Bundle bundle(String symbolicName) { + Bundle bundle = mock(Bundle.class); + when(bundle.getSymbolicName()).thenReturn(symbolicName); + return bundle; + } + + private Map entry(String key, String nodeType, boolean skipOnImport, boolean advanced) { + Map entry = new HashMap<>(); + entry.put("type", "node-validator"); + entry.put("key", key); + entry.put("nodeType", nodeType); + entry.put("skipOnImport", skipOnImport); + entry.put("advanced", advanced); + return entry; + } + + @Test + public void bridgeIsRegisteredOnFirstValidatorAndRemovedWithTheLastOne() { + Bundle bundleA = bundle("module-a"); + Bundle bundleB = bundle("module-b"); + + registryEntries.add(entry("a", "jnt:a", false, false)); + registrar.register(bundleA); + assertEquals(1, registrar.addCalls); + + registryEntries.clear(); + registryEntries.add(entry("b", "jnt:b", false, false)); + registrar.register(bundleB); + // still a single platform registration + assertEquals(1, registrar.addCalls); + + registrar.unregister(bundleA); + assertEquals(0, registrar.removeCalls); + + registrar.unregister(bundleB); + assertEquals(1, registrar.removeCalls); + assertNull(registrar.platformValidator); + } + + @Test + public void bundlesWithoutValidatorsDoNotRegisterTheBridge() { + registrar.register(bundle("module-without-validators")); + assertEquals(0, registrar.addCalls); + } + + @Test + public void aForeignPlatformValidatorIsNeverRemoved() throws Exception { + // simulate another module having clobbered the sentinel registration + registryEntries.add(entry("a", "jnt:a", false, false)); + Bundle bundleA = bundle("module-a"); + registrar.register(bundleA); + + Constructor foreign = String.class.getConstructor(); + registrar.platformValidator = foreign; + + registrar.unregister(bundleA); + assertEquals(0, registrar.removeCalls); + assertEquals(foreign, registrar.platformValidator); + } + + @Test + public void deactivateCleansUp() { + registryEntries.add(entry("a", "jnt:a", false, false)); + registrar.register(bundle("module-a")); + + registrar.deactivate(); + assertEquals(1, registrar.removeCalls); + } + + @Test + public void modesAreDerivedFromTheDeclarationFlags() { + assertEquals(JSValidation.Mode.DEFAULT, NodeValidatorRegistrar.modeOf(entry("k", "t", false, false))); + assertEquals(JSValidation.Mode.DEFAULT_SKIP_ON_IMPORT, NodeValidatorRegistrar.modeOf(entry("k", "t", true, false))); + assertEquals(JSValidation.Mode.ADVANCED, NodeValidatorRegistrar.modeOf(entry("k", "t", false, true))); + assertEquals(JSValidation.Mode.ADVANCED_SKIP_ON_IMPORT, NodeValidatorRegistrar.modeOf(entry("k", "t", true, true))); + } + + @Test + public void violationResultsAcceptAllDocumentedShapes() { + List violations = new ArrayList<>(); + + NodeValidatorRegistrar.appendViolations(violations, jsContext.eval("js", "undefined"), "test"); + NodeValidatorRegistrar.appendViolations(violations, jsContext.eval("js", "null"), "test"); + assertTrue(violations.isEmpty()); + + NodeValidatorRegistrar.appendViolations(violations, + jsContext.eval("js", "({message: 'single', propertyName: 'email'})"), "test"); + assertEquals(1, violations.size()); + assertEquals("single", violations.get(0).getMessage()); + assertEquals("email", violations.get(0).getPropertyName()); + + NodeValidatorRegistrar.appendViolations(violations, + jsContext.eval("js", "[{message: 'first'}, {message: 'second', propertyName: 'score'}]"), "test"); + assertEquals(3, violations.size()); + assertNull(violations.get(1).getPropertyName()); + assertEquals("score", violations.get(2).getPropertyName()); + + // malformed items (no string message) are skipped + NodeValidatorRegistrar.appendViolations(violations, + jsContext.eval("js", "[{propertyName: 'email'}, {message: 42}, 'not-an-object']"), "test"); + assertEquals(3, violations.size()); + } + + @Test + public void collectViolationsReturnsNothingWhenNoTypeMatchesTheMode() { + // no snapshot at all: the gate short-circuits before touching the engine + assertTrue(registrar.collectViolations(null, JSValidation.Mode.DEFAULT).isEmpty()); + } + + @Test + public void violationOfMissingMessageStringIsSkipped() { + List violations = new ArrayList<>(); + Value item = jsContext.eval("js", "({message: null})"); + NodeValidatorRegistrar.appendViolations(violations, item, "test"); + assertFalse(violations.stream().anyMatch(v -> v.getValidatorKey().equals("missing"))); + assertTrue(violations.isEmpty()); + } +} diff --git a/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvokerImplTest.java b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvokerImplTest.java new file mode 100644 index 00000000..26cf70a9 --- /dev/null +++ b/javascript-modules-engine-java/src/test/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvokerImplTest.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.jahia.modules.javascript.modules.engine.sdk; + +import org.graalvm.polyglot.Context; +import org.graalvm.polyglot.Value; +import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMException; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class JSServerExtensionInvokerImplTest { + + private static Context context; + + @BeforeClass + public static void setUp() { + context = Context.newBuilder("js").build(); + } + + @AfterClass + public static void tearDown() { + context.close(); + } + + private static Object invoke(String jsFunction, Object... args) { + Value fn = context.eval("js", "(" + jsFunction + ")"); + return JSServerExtensionInvokerImpl.invoke(fn, args); + } + + @Test + public void convertsScalarsAndNull() { + assertEquals(42L, invoke("() => 42")); + assertEquals(2.5, invoke("() => 2.5")); + assertEquals("hello", invoke("() => 'hello'")); + assertEquals(true, invoke("() => true")); + assertNull(invoke("() => null")); + assertNull(invoke("() => undefined")); + } + + @Test + public void convertsObjectsAndArraysToPlainJava() { + assertEquals(Map.of("valid", false, "message", "rejected!"), + invoke("() => ({ valid: false, message: 'rejected!' })")); + assertEquals(List.of(1L, "a"), invoke("() => [1, 'a']")); + } + + @Test + public void forwardsArguments() { + assertEquals(3L, invoke("(a, b) => a + b", 1, 2)); + } + + @Test + public void settlesAsyncCallables() { + // an async callable is the default idiom in modern JS: its verdict must not be lost + Object result = invoke("async () => ({ valid: false, message: 'rejected!' })"); + assertEquals(Map.of("valid", false, "message", "rejected!"), result); + } + + @Test + public void asyncRejectionsSurfaceAsRuntimeExceptions() { + try { + invoke("async () => { throw new Error('kaboom'); }"); + fail("expected a GraalVMException"); + } catch (GraalVMException e) { + assertTrue(e.getMessage().contains("kaboom")); + } + } + + @Test + public void synchronousThrowsSurfaceAsRuntimeExceptions() { + try { + invoke("() => { throw new Error('boom'); }"); + fail("expected a RuntimeException"); + } catch (RuntimeException e) { + assertTrue(e.getMessage().contains("boom")); + } + } +} diff --git a/javascript-modules-engine/pom.xml b/javascript-modules-engine/pom.xml index 8fbf1178..adb92df9 100644 --- a/javascript-modules-engine/pom.xml +++ b/javascript-modules-engine/pom.xml @@ -81,8 +81,15 @@ <_dsannotations>* - - bndlib,chromeinspector,commons-pool2,pax-swissbox-bnd,graal-sdk,truffle-api,js,icu4j,regex + + bndlib,chromeinspector,profiler,commons-pool2,pax-swissbox-bnd,graal-sdk,truffle-api,js,icu4j,regex + + org.jahia.modules.javascript.modules.engine.sdk;version="${project.version}" diff --git a/javascript-modules-engine/src/main/resources/META-INF/configurations/org.jahia.modules.jahiacsrfguard-javascriptmodulesengine.cfg b/javascript-modules-engine/src/main/resources/META-INF/configurations/org.jahia.modules.jahiacsrfguard-javascriptmodulesengine.cfg new file mode 100644 index 00000000..2a26f363 --- /dev/null +++ b/javascript-modules-engine/src/main/resources/META-INF/configurations/org.jahia.modules.jahiacsrfguard-javascriptmodulesengine.cfg @@ -0,0 +1,5 @@ +# Whitelists the single generic JS action dispatch endpoint (see GenericActionEndpoint and +# ADR-0008). The endpoint is protected against classic CSRF by a mandatory custom header +# (X-JS-Action) that HTML forms cannot set and that cross-origin scripts cannot send without a +# CORS preflight. +whitelist = *.jsAction.do diff --git a/javascript-modules-library/src/framework/actions/action.ts b/javascript-modules-library/src/framework/actions/action.ts new file mode 100644 index 00000000..73408c13 --- /dev/null +++ b/javascript-modules-library/src/framework/actions/action.ts @@ -0,0 +1,66 @@ +import type { StandardSchemaV1 } from "./standardSchema.js"; + +/** + * Thrown when the input of a safe action does not match its schema. Surfaced to the client as an + * error with the validation `issues` attached. + */ +/** + * An action failure whose message is meant for the caller: throw it for deliberate, user-facing + * errors (`throw new ActionError("Out of stock")`). Any other exception thrown by an action is + * logged on the server and replaced by a generic message in the response, because actions are + * guest-callable and unexpected error messages can leak implementation details. + */ +export class ActionError extends Error { + constructor(message: string) { + super(message); + this.name = "ActionError"; + } +} + +export class ActionValidationError extends Error { + readonly issues: ReadonlyArray; + + constructor(issues: ReadonlyArray) { + super(issues.map((issue) => issue.message).join("; ") || "Invalid action input"); + this.name = "ActionValidationError"; + this.issues = issues; + } +} + +/** + * Wraps an action implementation with input validation ("safe action"). + * + * The schema can come from any [Standard Schema](https://standardschema.dev) compatible library + * (zod, valibot, arktype, …). The wrapped function is only called when the input is valid, and its + * parameter type is inferred from the schema: + * + * ```ts + * // rates.action.ts + * import { action } from "@jahia/javascript-modules-library"; + * import { z } from "zod"; + * + * export const getExchangeRate = action(z.object({ currency: z.string() }), ({ currency }) => { + * return lookupRate(currency); + * }); + * ``` + * + * On invalid input, the client call rejects with an error carrying the validation `issues`. + * + * @param schema Validates the single argument passed by the client. + * @param implementation Runs with the validated (and possibly transformed) input. + */ +export const action = ( + schema: Schema, + implementation: (input: StandardSchemaV1.InferOutput) => Return, +): ((input: StandardSchemaV1.InferInput) => Promise>) => { + return async (input): Promise> => { + let result = schema["~standard"].validate(input); + if (result instanceof Promise) result = await result; + if (result.issues) { + throw new ActionValidationError(result.issues); + } + return (await implementation( + result.value as StandardSchemaV1.InferOutput, + )) as Awaited; + }; +}; diff --git a/javascript-modules-library/src/framework/actions/registerActionsModule.ts b/javascript-modules-library/src/framework/actions/registerActionsModule.ts new file mode 100644 index 00000000..e724a32d --- /dev/null +++ b/javascript-modules-library/src/framework/actions/registerActionsModule.ts @@ -0,0 +1,72 @@ +import { parse, stringify } from "devalue"; +import { ActionError, ActionValidationError } from "./action.js"; + +/** + * Registers every function exported by a `.action.ts` file as a callable action. + * + * Do not call this function yourself: `@jahia/vite-plugin` appends a call to it to every + * `.action.ts` file in the server bundle (declare actions by exporting functions from such a file). + * It stays in the main entry point only because the engine resolves the library as a single shared + * module at runtime; the underscore marks it as internal. + * + * The registered adapter is invoked by the engine's generic action endpoint + * (`GenericActionEndpoint`) with the raw devalue-serialized arguments array, and resolves to the + * devalue-serialized result — keep both shapes in sync. + * + * Action keys are `/`; the client stubs generated by the vite plugin use + * the same convention. Registering twice with the same key (duplicate export names across the + * `.action.ts` files of a module) fails at module startup. + * + * @internal + */ +export const __registerActionsModule = ( + actions: Record, + moduleName: string, +): void => { + for (const [name, fn] of Object.entries(actions)) { + if (typeof fn !== "function") { + console.warn( + `Skipping non-function export "${name}" of an action file in ${moduleName}: only functions can be actions (its generated client stub will fail if called)`, + ); + continue; + } + server.registry.add("action", `${moduleName}/${name}`, { + execute: (body: string) => + Promise.resolve() + .then(() => fn(...(parse(body) as unknown[]))) + .then( + (result) => stringify(result), + (error: unknown) => { + // Shape the rejection for the Java endpoint: a plain object with a string message + // and, for validation failures, pre-stringified issues. Actions are guest-callable: + // only deliberate error types carry their message to the caller, anything else is + // logged here and replaced by a generic message (unexpected messages can leak + // implementation details, node paths or permissions). + const issues = (error as { issues?: unknown } | undefined)?.issues; + const deliberate = + Array.isArray(issues) || + error instanceof ActionError || + error instanceof ActionValidationError; + const shaped: { message: string; issues?: string } = { + message: deliberate ? messageOf(error) : "Action execution failed", + }; + if (Array.isArray(issues)) { + shaped.issues = JSON.stringify(issues); + } + if (!deliberate) { + console.error(`Action ${moduleName}/${name} threw: ${messageOf(error)}`); + } + throw shaped; + }, + ), + }); + console.debug(`Registered action: ${moduleName}/${name}`); + } +}; + +/** Extracts a human-readable message from an Error, an error-like object, or anything else. */ +const messageOf = (error: unknown): string => { + if (error instanceof Error) return error.message; + const message = (error as { message?: unknown } | undefined)?.message; + return String(message ?? error); +}; diff --git a/javascript-modules-library/src/framework/actions/standardSchema.ts b/javascript-modules-library/src/framework/actions/standardSchema.ts new file mode 100644 index 00000000..1ce1eee8 --- /dev/null +++ b/javascript-modules-library/src/framework/actions/standardSchema.ts @@ -0,0 +1,51 @@ +/** + * Minimal vendored subset of the Standard Schema v1 interface (https://standardschema.dev, MIT), + * implemented by zod, valibot, arktype and others. Vendoring the interface keeps the library free + * of any validation-library dependency while accepting all of them. + */ +export interface StandardSchemaV1 { + readonly "~standard": StandardSchemaV1.Props; +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace StandardSchemaV1 { + export interface Props { + readonly version: 1; + readonly vendor: string; + readonly validate: (value: unknown) => Result | Promise>; + readonly types?: Types | undefined; + } + + export type Result = SuccessResult | FailureResult; + + export interface SuccessResult { + readonly value: Output; + readonly issues?: undefined; + } + + export interface FailureResult { + readonly issues: ReadonlyArray; + } + + export interface Issue { + readonly message: string; + readonly path?: ReadonlyArray | undefined; + } + + export interface PathSegment { + readonly key: PropertyKey; + } + + export interface Types { + readonly input: Input; + readonly output: Output; + } + + export type InferInput = NonNullable< + S["~standard"]["types"] + >["input"]; + + export type InferOutput = NonNullable< + S["~standard"]["types"] + >["output"]; +} diff --git a/javascript-modules-library/src/framework/registerChoiceListInitializer.ts b/javascript-modules-library/src/framework/registerChoiceListInitializer.ts new file mode 100644 index 00000000..8d794706 --- /dev/null +++ b/javascript-modules-library/src/framework/registerChoiceListInitializer.ts @@ -0,0 +1,111 @@ +import type { JCRNodeWrapper } from "org.jahia.services.content"; +import type { ExtendedPropertyDefinition } from "org.jahia.services.content.nodetypes"; +import type { List, Locale, Map as JavaMap } from "java.util"; + +/** One selectable entry of a choicelist. */ +export interface ChoiceListValue { + /** Human-readable label shown in the editing UI. */ + label: string; + /** String persisted in the JCR when this choice is selected. */ + value: string; + /** + * Optional metadata attached to the choice, interpreted by the editing UI (e.g. `{ image: + * "/path.png" }` or `{ defaultProperty: true }`). + */ + properties?: Record; +} + +/** Context passed to a choicelist initializer callback. */ +export interface ChoiceListInitializerContext { + /** Parameter from the CND declaration `choicelist[myKey='myParam']`; empty string when absent. */ + param: string; + /** + * BCP-47 language tag to localize labels for, e.g. `"en"` or `"fr-FR"`. Whether the platform + * forwards the content language being edited or the editor's UI language varies across Jahia + * versions — do not build logic on that distinction. + */ + locale: string; + /** + * Choices accumulated by the previous initializers of the CND declaration chain (empty when this + * initializer is used alone). Return them as part of your result to keep them. + */ + values: ChoiceListValue[]; + /** The node being edited, when available (not available on creation forms). */ + node?: JCRNodeWrapper; + /** Escape hatch: the raw Java objects received by the underlying ModuleChoiceListInitializer. */ + java: { + propertyDefinition: ExtendedPropertyDefinition; + locale: Locale; + values: List; + context: JavaMap; + }; +} + +/** + * Registers a choicelist initializer, usable from CND property definitions to populate dropdowns in + * the editing UI: + * + * ```cnd + * -color(string, choicelist[myModuleColors]); + * ``` + * + * ```ts + * registerChoiceListInitializer({ key: "myModuleColors" }, ({ locale }) => [ + * { label: locale === "fr" ? "Rouge" : "Red", value: "red" }, + * { label: locale === "fr" ? "Vert" : "Green", value: "green" }, + * ]); + * ``` + * + * Keys live in a single platform-wide namespace shared with Java modules (last registration wins); + * prefix them with your module name to avoid collisions. + * + * The callback runs on a server thread every time an editor form displays the choicelist — keep it + * fast. It may be `async` (microtask-only: the server runtime has no timers or async I/O). + * + * @param options The initializer declaration; `key` is the name referenced from CND definitions. + * @param resolveValues Returns the choices offered to the editor. + */ +export const registerChoiceListInitializer = ( + { key }: { key: string }, + resolveValues: ( + context: ChoiceListInitializerContext, + ) => ChoiceListValue[] | Promise, +): void => { + server.registry.add("choicelist-initializer", key, { + // Raw adapter invoked by the Java bridge (ChoiceListInitializerRegistrar) with the + // ModuleChoiceListInitializer#getChoiceListValues arguments. Keep both shapes in sync. + getChoiceListValues: ( + propertyDefinition: ExtendedPropertyDefinition, + param: string | null, + values: List, + locale: Locale, + context: JavaMap, + ): ChoiceListValue[] | Promise => + resolveValues({ + param: param ?? "", + locale: locale ? locale.toLanguageTag() : "", + values: toJsChoiceListValues(values), + node: (context?.get("contextNode") as JCRNodeWrapper | null) ?? undefined, + java: { propertyDefinition, locale, values, context }, + }), + }); + console.debug(`Registered choicelist initializer: ${key}`); +}; + +/** Converts the Java List of org.jahia...ChoiceListValue accumulated so far into plain JS objects. */ +const toJsChoiceListValues = (values: List): ChoiceListValue[] => { + const result: ChoiceListValue[] = []; + if (values) { + for (let i = 0; i < values.size(); i++) { + // Jahia's ChoiceListValue: getDisplayName(), getValue() (a JCR Value) + const value = values.get(i) as { + getDisplayName(): string; + getValue(): { getString(): string }; + }; + // properties of accumulated values are intentionally not surfaced here; + // the raw list stays available under the `java` escape hatch + result.push({ label: value.getDisplayName(), value: value.getValue().getString() }); + } + } + return result; +}; diff --git a/javascript-modules-library/src/framework/registerNodeLegacyAction.ts b/javascript-modules-library/src/framework/registerNodeLegacyAction.ts new file mode 100644 index 00000000..14d7c9bd --- /dev/null +++ b/javascript-modules-library/src/framework/registerNodeLegacyAction.ts @@ -0,0 +1,170 @@ +import type { JCRSessionWrapper } from "org.jahia.services.content"; +import type { RenderContext, Resource, URLResolver } from "org.jahia.services.render"; +import type { HttpServletRequest } from "javax.servlet.http"; +import type { List, Map as JavaMap } from "java.util"; + +/** Declaration of an action, invoked through `..do` URLs. */ +export interface NodeLegacyActionDeclaration { + /** + * The action name; the action is triggered by URLs of the form `..do`. + * + * Names live in a single platform-wide namespace shared with Java modules (last registration + * wins); prefix them with your module name to avoid collisions. + */ + name: string; + /** + * HTTP methods allowed to trigger the action. When omitted, Jahia's default applies (GET and + * POST). Note that POST/PUT/DELETE requests to `.do` URLs must be whitelisted in Jahia's CSRF + * guard configuration — see the actions documentation. + */ + requiredMethods?: ("GET" | "POST" | "PUT" | "DELETE")[]; + /** + * Restrict the action to authenticated users. + * + * @default true (Jahia's default — set it to false explicitly for guest-accessible actions) + */ + requireAuthenticatedUser?: boolean; + /** Permission required on the target node to execute the action, e.g. `"jcr:write"`. */ + requiredPermission?: string; + /** Restrict the action to a workspace. */ + requiredWorkspace?: "default" | "live"; +} + +/** Context passed to an action handler. */ +export interface NodeLegacyActionContext { + /** Merged query-string and form parameters of the request. */ + parameters: Record; + /** The render context of the action request. */ + renderContext: RenderContext; + /** The resource targeted by the action URL. */ + resource: Resource; + /** The JCR session of the calling user. */ + session: JCRSessionWrapper; + /** Escape hatch: the raw servlet request (headers, cookies, body). */ + request: HttpServletRequest; + /** Escape hatch: the Jahia URL resolver for the action URL. */ + urlResolver: URLResolver; +} + +/** Result of an action handler. */ +export interface NodeLegacyActionResult { + /** HTTP status code of the response. @default 200 */ + statusCode?: number; + /** Serialized as the JSON response body. Must be a JSON object at the top level. */ + json?: Record; + /** URL to redirect the client to. */ + redirect?: string; + /** Whether `redirect` is an absolute URL. @default false */ + absoluteRedirect?: boolean; +} + +/** + * Registers a legacy node action: an HTTP endpoint bound to a content node, invoked through + * `..do` URLs — Jahia's classic `org.jahia.bin.Action` mechanism, exposed for parity + * with Java modules. + * + * To call server code from client components (islands), prefer actions declared in `.action.ts` + * files: typed, client-callable functions with automatic serialization. + * + * ```ts + * registerNodeLegacyAction( + * { name: "myModuleGreet", requiredMethods: ["GET"] }, + * ({ parameters, resource }) => ({ + * json: { + * greeting: `Hello ${parameters.who?.[0] ?? "world"}`, + * path: resource.getNode().getPath(), + * }, + * }), + * ); + * ``` + * + * Handlers may be `async`: `await` over synchronous work is fully supported, but the server runtime + * has no timers and no asynchronous I/O — a promise relying on them never settles and the request + * fails. + * + * @param declaration The action declaration; `name` is the URL-visible action name. + * @param handler Executes the action and returns the response to send. + */ +export const registerNodeLegacyAction = ( + { + name, + requiredMethods, + requireAuthenticatedUser, + requiredPermission, + requiredWorkspace, + }: NodeLegacyActionDeclaration, + handler: ( + context: NodeLegacyActionContext, + ) => NodeLegacyActionResult | undefined | Promise, +): void => { + server.registry.add("node-legacy-action", name, { + ...(requiredMethods !== undefined && { requiredMethods: requiredMethods.join(",") }), + ...(requireAuthenticatedUser !== undefined && { requireAuthenticatedUser }), + ...(requiredPermission !== undefined && { requiredPermission }), + ...(requiredWorkspace !== undefined && { requiredWorkspace }), + // Raw adapter invoked by the Java bridge (NodeLegacyActionRegistrar.ActionBridge) with the + // Action#doExecute arguments; resolves to {statusCode, json?: string, redirect?, + // absoluteRedirect?} with json pre-stringified (the bridge settles the promise). Keep both + // shapes in sync. + doExecute: ( + request: HttpServletRequest, + renderContext: RenderContext, + resource: Resource, + session: JCRSessionWrapper, + javaParameters: JavaMap>, + urlResolver: URLResolver, + ) => + Promise.resolve( + handler({ + parameters: toJsParameters(javaParameters), + renderContext, + resource, + session, + request, + urlResolver, + }), + ).then((result) => { + if (!result) return { statusCode: 200 }; + return { + statusCode: result.statusCode ?? 200, + ...(result.json !== undefined && { json: JSON.stringify(result.json) }), + ...(result.redirect !== undefined && { + redirect: result.redirect, + absoluteRedirect: result.absoluteRedirect ?? false, + }), + }; + }), + }); + console.debug(`Registered node legacy action: ${name}`); +}; + +/** Converts the Java Map> of request parameters into a plain JS object. */ +const toJsParameters = ( + javaParameters: JavaMap>, +): Record => { + const parameters: Record = {}; + if (javaParameters) { + // keySet() is not part of the generated Map typing but is available at runtime + const keys = (javaParameters as unknown as { keySet(): { iterator(): Iterator } }) + .keySet() + .iterator(); + while (keys.hasNext()) { + const key = keys.next(); + const values = javaParameters.get(key); + const jsValues: string[] = []; + if (values) { + for (let i = 0; i < values.size(); i++) { + jsValues.push(values.get(i)); + } + } + parameters[key] = jsValues; + } + } + return parameters; +}; + +/** Minimal typing of a java.util.Iterator, which is not part of the generated Map typing. */ +interface Iterator { + hasNext(): boolean; + next(): T; +} diff --git a/javascript-modules-library/src/framework/registerNodeValidator.ts b/javascript-modules-library/src/framework/registerNodeValidator.ts new file mode 100644 index 00000000..416350d3 --- /dev/null +++ b/javascript-modules-library/src/framework/registerNodeValidator.ts @@ -0,0 +1,105 @@ +import type { JCRNodeWrapper } from "org.jahia.services.content"; +import type { Locale } from "java.util"; + +/** + * `registerNodeValidator` calls are executed synchronously during module initialization. During + * this time, `bundleKey` is set to the symbolic name of the active bundle. + */ +declare const bundleKey: string; + +/** A violation reported by a node validator. */ +export interface NodeValidatorViolation { + /** + * The message shown to the editor. Either literal text, or a `{resource.bundle.key}` reference + * resolved by Jahia against the deployed resource bundles (in the editor's UI locale) — the same + * i18n mechanism used by Java validators. Any other text is displayed verbatim. + */ + message: string; + /** + * JCR property name (e.g. `"jcr:title"`) to attach the violation to a specific field in the + * editing UI; omit for a node-level violation. + */ + propertyName?: string; +} + +/** Context passed to a node validator callback. */ +export interface NodeValidatorContext { + /** + * BCP-47 language tag of the saving session's locale, or null when the save is not bound to a + * locale. Note that Jahia silently drops violations on internationalized properties when the + * session locale is null. + */ + locale: string | null; + /** Escape hatch: the raw Java objects. */ + java: { + locale: Locale | null; + }; +} + +/** Declaration of a node validator. */ +export interface NodeValidatorDeclaration { + /** Node type (primary or mixin) this validator applies to, matched with `isNodeType()`. */ + nodeType: string; + /** + * Distinguishes several validators declared for the same node type in the same module. + * + * @default "default" + */ + name?: string; + /** Skip this validator during content imports. @default false */ + skipOnImport?: boolean; + /** + * Run this validator in the advanced phase, which only runs once all default-phase validators + * passed. @default false + */ + advanced?: boolean; +} + +/** + * Registers a server-side node validator, executed by Jahia on every session save of a node of the + * declared type. Returning one or more violations rejects the save and surfaces the messages in the + * editing UI (field-level when `propertyName` is set, node-level otherwise). + * + * ```ts + * registerNodeValidator({ nodeType: "mymodule:article" }, (node) => { + * const email = node.getPropertyAsString("email"); + * if (email && !email.includes("@")) { + * return { message: "Please provide a valid email address", propertyName: "email" }; + * } + * }); + * ``` + * + * Validators run on every matching save — keep them fast, and never call `session.save()` from a + * validator. They may be `async` (microtask-only: the server runtime has no timers or async I/O) + * **only when the save is host-initiated** (editing UI, REST, GraphQL). A save triggered from JS + * server code cannot drain the microtask queue: an async validator then fails to settle and the + * save is rejected. Use synchronous validators if your content may be saved from JS. + * + * @param declaration The validator declaration. + * @param validate Returns the violations (array, single violation, or nothing when valid). + */ +export const registerNodeValidator = ( + { nodeType, name = "default", skipOnImport = false, advanced = false }: NodeValidatorDeclaration, + validate: ( + node: JCRNodeWrapper, + context: NodeValidatorContext, + ) => + | NodeValidatorViolation[] + | NodeValidatorViolation + | undefined + | Promise, +): void => { + server.registry.add("node-validator", `${bundleKey}_node-validator_${nodeType}_${name}`, { + nodeType, + skipOnImport, + advanced, + // Raw adapter invoked by the Java bridge (NodeValidatorRegistrar) with the node and a context + // holding the raw session locale. Keep both shapes in sync. + validate: (node: JCRNodeWrapper, javaContext: { locale: Locale | null }) => + validate(node, { + locale: javaContext.locale ? javaContext.locale.toLanguageTag() : null, + java: { locale: javaContext.locale }, + }), + }); + console.debug(`Registered node validator for ${nodeType} (${name})`); +}; diff --git a/javascript-modules-library/src/framework/registerRenderFilter.ts b/javascript-modules-library/src/framework/registerRenderFilter.ts new file mode 100644 index 00000000..ffe4e30e --- /dev/null +++ b/javascript-modules-library/src/framework/registerRenderFilter.ts @@ -0,0 +1,104 @@ +import type { RenderContext, Resource } from "org.jahia.services.render"; + +/** Declaration of a render filter. */ +export interface RenderFilterDeclaration { + /** Unique key of the filter in the registry. */ + key: string; + /** + * Position of the filter in the render chain (may be fractional). Lower priorities execute first. + * + * @default 0 + */ + priority?: number; + /** Human-readable description of the filter. */ + description?: string; + /** Only apply the filter to resources of these node types. */ + applyOnNodeTypes?: string | string[]; + /** Only apply the filter in these render modes (e.g. "live", "preview", "edit"). */ + applyOnModes?: string | string[]; + /** Only apply the filter on these render configurations (e.g. "page", "module"). */ + applyOnConfigurations?: string | string[]; + /** Only apply the filter on these templates. */ + applyOnTemplates?: string | string[]; + /** Only apply the filter on these template types (e.g. "html"). */ + applyOnTemplateTypes?: string | string[]; +} + +/** Callbacks of a render filter; both receive the raw Java rendering objects. */ +export interface RenderFilterCallbacks { + /** + * Invoked before the resource is rendered; returning a non-null string short-circuits the chain + * with that output. + * + * `chain` is the raw Java `RenderChain`, typed as `unknown` because it has no generated typing. + */ + prepare?: ( + renderContext: RenderContext, + resource: Resource, + chain: unknown, + ) => string | null | undefined | Promise; + /** + * Invoked after the resource is rendered, with the output produced so far; returns the (possibly + * transformed) output. Returning null/undefined keeps the previous output. + * + * `chain` is the raw Java `RenderChain`, typed as `unknown` because it has no generated typing. + */ + execute?: ( + previousOutput: string, + renderContext: RenderContext, + resource: Resource, + chain: unknown, + ) => string | null | undefined | Promise; +} + +/** + * Registers a render filter, participating in Jahia's render chain like a Java `AbstractFilter`. + * + * ```ts + * registerRenderFilter( + * { key: "myModuleUppercaseTitles", priority: 50, applyOnNodeTypes: "mymodule:title" }, + * { execute: (previousOutput) => previousOutput.toUpperCase() }, + * ); + * ``` + * + * Filters run on every matching render — keep them fast. Callbacks may be `async` (microtask-only: + * the server runtime has no timers or async I/O) **only when the render is host-initiated**. A render + * started from JS — typically a nested render through the `` component — cannot drain the + * microtask queue, so filters that may match nested renders must use synchronous callbacks. + * + * Keys live in a single platform-wide registry namespace; prefix them with your module name to + * avoid collisions. + * + * @param declaration The filter declaration; `applyOn*` options restrict when the filter runs. + * @param callbacks The `prepare` and/or `execute` callbacks. + */ +export const registerRenderFilter = ( + { + key, + priority, + description, + applyOnNodeTypes, + applyOnModes, + applyOnConfigurations, + applyOnTemplates, + applyOnTemplateTypes, + }: RenderFilterDeclaration, + { prepare, execute }: RenderFilterCallbacks, +): void => { + server.registry.add("render-filter", key, { + ...(priority !== undefined && { priority }), + ...(description !== undefined && { description }), + ...(applyOnNodeTypes !== undefined && { applyOnNodeTypes: join(applyOnNodeTypes) }), + ...(applyOnModes !== undefined && { applyOnModes: join(applyOnModes) }), + ...(applyOnConfigurations !== undefined && { + applyOnConfigurations: join(applyOnConfigurations), + }), + ...(applyOnTemplates !== undefined && { applyOnTemplates: join(applyOnTemplates) }), + ...(applyOnTemplateTypes !== undefined && { applyOnTemplateTypes: join(applyOnTemplateTypes) }), + ...(prepare !== undefined && { prepare }), + ...(execute !== undefined && { execute }), + }); + console.debug(`Registered render filter: ${key}`); +}; + +const join = (value: string | string[]): string => (Array.isArray(value) ? value.join(",") : value); diff --git a/javascript-modules-library/src/globals.d.ts b/javascript-modules-library/src/globals.d.ts index 60ceade7..e9feba7d 100644 --- a/javascript-modules-library/src/globals.d.ts +++ b/javascript-modules-library/src/globals.d.ts @@ -24,7 +24,14 @@ declare global { osgi: OSGiHelper; /** * This helper provides access to Jahia's registry API, to register new UI objects or retrieving - * existing ones + * existing ones. + * + * Do not call `registry.add` directly for server extension points (views, actions, choicelist + * initializers, node validators, render filters): the entry shapes consumed by the engine are + * internal contracts that may change between versions. Use the typed registration functions of + * this library instead (`jahiaComponent`, `registerNodeValidator`, + * `registerChoiceListInitializer`, `registerRenderFilter`, `registerNodeLegacyAction`, and + * `.action.ts` files for actions). */ registry: RegistryHelper; /** diff --git a/javascript-modules-library/src/index.ts b/javascript-modules-library/src/index.ts index 276252f4..09d8c926 100644 --- a/javascript-modules-library/src/index.ts +++ b/javascript-modules-library/src/index.ts @@ -12,6 +12,31 @@ export { Area } from "./components/Area.js"; // Declaration and registration export { jahiaComponent } from "./framework/jahiaComponent.js"; +export { action, ActionError, ActionValidationError } from "./framework/actions/action.js"; +export { __registerActionsModule } from "./framework/actions/registerActionsModule.js"; +export type { StandardSchemaV1 } from "./framework/actions/standardSchema.js"; +export { + registerNodeLegacyAction, + type NodeLegacyActionDeclaration, + type NodeLegacyActionContext, + type NodeLegacyActionResult, +} from "./framework/registerNodeLegacyAction.js"; +export { + registerChoiceListInitializer, + type ChoiceListValue, + type ChoiceListInitializerContext, +} from "./framework/registerChoiceListInitializer.js"; +export { + registerNodeValidator, + type NodeValidatorDeclaration, + type NodeValidatorContext, + type NodeValidatorViolation, +} from "./framework/registerNodeValidator.js"; +export { + registerRenderFilter, + type RenderFilterDeclaration, + type RenderFilterCallbacks, +} from "./framework/registerRenderFilter.js"; // Hooks export { useGQLQuery } from "./hooks/useGQLQuery.js"; diff --git a/samples/hydrogen/settings/configurations/org.jahia.modules.jahiacsrfguard-hydrogen.cfg b/samples/hydrogen/settings/configurations/org.jahia.modules.jahiacsrfguard-hydrogen.cfg new file mode 100644 index 00000000..70f55d4c --- /dev/null +++ b/samples/hydrogen/settings/configurations/org.jahia.modules.jahiacsrfguard-hydrogen.cfg @@ -0,0 +1,3 @@ +# Whitelists the contact form action in Jahia's CSRF guard so browsers can POST to it +# without a CSRF token (see docs/2-guides/4-legacy-node-actions). +whitelist = *.hydrogenContact.do diff --git a/samples/hydrogen/src/components/ContactForm/default.server.tsx b/samples/hydrogen/src/components/ContactForm/default.server.tsx new file mode 100644 index 00000000..71ebc276 --- /dev/null +++ b/samples/hydrogen/src/components/ContactForm/default.server.tsx @@ -0,0 +1,26 @@ +import { buildNodeUrl, jahiaComponent } from "@jahia/javascript-modules-library"; + +jahiaComponent( + { + nodeType: "hydrogen:contactForm", + componentType: "view", + displayName: "Contact form", + }, + ({ title, style }: { title: string; style: string }, { currentNode }) => ( +

+

{title}

+ {/* posts to the hydrogenContact action declared in extensions.server.tsx */} +
+ +