@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 +6 -2
- package/package.json +5 -5
- package/src/client/executor.ts +6 -1
- package/src/client/manager.ts +5 -2
- package/src/core/core.ts +64 -0
- package/src/server/server.ts +19 -5
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
|
|
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.
|
|
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.
|
|
19
|
-
"@mosetta/
|
|
20
|
-
"@mosetta/router": "0.0.
|
|
21
|
-
"@mosetta/
|
|
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"
|
package/src/client/executor.ts
CHANGED
|
@@ -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:
|
|
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
|
|
package/src/client/manager.ts
CHANGED
|
@@ -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
|
|
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, ... } });
|
package/src/server/server.ts
CHANGED
|
@@ -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';
|