hanzio 1.1.0 → 1.2.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/README.md +70 -4
- package/dist/api-wrapper/builders.d.ts +13 -2
- package/dist/api-wrapper/errors.d.ts +1 -0
- package/dist/api-wrapper/headers.d.ts +2 -1
- package/dist/api-wrapper/transport.d.ts +8 -5
- package/dist/api-wrapper/types.d.ts +1 -0
- package/dist/cache/index.d.ts +26 -0
- package/dist/secrets/core.d.ts +47 -0
- package/dist/secrets/deadline.d.ts +6 -0
- package/dist/secrets/environment.d.ts +9 -0
- package/dist/secrets/errors.d.ts +11 -0
- package/dist/secrets/index.d.ts +13 -25
- package/dist/secrets/infisical-cli.d.ts +6 -0
- package/dist/secrets/infisical-config.d.ts +17 -0
- package/dist/secrets/infisical-session.d.ts +4 -0
- package/dist/secrets/infisical.d.ts +8 -13
- package/dist/secrets/loaders.d.ts +5 -12
- package/dist/secrets/vite.d.ts +7 -3
- package/dist/secrets/worker-loader.d.ts +9 -0
- package/dist/secrets/worker.d.ts +3 -0
- package/dist/src/api-wrapper/index.js +152 -122
- package/dist/src/index.js +3173 -2713
- package/dist/src/jwt/index.js +2 -2
- package/dist/src/p-queue/index.js +3 -3
- package/dist/src/secrets/index.js +670 -402
- package/dist/src/secrets/infisical.js +451 -168
- package/dist/src/secrets/loaders.js +467 -174
- package/dist/src/secrets/vite.js +33 -11
- package/dist/src/secrets/worker.js +275 -0
- package/dist/src/sitemap/index.js +1 -1
- package/dist/src/zod/index.js +7 -7
- package/dist/typedswitch/index.d.ts +9 -5
- package/package.json +12 -3
- package/src/api-wrapper/builders.ts +68 -43
- package/src/api-wrapper/create-client.ts +82 -62
- package/src/api-wrapper/errors.ts +10 -0
- package/src/api-wrapper/headers.ts +14 -1
- package/src/api-wrapper/redaction.ts +1 -0
- package/src/api-wrapper/transport.ts +34 -38
- package/src/api-wrapper/types.ts +1 -0
- package/src/cache/index.ts +228 -61
- package/src/secrets/core.ts +348 -0
- package/src/secrets/deadline.ts +69 -0
- package/src/secrets/environment.ts +69 -0
- package/src/secrets/errors.ts +29 -0
- package/src/secrets/index.ts +79 -210
- package/src/secrets/infisical-cli.ts +99 -0
- package/src/secrets/infisical-config.ts +70 -0
- package/src/secrets/infisical-session.ts +42 -0
- package/src/secrets/infisical.ts +214 -66
- package/src/secrets/loaders.ts +24 -48
- package/src/secrets/vite.ts +44 -15
- package/src/secrets/worker-loader.ts +56 -0
- package/src/secrets/worker.ts +4 -0
- package/src/typedswitch/index.ts +80 -69
package/src/secrets/infisical.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import { createApiClient } from '../api-wrapper'
|
|
3
|
-
import
|
|
2
|
+
import { createApiClient, HttpResponseError } from '../api-wrapper'
|
|
3
|
+
import { withSecretDeadline } from './deadline'
|
|
4
|
+
import { getProcessEnvironment } from './environment'
|
|
5
|
+
import { SecretLoadError } from './errors'
|
|
6
|
+
import { readInfisicalCliToken } from './infisical-cli'
|
|
7
|
+
import { validateInfisicalScope, type InfisicalScope } from './infisical-config'
|
|
8
|
+
import { parseInfisicalSessionToken } from './infisical-session'
|
|
4
9
|
|
|
5
10
|
export const DEFAULT_INFISICAL_SITE_URL = 'https://eu.infisical.com'
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
export type InfisicalAuthMode = 'auto' | 'http' | 'cli'
|
|
8
13
|
|
|
9
|
-
const
|
|
10
|
-
accessToken: z.string()
|
|
11
|
-
})
|
|
14
|
+
const defaultFetch = globalThis.fetch
|
|
12
15
|
|
|
16
|
+
// Validate provider responses before exposing any values to a secret set.
|
|
17
|
+
const infisicalAuthResponseSchema = z.object({ accessToken: z.string() })
|
|
13
18
|
const infisicalSecretSchema = z.object({
|
|
14
19
|
secretKey: z.string(),
|
|
15
20
|
secretValue: z.string()
|
|
@@ -19,84 +24,204 @@ const infisicalSecretsResponseSchema = z.object({
|
|
|
19
24
|
secrets: z.array(infisicalSecretSchema)
|
|
20
25
|
})
|
|
21
26
|
|
|
22
|
-
export async function fetchInfisicalSecrets<
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
27
|
+
export async function fetchInfisicalSecrets<K extends string>(
|
|
28
|
+
options: InfisicalScope<K> & {
|
|
29
|
+
readonly clientId: string
|
|
30
|
+
readonly clientSecret: string
|
|
31
|
+
}
|
|
32
|
+
): Promise<Partial<Record<K, string>>> {
|
|
33
|
+
validateInfisicalScope(options)
|
|
34
|
+
|
|
35
|
+
return withSecretDeadline(options, async (signal) => {
|
|
36
|
+
let operation = 'login'
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const infisical = createInfisicalApiClient(options.siteUrl)
|
|
40
|
+
const { data: auth } = await infisical.request('login', {
|
|
41
|
+
signal,
|
|
42
|
+
reqBody: {
|
|
43
|
+
clientId: options.clientId,
|
|
44
|
+
clientSecret: options.clientSecret
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// Authentication and fetching share a deadline. Do not start a second
|
|
49
|
+
// request if the caller cancelled while authentication was in progress.
|
|
50
|
+
signal.throwIfAborted()
|
|
51
|
+
operation = 'listSecrets'
|
|
52
|
+
|
|
53
|
+
return await listInfisicalSecrets(infisical, auth.accessToken, {
|
|
54
|
+
...options,
|
|
55
|
+
signal
|
|
56
|
+
})
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throwInfisicalHttpError(error, operation, signal)
|
|
42
59
|
}
|
|
43
60
|
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type LoadInfisicalSecretsFromEnvOptions<K extends string> =
|
|
64
|
+
InfisicalScope<K> & {
|
|
65
|
+
readonly clientIdEnvKey: string
|
|
66
|
+
readonly clientSecretEnvKey: string
|
|
67
|
+
readonly auth?: InfisicalAuthMode
|
|
68
|
+
readonly reportSource?: (source: string) => void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function loadInfisicalSecrets<K extends string>(
|
|
72
|
+
options: LoadInfisicalSecretsFromEnvOptions<K>
|
|
73
|
+
): Promise<Partial<Record<K, string>>> {
|
|
74
|
+
validateInfisicalScope(options)
|
|
75
|
+
|
|
76
|
+
const env = getProcessEnvironment()
|
|
77
|
+
const clientId = env?.[options.clientIdEnvKey]
|
|
78
|
+
const clientSecret = env?.[options.clientSecretEnvKey]
|
|
79
|
+
const mode = options.auth ?? 'auto'
|
|
80
|
+
|
|
81
|
+
if (!['auto', 'http', 'cli'].includes(mode)) {
|
|
82
|
+
throw new SecretLoadError('CONFIGURATION', 'Invalid Infisical auth mode.')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Explicit modes take precedence. Auto mode uses HTTP only when both
|
|
86
|
+
// credentials exist; a failed HTTP request must not switch accounts.
|
|
87
|
+
const useHttp =
|
|
88
|
+
mode === 'http' || (mode === 'auto' && Boolean(clientId && clientSecret))
|
|
89
|
+
|
|
90
|
+
if (useHttp) {
|
|
91
|
+
if (!clientId || !clientSecret) {
|
|
92
|
+
throw new SecretLoadError(
|
|
93
|
+
'AUTHENTICATION',
|
|
94
|
+
'Infisical HTTP authentication requires both client credentials.'
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
options.reportSource?.('Infisical HTTP')
|
|
99
|
+
|
|
100
|
+
return fetchInfisicalSecrets({ ...options, clientId, clientSecret })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
options.reportSource?.(
|
|
104
|
+
`Infisical CLI session (organization=${options.organizationId} project=${options.projectId} environment=${options.environment})`
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return withSecretDeadline(options, (signal) =>
|
|
108
|
+
fetchInfisicalSessionSecrets({ ...options, signal })
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function fetchInfisicalSessionSecrets<K extends string>(
|
|
113
|
+
options: InfisicalScope<K> & { signal: AbortSignal }
|
|
114
|
+
): Promise<Partial<Record<K, string>>> {
|
|
115
|
+
const { signal } = options
|
|
116
|
+
let token = await readInfisicalCliToken(options)
|
|
117
|
+
|
|
118
|
+
signal.throwIfAborted()
|
|
119
|
+
|
|
120
|
+
const session = parseInfisicalSessionToken(token)
|
|
121
|
+
const infisical = createInfisicalApiClient(options.siteUrl)
|
|
122
|
+
let operation = 'selectOrganization'
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// JWT claims only guide routing; Infisical verifies authentication and access.
|
|
126
|
+
// An already scoped session needs no exchange (or additional MFA challenge).
|
|
127
|
+
if (session.organizationId !== options.organizationId) {
|
|
128
|
+
const { data } = await infisical.request('selectOrganization', {
|
|
129
|
+
signal,
|
|
130
|
+
reqHeaders: {
|
|
131
|
+
Authorization: `Bearer ${token}`,
|
|
132
|
+
'User-Agent': 'hanzio/secrets'
|
|
133
|
+
},
|
|
134
|
+
reqBody: { organizationId: options.organizationId }
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
signal.throwIfAborted()
|
|
138
|
+
|
|
139
|
+
if (data.isMfaEnabled) {
|
|
140
|
+
throw new SecretLoadError(
|
|
141
|
+
'AUTHENTICATION',
|
|
142
|
+
'Infisical requires MFA for this organization. Complete infisical login for the target organization and configured domain before loading secrets.',
|
|
143
|
+
{ operation }
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const scopedSession = parseInfisicalSessionToken(data.token)
|
|
148
|
+
|
|
149
|
+
if (scopedSession.organizationId !== options.organizationId) {
|
|
150
|
+
throw new SecretLoadError(
|
|
151
|
+
'AUTHENTICATION',
|
|
152
|
+
'Infisical returned a session for a different organization.',
|
|
153
|
+
{ operation }
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Keep the exchanged token local: concurrent sets must never share or
|
|
158
|
+
// overwrite the CLI's persisted session or another organization's token.
|
|
159
|
+
token = data.token
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
signal.throwIfAborted()
|
|
163
|
+
operation = 'listSecrets'
|
|
164
|
+
|
|
165
|
+
return await listInfisicalSecrets(infisical, token, options)
|
|
166
|
+
} catch (error) {
|
|
167
|
+
throwInfisicalHttpError(error, operation, signal)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function listInfisicalSecrets<K extends string>(
|
|
172
|
+
infisical: ReturnType<typeof createInfisicalApiClient>,
|
|
173
|
+
token: string,
|
|
174
|
+
options: InfisicalScope<K> & { signal: AbortSignal }
|
|
175
|
+
): Promise<Partial<Record<K, string>>> {
|
|
44
176
|
const { data } = await infisical.request('listSecrets', {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
},
|
|
177
|
+
signal: options.signal,
|
|
178
|
+
reqHeaders: { Authorization: `Bearer ${token}` },
|
|
48
179
|
reqQuery: {
|
|
49
|
-
environment,
|
|
50
|
-
projectId,
|
|
51
|
-
secretPath: '/',
|
|
180
|
+
environment: options.environment,
|
|
181
|
+
projectId: options.projectId,
|
|
182
|
+
secretPath: options.secretPath ?? '/',
|
|
52
183
|
viewSecretValue: true,
|
|
53
184
|
expandSecretReferences: true,
|
|
54
185
|
includeImports: true
|
|
55
186
|
}
|
|
56
187
|
})
|
|
57
188
|
|
|
58
|
-
|
|
189
|
+
// Infisical lists the folder, but callers only receive their requested keys.
|
|
190
|
+
const wantedKeys = new Set<string>(options.keys)
|
|
191
|
+
|
|
59
192
|
return Object.fromEntries(
|
|
60
193
|
data.secrets
|
|
61
194
|
.filter((secret) => wantedKeys.has(secret.secretKey))
|
|
62
195
|
.map((secret) => [secret.secretKey, secret.secretValue])
|
|
63
|
-
) as Partial<Record<
|
|
196
|
+
) as Partial<Record<K, string>>
|
|
64
197
|
}
|
|
65
198
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
199
|
+
function throwInfisicalHttpError(
|
|
200
|
+
error: unknown,
|
|
201
|
+
operation: string,
|
|
202
|
+
signal: AbortSignal
|
|
203
|
+
): never {
|
|
204
|
+
if (signal.aborted) {
|
|
205
|
+
throw signal.reason
|
|
206
|
+
}
|
|
74
207
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
projectId,
|
|
78
|
-
environment,
|
|
79
|
-
siteUrl,
|
|
80
|
-
clientIdEnvKey,
|
|
81
|
-
clientSecretEnvKey
|
|
82
|
-
}: LoadInfisicalSecretsFromEnvOptions<SecretKey>): Promise<
|
|
83
|
-
Partial<Record<SecretKey, string>>
|
|
84
|
-
> {
|
|
85
|
-
const clientId = process.env[clientIdEnvKey]
|
|
86
|
-
const clientSecret = process.env[clientSecretEnvKey]
|
|
87
|
-
|
|
88
|
-
if (!clientId || !clientSecret) {
|
|
89
|
-
throw new Error('Missing Infisical credentials')
|
|
208
|
+
if (error instanceof SecretLoadError) {
|
|
209
|
+
throw error
|
|
90
210
|
}
|
|
91
211
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
212
|
+
// API-wrapper errors can retain request bodies and raw responses.
|
|
213
|
+
// Copy safe metadata only; never preserve the original error as a cause.
|
|
214
|
+
const status = error instanceof HttpResponseError ? error.status : undefined
|
|
215
|
+
const guidance =
|
|
216
|
+
operation === 'selectOrganization' && (status === 401 || status === 403)
|
|
217
|
+
? ' Run infisical login for the target organization and configured domain, then verify your access.'
|
|
218
|
+
: ''
|
|
219
|
+
|
|
220
|
+
throw new SecretLoadError(
|
|
221
|
+
'HTTP',
|
|
222
|
+
`Infisical HTTP ${operation} failed${status ? ` (status ${status})` : ''}.${guidance}`,
|
|
223
|
+
{ operation, status }
|
|
224
|
+
)
|
|
100
225
|
}
|
|
101
226
|
|
|
102
227
|
function createInfisicalApiClient(siteUrl: string) {
|
|
@@ -109,6 +234,22 @@ function createInfisicalApiClient(siteUrl: string) {
|
|
|
109
234
|
fetch: getFetch(),
|
|
110
235
|
retries: 1,
|
|
111
236
|
endpoints: {
|
|
237
|
+
// Internal endpoint also used by Infisical CLI init. Keep its contract
|
|
238
|
+
// isolated here so upstream authentication changes have one integration point.
|
|
239
|
+
selectOrganization: {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
path: '/api/v3/auth/select-organization',
|
|
242
|
+
reqHeadersSchema: z.object({
|
|
243
|
+
Authorization: z.string(),
|
|
244
|
+
'User-Agent': z.string()
|
|
245
|
+
}),
|
|
246
|
+
reqBodySchema: z.object({ organizationId: z.string() }),
|
|
247
|
+
resSchema: z.object({
|
|
248
|
+
token: z.string().min(1),
|
|
249
|
+
isMfaEnabled: z.boolean().optional()
|
|
250
|
+
}),
|
|
251
|
+
reqBodyFormat: 'json'
|
|
252
|
+
},
|
|
112
253
|
login: {
|
|
113
254
|
method: 'POST',
|
|
114
255
|
path: '/api/v1/auth/universal-auth/login',
|
|
@@ -140,5 +281,12 @@ function createInfisicalApiClient(siteUrl: string) {
|
|
|
140
281
|
}
|
|
141
282
|
|
|
142
283
|
function getFetch(): typeof fetch {
|
|
143
|
-
|
|
284
|
+
const fetchFn = globalThis.fetch ?? defaultFetch
|
|
285
|
+
|
|
286
|
+
// A retry may wake after the overall deadline; stop before issuing fetch.
|
|
287
|
+
return ((input, init) => {
|
|
288
|
+
init?.signal?.throwIfAborted()
|
|
289
|
+
|
|
290
|
+
return fetchFn(input, init)
|
|
291
|
+
}) as typeof fetch
|
|
144
292
|
}
|
package/src/secrets/loaders.ts
CHANGED
|
@@ -1,67 +1,43 @@
|
|
|
1
1
|
import { DEFAULT_INFISICAL_SITE_URL, fetchInfisicalSecrets } from './infisical'
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
validateInfisicalOrganizationId,
|
|
4
|
+
type InfisicalProjectOptions
|
|
5
|
+
} from './infisical-config'
|
|
6
|
+
import type { SecretSetLoader, SecretSetLoaderContext } from './core'
|
|
3
7
|
|
|
4
|
-
|
|
5
|
-
* Reads secrets from a Cloudflare Worker `env` bindings object (or any similar
|
|
6
|
-
* `Record`), for Workers where `process.env` is not populated. Pass the same
|
|
7
|
-
* object you get as `env` in your fetch handler, or values you’d read from
|
|
8
|
-
* `import { env } from "cloudflare:workers"` in your Worker bundle — this
|
|
9
|
-
* package does not import that module.
|
|
10
|
-
*/
|
|
11
|
-
export function cloudflareWorkerEnvLoader<SecretKey extends string>(
|
|
12
|
-
workerEnv: Record<string, unknown>
|
|
13
|
-
): SecretSetLoader<SecretKey> {
|
|
14
|
-
return async (ctx) => {
|
|
15
|
-
const out: Partial<Record<SecretKey, string>> = {}
|
|
16
|
-
for (const key of ctx.keys) {
|
|
17
|
-
const str = workerBindingToString(workerEnv[key])
|
|
18
|
-
if (str !== undefined) {
|
|
19
|
-
out[key] = str
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
return out
|
|
23
|
-
}
|
|
24
|
-
}
|
|
8
|
+
export { cloudflareWorkerEnvLoader } from './worker-loader'
|
|
25
9
|
|
|
26
|
-
function
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return value === '' ? undefined : value
|
|
32
|
-
}
|
|
33
|
-
if (
|
|
34
|
-
typeof value === 'number' ||
|
|
35
|
-
typeof value === 'boolean' ||
|
|
36
|
-
typeof value === 'bigint'
|
|
37
|
-
) {
|
|
38
|
-
return String(value)
|
|
10
|
+
export function infisicalLoader<SecretKey extends string>(
|
|
11
|
+
config: InfisicalProjectOptions & {
|
|
12
|
+
readonly clientId: string
|
|
13
|
+
readonly clientSecret: string
|
|
14
|
+
readonly timeoutMs?: number
|
|
39
15
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
return String(value)
|
|
44
|
-
}
|
|
16
|
+
): SecretSetLoader<SecretKey> {
|
|
17
|
+
validateInfisicalOrganizationId(config.organizationId)
|
|
45
18
|
|
|
46
|
-
|
|
47
|
-
readonly projectId: string
|
|
48
|
-
readonly clientId: string
|
|
49
|
-
readonly clientSecret: string
|
|
50
|
-
readonly siteUrl?: string
|
|
51
|
-
}): SecretSetLoader<SecretKey> {
|
|
52
|
-
return async (ctx) =>
|
|
19
|
+
const loader: SecretSetLoader<SecretKey> = async (ctx) =>
|
|
53
20
|
fetchInfisicalSecrets({
|
|
54
21
|
keys: ctx.keys,
|
|
55
22
|
projectId: config.projectId,
|
|
23
|
+
organizationId: config.organizationId,
|
|
56
24
|
environment: ctx.environment,
|
|
57
25
|
siteUrl: config.siteUrl ?? DEFAULT_INFISICAL_SITE_URL,
|
|
58
26
|
clientId: config.clientId,
|
|
59
|
-
clientSecret: config.clientSecret
|
|
27
|
+
clientSecret: config.clientSecret,
|
|
28
|
+
secretPath: config.secretPath,
|
|
29
|
+
timeoutMs: config.timeoutMs ?? ctx.timeoutMs,
|
|
30
|
+
signal: ctx.signal
|
|
60
31
|
})
|
|
32
|
+
|
|
33
|
+
return Object.assign(loader, { source: 'Infisical HTTP' })
|
|
61
34
|
}
|
|
62
35
|
|
|
63
36
|
export async function processEnvLoader<SecretKey extends string>(
|
|
64
37
|
_ctx: SecretSetLoaderContext<SecretKey>
|
|
65
38
|
): Promise<Partial<Record<SecretKey, string>>> {
|
|
39
|
+
// The core has already read environment overrides; there is no remote source.
|
|
66
40
|
return {}
|
|
67
41
|
}
|
|
42
|
+
|
|
43
|
+
Object.assign(processEnvLoader, { source: 'process.env' })
|
package/src/secrets/vite.ts
CHANGED
|
@@ -1,30 +1,59 @@
|
|
|
1
|
-
import type { SecretSet } from '
|
|
1
|
+
import type { SecretSet } from './core'
|
|
2
|
+
import { SecretLoadError } from './errors'
|
|
2
3
|
|
|
3
4
|
export type ViteSecretSetPlugin = {
|
|
4
5
|
name: string
|
|
5
|
-
config: () => {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
config: () => { define: Record<string, string> }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type ViteSecretSetOptions<K extends string> = {
|
|
10
|
+
/** Explicit acknowledgement that these values may be shipped to browsers. */
|
|
11
|
+
readonly publicKeys?: readonly K[]
|
|
8
12
|
}
|
|
9
13
|
|
|
10
|
-
export function getViteDefine<
|
|
11
|
-
secretSet: SecretSet<
|
|
14
|
+
export function getViteDefine<K extends string>(
|
|
15
|
+
secretSet: Pick<SecretSet<K, unknown>, 'secrets'>,
|
|
16
|
+
options: ViteSecretSetOptions<K> = {}
|
|
12
17
|
): Record<string, string> {
|
|
18
|
+
const values = secretSet.secrets()
|
|
19
|
+
const keys = options.publicKeys ?? (Object.keys(values) as K[])
|
|
20
|
+
|
|
21
|
+
// An explicit allowlist is required before exposing non-prefixed values.
|
|
22
|
+
if (!options.publicKeys && keys.some((key) => !key.startsWith('VITE_'))) {
|
|
23
|
+
throw new SecretLoadError(
|
|
24
|
+
'CONFIGURATION',
|
|
25
|
+
'Vite secret sets must contain only VITE_ keys, or provide an explicit publicKeys list.'
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const key of keys) {
|
|
30
|
+
if (!Object.hasOwn(values, key) || !/^[A-Za-z_$][\w$]*$/.test(key)) {
|
|
31
|
+
throw new SecretLoadError(
|
|
32
|
+
'CONFIGURATION',
|
|
33
|
+
'Vite publicKeys must reference configured keys with valid JavaScript identifiers.'
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (['MODE', 'BASE_URL', 'PROD', 'DEV', 'SSR'].includes(key)) {
|
|
38
|
+
throw new SecretLoadError(
|
|
39
|
+
'CONFIGURATION',
|
|
40
|
+
'Vite built-in environment constants cannot be overridden by secrets.'
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Only selected public values become browser-visible replacements.
|
|
13
46
|
return Object.fromEntries(
|
|
14
|
-
|
|
15
|
-
`import.meta.env.${key}`,
|
|
16
|
-
JSON.stringify(value)
|
|
17
|
-
])
|
|
47
|
+
keys.map((key) => [`import.meta.env.${key}`, JSON.stringify(values[key])])
|
|
18
48
|
)
|
|
19
49
|
}
|
|
20
50
|
|
|
21
|
-
export function viteSecretSetPlugin<
|
|
22
|
-
secretSet: SecretSet<
|
|
51
|
+
export function viteSecretSetPlugin<K extends string>(
|
|
52
|
+
secretSet: Pick<SecretSet<K, unknown>, 'secrets'>,
|
|
53
|
+
options: ViteSecretSetOptions<K> = {}
|
|
23
54
|
): ViteSecretSetPlugin {
|
|
24
55
|
return {
|
|
25
56
|
name: 'hanzio-secret-set',
|
|
26
|
-
config: () => ({
|
|
27
|
-
define: getViteDefine(secretSet)
|
|
28
|
-
})
|
|
57
|
+
config: () => ({ define: getViteDefine(secretSet, options) })
|
|
29
58
|
}
|
|
30
59
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SecretSetLoader } from './core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads secrets from a Cloudflare Worker `env` bindings object (or any similar
|
|
5
|
+
* `Record`), for Workers where `process.env` is not populated. Pass the same
|
|
6
|
+
* object you get as `env` in your fetch handler, or values you’d read from
|
|
7
|
+
* `import { env } from "cloudflare:workers"` in your Worker bundle — this
|
|
8
|
+
* package does not import that module.
|
|
9
|
+
*/
|
|
10
|
+
export function cloudflareWorkerEnvLoader<SecretKey extends string>(
|
|
11
|
+
workerEnv: Record<string, unknown>
|
|
12
|
+
): SecretSetLoader<SecretKey> {
|
|
13
|
+
const loader: SecretSetLoader<SecretKey> = async (ctx) => {
|
|
14
|
+
// A null prototype lets secret names safely overlap with object properties.
|
|
15
|
+
const out: Partial<Record<SecretKey, string>> = Object.create(null)
|
|
16
|
+
|
|
17
|
+
for (const key of ctx.keys) {
|
|
18
|
+
const str = workerBindingToString(
|
|
19
|
+
Object.hasOwn(workerEnv, key) ? workerEnv[key] : undefined
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if (str !== undefined) {
|
|
23
|
+
out[key] = str
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return out
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return Object.assign(loader, { source: 'Cloudflare Worker bindings' })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function workerBindingToString(value: unknown): string | undefined {
|
|
34
|
+
if (value === undefined || value === null) {
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (typeof value === 'string') {
|
|
39
|
+
return value === '' ? undefined : value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
typeof value === 'number' ||
|
|
44
|
+
typeof value === 'boolean' ||
|
|
45
|
+
typeof value === 'bigint'
|
|
46
|
+
) {
|
|
47
|
+
return String(value)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// JSON bindings remain strings until the optional schema parses them.
|
|
51
|
+
if (typeof value === 'object') {
|
|
52
|
+
return JSON.stringify(value)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return String(value)
|
|
56
|
+
}
|