@alienplatform/sdk 1.10.7 → 1.11.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/.turbo/turbo-build.log +37 -8
- package/dist/bearerTokenAuthenticationPolicy.js +3028 -0
- package/dist/bindings/index.d.ts +1 -0
- package/dist/bindings/index.d.ts.map +1 -1
- package/dist/bindings/postgres.d.ts +90 -0
- package/dist/bindings/postgres.d.ts.map +1 -0
- package/dist/bindings/postgres.test.d.ts +2 -0
- package/dist/bindings/postgres.test.d.ts.map +1 -0
- package/dist/client.js +2376 -0
- package/dist/commands/index.js +1 -1
- package/dist/dist-cjs.js +3 -0
- package/dist/dist-cjs2.js +718 -0
- package/dist/dist-cjs3.js +2486 -0
- package/dist/dist-cjs4.js +171 -0
- package/dist/dist-cjs5.js +429 -0
- package/dist/dist-cjs6.js +60 -0
- package/dist/dist-cjs7.js +978 -0
- package/dist/dist-cjs8.js +86 -0
- package/dist/dist-cjs9.js +319 -0
- package/dist/dist.js +498 -36
- package/dist/dist2.js +1239 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/es5.js +1624 -0
- package/dist/esm.js +18211 -0
- package/dist/esm2.js +3104 -0
- package/dist/event-streams.js +203 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +336 -24996
- package/dist/jws.js +611 -0
- package/dist/open.js +460 -0
- package/dist/package.js +4 -0
- package/dist/protocols.js +954 -0
- package/dist/protocols2.js +5565 -0
- package/dist/rolldown-runtime.js +37 -0
- package/dist/serde.js +5542 -0
- package/dist/signin.js +723 -0
- package/dist/src.js +24900 -0
- package/dist/src2.js +114030 -0
- package/dist/sso-oidc.js +940 -0
- package/dist/sts.js +1329 -0
- package/dist/tslib.es6.js +543 -0
- package/package.json +26 -2
- package/src/bindings/index.ts +1 -0
- package/src/bindings/postgres.test.ts +17 -0
- package/src/bindings/postgres.ts +446 -0
- package/src/errors.ts +21 -0
- package/src/index.ts +5 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres binding accessor.
|
|
3
|
+
*
|
|
4
|
+
* Postgres is connection-only: unlike Storage/Kv/Queue there is no gRPC service to call,
|
|
5
|
+
* so this module does not use {@link AlienContext}. Instead the manager injects the
|
|
6
|
+
* connection details for a linked database into `ALIEN_<NAME>_BINDING` as JSON, and the
|
|
7
|
+
* workload turns them into a live connection here.
|
|
8
|
+
*
|
|
9
|
+
* The cloud variants deliberately carry only a *reference* to the connection password
|
|
10
|
+
* (Secrets Manager ARN / Secret Manager name / Key Vault URI), never the password itself.
|
|
11
|
+
* The workload resolves that reference at runtime using its own least-privilege identity
|
|
12
|
+
* — exactly what the `postgres/data-access` permission set grants — so the password never
|
|
13
|
+
* flows through the control plane or sits as a plaintext environment variable.
|
|
14
|
+
*
|
|
15
|
+
* The matching cloud secret SDK is imported lazily, so a workload only loads the SDK for
|
|
16
|
+
* the cloud it is actually running on.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { AlienError } from "@alienplatform/core"
|
|
20
|
+
import * as z from "zod/v4"
|
|
21
|
+
import {
|
|
22
|
+
BindingNotFoundError,
|
|
23
|
+
InvalidBindingConfigError,
|
|
24
|
+
PostgresSecretResolutionError,
|
|
25
|
+
} from "../errors.js"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Everything a Postgres driver needs to connect. `ssl` is the node-postgres TLS value; for
|
|
29
|
+
* node-postgres pass it with the individual fields, not the connectionString (see the `ssl` note).
|
|
30
|
+
* It is a plain value any driver can read.
|
|
31
|
+
*/
|
|
32
|
+
export interface PostgresConnection {
|
|
33
|
+
/**
|
|
34
|
+
* `postgres://user:password@host:port/database?sslmode=<mode>`, with credentials
|
|
35
|
+
* percent-encoded. The `sslmode` mirrors the Rust resolver byte-for-byte (`disable`
|
|
36
|
+
* local, `prefer` external, `require` cloud) so a sslmode-aware consumer agrees with us.
|
|
37
|
+
*/
|
|
38
|
+
connectionString: string
|
|
39
|
+
/**
|
|
40
|
+
* The TLS setting for the driver: `false` for plaintext-capable bindings (local/external),
|
|
41
|
+
* `{ rejectUnauthorized: false }` for managed clouds (TLS on, cert not yet verified against a
|
|
42
|
+
* pinned CA).
|
|
43
|
+
*
|
|
44
|
+
* For node-postgres, pass this with the individual fields — `new Client({ host, port, ..., ssl })`
|
|
45
|
+
* — NOT with the connectionString. node-postgres parses the URL's `sslmode` (it treats `require`
|
|
46
|
+
* as `verify-full`) and that overwrites an explicit `ssl`, so the URL form rejects the cloud cert
|
|
47
|
+
* with "unable to get local issuer certificate". Field style lets this value take effect.
|
|
48
|
+
*
|
|
49
|
+
* `rejectUnauthorized: false` is a deliberate v1 posture: Postgres is private-only on every cloud
|
|
50
|
+
* (no public IP, reachable only from same-stack workloads), so the network boundary is the primary
|
|
51
|
+
* control and an on-path MITM would already be inside the stack VPC. Verified TLS (provider CAs) is
|
|
52
|
+
* future defense-in-depth, not a v1 requirement.
|
|
53
|
+
*
|
|
54
|
+
* For the `external` (BYO) backend this is always `false`: node-postgres has no `prefer` mode, so
|
|
55
|
+
* the field stays plaintext while the connection string's `sslmode=prefer` only reaches sslmode-aware
|
|
56
|
+
* consumers (psql). A BYO database that *requires* TLS must have TLS configured outside this struct.
|
|
57
|
+
*/
|
|
58
|
+
ssl: false | { rejectUnauthorized: boolean }
|
|
59
|
+
/**
|
|
60
|
+
* The same connection details as individual fields, for drivers that take them separately
|
|
61
|
+
* (`new Client({ host, port, user: username, password, database })`) instead of a URL. These are
|
|
62
|
+
* the un-encoded values that `connectionString` percent-encodes; `host` is the address to dial
|
|
63
|
+
* (the Aurora cluster endpoint for the managed-AWS backend).
|
|
64
|
+
*/
|
|
65
|
+
host: string
|
|
66
|
+
port: number
|
|
67
|
+
database: string
|
|
68
|
+
username: string
|
|
69
|
+
password: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const connectionFields = {
|
|
73
|
+
// Fail fast at the trust boundary: reject any value that can't form a connectable URL rather than
|
|
74
|
+
// pass it on. Port 0 is never dialable (the Rust `u16` domain allows it, so `.min(1)` is one
|
|
75
|
+
// stricter); `database`/`username` likewise reject the empty string (Postgres has no empty
|
|
76
|
+
// identifier, and `postgres://:p@h/` is unconnectable).
|
|
77
|
+
port: z.number().int().min(1).max(65535),
|
|
78
|
+
database: z.string().min(1),
|
|
79
|
+
username: z.string().min(1),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mirror of the Rust `PostgresBinding` enum (tagged by `service`, camelCase fields).
|
|
84
|
+
* `BindingValue<T>` is serialized untagged, so concrete values arrive as plain scalars.
|
|
85
|
+
*/
|
|
86
|
+
// `.strict()` on every variant: this binding is the control-plane → workload trust boundary, so an
|
|
87
|
+
// unexpected key (a Rust-side rename the SDK doesn't yet know, or env tampering) should fail loudly
|
|
88
|
+
// here rather than being silently dropped. This is the template other connection bindings will copy.
|
|
89
|
+
const postgresBindingSchema = z.discriminatedUnion("service", [
|
|
90
|
+
z
|
|
91
|
+
.object({
|
|
92
|
+
service: z.literal("aurora"),
|
|
93
|
+
clusterEndpoint: z.string(),
|
|
94
|
+
passwordSecretArn: z.string(),
|
|
95
|
+
...connectionFields,
|
|
96
|
+
})
|
|
97
|
+
.strict(),
|
|
98
|
+
z
|
|
99
|
+
.object({
|
|
100
|
+
service: z.literal("cloud-sql"),
|
|
101
|
+
host: z.string(),
|
|
102
|
+
passwordSecretName: z.string(),
|
|
103
|
+
...connectionFields,
|
|
104
|
+
})
|
|
105
|
+
.strict(),
|
|
106
|
+
z
|
|
107
|
+
.object({
|
|
108
|
+
service: z.literal("flexible-server"),
|
|
109
|
+
host: z.string(),
|
|
110
|
+
passwordSecretUri: z.string(),
|
|
111
|
+
...connectionFields,
|
|
112
|
+
})
|
|
113
|
+
.strict(),
|
|
114
|
+
z
|
|
115
|
+
.object({
|
|
116
|
+
service: z.literal("external"),
|
|
117
|
+
host: z.string(),
|
|
118
|
+
password: z.string().min(1),
|
|
119
|
+
...connectionFields,
|
|
120
|
+
})
|
|
121
|
+
.strict(),
|
|
122
|
+
z
|
|
123
|
+
.object({
|
|
124
|
+
service: z.literal("local-postgres"),
|
|
125
|
+
host: z.string(),
|
|
126
|
+
password: z.string().min(1),
|
|
127
|
+
...connectionFields,
|
|
128
|
+
})
|
|
129
|
+
.strict(),
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
/** Matches the Rust `binding_env_var_name`: `ALIEN_<NAME>_BINDING`, hyphens to underscores. */
|
|
133
|
+
function bindingEnvVarName(bindingName: string): string {
|
|
134
|
+
return `ALIEN_${bindingName.replace(/-/g, "_").toUpperCase()}_BINDING`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Mirrors the Rust `SslMode` query param so the two resolvers emit identical URLs. */
|
|
138
|
+
type SslMode = "disable" | "prefer" | "require"
|
|
139
|
+
|
|
140
|
+
// Percent-encode a userinfo/path component to the RFC 3986 *unreserved* set only, byte-for-byte
|
|
141
|
+
// matching the Rust resolver's `encode_userinfo`. `encodeURIComponent` leaves the sub-delims
|
|
142
|
+
// ! ' ( ) * literal; encode them too so a password containing any of them produces the same
|
|
143
|
+
// connection string on the TS and Rust runtimes (a generated password can include them).
|
|
144
|
+
export function encodeUserinfo(value: string): string {
|
|
145
|
+
return encodeURIComponent(value).replace(
|
|
146
|
+
/[!'()*]/g,
|
|
147
|
+
c => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function connectionString(
|
|
152
|
+
host: string,
|
|
153
|
+
params: { port: number; database: string; username: string },
|
|
154
|
+
password: string,
|
|
155
|
+
sslmode: SslMode,
|
|
156
|
+
): string {
|
|
157
|
+
const user = encodeUserinfo(params.username)
|
|
158
|
+
const pass = encodeUserinfo(password)
|
|
159
|
+
return `postgres://${user}:${pass}@${host}:${params.port}/${encodeUserinfo(params.database)}?sslmode=${sslmode}`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Builds the full connection result — the URL plus the same details as individual fields. */
|
|
163
|
+
function makeConnection(
|
|
164
|
+
host: string,
|
|
165
|
+
params: { port: number; database: string; username: string },
|
|
166
|
+
password: string,
|
|
167
|
+
sslmode: SslMode,
|
|
168
|
+
ssl: PostgresConnection["ssl"],
|
|
169
|
+
): PostgresConnection {
|
|
170
|
+
return {
|
|
171
|
+
connectionString: connectionString(host, params, password, sslmode),
|
|
172
|
+
ssl,
|
|
173
|
+
host,
|
|
174
|
+
port: params.port,
|
|
175
|
+
database: params.database,
|
|
176
|
+
username: params.username,
|
|
177
|
+
password,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Resolves the connection details for a linked Postgres database.
|
|
183
|
+
*
|
|
184
|
+
* @param bindingName The resource id used when the database was linked (e.g. the value
|
|
185
|
+
* passed to `new alien.Postgres(id)`).
|
|
186
|
+
* @returns A connection string plus `ssl`, and the same details as individual fields
|
|
187
|
+
* (`host`/`port`/`database`/`username`/`password`) — use whichever style your driver prefers.
|
|
188
|
+
* @throws AlienError if the binding env var is missing, malformed, or names a backend
|
|
189
|
+
* whose secret resolution is not available in this build.
|
|
190
|
+
*
|
|
191
|
+
* @example
|
|
192
|
+
* ```typescript
|
|
193
|
+
* import { Client } from "pg"
|
|
194
|
+
* import { getPostgresConnection } from "@alienplatform/sdk"
|
|
195
|
+
*
|
|
196
|
+
* const conn = await getPostgresConnection("my-db")
|
|
197
|
+
* // Field style: node-postgres parses a URL's sslmode and would override `ssl`, so pass fields.
|
|
198
|
+
* const client = new Client({
|
|
199
|
+
* host: conn.host, port: conn.port, database: conn.database,
|
|
200
|
+
* user: conn.username, password: conn.password, ssl: conn.ssl,
|
|
201
|
+
* })
|
|
202
|
+
* await client.connect()
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
205
|
+
export async function getPostgresConnection(bindingName: string): Promise<PostgresConnection> {
|
|
206
|
+
const envVar = bindingEnvVarName(bindingName)
|
|
207
|
+
const raw = process.env[envVar]
|
|
208
|
+
if (!raw) {
|
|
209
|
+
// A missing binding env var means the workload didn't link a Postgres by this name — a
|
|
210
|
+
// user-fixable config problem (404), not an internal failure.
|
|
211
|
+
throw new AlienError(
|
|
212
|
+
BindingNotFoundError.create({
|
|
213
|
+
bindingName,
|
|
214
|
+
bindingType: "Postgres",
|
|
215
|
+
}),
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let parsed: unknown
|
|
220
|
+
try {
|
|
221
|
+
parsed = JSON.parse(raw)
|
|
222
|
+
} catch (cause) {
|
|
223
|
+
throw (await AlienError.from(cause)).withContext(
|
|
224
|
+
InvalidBindingConfigError.create({
|
|
225
|
+
message: `Postgres binding '${bindingName}' is not valid JSON`,
|
|
226
|
+
}),
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const result = postgresBindingSchema.safeParse(parsed)
|
|
231
|
+
if (!result.success) {
|
|
232
|
+
// Carry the Zod detail only in the wrapped internal source; the user-facing
|
|
233
|
+
// InvalidBindingConfigError is `internal:false` and would leak it externally.
|
|
234
|
+
throw (await AlienError.from(result.error)).withContext(
|
|
235
|
+
InvalidBindingConfigError.create({
|
|
236
|
+
message: `Postgres binding '${bindingName}' has an unexpected shape`,
|
|
237
|
+
}),
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const binding = result.data
|
|
242
|
+
switch (binding.service) {
|
|
243
|
+
case "local-postgres":
|
|
244
|
+
return makeConnection(binding.host, binding, binding.password, "disable", false)
|
|
245
|
+
case "external":
|
|
246
|
+
// node-postgres has no `prefer` mode: `ssl: false` always connects plaintext. We carry
|
|
247
|
+
// `sslmode=prefer` in the URL for sslmode-aware consumers (psql), so against a server that
|
|
248
|
+
// offers-but-doesn't-require TLS the two diverge (psql upgrades, pg stays plaintext) — an
|
|
249
|
+
// accepted v1 downgrade; a BYO database requiring TLS needs explicit config.
|
|
250
|
+
return makeConnection(binding.host, binding, binding.password, "prefer", false)
|
|
251
|
+
case "aurora": {
|
|
252
|
+
const password = await readAwsSecret(binding.passwordSecretArn)
|
|
253
|
+
return makeConnection(binding.clusterEndpoint, binding, password, "require", {
|
|
254
|
+
rejectUnauthorized: false,
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
case "cloud-sql": {
|
|
258
|
+
const password = await readGcpSecret(binding.passwordSecretName)
|
|
259
|
+
return makeConnection(binding.host, binding, password, "require", {
|
|
260
|
+
rejectUnauthorized: false,
|
|
261
|
+
})
|
|
262
|
+
}
|
|
263
|
+
case "flexible-server": {
|
|
264
|
+
const password = await readAzureSecret(binding.passwordSecretUri)
|
|
265
|
+
return makeConnection(binding.host, binding, password, "require", {
|
|
266
|
+
rejectUnauthorized: false,
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
default:
|
|
270
|
+
return assertNever(binding)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Reads the raw password the AWS controller stored as the secret's `SecretString`. */
|
|
275
|
+
async function readAwsSecret(secretArn: string): Promise<string> {
|
|
276
|
+
// Literal (not computed) import specifier so a bundler can leave it external — see the module
|
|
277
|
+
// doc for why each cloud's secret SDK is loaded lazily.
|
|
278
|
+
let sdk: typeof import("@aws-sdk/client-secrets-manager")
|
|
279
|
+
try {
|
|
280
|
+
sdk = await import("@aws-sdk/client-secrets-manager")
|
|
281
|
+
} catch (cause) {
|
|
282
|
+
throw (await AlienError.from(cause)).withContext(
|
|
283
|
+
InvalidBindingConfigError.create({
|
|
284
|
+
message: "Failed to load '@aws-sdk/client-secrets-manager'",
|
|
285
|
+
suggestion: "Add '@aws-sdk/client-secrets-manager' to this workload's dependencies",
|
|
286
|
+
}),
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
const client = new sdk.SecretsManagerClient({})
|
|
290
|
+
let password: string | undefined
|
|
291
|
+
try {
|
|
292
|
+
const response = await client.send(new sdk.GetSecretValueCommand({ SecretId: secretArn }))
|
|
293
|
+
password = response.SecretString
|
|
294
|
+
} catch (cause) {
|
|
295
|
+
// A failed read is an upstream/transient failure (throttle, network, service unavailable), not
|
|
296
|
+
// a user-fixable config error — make it retryable so an automated retry layer can recover.
|
|
297
|
+
throw (await AlienError.from(cause)).withContext(
|
|
298
|
+
PostgresSecretResolutionError.create({
|
|
299
|
+
secret: secretArn,
|
|
300
|
+
reason: "Secrets Manager GetSecretValue failed",
|
|
301
|
+
}),
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
if (!password) {
|
|
305
|
+
// An empty stored secret is a control-plane invariant the workload can't fix, not bad user
|
|
306
|
+
// input — surface it as a (retryable) resolution failure, like the read-failure path above.
|
|
307
|
+
throw new AlienError(
|
|
308
|
+
PostgresSecretResolutionError.create({
|
|
309
|
+
secret: secretArn,
|
|
310
|
+
reason: "Secrets Manager secret has no SecretString",
|
|
311
|
+
}),
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
return password
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Reads the raw password the GCP controller stored as the secret version's payload. */
|
|
318
|
+
async function readGcpSecret(secretName: string): Promise<string> {
|
|
319
|
+
let sdk: typeof import("@google-cloud/secret-manager")
|
|
320
|
+
try {
|
|
321
|
+
sdk = await import("@google-cloud/secret-manager")
|
|
322
|
+
} catch (cause) {
|
|
323
|
+
throw (await AlienError.from(cause)).withContext(
|
|
324
|
+
InvalidBindingConfigError.create({
|
|
325
|
+
message: "Failed to load '@google-cloud/secret-manager'",
|
|
326
|
+
suggestion: "Add '@google-cloud/secret-manager' to this workload's dependencies",
|
|
327
|
+
}),
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
// `fallback: true` forces the REST/HTTPS transport instead of the default native gRPC
|
|
331
|
+
// (@grpc/grpc-js over HTTP/2). Workloads run on Bun, whose HTTP/2 support doesn't carry gRPC
|
|
332
|
+
// reliably: the gRPC stub init rejects and the first `accessSecretVersion` re-throws it
|
|
333
|
+
// synchronously (no network call). REST matches the HTTP transport the AWS/Azure SDKs already use.
|
|
334
|
+
const client = new sdk.SecretManagerServiceClient({ fallback: true })
|
|
335
|
+
let data: Uint8Array | string | null | undefined
|
|
336
|
+
try {
|
|
337
|
+
const projectId = await client.getProjectId()
|
|
338
|
+
const [version] = await client.accessSecretVersion({
|
|
339
|
+
name: `projects/${projectId}/secrets/${secretName}/versions/latest`,
|
|
340
|
+
})
|
|
341
|
+
data = version.payload?.data
|
|
342
|
+
} catch (cause) {
|
|
343
|
+
throw (await AlienError.from(cause)).withContext(
|
|
344
|
+
PostgresSecretResolutionError.create({
|
|
345
|
+
secret: secretName,
|
|
346
|
+
reason: "Secret Manager accessSecretVersion failed",
|
|
347
|
+
}),
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
// `!data` does not catch a zero-length Uint8Array (an empty typed array is truthy), which would
|
|
351
|
+
// otherwise decode to an empty password and silently pass. Guard on length so an empty payload
|
|
352
|
+
// fails fast like the AWS/Azure paths (`!password` / `!value`).
|
|
353
|
+
if (data == null || data.length === 0) {
|
|
354
|
+
// An empty stored secret is a control-plane invariant the workload can't fix, not bad user
|
|
355
|
+
// input — surface it as a (retryable) resolution failure, like the read-failure path above.
|
|
356
|
+
throw new AlienError(
|
|
357
|
+
PostgresSecretResolutionError.create({
|
|
358
|
+
secret: secretName,
|
|
359
|
+
reason: "Secret Manager secret has no payload",
|
|
360
|
+
}),
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
return Buffer.from(data).toString("utf8")
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Reads the raw password the Azure controller stored as a Key Vault secret. `secretUri` is the full
|
|
368
|
+
* Key Vault secret URI the binding carries:
|
|
369
|
+
* `https://<vault>.vault.azure.net/secrets/<name>[/<version>]`.
|
|
370
|
+
*/
|
|
371
|
+
async function readAzureSecret(secretUri: string): Promise<string> {
|
|
372
|
+
let url: URL
|
|
373
|
+
try {
|
|
374
|
+
url = new URL(secretUri)
|
|
375
|
+
} catch (cause) {
|
|
376
|
+
throw (await AlienError.from(cause)).withContext(
|
|
377
|
+
InvalidBindingConfigError.create({
|
|
378
|
+
message: `Postgres password secret URI '${secretUri}' is not a valid URL`,
|
|
379
|
+
}),
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
// Path is exactly `/secrets/<name>[/<version>]`; a missing version means "latest". Reject extra
|
|
383
|
+
// segments rather than silently ignore them, matching the schema's fail-loud posture.
|
|
384
|
+
const segments = url.pathname.split("/").filter(Boolean)
|
|
385
|
+
const secretName = segments[1]
|
|
386
|
+
const secretVersion = segments[2]
|
|
387
|
+
if (segments[0] !== "secrets" || !secretName || segments.length > 3) {
|
|
388
|
+
throw new AlienError(
|
|
389
|
+
InvalidBindingConfigError.create({
|
|
390
|
+
message: `Key Vault secret URI '${secretUri}' is not a '/secrets/<name>' URL`,
|
|
391
|
+
}),
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
let secrets: typeof import("@azure/keyvault-secrets")
|
|
396
|
+
let identity: typeof import("@azure/identity")
|
|
397
|
+
try {
|
|
398
|
+
secrets = await import("@azure/keyvault-secrets")
|
|
399
|
+
identity = await import("@azure/identity")
|
|
400
|
+
} catch (cause) {
|
|
401
|
+
throw (await AlienError.from(cause)).withContext(
|
|
402
|
+
InvalidBindingConfigError.create({
|
|
403
|
+
message: "Failed to load '@azure/keyvault-secrets' / '@azure/identity'",
|
|
404
|
+
suggestion:
|
|
405
|
+
"Add '@azure/keyvault-secrets' and '@azure/identity' to this workload's dependencies",
|
|
406
|
+
}),
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
const client = new secrets.SecretClient(url.origin, new identity.DefaultAzureCredential())
|
|
410
|
+
let value: string | undefined
|
|
411
|
+
try {
|
|
412
|
+
const secret = await client.getSecret(
|
|
413
|
+
secretName,
|
|
414
|
+
secretVersion ? { version: secretVersion } : undefined,
|
|
415
|
+
)
|
|
416
|
+
value = secret.value
|
|
417
|
+
} catch (cause) {
|
|
418
|
+
throw (await AlienError.from(cause)).withContext(
|
|
419
|
+
PostgresSecretResolutionError.create({
|
|
420
|
+
secret: secretName,
|
|
421
|
+
reason: "Key Vault getSecret failed",
|
|
422
|
+
}),
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
if (!value) {
|
|
426
|
+
// An empty stored secret is a control-plane invariant the workload can't fix, not bad user
|
|
427
|
+
// input — surface it as a (retryable) resolution failure, like the read-failure path above.
|
|
428
|
+
throw new AlienError(
|
|
429
|
+
PostgresSecretResolutionError.create({
|
|
430
|
+
secret: secretName,
|
|
431
|
+
reason: "Key Vault secret has no value",
|
|
432
|
+
}),
|
|
433
|
+
)
|
|
434
|
+
}
|
|
435
|
+
return value
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function assertNever(_value: never): never {
|
|
439
|
+
// Don't serialize the value: the external/local variants carry a plaintext password, and this
|
|
440
|
+
// error is `internal: false`, so its message can surface to API clients.
|
|
441
|
+
throw new AlienError(
|
|
442
|
+
InvalidBindingConfigError.create({
|
|
443
|
+
message: "Postgres binding has an unhandled service variant",
|
|
444
|
+
}),
|
|
445
|
+
)
|
|
446
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -257,3 +257,24 @@ export const InvalidBindingConfigError = defineError({
|
|
|
257
257
|
internal: false,
|
|
258
258
|
httpStatusCode: 400,
|
|
259
259
|
})
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Error thrown when reading a Postgres connection-password secret from a cloud secret store yields
|
|
263
|
+
* no usable value — an upstream read failure (throttle, network blip, service unavailable) or a
|
|
264
|
+
* stored secret that is present but empty. Both are control-plane invariants the workload cannot
|
|
265
|
+
* fix, so unlike {@link InvalidBindingConfigError} this is retryable and maps to 503, not a
|
|
266
|
+
* user-fixable 400. The genuinely user-invalid cases (a malformed secret URI, a malformed binding)
|
|
267
|
+
* keep using InvalidBindingConfigError.
|
|
268
|
+
*/
|
|
269
|
+
export const PostgresSecretResolutionError = defineError({
|
|
270
|
+
code: "POSTGRES_SECRET_RESOLUTION_ERROR",
|
|
271
|
+
context: z.object({
|
|
272
|
+
secret: z.string(),
|
|
273
|
+
reason: z.string(),
|
|
274
|
+
}),
|
|
275
|
+
message: ({ secret, reason }) =>
|
|
276
|
+
`Failed to read Postgres connection-password secret '${secret}': ${reason}`,
|
|
277
|
+
retryable: true,
|
|
278
|
+
internal: false,
|
|
279
|
+
httpStatusCode: 503,
|
|
280
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -74,6 +74,10 @@ export { ArtifactRegistry } from "./bindings/artifact-registry.js"
|
|
|
74
74
|
export { WorkerBinding } from "./bindings/worker.js"
|
|
75
75
|
export { ServiceAccount } from "./bindings/service-account.js"
|
|
76
76
|
|
|
77
|
+
// Postgres is connection-only (no gRPC service): a function that resolves connection
|
|
78
|
+
// details for a linked database, not a channel-backed binding class.
|
|
79
|
+
export { getPostgresConnection, type PostgresConnection } from "./bindings/postgres.js"
|
|
80
|
+
|
|
77
81
|
// ============================================================================
|
|
78
82
|
// Types
|
|
79
83
|
// ============================================================================
|
|
@@ -140,6 +144,7 @@ export {
|
|
|
140
144
|
EventHandlerAlreadyRegisteredError,
|
|
141
145
|
CommandAlreadyRegisteredError,
|
|
142
146
|
InvalidBindingConfigError,
|
|
147
|
+
PostgresSecretResolutionError,
|
|
143
148
|
} from "./errors.js"
|
|
144
149
|
|
|
145
150
|
// ============================================================================
|