@decocms/start 2.0.0 → 2.1.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/package.json +8 -3
- package/src/admin/decofile.ts +7 -7
- package/src/admin/index.ts +3 -0
- package/src/admin/meta.ts +36 -11
- package/src/admin/schema.ts +64 -0
- package/src/admin/setup.ts +3 -0
- package/src/cms/loader.ts +37 -11
- package/src/cms/resolve.ts +32 -0
- package/src/daemon/auth.ts +204 -0
- package/src/daemon/fs.ts +238 -0
- package/src/daemon/index.ts +8 -0
- package/src/daemon/middleware.ts +156 -0
- package/src/daemon/tunnel.ts +129 -0
- package/src/daemon/volumes.ts +366 -0
- package/src/daemon/watch.ts +272 -0
- package/src/sdk/cachedLoader.ts +1 -1
- package/src/vite/plugin.js +42 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/start",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
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
|
-
"
|
|
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",
|
package/src/admin/decofile.ts
CHANGED
|
@@ -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("
|
|
23
|
+
const authHeader = request.headers.get("Authorization") || "";
|
|
24
24
|
const expectedToken =
|
|
25
|
-
(env?.
|
|
25
|
+
(env?.DECO_RELEASE_RELOAD_TOKEN as string | undefined) ??
|
|
26
26
|
(typeof globalThis.process !== "undefined"
|
|
27
|
-
? globalThis.process.env?.
|
|
27
|
+
? globalThis.process.env?.DECO_RELEASE_RELOAD_TOKEN
|
|
28
28
|
: undefined);
|
|
29
29
|
|
|
30
|
-
if (expectedToken
|
|
30
|
+
if (!expectedToken || authHeader !== expectedToken) {
|
|
31
31
|
return new Response("Unauthorized", { status: 401 });
|
|
32
32
|
}
|
|
33
33
|
|
package/src/admin/index.ts
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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,
|
package/src/admin/schema.ts
CHANGED
|
@@ -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,
|
package/src/admin/setup.ts
CHANGED
|
@@ -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;
|
|
@@ -127,9 +127,25 @@ export function withBlocksOverride<T>(override: Record<string, unknown>, fn: ()
|
|
|
127
127
|
return blocksOverrideStorage.run(override, fn);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
// Higher key wins. Compared lexicographically:
|
|
131
|
+
// [literalSegments, paramSegments, hasNoSplat]
|
|
132
|
+
// So `/foo/bar` > `/foo/:x` > `/foo/*` > `/*`, and `/my-account/*` > `/*`.
|
|
133
|
+
function pathSpecificityKey(path: string): [number, number, number] {
|
|
134
|
+
const parts = path.split("/").filter(Boolean);
|
|
135
|
+
let literals = 0;
|
|
136
|
+
let params = 0;
|
|
137
|
+
let hasSplat = false;
|
|
138
|
+
for (const part of parts) {
|
|
139
|
+
if (part === "*") hasSplat = true;
|
|
140
|
+
else if (part.startsWith(":") || part.startsWith("$")) params++;
|
|
141
|
+
else literals++;
|
|
142
|
+
}
|
|
143
|
+
return [literals, params, hasSplat ? 0 : 1];
|
|
144
|
+
}
|
|
145
|
+
|
|
130
146
|
export function getAllPages(): Array<{ key: string; page: DecoPage }> {
|
|
131
147
|
const blocks = loadBlocks();
|
|
132
|
-
const pages: Array<{ key: string; page: DecoPage;
|
|
148
|
+
const pages: Array<{ key: string; page: DecoPage; key2: [number, number, number] }> = [];
|
|
133
149
|
|
|
134
150
|
for (const [key, block] of Object.entries(blocks)) {
|
|
135
151
|
if (!key.startsWith("pages-")) continue;
|
|
@@ -137,16 +153,16 @@ export function getAllPages(): Array<{ key: string; page: DecoPage }> {
|
|
|
137
153
|
if (!page.sections) continue;
|
|
138
154
|
if (!page.path) continue;
|
|
139
155
|
|
|
140
|
-
|
|
141
|
-
if (page.path === "/*") specificity = 0;
|
|
142
|
-
else if (page.path.includes(":") || page.path.includes("$")) specificity = 1;
|
|
143
|
-
else specificity = 2;
|
|
144
|
-
|
|
145
|
-
pages.push({ key, page, specificity });
|
|
156
|
+
pages.push({ key, page, key2: pathSpecificityKey(page.path) });
|
|
146
157
|
}
|
|
147
158
|
|
|
148
159
|
return pages
|
|
149
|
-
.sort((a, b) =>
|
|
160
|
+
.sort((a, b) => {
|
|
161
|
+
for (let i = 0; i < a.key2.length; i++) {
|
|
162
|
+
if (a.key2[i] !== b.key2[i]) return b.key2[i] - a.key2[i];
|
|
163
|
+
}
|
|
164
|
+
return 0;
|
|
165
|
+
})
|
|
150
166
|
.map(({ key, page }) => ({ key, page }));
|
|
151
167
|
}
|
|
152
168
|
|
|
@@ -156,16 +172,26 @@ function matchPath(pattern: string, urlPath: string): Record<string, string> | n
|
|
|
156
172
|
const patternParts = pattern.split("/").filter(Boolean);
|
|
157
173
|
const urlParts = urlPath.split("/").filter(Boolean);
|
|
158
174
|
|
|
159
|
-
|
|
175
|
+
// Trailing `*` means "match this prefix and any remaining segments".
|
|
176
|
+
const hasSplat = patternParts[patternParts.length - 1] === "*";
|
|
177
|
+
const fixedLen = hasSplat ? patternParts.length - 1 : patternParts.length;
|
|
178
|
+
|
|
179
|
+
if (hasSplat) {
|
|
180
|
+
if (urlParts.length < fixedLen) return null;
|
|
181
|
+
} else if (urlParts.length !== fixedLen) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
160
184
|
|
|
161
185
|
const params: Record<string, string> = {};
|
|
162
|
-
for (let i = 0; i <
|
|
186
|
+
for (let i = 0; i < fixedLen; i++) {
|
|
163
187
|
const pp = patternParts[i];
|
|
164
188
|
const up = urlParts[i];
|
|
165
189
|
if (pp.startsWith(":")) params[pp.slice(1)] = up;
|
|
166
190
|
else if (pp !== up) return null;
|
|
167
191
|
}
|
|
168
192
|
|
|
193
|
+
if (hasSplat) params._splat = urlParts.slice(fixedLen).join("/");
|
|
194
|
+
|
|
169
195
|
return params;
|
|
170
196
|
}
|
|
171
197
|
|
package/src/cms/resolve.ts
CHANGED
|
@@ -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
|
+
}
|