@decocms/start 0.35.1 → 0.36.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 +2 -1
- package/src/apps/autoconfig.ts +75 -62
- package/src/sdk/crypto.ts +150 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/start",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.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",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./sdk/cachedLoader": "./src/sdk/cachedLoader.ts",
|
|
22
22
|
"./sdk/serverTimings": "./src/sdk/serverTimings.ts",
|
|
23
23
|
"./sdk/cacheHeaders": "./src/sdk/cacheHeaders.ts",
|
|
24
|
+
"./sdk/crypto": "./src/sdk/crypto.ts",
|
|
24
25
|
"./sdk/invoke": "./src/sdk/invoke.ts",
|
|
25
26
|
"./sdk/instrumentedFetch": "./src/sdk/instrumentedFetch.ts",
|
|
26
27
|
"./sdk/workerEntry": "./src/sdk/workerEntry.ts",
|
package/src/apps/autoconfig.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Auto-configures known apps from CMS blocks.
|
|
3
3
|
*
|
|
4
|
-
* Scans the decofile for
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Scans the decofile for block keys matching known apps and dynamically imports
|
|
5
|
+
* their `mod.ts` from @decocms/apps. Each app mod exports:
|
|
6
|
+
* - `configure(blockData, resolveSecret)` → configures the app client
|
|
7
|
+
* - `handlers` → record of invoke handler keys → handler functions
|
|
8
|
+
*
|
|
9
|
+
* Zero hardcoded app logic in the framework — all app-specific code lives in
|
|
10
|
+
* @decocms/apps/{app}/mod.ts.
|
|
7
11
|
*
|
|
8
12
|
* Usage in setup.ts:
|
|
9
13
|
* import { autoconfigApps } from "@decocms/start/apps/autoconfig";
|
|
@@ -13,57 +17,85 @@
|
|
|
13
17
|
|
|
14
18
|
import { setInvokeActions, type InvokeAction } from "../admin/invoke";
|
|
15
19
|
import { onChange } from "../cms/loader";
|
|
20
|
+
import { resolveSecret } from "../sdk/crypto";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Block key → @decocms/apps module mapping
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Maps CMS block keys (e.g. "deco-resend") to their @decocms/apps module path.
|
|
28
|
+
* To add a new app, just add an entry here — no other code changes needed.
|
|
29
|
+
*/
|
|
30
|
+
const BLOCK_TO_APP: Record<string, string> = {
|
|
31
|
+
"deco-resend": "resend",
|
|
32
|
+
// "deco-analytics": "analytics",
|
|
33
|
+
// "deco-shopify": "shopify",
|
|
34
|
+
// "deco-vtex": "vtex",
|
|
35
|
+
};
|
|
16
36
|
|
|
17
37
|
// ---------------------------------------------------------------------------
|
|
18
|
-
//
|
|
38
|
+
// Generic app loader
|
|
19
39
|
// ---------------------------------------------------------------------------
|
|
20
40
|
|
|
21
|
-
interface
|
|
22
|
-
|
|
23
|
-
|
|
41
|
+
interface AppMod {
|
|
42
|
+
configure: (
|
|
43
|
+
blockData: unknown,
|
|
44
|
+
resolveSecret: (value: unknown, envKey: string) => Promise<string | null>,
|
|
45
|
+
) => Promise<boolean>;
|
|
46
|
+
handlers: Record<string, (props: any, request: Request) => Promise<any>>;
|
|
24
47
|
}
|
|
25
48
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
configureResend({
|
|
44
|
-
apiKey,
|
|
45
|
-
emailFrom: block.emailFrom
|
|
46
|
-
? `${block.emailFrom.name || "Contact"} ${block.emailFrom.domain || "<onboarding@resend.dev>"}`
|
|
47
|
-
: undefined,
|
|
48
|
-
emailTo: block.emailTo,
|
|
49
|
-
subject: block.subject,
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
return {
|
|
53
|
-
"resend/actions/emails/send.ts": async (props: any) =>
|
|
54
|
-
sendEmail(props),
|
|
55
|
-
};
|
|
56
|
-
} catch {
|
|
57
|
-
// @decocms/apps not installed or doesn't have resend — skip
|
|
49
|
+
async function loadAndConfigureApp(
|
|
50
|
+
blockKey: string,
|
|
51
|
+
appName: string,
|
|
52
|
+
blockData: unknown,
|
|
53
|
+
): Promise<Record<string, InvokeAction>> {
|
|
54
|
+
try {
|
|
55
|
+
// Dynamic import of @decocms/apps/{appName}/mod
|
|
56
|
+
const mod: AppMod = await import(
|
|
57
|
+
/* @vite-ignore */ `@decocms/apps/${appName}/mod`
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const ok = await mod.configure(blockData, resolveSecret);
|
|
61
|
+
if (!ok) {
|
|
62
|
+
console.warn(
|
|
63
|
+
`[autoconfig] ${blockKey}: configure() returned false.` +
|
|
64
|
+
` Set DECO_CRYPTO_KEY to decrypt CMS secrets, or set the app's env var fallback.`,
|
|
65
|
+
);
|
|
58
66
|
return {};
|
|
59
67
|
}
|
|
60
|
-
|
|
61
|
-
};
|
|
68
|
+
|
|
69
|
+
console.log(`[autoconfig] ${blockKey}: configured (${Object.keys(mod.handlers).length} handlers)`);
|
|
70
|
+
return mod.handlers;
|
|
71
|
+
} catch (e) {
|
|
72
|
+
// @decocms/apps not installed or app module doesn't exist — skip silently
|
|
73
|
+
if ((e as any)?.code === "ERR_MODULE_NOT_FOUND" || (e as any)?.message?.includes("Cannot find")) {
|
|
74
|
+
return {};
|
|
75
|
+
}
|
|
76
|
+
console.warn(`[autoconfig] ${blockKey}:`, e);
|
|
77
|
+
return {};
|
|
78
|
+
}
|
|
79
|
+
}
|
|
62
80
|
|
|
63
81
|
// ---------------------------------------------------------------------------
|
|
64
82
|
// Main
|
|
65
83
|
// ---------------------------------------------------------------------------
|
|
66
84
|
|
|
85
|
+
async function configureAll(blocks: Record<string, unknown>): Promise<Record<string, InvokeAction>> {
|
|
86
|
+
const actions: Record<string, InvokeAction> = {};
|
|
87
|
+
|
|
88
|
+
for (const [blockKey, appName] of Object.entries(BLOCK_TO_APP)) {
|
|
89
|
+
const block = blocks[blockKey];
|
|
90
|
+
if (!block) continue;
|
|
91
|
+
|
|
92
|
+
const appActions = await loadAndConfigureApp(blockKey, appName, block);
|
|
93
|
+
Object.assign(actions, appActions);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return actions;
|
|
97
|
+
}
|
|
98
|
+
|
|
67
99
|
/**
|
|
68
100
|
* Auto-configure apps from CMS blocks.
|
|
69
101
|
* Call in setup.ts after setBlocks(). Also re-runs on admin hot-reload.
|
|
@@ -71,19 +103,7 @@ const KNOWN_APPS: Record<string, AppAutoconfigurator> = {
|
|
|
71
103
|
export async function autoconfigApps(blocks: Record<string, unknown>) {
|
|
72
104
|
if (typeof document !== "undefined") return; // server-only
|
|
73
105
|
|
|
74
|
-
const actions
|
|
75
|
-
|
|
76
|
-
for (const [blockKey, configurator] of Object.entries(KNOWN_APPS)) {
|
|
77
|
-
const block = blocks[blockKey];
|
|
78
|
-
if (!block) continue;
|
|
79
|
-
|
|
80
|
-
try {
|
|
81
|
-
const appActions = await configurator(block);
|
|
82
|
-
Object.assign(actions, appActions);
|
|
83
|
-
} catch (e) {
|
|
84
|
-
console.warn(`[autoconfig] ${blockKey}:`, e);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
106
|
+
const actions = await configureAll(blocks);
|
|
87
107
|
|
|
88
108
|
if (Object.keys(actions).length > 0) {
|
|
89
109
|
setInvokeActions(() => ({ ...actions }));
|
|
@@ -92,14 +112,7 @@ export async function autoconfigApps(blocks: Record<string, unknown>) {
|
|
|
92
112
|
// Re-configure on admin hot-reload
|
|
93
113
|
onChange(async (newBlocks) => {
|
|
94
114
|
if (typeof document !== "undefined") return;
|
|
95
|
-
const updatedActions
|
|
96
|
-
for (const [blockKey, configurator] of Object.entries(KNOWN_APPS)) {
|
|
97
|
-
const block = newBlocks[blockKey];
|
|
98
|
-
if (!block) continue;
|
|
99
|
-
try {
|
|
100
|
-
Object.assign(updatedActions, await configurator(block));
|
|
101
|
-
} catch {}
|
|
102
|
-
}
|
|
115
|
+
const updatedActions = await configureAll(newBlocks);
|
|
103
116
|
if (Object.keys(updatedActions).length > 0) {
|
|
104
117
|
setInvokeActions(() => ({ ...updatedActions }));
|
|
105
118
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret decryption for CMS encrypted values.
|
|
3
|
+
*
|
|
4
|
+
* CMS blocks store sensitive values (API keys, tokens) encrypted with AES-CBC.
|
|
5
|
+
* The encryption key is stored in the DECO_CRYPTO_KEY environment variable
|
|
6
|
+
* as a base64-encoded JSON: { key: number[], iv: number[] }
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { decryptSecret, resolveSecret } from "@decocms/start/sdk/crypto";
|
|
10
|
+
*
|
|
11
|
+
* // Decrypt a hex-encoded encrypted string
|
|
12
|
+
* const apiKey = await decryptSecret("888fafd937dd...");
|
|
13
|
+
*
|
|
14
|
+
* // Or resolve a CMS secret block (handles all formats)
|
|
15
|
+
* const apiKey = await resolveSecret(block.apiKey, "RESEND_API_KEY");
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const textDecoder = new TextDecoder();
|
|
19
|
+
const textEncoder = new TextEncoder();
|
|
20
|
+
|
|
21
|
+
// Cache the imported key
|
|
22
|
+
let cachedKey: Promise<{ key: CryptoKey; iv: Uint8Array }> | null = null;
|
|
23
|
+
|
|
24
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
25
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
26
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
27
|
+
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
28
|
+
}
|
|
29
|
+
return bytes;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get the AES key from DECO_CRYPTO_KEY environment variable.
|
|
34
|
+
* Returns null if not set.
|
|
35
|
+
*/
|
|
36
|
+
function getKeyFromEnv(): Promise<{ key: CryptoKey; iv: Uint8Array }> | null {
|
|
37
|
+
const envKey = process.env.DECO_CRYPTO_KEY;
|
|
38
|
+
if (!envKey) return null;
|
|
39
|
+
|
|
40
|
+
return cachedKey ??= (async () => {
|
|
41
|
+
const parsed = JSON.parse(atob(envKey));
|
|
42
|
+
const keyBytes = new Uint8Array(
|
|
43
|
+
Array.isArray(parsed.key) ? parsed.key : Object.values(parsed.key),
|
|
44
|
+
);
|
|
45
|
+
const iv = new Uint8Array(
|
|
46
|
+
Array.isArray(parsed.iv) ? parsed.iv : Object.values(parsed.iv),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const importedKey = await crypto.subtle.importKey(
|
|
50
|
+
"raw",
|
|
51
|
+
keyBytes.buffer,
|
|
52
|
+
"AES-CBC",
|
|
53
|
+
false,
|
|
54
|
+
["decrypt"],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return { key: importedKey, iv };
|
|
58
|
+
})();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check if the crypto key is available.
|
|
63
|
+
*/
|
|
64
|
+
export function hasCryptoKey(): boolean {
|
|
65
|
+
return !!process.env.DECO_CRYPTO_KEY;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Decrypt a hex-encoded AES-CBC encrypted string.
|
|
70
|
+
* Requires DECO_CRYPTO_KEY environment variable.
|
|
71
|
+
*
|
|
72
|
+
* @returns The decrypted string, or null if decryption fails.
|
|
73
|
+
*/
|
|
74
|
+
export async function decryptSecret(encryptedHex: string): Promise<string | null> {
|
|
75
|
+
const keyPromise = getKeyFromEnv();
|
|
76
|
+
if (!keyPromise) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const { key, iv } = await keyPromise;
|
|
82
|
+
const encryptedBytes = hexToBytes(encryptedHex);
|
|
83
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
84
|
+
{ name: "AES-CBC", iv } as AesCbcParams,
|
|
85
|
+
key,
|
|
86
|
+
encryptedBytes as unknown as BufferSource,
|
|
87
|
+
);
|
|
88
|
+
return textDecoder.decode(new Uint8Array(decrypted));
|
|
89
|
+
} catch (e) {
|
|
90
|
+
console.warn("[crypto] Failed to decrypt secret:", (e as Error).message);
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// In-memory cache for resolved secrets
|
|
96
|
+
const secretCache = new Map<string, string | null>();
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Resolve a CMS secret value from multiple sources.
|
|
100
|
+
*
|
|
101
|
+
* Resolution order:
|
|
102
|
+
* 1. Plain string → use directly
|
|
103
|
+
* 2. Object with .get() → call .get() (old Secret loader pattern)
|
|
104
|
+
* 3. Object with .encrypted → decrypt using DECO_CRYPTO_KEY
|
|
105
|
+
* 4. Environment variable (envVarName) → fallback
|
|
106
|
+
*
|
|
107
|
+
* @param value - The secret value from CMS block (string | { encrypted } | { get })
|
|
108
|
+
* @param envVarName - Optional env var name to use as fallback (e.g. "RESEND_API_KEY")
|
|
109
|
+
* @returns The resolved secret string, or null if not available
|
|
110
|
+
*/
|
|
111
|
+
export async function resolveSecret(
|
|
112
|
+
value: unknown,
|
|
113
|
+
envVarName?: string,
|
|
114
|
+
): Promise<string | null> {
|
|
115
|
+
// 1. Plain string
|
|
116
|
+
if (typeof value === "string" && value.length > 0) {
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (value && typeof value === "object") {
|
|
121
|
+
const obj = value as Record<string, any>;
|
|
122
|
+
|
|
123
|
+
// 2. Secret object with .get()
|
|
124
|
+
if (typeof obj.get === "function") {
|
|
125
|
+
const result = obj.get();
|
|
126
|
+
if (typeof result === "string" && result.length > 0) return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 3. Encrypted secret
|
|
130
|
+
if (typeof obj.encrypted === "string" && obj.encrypted.length > 0) {
|
|
131
|
+
const cacheKey = obj.encrypted;
|
|
132
|
+
if (secretCache.has(cacheKey)) return secretCache.get(cacheKey)!;
|
|
133
|
+
|
|
134
|
+
const decrypted = await decryptSecret(obj.encrypted);
|
|
135
|
+
// Only cache successful decryptions — null would block env var fallback
|
|
136
|
+
if (decrypted) {
|
|
137
|
+
secretCache.set(cacheKey, decrypted);
|
|
138
|
+
return decrypted;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 4. Environment variable fallback
|
|
144
|
+
if (envVarName) {
|
|
145
|
+
const envValue = process.env[envVarName];
|
|
146
|
+
if (envValue) return envValue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return null;
|
|
150
|
+
}
|