@cosmicdrift/kumiko-dispatcher-live 0.361.0 → 0.363.0

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/dist/csrf.js CHANGED
@@ -14,6 +14,7 @@
14
14
  // cannot READ `document.cookie` of our origin — so they can't populate the
15
15
  // header with the matching value.
16
16
  import { CSRF_COOKIE_BASE, sessionCookieNameCandidates, } from "@cosmicdrift/kumiko-types/session-cookies";
17
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
17
18
  // Kept here as a literal rather than imported from @cosmicdrift/kumiko-framework because this
18
19
  // package must remain server-dep-free (runs in browsers and React Native).
19
20
  // The framework's csrf-constants-sync test guards against drift.
@@ -54,6 +55,6 @@ function readDocumentCookie() {
54
55
  // Guard for non-browser environments. Avoid `typeof document` on the
55
56
  // left so a bundler that const-folds to "document is defined" still
56
57
  // compiles — the actual runtime check is what matters.
57
- const g = globalThis;
58
- return g.document?.cookie;
58
+ const cookie = propertyOf(propertyOf(globalThis, "document"), "cookie");
59
+ return typeof cookie === "string" ? cookie : undefined;
59
60
  }
@@ -1,5 +1,7 @@
1
1
  import { createStore, } from "@cosmicdrift/kumiko-headless";
2
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
2
3
  import { CSRF_HEADER_NAME, readCsrfToken } from "./csrf.js";
4
+ import { batchEnvelopeSchema, errorOnlyEnvelopeSchema, queryEnvelopeSchema, writeEnvelopeSchema, } from "./envelopes.js";
3
5
  import { buildAbortError, buildNetworkError, mapServerError } from "./error-mapping.js";
4
6
  import { LOCALE_HEADER_NAME, readActiveLocale } from "./locale.js";
5
7
  import { iterateSseChunks } from "./sse-stream.js";
@@ -153,9 +155,9 @@ export function createLiveDispatcher(options = {}) {
153
155
  catch (e) {
154
156
  throw buildNetworkError(`invalid stream response (${response.status}): ${e instanceof Error ? e.message : String(e)}`);
155
157
  }
156
- const body = parsed;
157
- if (body?.error) {
158
- throw mapServerErrorWithStatus(body.error, response.status);
158
+ const gateFailure = errorOnlyEnvelopeSchema.safeParse(parsed);
159
+ if (gateFailure.success) {
160
+ throw mapServerErrorWithStatus(gateFailure.data.error, response.status);
159
161
  }
160
162
  throw buildNetworkError(`unexpected non-SSE stream response (${response.status})`);
161
163
  }
@@ -191,34 +193,67 @@ const EMPTY_PENDING_FILES = Object.freeze([]);
191
193
  // Uniqueness only needs to hold per user within the server's dedup window —
192
194
  // this is an idempotency key, not a security token.
193
195
  function generateRequestId() {
194
- const c = globalThis.crypto;
195
- if (typeof c?.randomUUID === "function")
196
- return c.randomUUID();
196
+ const webCrypto = propertyOf(globalThis, "crypto");
197
+ const randomUUID = propertyOf(webCrypto, "randomUUID");
198
+ if (typeof randomUUID === "function") {
199
+ const id = Reflect.apply(randomUUID, webCrypto, []);
200
+ if (typeof id === "string")
201
+ return id;
202
+ }
197
203
  return `req-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
198
204
  }
205
+ function buildUnexpectedShapeError(status) {
206
+ return buildNetworkError(`unexpected response shape (${status})`);
207
+ }
208
+ // Middleware gates (csrf, origin, auth, rate limits) answer write and batch
209
+ // calls with a bare `{ error }`, without the isSuccess envelope.
210
+ function gateOrShapeError(body, status) {
211
+ const gateFailure = errorOnlyEnvelopeSchema.safeParse(body);
212
+ return gateFailure.success
213
+ ? mapServerErrorWithStatus(gateFailure.data.error, status)
214
+ : buildUnexpectedShapeError(status);
215
+ }
199
216
  // Write / Batch share the same envelope: `{ isSuccess: true, data }` on
200
217
  // success, `{ isSuccess: false, error: ServerErrorInfo, ... }` on failure.
201
218
  // Query uses a different envelope (see normalizeQueryResponse).
202
219
  function normalizeWriteResult(call) {
203
220
  if (!call.ok)
204
221
  return { isSuccess: false, error: call.networkFailure };
205
- const body = call.body;
222
+ const envelope = writeEnvelopeSchema.safeParse(call.body);
223
+ if (!envelope.success) {
224
+ return { isSuccess: false, error: gateOrShapeError(call.body, call.status) };
225
+ }
226
+ const body = envelope.data;
206
227
  if (body.isSuccess)
207
- return body;
228
+ return { isSuccess: true, data: body.data };
208
229
  return { isSuccess: false, error: mapServerErrorWithStatus(body.error, call.status) };
209
230
  }
210
231
  function normalizeBatchResponse(call) {
211
232
  if (!call.ok) {
212
233
  return { isSuccess: false, error: call.networkFailure, failedIndex: -1, results: [] };
213
234
  }
214
- const body = call.body;
235
+ const envelope = batchEnvelopeSchema.safeParse(call.body);
236
+ if (!envelope.success) {
237
+ return {
238
+ isSuccess: false,
239
+ error: gateOrShapeError(call.body, call.status),
240
+ failedIndex: -1,
241
+ results: [],
242
+ };
243
+ }
244
+ const body = envelope.data;
245
+ const results = body.results.map((item) => {
246
+ if (item.isSuccess)
247
+ return { isSuccess: true, data: item.data };
248
+ return { isSuccess: false, error: mapServerErrorWithStatus(item.error, call.status) };
249
+ });
215
250
  if (body.isSuccess)
216
- return body;
251
+ return { isSuccess: true, results };
217
252
  return {
218
253
  isSuccess: false,
219
254
  error: mapServerErrorWithStatus(body.error, call.status),
220
255
  failedIndex: body.failedIndex,
221
- results: body.results,
256
+ results,
222
257
  };
223
258
  }
224
259
  // Query envelope: Kumiko's /api/query returns `{ data: ... }` on success
@@ -228,11 +263,15 @@ function normalizeBatchResponse(call) {
228
263
  function normalizeQueryResponse(call) {
229
264
  if (!call.ok)
230
265
  return { isSuccess: false, error: call.networkFailure };
231
- const body = call.body;
232
- if (body && "error" in body && body.error) {
266
+ const envelope = queryEnvelopeSchema.safeParse(call.body);
267
+ if (!envelope.success) {
268
+ return { isSuccess: false, error: buildUnexpectedShapeError(call.status) };
269
+ }
270
+ const body = envelope.data;
271
+ if (body.error) {
233
272
  return { isSuccess: false, error: mapServerErrorWithStatus(body.error, call.status) };
234
273
  }
235
- return { isSuccess: true, data: body?.data };
274
+ return { isSuccess: true, data: body.data };
236
275
  }
237
276
  // The HTTP status carries the failure-status; the error body itself
238
277
  // doesn't repeat it (serializeError drops httpStatus to keep the wire
@@ -242,8 +281,5 @@ function mapServerErrorWithStatus(error, responseStatus) {
242
281
  return mapServerError({ ...error, httpStatus: error.httpStatus ?? responseStatus });
243
282
  }
244
283
  function isAbortError(e) {
245
- return (!!e &&
246
- typeof e === "object" &&
247
- "name" in e && // @cast-boundary error-details
248
- e.name === "AbortError");
284
+ return !!e && typeof e === "object" && "name" in e && e.name === "AbortError";
249
285
  }
@@ -0,0 +1,101 @@
1
+ import * as z from "zod";
2
+ export declare const serverErrorSchema: z.ZodObject<{
3
+ code: z.ZodString;
4
+ httpStatus: z.ZodOptional<z.ZodNumber>;
5
+ i18nKey: z.ZodDefault<z.ZodString>;
6
+ message: z.ZodString;
7
+ details: z.ZodOptional<z.ZodUnknown>;
8
+ i18nParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
9
+ docsUrl: z.ZodOptional<z.ZodString>;
10
+ requestId: z.ZodOptional<z.ZodString>;
11
+ }, z.core.$strip>;
12
+ export type ServerErrorLike = z.infer<typeof serverErrorSchema>;
13
+ export declare const writeEnvelopeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
14
+ isSuccess: z.ZodLiteral<true>;
15
+ data: z.ZodOptional<z.ZodUnknown>;
16
+ }, z.core.$strip>, z.ZodObject<{
17
+ isSuccess: z.ZodLiteral<false>;
18
+ error: z.ZodObject<{
19
+ code: z.ZodString;
20
+ httpStatus: z.ZodOptional<z.ZodNumber>;
21
+ i18nKey: z.ZodDefault<z.ZodString>;
22
+ message: z.ZodString;
23
+ details: z.ZodOptional<z.ZodUnknown>;
24
+ i18nParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
25
+ docsUrl: z.ZodOptional<z.ZodString>;
26
+ requestId: z.ZodOptional<z.ZodString>;
27
+ }, z.core.$strip>;
28
+ }, z.core.$strip>], "isSuccess">;
29
+ export declare const batchEnvelopeSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
30
+ isSuccess: z.ZodLiteral<true>;
31
+ results: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
32
+ isSuccess: z.ZodLiteral<true>;
33
+ data: z.ZodOptional<z.ZodUnknown>;
34
+ }, z.core.$strip>, z.ZodObject<{
35
+ isSuccess: z.ZodLiteral<false>;
36
+ error: z.ZodObject<{
37
+ code: z.ZodString;
38
+ httpStatus: z.ZodOptional<z.ZodNumber>;
39
+ i18nKey: z.ZodDefault<z.ZodString>;
40
+ message: z.ZodString;
41
+ details: z.ZodOptional<z.ZodUnknown>;
42
+ i18nParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
43
+ docsUrl: z.ZodOptional<z.ZodString>;
44
+ requestId: z.ZodOptional<z.ZodString>;
45
+ }, z.core.$strip>;
46
+ }, z.core.$strip>], "isSuccess">>;
47
+ }, z.core.$strip>, z.ZodObject<{
48
+ isSuccess: z.ZodLiteral<false>;
49
+ error: z.ZodObject<{
50
+ code: z.ZodString;
51
+ httpStatus: z.ZodOptional<z.ZodNumber>;
52
+ i18nKey: z.ZodDefault<z.ZodString>;
53
+ message: z.ZodString;
54
+ details: z.ZodOptional<z.ZodUnknown>;
55
+ i18nParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
56
+ docsUrl: z.ZodOptional<z.ZodString>;
57
+ requestId: z.ZodOptional<z.ZodString>;
58
+ }, z.core.$strip>;
59
+ failedIndex: z.ZodNumber;
60
+ results: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
61
+ isSuccess: z.ZodLiteral<true>;
62
+ data: z.ZodOptional<z.ZodUnknown>;
63
+ }, z.core.$strip>, z.ZodObject<{
64
+ isSuccess: z.ZodLiteral<false>;
65
+ error: z.ZodObject<{
66
+ code: z.ZodString;
67
+ httpStatus: z.ZodOptional<z.ZodNumber>;
68
+ i18nKey: z.ZodDefault<z.ZodString>;
69
+ message: z.ZodString;
70
+ details: z.ZodOptional<z.ZodUnknown>;
71
+ i18nParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
72
+ docsUrl: z.ZodOptional<z.ZodString>;
73
+ requestId: z.ZodOptional<z.ZodString>;
74
+ }, z.core.$strip>;
75
+ }, z.core.$strip>], "isSuccess">>;
76
+ }, z.core.$strip>], "isSuccess">;
77
+ export declare const queryEnvelopeSchema: z.ZodObject<{
78
+ data: z.ZodOptional<z.ZodUnknown>;
79
+ error: z.ZodOptional<z.ZodNullable<z.ZodObject<{
80
+ code: z.ZodString;
81
+ httpStatus: z.ZodOptional<z.ZodNumber>;
82
+ i18nKey: z.ZodDefault<z.ZodString>;
83
+ message: z.ZodString;
84
+ details: z.ZodOptional<z.ZodUnknown>;
85
+ i18nParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
86
+ docsUrl: z.ZodOptional<z.ZodString>;
87
+ requestId: z.ZodOptional<z.ZodString>;
88
+ }, z.core.$strip>>>;
89
+ }, z.core.$strip>;
90
+ export declare const errorOnlyEnvelopeSchema: z.ZodObject<{
91
+ error: z.ZodObject<{
92
+ code: z.ZodString;
93
+ httpStatus: z.ZodOptional<z.ZodNumber>;
94
+ i18nKey: z.ZodDefault<z.ZodString>;
95
+ message: z.ZodString;
96
+ details: z.ZodOptional<z.ZodUnknown>;
97
+ i18nParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
98
+ docsUrl: z.ZodOptional<z.ZodString>;
99
+ requestId: z.ZodOptional<z.ZodString>;
100
+ }, z.core.$strip>;
101
+ }, z.core.$strip>;
@@ -0,0 +1,35 @@
1
+ import * as z from "zod";
2
+ // Wire envelopes of /api/{write,query,batch,stream}. Only the envelope is
3
+ // validated; `data` payloads stay unknown and are typed by the caller.
4
+ export const serverErrorSchema = z.object({
5
+ code: z.string(),
6
+ httpStatus: z.number().optional(),
7
+ // Some gates (bad-request helper in server.ts, pii-leak-guard) send no
8
+ // i18nKey; keeping their `code` beats collapsing them into network_error.
9
+ i18nKey: z.string().default("errors.unknown"),
10
+ message: z.string(),
11
+ details: z.unknown().optional(),
12
+ i18nParams: z.record(z.string(), z.unknown()).optional(),
13
+ docsUrl: z.string().optional(),
14
+ requestId: z.string().optional(),
15
+ });
16
+ const writeItemSchema = z.discriminatedUnion("isSuccess", [
17
+ z.object({ isSuccess: z.literal(true), data: z.unknown().optional() }),
18
+ z.object({ isSuccess: z.literal(false), error: serverErrorSchema }),
19
+ ]);
20
+ export const writeEnvelopeSchema = writeItemSchema;
21
+ export const batchEnvelopeSchema = z.discriminatedUnion("isSuccess", [
22
+ z.object({ isSuccess: z.literal(true), results: z.array(writeItemSchema) }),
23
+ z.object({
24
+ isSuccess: z.literal(false),
25
+ error: serverErrorSchema,
26
+ failedIndex: z.number(),
27
+ results: z.array(writeItemSchema),
28
+ }),
29
+ ]);
30
+ // `error` is nullish: a null/absent error means success, as before.
31
+ export const queryEnvelopeSchema = z.object({
32
+ data: z.unknown().optional(),
33
+ error: serverErrorSchema.nullish(),
34
+ });
35
+ export const errorOnlyEnvelopeSchema = z.object({ error: serverErrorSchema });
@@ -1,3 +1,4 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
1
2
  // Narrow cast into the DispatcherError shape. Pass-through for everything
2
3
  // except `details.fields`, which we normalize: the server uses
3
4
  // ValidationFieldIssue (path/code/i18nKey/params), which is structurally
@@ -17,27 +18,25 @@ export function mapServerError(serverError) {
17
18
  };
18
19
  }
19
20
  function normalizeDetails(details) {
20
- if (!details || typeof details !== "object")
21
+ if (!isPlainObject(details))
21
22
  return undefined;
22
- const d = details; // @cast-boundary error-details — generic über alle DispatcherError-shapes
23
- const fields = d["fields"];
23
+ const { fields, ...otherDetails } = details;
24
24
  if (!Array.isArray(fields)) {
25
25
  // Details without fields still pass through — non-validation errors
26
26
  // (rate-limit, version-conflict, ...) carry their own structured
27
27
  // payload here.
28
- return d;
28
+ return otherDetails;
29
29
  }
30
30
  const mappedFields = [];
31
- for (const f of fields) {
32
- if (!f || typeof f !== "object") {
31
+ for (const r of fields) {
32
+ if (!isPlainObject(r)) {
33
33
  // Server sent a non-object entry in details.fields — contract
34
34
  // breach (ValidationFieldIssue shape is required). Warn so the
35
35
  // skip doesn't hide a broken server build.
36
36
  // biome-ignore lint/suspicious/noConsole: ops-visible warning when the server breaks the validation-error contract
37
- console.warn("[dispatcher-live] dropping malformed field issue (not an object):", f);
37
+ console.warn("[dispatcher-live] dropping malformed field issue (not an object):", r);
38
38
  continue;
39
39
  }
40
- const r = f; // @cast-boundary error-details
41
40
  if (typeof r["path"] !== "string" ||
42
41
  typeof r["code"] !== "string" ||
43
42
  typeof r["i18nKey"] !== "string") {
@@ -50,12 +49,10 @@ function normalizeDetails(details) {
50
49
  path: r["path"],
51
50
  code: r["code"],
52
51
  i18nKey: r["i18nKey"],
53
- ...(r["params"] !== undefined && {
54
- params: r["params"],
55
- }),
52
+ ...(isPlainObject(r["params"]) && { params: r["params"] }),
56
53
  });
57
54
  }
58
- return { ...d, fields: mappedFields };
55
+ return { ...otherDetails, fields: mappedFields };
59
56
  }
60
57
  // Builds a DispatcherError for a failure that never reached the server —
61
58
  // network dropped, DNS error, CORS block, fetch() threw. The UI should
package/dist/locale.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // LocaleResolver. This module reads that marker back so every outgoing
6
6
  // request carries the language the user is ACTUALLY using — not a static
7
7
  // <html lang> — with zero app-side wiring.
8
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
8
9
  // Kept in sync with LOCALE_HEADER_NAME in api-constants.ts by hand rather
9
10
  // than imported from @cosmicdrift/kumiko-framework — this package must
10
11
  // remain server-dep-free (runs in browsers and React Native).
@@ -16,9 +17,9 @@ const LOCALE_TAG = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
16
17
  // the value is not a safe language tag — callers skip the header and the
17
18
  // server falls back to Accept-Language, then its boot default.
18
19
  export function readActiveLocale() {
19
- const g = globalThis;
20
- const lang = g.document?.documentElement?.dataset?.kumikoLocale;
21
- if (lang === undefined || lang.length === 0)
20
+ const dataset = propertyOf(propertyOf(propertyOf(globalThis, "document"), "documentElement"), "dataset");
21
+ const lang = propertyOf(dataset, "kumikoLocale");
22
+ if (typeof lang !== "string" || lang.length === 0)
22
23
  return undefined;
23
24
  return LOCALE_TAG.test(lang) ? lang : undefined;
24
25
  }
@@ -1,4 +1,5 @@
1
1
  import { StreamFrame } from "@cosmicdrift/kumiko-headless";
2
+ import { serverErrorSchema } from "./envelopes.js";
2
3
  import { mapServerError } from "./error-mapping.js";
3
4
  export function parseSseBlock(block) {
4
5
  const trimmed = block.trim();
@@ -104,19 +105,24 @@ function frameDataToDispatcherError(data) {
104
105
  parsed = JSON.parse(data);
105
106
  }
106
107
  catch {
107
- return {
108
- code: "stream_error",
109
- httpStatus: 200,
110
- i18nKey: "errors.unknown",
111
- message: data.length > 0 ? data : "stream error frame",
112
- };
108
+ return unparsableErrorFrame(data);
113
109
  }
114
110
  // Server serializeError shape — mapServerError expects the same fields
115
111
  // as /api/query failures (httpStatus may be absent; reinject 200 for
116
112
  // mid-stream gates that flush SSE headers first).
117
- const err = parsed;
113
+ const envelope = serverErrorSchema.safeParse(parsed);
114
+ if (!envelope.success)
115
+ return unparsableErrorFrame(data);
118
116
  return mapServerError({
119
- ...err,
120
- httpStatus: err.httpStatus ?? 200,
117
+ ...envelope.data,
118
+ httpStatus: envelope.data.httpStatus ?? 200,
121
119
  });
122
120
  }
121
+ function unparsableErrorFrame(data) {
122
+ return {
123
+ code: "stream_error",
124
+ httpStatus: 200,
125
+ i18nKey: "errors.unknown",
126
+ message: data.length > 0 ? data : "stream error frame",
127
+ };
128
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-dispatcher-live",
3
- "version": "0.361.0",
3
+ "version": "0.363.0",
4
4
  "description": "HTTP-only Dispatcher for Kumiko UIs. Always-online; failures surface immediately. Default client for web admin/cockpit apps.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -24,8 +24,9 @@
24
24
  }
25
25
  },
26
26
  "dependencies": {
27
- "@cosmicdrift/kumiko-headless": "0.361.0",
28
- "@cosmicdrift/kumiko-types": "0.361.0"
27
+ "@cosmicdrift/kumiko-headless": "0.363.0",
28
+ "@cosmicdrift/kumiko-types": "0.363.0",
29
+ "zod": "^4.4.3"
29
30
  },
30
31
  "publishConfig": {
31
32
  "registry": "https://registry.npmjs.org",
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.363.0",
4
+ "type": "improvement",
5
+ "title": "dispatcher-live validates server envelopes with Zod",
6
+ "detail": "Query, write, batch and stream responses are parsed against Zod schemas for the envelope (isSuccess, error, failedIndex, results); the data payload stays typed by the caller. A malformed envelope now yields the network_error fallback (\"unexpected response shape\") instead of passing wrongly typed fields through; bare gate errors ({ error } from csrf, origin, auth or rate limits) keep their code, and a missing i18nKey defaults to errors.unknown; a malformed SSE error frame yields stream_error, and failing batch items get httpStatus filled from the response like top-level errors. renderer-web types CSS custom properties on React's CSSProperties instead of casting style objects."
7
+ },
2
8
  {
3
9
  "version": "0.338.0",
4
10
  "type": "fix",