@mosetta/server-action 0.0.12 → 0.0.13

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 CHANGED
@@ -35,13 +35,17 @@ export default defineServerSide({ services: [GreetAction] });
35
35
  The component side calls it with `useServerAction` from `@mosetta/react/server-action`; during
36
36
  server rendering the same call runs on the server, so the page arrives with the data in it.
37
37
 
38
+ Beside the input, an action gets the block it is called from — `block: { page, node, reference,
39
+ settings, draft }`, or `null`. The browser only says which block on which page calls; the host reads
40
+ that block from the page itself, so its settings are the site author's, never the caller's.
41
+
38
42
  ## Entry points
39
43
 
40
44
  | Entry | What it holds |
41
45
  |---|---|
42
- | `@mosetta/server-action/server` | `defineServerSide`, `ServerAction`, `DiServerAction`, `DiInject`; what an action may ask for: the caller, the request, the project's secrets by name (`SECRETS_TOKEN`), its stores (`PROVIDERS_TOKEN`), the site's tile hosts (`TILE_HOSTS_TOKEN`) |
46
+ | `@mosetta/server-action/server` | `defineServerSide`, `ServerAction`, `DiServerAction`, `DiInject`; what an action may ask for: the caller, the calling block (`BLOCK_TOKEN`), the request, the project's secrets by name (`SECRETS_TOKEN`), its stores (`PROVIDERS_TOKEN`), the site's tile hosts (`TILE_HOSTS_TOKEN`) |
43
47
  | `@mosetta/server-action/client` | the browser's call of an action |
44
- | `@mosetta/server-action/core` | `ServerActionToken` (shared by the server and the component side), serialization, the caller as the host vouches for it |
48
+ | `@mosetta/server-action/core` | `ServerActionToken` (shared by the server and the component side), serialization, the caller and the calling block as the host vouches for them |
45
49
 
46
50
  An action runs in an isolated line of the factory: its privileges are handed in, never assumed —
47
51
  no store key and no secret value sits in its environment.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mosetta/server-action",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "license": "MIT",
5
5
  "description": "mosetta server actions (RPC): shared token/serializer contract, client hook, server-side action definition. Web-API native (workerd).",
6
6
  "type": "module",
@@ -15,10 +15,10 @@
15
15
  "dependencies": {
16
16
  "react": "*",
17
17
  "zod": "^3",
18
- "@mosetta/di": "0.0.12",
19
- "@mosetta/runtime": "0.0.12",
20
- "@mosetta/router": "0.0.12",
21
- "@mosetta/stores": "0.0.12"
18
+ "@mosetta/di": "0.0.13",
19
+ "@mosetta/stores": "0.0.13",
20
+ "@mosetta/router": "0.0.13",
21
+ "@mosetta/runtime": "0.0.13"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/react": "^19"
@@ -4,6 +4,7 @@
4
4
  // it is not a server outcome — the manager initiated it and discards the whole try.
5
5
  import { DiInject, DiService } from '@mosetta/di';
6
6
  import {
7
+ BLOCK_ORIGIN_HEADER,
7
8
  CLIENT_SERIALIZER_TOKEN,
8
9
  ClientSerializer,
9
10
  IExecuteServerActionOptions,
@@ -43,7 +44,11 @@ export class FetchServerActionExecutor extends ServerActionExecutor {
43
44
  const response = await this.fetchImpl(this.route(request.fullId, request.actionName), {
44
45
  method: 'POST',
45
46
  body: body.content,
46
- headers: body.contentType ? { 'content-type': body.contentType } : {},
47
+ headers: {
48
+ ...(body.contentType ? { 'content-type': body.contentType } : {}),
49
+ // where the call comes from: the host reads the block's settings there itself
50
+ ...(request.origin ? { [BLOCK_ORIGIN_HEADER]: request.origin } : {}),
51
+ },
47
52
  signal: options.signal,
48
53
  });
49
54
 
@@ -38,6 +38,8 @@ export interface ICreateManualOptions {
38
38
  readonly actionName: string;
39
39
  readonly invalidates: readonly string[];
40
40
  readonly customSerializer?: ClientSerializer;
41
+ /** where each call comes from (blockOrigin), read when it starts — a mounted block can change pages */
42
+ readonly originOf?: () => string | null;
41
43
  }
42
44
 
43
45
  /** the hook-facing subscription surface — same shape for query entries and manual handles */
@@ -192,7 +194,7 @@ export class ServerDataManager {
192
194
 
193
195
  /** a per-hook mutation handle (START_MANUAL): never cached, never aborted (the write is already
194
196
  * on the server; killing the .then would silently lose `invalidate`), input arrives at start(input) */
195
- createManual<TIn, TOut>({ fullId, actionName, invalidates, customSerializer }: ICreateManualOptions): IServerDataHandle<TOut, TIn> {
197
+ createManual<TIn, TOut>({ fullId, actionName, invalidates, customSerializer, originOf }: ICreateManualOptions): IServerDataHandle<TOut, TIn> {
196
198
  const listeners = new Set<() => void>();
197
199
  const notify = () => {
198
200
  for (const listener of listeners) listener();
@@ -200,7 +202,8 @@ export class ServerDataManager {
200
202
 
201
203
  let current: ServerData<TOut, TIn>;
202
204
  const start: IManualStart<TIn> = (input) => {
203
- const request: IServerActionRequest = { fullId, actionName, input };
205
+ const origin = originOf?.() ?? null;
206
+ const request: IServerActionRequest = { fullId, actionName, input, ...(origin ? { origin } : {}) };
204
207
  const promise = this.executor
205
208
  .executeServerAction<TOut>({ request, requestHash: `${fullId}/${actionName}#manual`, customSerializer })
206
209
  .then((result) => {
package/src/core/core.ts CHANGED
@@ -49,6 +49,68 @@ export function parseCaller(header: string | null | undefined): ICaller | null {
49
49
 
50
50
  export const CALLER_TOKEN = new SingleDiToken<ICaller | null>('CALLER_TOKEN');
51
51
 
52
+ // THE CALLING BLOCK. A block's action may trust what the block is set to on its page — the
53
+ // working hours a booking form offers, the address a contact form writes to — only when the HOST
54
+ // read it from the published page, never when the browser sent it. The browser says WHERE the call
55
+ // comes from (BLOCK_ORIGIN_HEADER: `page=<name>&node=<id>`, `preview=<token>&node=<id>` in a draft's
56
+ // preview); the host finds that node on the page as published — the page's live revision, or the
57
+ // draft for a caller the host lets read drafts (DRAFT_ACCESS_HEADER) — and hands the action what it
58
+ // found (BLOCK_HEADER, stripped from every incoming request like the identity). A call from no page,
59
+ // or from a node the host cannot find, has no block.
60
+ export const BLOCK_ORIGIN_HEADER = 'x-mosetta-block-origin';
61
+ export const BLOCK_HEADER = 'x-mosetta-block';
62
+ // the host's word that this call may read the site's drafts (its editors may): set by the host,
63
+ // stripped from every incoming request
64
+ export const DRAFT_ACCESS_HEADER = 'x-mosetta-draft-access';
65
+
66
+ /** the block an action was called from, as the host read it from the page */
67
+ export interface IBlock {
68
+ /** the page's name (a draft's page for a draft) */
69
+ readonly page: string;
70
+ /** the node's id on the page (`<prefab node>/<inner>` inside a prefab) */
71
+ readonly node: string;
72
+ /** the node's tile — a tile of the action's own palette, not always the action's own tile */
73
+ readonly reference: string;
74
+ /** the node's settings as stored: translation references stay references */
75
+ readonly settings: Readonly<Record<string, unknown>>;
76
+ /** read from a draft (an editor's preview), not from the page as published */
77
+ readonly draft: boolean;
78
+ }
79
+
80
+ export const BLOCK_TOKEN = new SingleDiToken<IBlock | null>('BLOCK_TOKEN');
81
+
82
+ /** where a call comes from, as the browser says it: the page's origin and the node */
83
+ export function blockOrigin(pageOrigin: string | null | undefined, node: string | null | undefined): string | null {
84
+ return pageOrigin && node ? `${pageOrigin}&${new URLSearchParams({ node }).toString()}` : null;
85
+ }
86
+
87
+ const toBase64Url = (text: string): string => {
88
+ let binary = '';
89
+ for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);
90
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
91
+ };
92
+
93
+ const fromBase64Url = (value: string): string => {
94
+ const binary = atob(value.replace(/-/g, '+').replace(/_/g, '/'));
95
+ return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
96
+ };
97
+
98
+ /** the block as its header carries it: JSON, base64url — settings hold any language's words */
99
+ export const encodeBlock = (block: IBlock): string => toBase64Url(JSON.stringify(block));
100
+
101
+ /** the header's block, or null: absent or not one */
102
+ export function parseBlock(header: string | null | undefined): IBlock | null {
103
+ if (!header) return null;
104
+ try {
105
+ const raw = JSON.parse(fromBase64Url(header)) as Partial<IBlock> | null;
106
+ if (!raw || typeof raw.page !== 'string' || typeof raw.node !== 'string' || typeof raw.reference !== 'string') return null;
107
+ const settings = raw.settings && typeof raw.settings === 'object' && !Array.isArray(raw.settings) ? raw.settings : {};
108
+ return { page: raw.page, node: raw.node, reference: raw.reference, settings, draft: raw.draft === true };
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
52
114
  // ---- canonical naming ----
53
115
  // MUST agree with cli/runtime/naming.js (the build-side single source of truth for artifact ids) —
54
116
  // the CLI runtime is plain dependency-free JS bundled into bootstrap, so the encoder is duplicated
@@ -70,6 +132,8 @@ export interface IServerActionRequest {
70
132
  readonly fullId: string;
71
133
  readonly actionName: string;
72
134
  readonly input: unknown;
135
+ /** where the call comes from — the page and the node (blockOrigin) — absent outside a page */
136
+ readonly origin?: string | null;
73
137
  }
74
138
 
75
139
  // The error shape ON THE WIRE — exactly what the isolate glue returns ({ error: { code, ... } });
@@ -3,14 +3,17 @@ import { parseStoreConnectors } from '@mosetta/stores';
3
3
  import type { IStoreConnector } from '@mosetta/stores';
4
4
  import { z, ZodType } from 'zod';
5
5
  import {
6
+ BLOCK_HEADER,
7
+ BLOCK_TOKEN,
6
8
  CALLER_TOKEN,
7
9
  IDENTITY_HEADER,
8
10
  JsonServerSerializer,
11
+ parseBlock,
9
12
  parseCaller,
10
13
  ServerActionToken,
11
14
  ServerSerializer,
12
15
  } from '../core/core';
13
- import type { ICaller } from '../core/core';
16
+ import type { IBlock, ICaller } from '../core/core';
14
17
 
15
18
  // re-export the DI essentials a coder needs — so a server module imports everything from
16
19
  // `@mosetta/server-action/server` (not the legacy @mosetta/di directly).
@@ -86,6 +89,12 @@ export interface ICreateSchema {
86
89
 
87
90
  export interface IServerActionPayload<TIn> {
88
91
  input: TIn;
92
+ /**
93
+ * the block the call came from, as the HOST read it from the page — its settings to trust (the
94
+ * hours a booking form offers), where the input is only what the browser sent; null — the call
95
+ * came from no page, or the host found no such block
96
+ */
97
+ block: IBlock | null;
89
98
  }
90
99
 
91
100
  /** what an action's `authorize` decides by */
@@ -96,6 +105,8 @@ export interface IServerActionAccess<TIn> {
96
105
  caller: ICaller | null;
97
106
  /** the request the action answers (its body already read into `input`) */
98
107
  request: Request;
108
+ /** the block the call came from, as the host read it from the page — or null */
109
+ block: IBlock | null;
99
110
  }
100
111
 
101
112
  export abstract class ServerAction<TIn, TOut> {
@@ -190,6 +201,9 @@ export function defineServerSide({ services }: IServerSideDefinition): IServerSi
190
201
  // the caller: what the trust boundary vouches for, in its words (null — nobody)
191
202
  const caller = parseCaller(request.headers.get(IDENTITY_HEADER));
192
203
  di.registerValueOfToken(CALLER_TOKEN, caller);
204
+ // the calling block: what the host read from the page (null — none it could vouch for)
205
+ const block = parseBlock(request.headers.get(BLOCK_HEADER));
206
+ di.registerValueOfToken(BLOCK_TOKEN, block);
193
207
  di.registerValueOfToken(SECRETS_TOKEN, secretsOf(env?.SECRETS));
194
208
  di.registerValueOfToken(REQUEST_TOKEN, request);
195
209
 
@@ -209,14 +223,14 @@ export function defineServerSide({ services }: IServerSideDefinition): IServerSi
209
223
  }
210
224
 
211
225
  // the action decides who may call it — before it runs, by the valid input and the caller
212
- if (!(await action.authorize({ input: validation.data as unknown, caller, request }))) {
226
+ if (!(await action.authorize({ input: validation.data as unknown, caller, request, block }))) {
213
227
  return caller
214
228
  ? json({ error: { code: 'SERVER_ACTION_FORBIDDEN', actionName } }, 403)
215
229
  : json({ error: { code: 'SERVER_ACTION_UNAUTHORIZED', actionName } }, 401);
216
230
  }
217
231
 
218
232
  // the action's TOut is erased at this generic call site — pin to unknown for the serializer
219
- const output = (await action.action({ input: validation.data as unknown })) as unknown;
233
+ const output = (await action.action({ input: validation.data as unknown, block })) as unknown;
220
234
  const serialized = await serializer.serializeIntoString(output as object);
221
235
  return new Response(serialized.content, {
222
236
  status: 200,
@@ -232,5 +246,5 @@ export function defineServerSide({ services }: IServerSideDefinition): IServerSi
232
246
  };
233
247
  }
234
248
 
235
- export { CALLER_TOKEN, IDENTITY_HEADER, parseCaller } from '../core/core';
236
- export type { ICaller } from '../core/core';
249
+ export { BLOCK_TOKEN, CALLER_TOKEN, IDENTITY_HEADER, parseCaller } from '../core/core';
250
+ export type { IBlock, ICaller } from '../core/core';