@aircloudhq/web 1.0.0 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # @aircloudhq/web
2
+
3
+ The application package of an Air-Cloud product: what a product's site and its JavaScript functions
4
+ import to render pages, format values and speak the product's languages. It brings the Rails Action View
5
+ helpers to the browser and to functions, one module per Rails helper module, under the names a Rails
6
+ developer already knows (`numberToCurrency`, `timeAgoInWords`, `t`, `l`, and the `LinkTo` and
7
+ `FormWith` components).
8
+
9
+ - **Pages from your models.** `resourcePage("pet")` renders list, show, create, edit and delete for an
10
+ entity from the resource projection the platform derives from `db/schema/pet.toml`. Adding a field to
11
+ the schema changes the page with no edit to the view.
12
+ - **I18n.** Translations come from the locale bundle the platform compiles from `config/locales/`, with
13
+ Rails' lazy lookup: `t(".title")` in `pages/home/index.jsx` reads `home.index.title`. Plurals, dates,
14
+ numbers and currencies follow CLDR for every locale the product declares.
15
+ - **The same helpers in functions.** `@aircloudhq/web/function` gives a function the caller's locale,
16
+ so its error messages answer in the language of the request.
17
+ - **React bindings.** `@aircloudhq/web/react` carries the router, layouts, links and form components.
18
+
19
+ A product created with `aircloud new` already depends on this package, at the version the platform
20
+ builds and tests with. You rarely add it by hand.
21
+
22
+ ## Install
23
+
24
+ ```sh
25
+ npm install @aircloudhq/web
26
+ ```
27
+
28
+ It needs the Node.js release its `engines` field names (the platform's pinned line), and React 19.2.7 or later for
29
+ the React bindings.
30
+
31
+ ## Usage
32
+
33
+ The site entry point `aircloud new` generates (`app/site/src/main.jsx`) renders the product's routes
34
+ inside its layouts, with the locale bundle and the resource projection:
35
+
36
+ ```jsx
37
+ import { StrictMode } from "react";
38
+ import { createRoot } from "react-dom/client";
39
+ import { I18nProvider, SiteRouter } from "@aircloudhq/web/react";
40
+ import bundle from "../.aircloud/locale-bundle.json";
41
+ import projection from "../.aircloud/resource-projection.json";
42
+ import routes from "./routes.js";
43
+
44
+ // Every src/layouts/<name>.jsx, by name (`application` is the default layout).
45
+ const layouts = Object.fromEntries(
46
+ Object.entries(import.meta.glob("./layouts/*.jsx", { eager: true })).map(([file, module]) => [
47
+ file.slice(file.lastIndexOf("/") + 1, -4),
48
+ module.default,
49
+ ]),
50
+ );
51
+
52
+ createRoot(document.getElementById("root")).render(
53
+ <StrictMode>
54
+ <I18nProvider bundle={bundle}>
55
+ <SiteRouter routes={routes} layouts={layouts} projection={projection} />
56
+ </I18nProvider>
57
+ </StrictMode>,
58
+ );
59
+ ```
60
+
61
+ A page reads its strings by lazy lookup (`app/site/src/pages/home/index.jsx`):
62
+
63
+ ```jsx
64
+ import { useT } from "@aircloudhq/web/react";
65
+
66
+ export default function HomePage() {
67
+ const t = useT();
68
+ return (
69
+ <section>
70
+ <h1>{t(".title")}</h1>
71
+ <p>{t(".intro")}</p>
72
+ </section>
73
+ );
74
+ }
75
+ ```
76
+
77
+ The resource page `aircloud generate scaffold pet` writes is one line (`app/site/src/pages/pet/index.jsx`):
78
+
79
+ ```jsx
80
+ import { resourcePage } from "@aircloudhq/web/react";
81
+
82
+ export default resourcePage("pet");
83
+ ```
84
+
85
+ A function answers in the caller's locale (`app/functions/pet/index.js`):
86
+
87
+ ```js
88
+ import { i18nFor } from "@aircloudhq/web/function";
89
+
90
+ async function methodNotAllowed(request) {
91
+ // functions.pet.errors.method_not_allowed, in the locale the request negotiates
92
+ const i18n = await i18nFor(request, { function: "pet" });
93
+ return Response.json({ error: { code: "method_not_allowed", message: i18n.t(".errors.method_not_allowed", { method: request.method }) } }, { status: 405 });
94
+ }
95
+ ```
96
+
97
+ ## Modules
98
+
99
+ | Import | What it carries |
100
+ |---|---|
101
+ | `@aircloudhq/web/react` | `SiteRouter`, `I18nProvider`, `useT`, `useSite`, `LinkTo`, `FormWith`, `resourcePage` |
102
+ | `@aircloudhq/web/helpers/number` | `numberToCurrency`, `numberToPercentage`, `numberWithDelimiter`, … |
103
+ | `@aircloudhq/web/helpers/date` | `timeAgoInWords`, `distanceOfTimeInWords`, `distanceOfTimeInWordsToNow` |
104
+ | `@aircloudhq/web/helpers/text`, `…/tag`, `…/url`, `…/form`, `…/sanitize`, `…/navigation` | the Action View helper modules of the same name |
105
+ | `@aircloudhq/web/helpers/translation` | `t` and `l` |
106
+ | `@aircloudhq/web/i18n` | `createI18n`, the runtime of the locale bundle |
107
+ | `@aircloudhq/web/model` | `modelName` and the model naming helpers |
108
+ | `@aircloudhq/web/function` | `i18nFor`, for functions |
109
+
110
+ ## Documentation
111
+
112
+ The full Rails-to-Air-Cloud map — every Action View helper and where it lives here — is the
113
+ *Rails to Air-Cloud* reference in the Air-Cloud documentation. Its tests in a product run with
114
+ `@aircloudhq/testing`. Questions and bug reports go to this package's issue tracker (the `bugs` link
115
+ on its npm page).
116
+
117
+ ## License
118
+
119
+ MIT
package/package.json CHANGED
@@ -1,14 +1,36 @@
1
1
  {
2
2
  "name": "@aircloudhq/web",
3
- "version": "1.0.0",
4
- "description": "The Air-Cloud site application package (ADR-222): the Rails Action View helpers, the i18n runtime of the locale bundle (ADR-223), resource pages over the resource projection, and React bindings.",
3
+ "version": "1.0.4",
4
+ "description": "The application package of an Air-Cloud product: the Rails Action View helpers, i18n over the product's locale bundle, resource pages over its models, and React bindings, for its site and its JavaScript functions.",
5
+ "keywords": [
6
+ "aircloud",
7
+ "rails",
8
+ "action-view",
9
+ "helpers",
10
+ "i18n",
11
+ "react",
12
+ "site",
13
+ "functions"
14
+ ],
15
+ "homepage": "https://github.com/aircloudhq/packages/tree/main/aircloud-web#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/aircloudhq/packages/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/aircloudhq/packages.git",
22
+ "directory": "aircloud-web"
23
+ },
24
+ "author": "Air-Cloud",
5
25
  "license": "MIT",
6
26
  "type": "module",
7
27
  "engines": {
8
- "node": ">=22.22.0"
28
+ "node": "^24.21.0"
9
29
  },
10
30
  "exports": {
11
31
  ".": "./src/index.js",
32
+ "./principal": "./src/principal.js",
33
+ "./assertion": "./src/assertion.js",
12
34
  "./helpers/number": "./src/helpers/number.js",
13
35
  "./helpers/date": "./src/helpers/date.js",
14
36
  "./helpers/text": "./src/helpers/text.js",
@@ -27,7 +49,9 @@
27
49
  "./package.json": "./package.json"
28
50
  },
29
51
  "files": [
30
- "src/"
52
+ "src/",
53
+ "README.md",
54
+ "LICENSE"
31
55
  ],
32
56
  "sideEffects": false,
33
57
  "dependencies": {
@@ -0,0 +1,114 @@
1
+ // @aircloudhq/web/assertion — a CONTAINER's verification of the platform principal assertion (#632
2
+ // ADR-241 decision 4). The web-origin holds the end-user session; an authenticated request reaches the
3
+ // Builder's container with `x-air-principal`, an ES256 JWS the platform signed under its dedicated
4
+ // `web-origin-principal` key, and never the session cookie or a token. The container verifies it here
5
+ // with the same semantics as the functions-host route trigger (functions/src/caps_principal.rs):
6
+ //
7
+ // import { verifyPrincipal } from '@aircloudhq/web/assertion';
8
+ // const me = await verifyPrincipal(request); // throws on anything but a valid assertion
9
+ //
10
+ // Checked: alg ES256 under a key of the principal key set (`jwksFile` — default AIR_PRINCIPAL_JWKS_FILE,
11
+ // the file the platform projects into every container from the key set its control plane reconciles into
12
+ // the tenant namespace; a container never fetches it, so it needs no path to a platform API), the issuer,
13
+ // `aud` = the product host the request names, `tenant_id` = this product's tenant (default
14
+ // AIR_TENANT_ID), a lifetime of at most 60 s that has not ended, and a well-formed principal. A rotation
15
+ // rewrites the file: an assertion naming a kid the cached set lacks re-reads it once. Fail-closed: a
16
+ // missing setting, an absent or malformed key set or any mismatch throws.
17
+
18
+ import { readFile } from 'node:fs/promises';
19
+
20
+ export const PRINCIPAL_HEADER = 'x-air-principal';
21
+ export const PRINCIPAL_ISSUER = 'aircloud-web-origin-principal';
22
+ /** == functions/src/caps_principal.rs MAX_ASSERTION_LIFETIME_SECS / ASSERTION_CLOCK_SKEW_SECS. */
23
+ const MAX_LIFETIME_SECS = 60;
24
+ const CLOCK_SKEW_SECS = 5;
25
+ const JWKS_MAX_AGE_MS = 300_000;
26
+
27
+ const cache = new Map(); // jwksFile → { keys, at }
28
+
29
+ const env = (k) => (typeof process !== 'undefined' && process.env ? process.env[k] : undefined);
30
+
31
+ function b64uDecode(s) {
32
+ return Uint8Array.from(Buffer.from(s, 'base64url'));
33
+ }
34
+
35
+ function parse(token) {
36
+ const parts = typeof token === 'string' ? token.split('.') : [];
37
+ if (parts.length !== 3) throw new Error('the principal assertion is not a compact JWS');
38
+ const [h, p, s] = parts;
39
+ const json = (seg) => JSON.parse(new TextDecoder().decode(b64uDecode(seg)));
40
+ return { header: json(h), claims: json(p), input: new TextEncoder().encode(`${h}.${p}`), sig: b64uDecode(s) };
41
+ }
42
+
43
+ async function readKeySet(file, now) {
44
+ const doc = JSON.parse(await readFile(file, 'utf8'));
45
+ if (!doc || !Array.isArray(doc.keys)) throw new Error(`the principal key set ${file} is not a JWK set`);
46
+ cache.set(file, { keys: doc.keys, at: now });
47
+ return doc.keys;
48
+ }
49
+
50
+ /** The key `kid` of the set at `file`: cached, re-read when stale or when the cached set lacks the kid. */
51
+ async function keyFor(file, kid, now) {
52
+ const hit = cache.get(file);
53
+ const fresh = hit && now - hit.at < JWKS_MAX_AGE_MS;
54
+ let found = fresh ? hit.keys.find((k) => k.kid === kid) : undefined;
55
+ if (!found) found = (await readKeySet(file, now)).find((k) => k.kid === kid);
56
+ if (!found) throw new Error(`no principal key '${kid}' in ${file}`);
57
+ return found;
58
+ }
59
+
60
+ const text = (v, what) => {
61
+ if (v === undefined || v === null) return null;
62
+ if (typeof v !== 'string') throw new Error(`the assertion's ${what} is not text`);
63
+ return v;
64
+ };
65
+
66
+ /**
67
+ * Verify the platform principal assertion of `request` (a Fetch API Request, or headers) and return the
68
+ * end user it asserts.
69
+ * @param {Request|{headers: Headers}} request
70
+ * @param {{jwksFile?: string, tenantId?: string, issuer?: string, host?: string, now?: () => number}} [options]
71
+ * @returns {Promise<{sub: string, tenantId: string, orgPath: string, roles: string[], act: object|null, flash: string|null, csrf: string|null}>}
72
+ */
73
+ export async function verifyPrincipal(request, options = {}) {
74
+ const jwksFile = options.jwksFile ?? env('AIR_PRINCIPAL_JWKS_FILE');
75
+ const tenantId = options.tenantId ?? env('AIR_TENANT_ID');
76
+ const issuer = options.issuer ?? PRINCIPAL_ISSUER;
77
+ const now = (options.now ?? (() => Math.floor(Date.now() / 1000)))();
78
+ if (!jwksFile) throw new Error('verifyPrincipal: no key-set file (AIR_PRINCIPAL_JWKS_FILE) — refusing (fail-closed)');
79
+ if (!tenantId) throw new Error('verifyPrincipal: no tenant (AIR_TENANT_ID) — refusing (fail-closed)');
80
+ const headers = request.headers;
81
+ const host = (options.host ?? headers.get('x-forwarded-host') ?? headers.get('host') ?? '').toLowerCase();
82
+ const token = headers.get(PRINCIPAL_HEADER);
83
+ if (!token) throw new Error('the request carries no principal assertion');
84
+
85
+ const { header, claims: c, input, sig } = parse(token);
86
+ if (header.alg !== 'ES256' || typeof header.kid !== 'string') throw new Error('the assertion is not ES256 with a kid');
87
+ const jwk = await keyFor(jwksFile, header.kid, now * 1000);
88
+ const { kid, alg, use, key_ops: _ops, ...material } = jwk;
89
+ const pub = await crypto.subtle.importKey('jwk', material, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
90
+ const ok = await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, pub, sig, input);
91
+ if (!ok) throw new Error('the assertion does not verify');
92
+
93
+ if (c.iss !== issuer) throw new Error('the assertion names another issuer');
94
+ if (!host || c.aud !== host) throw new Error('the assertion is for another product host');
95
+ if (!Number.isInteger(c.iat) || !Number.isInteger(c.exp) || c.exp <= c.iat || c.exp - c.iat > MAX_LIFETIME_SECS) {
96
+ throw new Error('the assertion lifetime is out of bounds');
97
+ }
98
+ if (now >= c.exp) throw new Error('the assertion has expired');
99
+ if (Math.max(c.iat, c.nbf ?? 0) > now + CLOCK_SKEW_SECS) throw new Error('the assertion is not valid yet');
100
+ if (c.tenant_id !== tenantId) throw new Error('the assertion is for another tenant');
101
+ if (typeof c.org_path !== 'string' || !c.org_path.startsWith('/')) throw new Error('the assertion carries no org_path');
102
+ if (typeof c.sub !== 'string' || c.sub === '') throw new Error('the assertion carries no sub');
103
+ const roles = c.roles ?? [];
104
+ if (!Array.isArray(roles) || roles.some((r) => typeof r !== 'string')) throw new Error("the assertion's roles are not strings");
105
+ return {
106
+ sub: c.sub,
107
+ tenantId: c.tenant_id,
108
+ orgPath: c.org_path,
109
+ roles,
110
+ act: c.act ?? null,
111
+ flash: text(c.flash, 'flash'),
112
+ csrf: text(c.csrf, 'csrf'),
113
+ };
114
+ }
@@ -0,0 +1,58 @@
1
+ // @aircloudhq/web/principal — the end user of a `session` route, its flash and its CSRF proof, for a
2
+ // JavaScript function (#632 ADR-241 decision 4, plan W15.4/W15.7; the Rails `current_user`-shaped
3
+ // principal, `flash` and `csrf_meta_tags`, ADR-221 D4).
4
+ //
5
+ // The web-origin holds the end-user session; the function never sees the session cookie or a token.
6
+ // The functions host hands the guest what the platform asserted, through the
7
+ // `aircloud:functions/principal` interface, and this module reads it:
8
+ //
9
+ // import { principal, flash, setFlash, csrfMeta } from '@aircloudhq/web/principal';
10
+ // const me = principal(); // null on a public route
11
+ // const { notice } = flash(); // the message the previous request set (read once)
12
+ // setFlash({ notice: 'Saved' }); // the next request's message
13
+ // `<head>${csrfMeta()}</head>` // the token a form or fetch sends as X-CSRF-Token
14
+ //
15
+ // The CSRF check is the session edge's (the web-origin): an unsafe request is accepted when its
16
+ // `Origin` is the product's, and a client that sends no `Origin` proves it with the session's token.
17
+ import { csrfToken as wireCsrf, current, flash as wireFlash, setFlash as wireSetFlash } from 'aircloud:functions/principal@1.0.0';
18
+
19
+ const escapeAttr = (s) =>
20
+ String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
21
+
22
+ /**
23
+ * The end user this invocation serves, or null outside a `session` route.
24
+ * @returns {{sub: string, tenantId: string, orgPath: string, roles: string[], act: object|null}|null}
25
+ */
26
+ export function principal() {
27
+ const p = current();
28
+ if (!p) return null;
29
+ return { sub: p.sub, tenantId: p.tenantId, orgPath: p.orgPath, roles: [...p.roles], act: p.act ? JSON.parse(p.act) : null };
30
+ }
31
+
32
+ /** The flash the previous request of this session set (`{}` when none) — the platform clears it. */
33
+ export function flash() {
34
+ const f = wireFlash();
35
+ if (!f) return {};
36
+ try {
37
+ return JSON.parse(f);
38
+ } catch {
39
+ return { notice: f }; // a flash a container set as plain text
40
+ }
41
+ }
42
+
43
+ /** Set the flash the next request of this session reads (an object, stored as JSON). */
44
+ export function setFlash(message) {
45
+ wireSetFlash(JSON.stringify(message));
46
+ }
47
+
48
+ /** The session's CSRF token, or null outside a `session` route. */
49
+ export function csrfToken() {
50
+ return wireCsrf() ?? null;
51
+ }
52
+
53
+ /** The Rails `csrf_meta_tags` pair carrying the session's token; empty outside a session. */
54
+ export function csrfMeta() {
55
+ const t = csrfToken();
56
+ if (!t) return '';
57
+ return `<meta name="csrf-param" content="authenticity_token" />\n<meta name="csrf-token" content="${escapeAttr(t)}" />`;
58
+ }