@decocms/start 2.0.1 → 2.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "2.0.1",
3
+ "version": "2.1.1",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -60,7 +60,8 @@
60
60
  "./scripts/generate-invoke": "./scripts/generate-invoke.ts",
61
61
  "./scripts/migrate": "./scripts/migrate.ts",
62
62
  "./scripts/tailwind-lint": "./scripts/tailwind-lint.ts",
63
- "./vite": "./src/vite/plugin.js"
63
+ "./vite": "./src/vite/plugin.js",
64
+ "./daemon": "./src/daemon/index.ts"
64
65
  },
65
66
  "scripts": {
66
67
  "build": "tsc",
@@ -90,7 +91,10 @@
90
91
  "access": "public"
91
92
  },
92
93
  "dependencies": {
93
- "tsx": "^4.19.0"
94
+ "@deco-cx/warp-node": "^0.3.16",
95
+ "fast-json-patch": "^3.1.0",
96
+ "tsx": "^4.19.0",
97
+ "ws": "^8.18.0"
94
98
  },
95
99
  "peerDependencies": {
96
100
  "@microlabs/otel-cf-workers": ">=1.0.0-rc.0",
@@ -119,6 +123,7 @@
119
123
  "@tanstack/react-query": "^5.96.0",
120
124
  "@tanstack/store": "^0.9.1",
121
125
  "@types/react": "^19.0.0",
126
+ "@types/ws": "^8.18.0",
122
127
  "@types/react-dom": "^19.0.0",
123
128
  "jsdom": "^29.0.0",
124
129
  "knip": "^5.86.0",
@@ -1,6 +1,6 @@
1
- import { getRevision, loadBlocks, setBlocks } from "../cms/loader";
2
- import { clearLoaderCache } from "../sdk/cachedLoader";
3
- import { invalidateMetaCache } from "./meta";
1
+ import { getRevision, loadBlocks, setBlocks } from "../cms/loader.ts";
2
+ import { clearLoaderCache } from "../sdk/cachedLoader.ts";
3
+ import { invalidateMetaCache } from "./meta.ts";
4
4
 
5
5
  export function handleDecofileRead(): Response {
6
6
  const blocks = loadBlocks();
@@ -20,14 +20,14 @@ export async function handleDecofileReload(
20
20
  request: Request,
21
21
  env?: Record<string, unknown>,
22
22
  ): Promise<Response> {
23
- const authHeader = request.headers.get("authorization") || "";
23
+ const authHeader = request.headers.get("Authorization") || "";
24
24
  const expectedToken =
25
- (env?.DECO_RELOAD_TOKEN as string | undefined) ??
25
+ (env?.DECO_RELEASE_RELOAD_TOKEN as string | undefined) ??
26
26
  (typeof globalThis.process !== "undefined"
27
- ? globalThis.process.env?.DECO_RELOAD_TOKEN
27
+ ? globalThis.process.env?.DECO_RELEASE_RELOAD_TOKEN
28
28
  : undefined);
29
29
 
30
- if (expectedToken && !authHeader.includes(expectedToken)) {
30
+ if (!expectedToken || authHeader !== expectedToken) {
31
31
  return new Response("Unauthorized", { status: 401 });
32
32
  }
33
33
 
@@ -16,9 +16,12 @@ export {
16
16
  composeMeta,
17
17
  getRegisteredLoaders,
18
18
  getRegisteredMatchers,
19
+ type ActionConfig,
19
20
  type LoaderConfig,
20
21
  type MatcherConfig,
21
22
  type MetaResponse,
23
+ registerActionSchema,
24
+ registerActionSchemas,
22
25
  registerLoaderSchema,
23
26
  registerLoaderSchemas,
24
27
  registerMatcherSchema,
package/src/admin/meta.ts CHANGED
@@ -1,8 +1,30 @@
1
- import { djb2Hex } from "../sdk/djb2";
2
- import { composeMeta, type MetaResponse } from "./schema";
1
+ import { djb2Hex } from "../sdk/djb2.ts";
2
+ import { composeMeta, type MetaResponse } from "./schema.ts";
3
3
 
4
- let metaData: MetaResponse | null = null;
5
- let cachedEtag: string | null = null;
4
+ // Use globalThis to share meta state across module instances.
5
+ // The daemon middleware imports this module via native import() (outside Vite SSR),
6
+ // while setup.ts calls setMetaData() via Vite SSR — these are different module instances.
7
+ // globalThis bridges them so both see the same metaData.
8
+ const G = globalThis as unknown as {
9
+ __deco_meta_data?: MetaResponse | null;
10
+ __deco_meta_etag?: string | null;
11
+ };
12
+
13
+ function getMetaData(): MetaResponse | null {
14
+ return G.__deco_meta_data ?? null;
15
+ }
16
+
17
+ function setMetaDataInternal(data: MetaResponse | null) {
18
+ G.__deco_meta_data = data;
19
+ }
20
+
21
+ function getCachedEtag(): string | null {
22
+ return G.__deco_meta_etag ?? null;
23
+ }
24
+
25
+ function setCachedEtag(etag: string | null) {
26
+ G.__deco_meta_etag = etag;
27
+ }
6
28
 
7
29
  /**
8
30
  * Invalidate the cached ETag so the admin re-fetches meta after a
@@ -12,7 +34,7 @@ let cachedEtag: string | null = null;
12
34
  * needed here, keeping this module safe for client-side bundles.
13
35
  */
14
36
  export function invalidateMetaCache() {
15
- cachedEtag = null;
37
+ setCachedEtag(null);
16
38
  }
17
39
 
18
40
  /**
@@ -21,8 +43,8 @@ export function invalidateMetaCache() {
21
43
  * on top of the site-generated section schemas.
22
44
  */
23
45
  export function setMetaData(data: MetaResponse) {
24
- metaData = composeMeta(data);
25
- cachedEtag = null;
46
+ setMetaDataInternal(composeMeta(data));
47
+ setCachedEtag(null);
26
48
  }
27
49
 
28
50
  /**
@@ -31,14 +53,17 @@ export function setMetaData(data: MetaResponse) {
31
53
  * results in a different ETag, forcing admin to re-fetch.
32
54
  */
33
55
  function getEtag(): string {
34
- if (!cachedEtag) {
35
- const str = JSON.stringify(metaData || {});
36
- cachedEtag = `"meta-${djb2Hex(str)}"`;
56
+ let etag = getCachedEtag();
57
+ if (!etag) {
58
+ const str = JSON.stringify(getMetaData() || {});
59
+ etag = `"meta-${djb2Hex(str)}"`;
60
+ setCachedEtag(etag);
37
61
  }
38
- return cachedEtag;
62
+ return etag;
39
63
  }
40
64
 
41
65
  export function handleMeta(request: Request): Response {
66
+ const metaData = getMetaData();
42
67
  if (!metaData) {
43
68
  return new Response(JSON.stringify({ error: "Schema not initialized" }), {
44
69
  status: 503,
@@ -94,6 +94,64 @@ function getProductListLoaderKeys(): string[] {
94
94
  return loaderRegistry.filter((l) => l.tags?.includes("product-list")).map((l) => l.key);
95
95
  }
96
96
 
97
+ // ---------------------------------------------------------------------------
98
+ // Action definitions — dynamic registry
99
+ // ---------------------------------------------------------------------------
100
+
101
+ export interface ActionConfig {
102
+ key: string;
103
+ title: string;
104
+ namespace: string;
105
+ propsSchema: Record<string, any>;
106
+ }
107
+
108
+ const actionRegistry: ActionConfig[] = [];
109
+
110
+ /** Register a single action schema for the admin. */
111
+ export function registerActionSchema(config: ActionConfig) {
112
+ const idx = actionRegistry.findIndex((a) => a.key === config.key);
113
+ if (idx >= 0) {
114
+ actionRegistry[idx] = config;
115
+ } else {
116
+ actionRegistry.push(config);
117
+ }
118
+ }
119
+
120
+ /** Register multiple action schemas at once. */
121
+ export function registerActionSchemas(configs: ActionConfig[]) {
122
+ for (const config of configs) registerActionSchema(config);
123
+ }
124
+
125
+ function buildActionDefinitions() {
126
+ const definitions: Record<string, any> = {};
127
+ const manifestBlocks: Record<string, any> = {};
128
+
129
+ for (const action of actionRegistry) {
130
+ const defKey = toBase64(action.key);
131
+
132
+ definitions[defKey] = {
133
+ title: action.key,
134
+ type: "object",
135
+ required: ["__resolveType"],
136
+ properties: {
137
+ __resolveType: {
138
+ type: "string",
139
+ enum: [action.key],
140
+ default: action.key,
141
+ },
142
+ props: action.propsSchema,
143
+ },
144
+ };
145
+
146
+ manifestBlocks[action.key] = {
147
+ $ref: `#/definitions/${defKey}`,
148
+ namespace: action.namespace,
149
+ };
150
+ }
151
+
152
+ return { definitions, manifestBlocks };
153
+ }
154
+
97
155
  // ---------------------------------------------------------------------------
98
156
  // Matcher definitions — dynamic registry
99
157
  // ---------------------------------------------------------------------------
@@ -774,6 +832,7 @@ export function composeMeta(siteMeta: MetaResponse): MetaResponse {
774
832
  const fullSectionAnyOf = [...siteAnyOf, ...fwSections.extraAnyOf];
775
833
  const page = buildPageSchema(fullSectionAnyOf);
776
834
  const loaders = buildLoaderDefinitions();
835
+ const actions = buildActionDefinitions();
777
836
  const matchers = buildMatcherDefinitions();
778
837
 
779
838
  const sectionRefDef = { title: "Section", anyOf: fullSectionAnyOf };
@@ -786,6 +845,7 @@ export function composeMeta(siteMeta: MetaResponse): MetaResponse {
786
845
  ...fwSections.definitions,
787
846
  ...page.definitions,
788
847
  ...loaders.definitions,
848
+ ...actions.definitions,
789
849
  ...matchers.definitions,
790
850
  [SECTION_REF_DEF_KEY]: sectionRefDef,
791
851
  [RESOLVABLE_LITERAL_KEY]: resolvableDef,
@@ -813,6 +873,10 @@ export function composeMeta(siteMeta: MetaResponse): MetaResponse {
813
873
  ...(siteMeta.manifest?.blocks?.loaders || {}),
814
874
  ...loaders.manifestBlocks,
815
875
  },
876
+ actions: {
877
+ ...(siteMeta.manifest?.blocks?.actions || {}),
878
+ ...actions.manifestBlocks,
879
+ },
816
880
  matchers: {
817
881
  ...(siteMeta.manifest?.blocks?.matchers || {}),
818
882
  ...matchers.manifestBlocks,
@@ -17,8 +17,11 @@ export {
17
17
  } from "./invoke";
18
18
  export { setMetaData } from "./meta";
19
19
  export {
20
+ type ActionConfig,
20
21
  type LoaderConfig,
21
22
  type MatcherConfig,
23
+ registerActionSchema,
24
+ registerActionSchemas,
22
25
  registerLoaderSchema,
23
26
  registerLoaderSchemas,
24
27
  registerMatcherSchema,
package/src/cms/loader.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import * as asyncHooks from "node:async_hooks";
2
- import { djb2Hex } from "../sdk/djb2";
2
+ import { djb2Hex } from "../sdk/djb2.ts";
3
3
 
4
4
  export type Resolvable = {
5
5
  __resolveType?: string;
@@ -3,6 +3,7 @@ import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } fro
3
3
  import { isLayoutSection, runSingleSectionLoader } from "./sectionLoaders";
4
4
  import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
5
5
  import { djb2Hex } from "../sdk/djb2";
6
+ import { registerLoaderSchemas, registerActionSchemas, type LoaderConfig, type ActionConfig } from "../admin/schema";
6
7
 
7
8
  // globalThis-backed: share state across Vite server function split modules
8
9
  const G = globalThis as any;
@@ -307,6 +308,37 @@ export function registerCommerceLoader(key: string, loader: CommerceLoader) {
307
308
 
308
309
  export function registerCommerceLoaders(loaders: Record<string, CommerceLoader>) {
309
310
  Object.assign(commerceLoaders, loaders);
311
+
312
+ // Auto-register loader + action schemas for the admin manifest.
313
+ // Separate actions (keys containing "/actions/") from loaders.
314
+ const loaderConfigs: LoaderConfig[] = [];
315
+ const actionConfigs: ActionConfig[] = [];
316
+
317
+ for (const key of Object.keys(loaders)) {
318
+ const namespace = key.startsWith("vtex/") ? "vtex" : "site";
319
+ const schema = { type: "object" as const, additionalProperties: true };
320
+
321
+ if (key.includes("/actions/")) {
322
+ actionConfigs.push({ key, title: key, namespace, propsSchema: schema });
323
+ } else {
324
+ loaderConfigs.push({ key, title: key, namespace, propsSchema: schema, tags: inferLoaderTags(key) });
325
+ }
326
+ }
327
+
328
+ registerLoaderSchemas(loaderConfigs);
329
+ registerActionSchemas(actionConfigs);
330
+ }
331
+
332
+ function inferLoaderTags(key: string): string[] {
333
+ if (
334
+ key.includes("productList") ||
335
+ key.includes("ProductList") ||
336
+ key.includes("ProductShelf") ||
337
+ key.includes("SearchResult")
338
+ ) {
339
+ return ["product-list"];
340
+ }
341
+ return [];
310
342
  }
311
343
 
312
344
  /** Delete a single commerce loader by key. No-op if key is absent. */
@@ -0,0 +1,204 @@
1
+ /**
2
+ * JWT verification for admin.deco.cx requests.
3
+ * Uses Web Crypto only — no external dependencies.
4
+ *
5
+ * Ported from: deco-cx/deco daemon/auth.ts + commons/jwt/*
6
+ */
7
+ import type { IncomingMessage, ServerResponse } from "node:http";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Public key — same key used by all sites (from commons/jwt/trusted.ts)
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const ADMIN_PUBLIC_KEY =
14
+ process.env.DECO_ADMIN_PUBLIC_KEY ??
15
+ "eyJrdHkiOiJSU0EiLCJhbGciOiJSUzI1NiIsIm4iOiJ1N0Y3UklDN19Zc3ljTFhEYlBvQ1pUQnM2elZ6VjVPWkhXQ0M4akFZeFdPUnByem9WNDJDQ1JBVkVOVjJldzk1MnJOX2FTMmR3WDlmVGRvdk9zWl9jX2RVRXctdGlPN3hJLXd0YkxsanNUbUhoNFpiYXU0aUVoa0o1VGNHc2VaelhFYXNOSEhHdUo4SzY3WHluRHJSX0h4Ym9kQ2YxNFFJTmc5QnJjT3FNQmQyMUl4eUctVVhQampBTnRDTlNici1rXzFKeTZxNmtPeVJ1ZmV2Mjl0djA4Ykh5WDJQenp5Tnp3RWpjY0lROWpmSFdMN0JXX2tzdFpOOXU3TUtSLWJ4bjlSM0FKMEpZTHdXR3VnZGpNdVpBRnk0dm5BUXZzTk5Cd3p2YnFzMnZNd0dDTnF1ZE1tVmFudlNzQTJKYkE3Q0JoazI5TkRFTXRtUS1wbmo1cUlYSlEiLCJlIjoiQVFBQiIsImtleV9vcHMiOlsidmVyaWZ5Il0sImV4dCI6dHJ1ZX0";
16
+
17
+ const BYPASS_JWT =
18
+ process.env.DANGEROUSLY_ALLOW_PUBLIC_ACCESS === "true";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // JWT types
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export interface JwtPayload {
25
+ [key: string]: unknown;
26
+ iss?: string;
27
+ sub?: string;
28
+ aud?: string | string[];
29
+ exp?: number;
30
+ nbf?: number;
31
+ iat?: number;
32
+ jti?: string;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Crypto helpers — ported from commons/jwt/keys.ts
37
+ // ---------------------------------------------------------------------------
38
+
39
+ const ALG = "RSASSA-PKCS1-v1_5";
40
+ const HASH = "SHA-256";
41
+
42
+ function parseJWK(b64: string): JsonWebKey {
43
+ return JSON.parse(atob(b64));
44
+ }
45
+
46
+ let cachedKey: Promise<CryptoKey> | null = null;
47
+
48
+ function getAdminPublicKey(): Promise<CryptoKey> {
49
+ cachedKey ??= crypto.subtle.importKey(
50
+ "jwk",
51
+ parseJWK(ADMIN_PUBLIC_KEY),
52
+ { name: ALG, hash: HASH },
53
+ false,
54
+ ["verify"],
55
+ );
56
+ return cachedKey;
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // JWT verification — ported from commons/jwt/jwt.ts
61
+ // ---------------------------------------------------------------------------
62
+
63
+ function base64UrlDecode(str: string): Uint8Array {
64
+ const b64 = str.replace(/-/g, "+").replace(/_/g, "/");
65
+ const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
66
+ const binary = atob(b64 + pad);
67
+ const bytes = new Uint8Array(binary.length);
68
+ for (let i = 0; i < binary.length; i++) {
69
+ bytes[i] = binary.charCodeAt(i);
70
+ }
71
+ return bytes;
72
+ }
73
+
74
+ export async function verifyAdminJwt(
75
+ token: string,
76
+ ): Promise<JwtPayload | null> {
77
+ const parts = token.split(".");
78
+ if (parts.length !== 3) return null;
79
+
80
+ const [headerB64, payloadB64, signatureB64] = parts;
81
+ const signingInput = new TextEncoder().encode(
82
+ `${headerB64}.${payloadB64}`,
83
+ );
84
+ const signature = base64UrlDecode(signatureB64);
85
+
86
+ try {
87
+ const key = await getAdminPublicKey();
88
+ const valid = await crypto.subtle.verify(
89
+ ALG,
90
+ key,
91
+ new Uint8Array(signature),
92
+ new Uint8Array(signingInput),
93
+ );
94
+ if (!valid) return null;
95
+ } catch {
96
+ return null;
97
+ }
98
+
99
+ try {
100
+ const payload: JwtPayload = JSON.parse(
101
+ new TextDecoder().decode(base64UrlDecode(payloadB64)),
102
+ );
103
+ return payload;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // URN matching — ported from commons/jwt/engine.ts
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function matchPart(urnPart: string, otherPart: string): boolean {
114
+ return urnPart === "*" || otherPart === urnPart;
115
+ }
116
+
117
+ function matchParts(urn: string[], resource: string[]): boolean {
118
+ return urn.every((part, idx) => matchPart(part, resource[idx]));
119
+ }
120
+
121
+ function matches(urnParts: string[]) {
122
+ return (resourceUrn: string) => {
123
+ const resourceParts = resourceUrn.split(":");
124
+ if (resourceParts.length > urnParts.length) return false;
125
+ const lastIdx = resourceParts.length - 1;
126
+ return resourceParts.every((part, idx) => {
127
+ if (part === "*") return true;
128
+ if (lastIdx === idx) {
129
+ return matchParts(part.split("/"), urnParts[idx].split("/"));
130
+ }
131
+ return part === urnParts[idx];
132
+ });
133
+ };
134
+ }
135
+
136
+ export function tokenIsValid(site: string, jwt: JwtPayload): boolean {
137
+ const { iss, sub, exp } = jwt;
138
+ if (!iss || !sub) return false;
139
+ if (exp && exp * 1000 <= Date.now()) return false;
140
+ const siteUrn = `urn:deco:site:*:${site}:deployment/*`;
141
+ return matches(sub.split(":"))(siteUrn);
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Auth middleware for Connect (Vite dev server)
146
+ // ---------------------------------------------------------------------------
147
+
148
+ function extractToken(req: IncomingMessage): string | null {
149
+ const auth = req.headers.authorization;
150
+ if (auth) {
151
+ const parts = auth.split(/\s+/);
152
+ if (parts.length === 2) return parts[1];
153
+ }
154
+ // Fallback: ?token= query param
155
+ try {
156
+ const url = new URL(req.url ?? "/", "http://localhost");
157
+ const t = url.searchParams.get("token");
158
+ if (t) return t;
159
+ } catch {
160
+ // ignore
161
+ }
162
+ return null;
163
+ }
164
+
165
+ export type NextFn = () => void;
166
+
167
+ /**
168
+ * Returns a Connect-style middleware that verifies JWT on every request.
169
+ * If invalid, responds 401/403. If valid (or bypass enabled), calls next().
170
+ */
171
+ export function createAuthMiddleware(site: string) {
172
+ return async (
173
+ req: IncomingMessage,
174
+ res: ServerResponse,
175
+ next: NextFn,
176
+ ): Promise<void> => {
177
+ if (BYPASS_JWT) {
178
+ next();
179
+ return;
180
+ }
181
+
182
+ const token = extractToken(req);
183
+ if (!token) {
184
+ res.writeHead(401);
185
+ res.end();
186
+ return;
187
+ }
188
+
189
+ const jwt = await verifyAdminJwt(token);
190
+ if (!jwt) {
191
+ res.writeHead(401);
192
+ res.end();
193
+ return;
194
+ }
195
+
196
+ if (!tokenIsValid(site, jwt)) {
197
+ res.writeHead(403);
198
+ res.end();
199
+ return;
200
+ }
201
+
202
+ next();
203
+ };
204
+ }