toiljs 0.0.43 → 0.0.44
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/CHANGELOG.md +9 -0
- package/RSG.md +256 -0
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/index.js +2 -1
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/auth.js +1 -1
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/generate.js +1 -0
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/crypto.js +1 -1
- package/build/devserver/env.d.ts +2 -0
- package/build/devserver/env.js +58 -0
- package/build/devserver/host.js +17 -0
- package/build/devserver/module.js +1 -1
- package/docs/README.md +4 -1
- package/docs/email.md +62 -51
- package/docs/environment.md +97 -0
- package/examples/basic/server/main.ts +1 -0
- package/examples/basic/server/routes/EnvDemo.ts +42 -0
- package/package.json +2 -2
- package/server/globals/environment.ts +82 -0
- package/src/cli/create.ts +2 -1
- package/src/client/auth.ts +1 -1
- package/src/compiler/generate.ts +1 -0
- package/src/devserver/crypto.ts +1 -1
- package/src/devserver/env.ts +87 -0
- package/src/devserver/host.ts +42 -0
- package/src/devserver/module.ts +1 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Environment variables & secrets
|
|
2
|
+
|
|
3
|
+
`Environment` gives a tenant **per-app environment variables and secrets**, set
|
|
4
|
+
out of band (a dashboard, like GitHub Actions) so the deployed `.wasm` carries
|
|
5
|
+
**no credentials**. It is read-only from app code — there is no `set`; values are
|
|
6
|
+
configured on the deployment side, never from the module.
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { Response, RouteContext } from 'toiljs/server/runtime';
|
|
10
|
+
|
|
11
|
+
@rest('cfg')
|
|
12
|
+
class Cfg {
|
|
13
|
+
@get('/')
|
|
14
|
+
show(ctx: RouteContext): Response {
|
|
15
|
+
const base = Environment.get('PUBLIC_API_BASE'); // plain var, or null
|
|
16
|
+
const key = Environment.getSecure('STRIPE_KEY'); // secret, or null
|
|
17
|
+
// Use `key` to call a third party; never log it or return it to a client.
|
|
18
|
+
return Response.text(base != null ? base : 'unset');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`Environment` is a global — no import needed (like `EmailService` / `AuthService`).
|
|
24
|
+
|
|
25
|
+
## Two disjoint buckets
|
|
26
|
+
|
|
27
|
+
Just like GitHub Actions' `vars` vs `secrets`:
|
|
28
|
+
|
|
29
|
+
- **`Environment.get(key)`** reads **plain vars** — non-sensitive config (a public
|
|
30
|
+
API base URL, a feature flag, a region). Returns the string, or `null`.
|
|
31
|
+
- **`Environment.getSecure(key)`** reads **secrets** — sensitive values (a
|
|
32
|
+
third-party API key). Returns the string, or `null`.
|
|
33
|
+
|
|
34
|
+
The buckets are **disjoint**: a secret is **never** returned by `get()`, and a
|
|
35
|
+
plain var is never returned by `getSecure()`. That keeps a secret from leaking
|
|
36
|
+
through a code path that logs the result of a `get()`. Keys are case-sensitive,
|
|
37
|
+
exact-match.
|
|
38
|
+
|
|
39
|
+
> Secrets you read with `getSecure` are plaintext in your module at runtime
|
|
40
|
+
> (that's the point — you need them to call out). Don't log them, don't put them
|
|
41
|
+
> in a response, and don't copy them into a client bundle.
|
|
42
|
+
|
|
43
|
+
## What is NOT here
|
|
44
|
+
|
|
45
|
+
Framework-reserved namespaces (today: **email** provider config) are **host-only**
|
|
46
|
+
— resolved and used in Rust where the framework needs them, and **never exposed to
|
|
47
|
+
the `.wasm`**. There is no `Environment.email`; you configure email in the
|
|
48
|
+
`[email]` block of the same env file and the platform uses it for you (see
|
|
49
|
+
[Email](./email.md)). The env imports only ever see your own `vars` / `secrets`.
|
|
50
|
+
|
|
51
|
+
## Where values live
|
|
52
|
+
|
|
53
|
+
Vars and secrets live in **two separate dotenv (`.env`) files**, so the disjoint
|
|
54
|
+
split is structural and the secrets file can be locked down on its own. On the
|
|
55
|
+
edge they are per host, **out of `hosts/`** (so the config watcher never sees a
|
|
56
|
+
credential) — the dashboard / edge database replaces them later:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
# $TOIL_ENV_DIR/<host>.env (default dir /run/toil/env)
|
|
60
|
+
PUBLIC_API_BASE=https://api.example.com # -> Environment.get("PUBLIC_API_BASE")
|
|
61
|
+
REGION=eu
|
|
62
|
+
|
|
63
|
+
# $TOIL_ENV_DIR/<host>.env.secrets (mode 0600)
|
|
64
|
+
STRIPE_KEY=sk_live_xxx # -> Environment.getSecure("STRIPE_KEY")
|
|
65
|
+
|
|
66
|
+
# host-only email config — reserved TOIL_EMAIL_* keys, NEVER exposed to the .wasm
|
|
67
|
+
TOIL_EMAIL_ENABLED=true
|
|
68
|
+
TOIL_EMAIL_PROVIDER=resend
|
|
69
|
+
TOIL_EMAIL_FROM=noreply@example.com
|
|
70
|
+
TOIL_EMAIL_API_KEY=re_xxx
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Each file is plain dotenv: `KEY=value` per line, `#` comments, optional `export`,
|
|
74
|
+
optional quotes. Keys with the reserved **`TOIL_`** prefix are framework/host-only
|
|
75
|
+
and are stripped from BOTH guest buckets — a tenant can never read them via
|
|
76
|
+
`get`/`getSecure` (see [Email](./email.md) for `TOIL_EMAIL_*`).
|
|
77
|
+
|
|
78
|
+
On the edge, env is loaded **lazily** (the first time your code reads it) into a
|
|
79
|
+
**bounded, shared, read-only cache** with idle eviction: the data lives in one
|
|
80
|
+
place and is never copied per request, a host that never reads env costs nothing,
|
|
81
|
+
secrets are wiped when a host goes cold, and a flood of requests to many distinct
|
|
82
|
+
hosts can never grow memory without bound.
|
|
83
|
+
|
|
84
|
+
## In dev
|
|
85
|
+
|
|
86
|
+
`toiljs dev` reads `.env` (vars) and `.env.secrets` (secrets) at your project
|
|
87
|
+
root, and overlays `process.env` as plain vars for convenience. Both are
|
|
88
|
+
gitignored by the scaffold. The ABI is identical to the edge, so code that runs
|
|
89
|
+
in dev runs on the edge.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# .env (vars)
|
|
93
|
+
PUBLIC_API_BASE=http://localhost:4000
|
|
94
|
+
|
|
95
|
+
# .env.secrets (secrets; 0600; gitignored)
|
|
96
|
+
STRIPE_KEY=sk_test_xxx
|
|
97
|
+
```
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Response, RouteContext } from 'toiljs/server/runtime';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Environment demo: per-tenant config + secrets, read from server code with no
|
|
5
|
+
* import (`Environment` is a server global).
|
|
6
|
+
*
|
|
7
|
+
* Values come from out-of-band dotenv files the edge loads lazily and never
|
|
8
|
+
* bundles into the `.wasm`: plain vars from `<host>.env`, secrets from
|
|
9
|
+
* `<host>.env.secrets`. In `toiljs dev` those are `.env` / `.env.secrets` at the
|
|
10
|
+
* project root — copy `.env.example` / `.env.secrets.example` to see this populate.
|
|
11
|
+
*
|
|
12
|
+
* Environment.get("KEY") -> a plain var, or null
|
|
13
|
+
* Environment.getSecure("KEY") -> a secret, or null (a secret is NEVER
|
|
14
|
+
* returned by get(), the buckets are disjoint)
|
|
15
|
+
*
|
|
16
|
+
* Reserved `TOIL_*` keys (e.g. the `TOIL_EMAIL_*` mailer config) are host-only:
|
|
17
|
+
* the edge reads them, but they are unreachable through get()/getSecure().
|
|
18
|
+
*/
|
|
19
|
+
@rest('env')
|
|
20
|
+
class EnvDemo {
|
|
21
|
+
/** GET /env/show -> the public vars, plus WHETHER the demo secret is set.
|
|
22
|
+
* We return the secret's presence, not its value: getSecure() hands the guest
|
|
23
|
+
* the real secret, but a demo must never echo a secret back over the wire. */
|
|
24
|
+
@get('/show')
|
|
25
|
+
public show(_ctx: RouteContext): Response {
|
|
26
|
+
let greeting = '(unset)';
|
|
27
|
+
const g = Environment.get('PUBLIC_GREETING');
|
|
28
|
+
if (g != null) greeting = g;
|
|
29
|
+
|
|
30
|
+
let region = '(unset)';
|
|
31
|
+
const r = Environment.get('REGION');
|
|
32
|
+
if (r != null) region = r;
|
|
33
|
+
|
|
34
|
+
const apiKeySet = Environment.getSecure('DEMO_API_KEY') != null;
|
|
35
|
+
|
|
36
|
+
const body =
|
|
37
|
+
'PUBLIC_GREETING=' + greeting + '\n' +
|
|
38
|
+
'REGION=' + region + '\n' +
|
|
39
|
+
'DEMO_API_KEY set=' + (apiKeySet ? 'yes' : 'no') + '\n';
|
|
40
|
+
return Response.text(body, 200);
|
|
41
|
+
}
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toiljs",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.44",
|
|
5
5
|
"author": "Dacely",
|
|
6
6
|
"description": "The modern React framework: a file-based React frontend and a ToilScript-compiled WebAssembly backend.",
|
|
7
7
|
"repository": {
|
|
@@ -116,8 +116,8 @@
|
|
|
116
116
|
"setup": "npm i && npm run build"
|
|
117
117
|
},
|
|
118
118
|
"dependencies": {
|
|
119
|
-
"@btc-vision/post-quantum": "^0.5.3",
|
|
120
119
|
"@dacely/hyper-express": "6.17.4",
|
|
120
|
+
"@dacely/noble-post-quantum": "^0.6.1",
|
|
121
121
|
"@dacely/toilscript-loader": "^0.1.0",
|
|
122
122
|
"@eslint-react/eslint-plugin": "^5.8.8",
|
|
123
123
|
"@eslint/js": "^10.0.1",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Environment: the per-tenant environment variables + secrets a tenant
|
|
2
|
+
// configures OUT OF BAND (a dashboard today, backed by a file; the edge DB
|
|
3
|
+
// later) so the deployed `.wasm` carries no credentials — the GitHub-Actions
|
|
4
|
+
// model. Available as a no-import global (the toilscript `--lib` mechanism, like
|
|
5
|
+
// `AuthService` / `EmailService`).
|
|
6
|
+
//
|
|
7
|
+
// const base = Environment.get("PUBLIC_API_BASE"); // plain var, or null
|
|
8
|
+
// const key = Environment.getSecure("STRIPE_KEY"); // secret, or null
|
|
9
|
+
//
|
|
10
|
+
// Two DISJOINT buckets (like GitHub `vars.*` vs `secrets.*`): `get` reads ONLY
|
|
11
|
+
// plain vars, `getSecure` reads ONLY secrets, so a secret can never come back
|
|
12
|
+
// through `get()` (and leak into a log of a `get`). READ-ONLY — there is no
|
|
13
|
+
// `set`; a tenant sets values on their dashboard, never from the `.wasm`.
|
|
14
|
+
//
|
|
15
|
+
// Framework-reserved namespaces (e.g. the email provider config) are HOST-ONLY:
|
|
16
|
+
// they are resolved and consumed in Rust where the framework uses them and are
|
|
17
|
+
// NEVER reachable here — these imports only see the tenant's own vars/secrets.
|
|
18
|
+
//
|
|
19
|
+
// Backed by the `env_get` / `env_get_secure` host imports (toil-backend
|
|
20
|
+
// `env_get_import.rs`, reading the lazy + bounded `env_cache`, plus the toiljs
|
|
21
|
+
// dev-server mock). A tenant that never reads env imports neither, so
|
|
22
|
+
// AssemblyScript tree-shakes this away.
|
|
23
|
+
|
|
24
|
+
// Host imports: copy one value into `outPtr[0..outCap]`. Return the value's byte
|
|
25
|
+
// length (`0` = present but empty), `-1` if `outCap` is too small (retry with a
|
|
26
|
+
// bigger buffer), `-2` if the key is absent. Resolved from the trusted request
|
|
27
|
+
// host, never anything the guest passes.
|
|
28
|
+
// @ts-ignore: decorator
|
|
29
|
+
@external('env', 'env_get')
|
|
30
|
+
declare function __toilEnvGet(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
|
|
31
|
+
// @ts-ignore: decorator
|
|
32
|
+
@external('env', 'env_get_secure')
|
|
33
|
+
declare function __toilEnvGetSecure(keyPtr: usize, keyLen: i32, outPtr: usize, outCap: i32): i32;
|
|
34
|
+
|
|
35
|
+
export namespace Environment {
|
|
36
|
+
/** The key is absent from the bucket. */
|
|
37
|
+
const ABSENT: i32 = -2;
|
|
38
|
+
/** The output buffer was too small; retry with a bigger one. */
|
|
39
|
+
const TOO_SMALL: i32 = -1;
|
|
40
|
+
/** First attempt buffer; doubles on `TOO_SMALL`. */
|
|
41
|
+
const INITIAL_CAP: i32 = 256;
|
|
42
|
+
/** Upper bound on a value, matching the host's per-value cap. */
|
|
43
|
+
const MAX_CAP: i32 = 64 * 1024;
|
|
44
|
+
|
|
45
|
+
/** Shared reader for both buckets; `secure` picks the secret import. */
|
|
46
|
+
function read(key: string, secure: bool): string | null {
|
|
47
|
+
const keyB = Uint8Array.wrap(String.UTF8.encode(key));
|
|
48
|
+
let cap = INITIAL_CAP;
|
|
49
|
+
while (cap <= MAX_CAP) {
|
|
50
|
+
const buf = new Uint8Array(cap);
|
|
51
|
+
const n = secure
|
|
52
|
+
? __toilEnvGetSecure(keyB.dataStart, keyB.length, buf.dataStart, cap)
|
|
53
|
+
: __toilEnvGet(keyB.dataStart, keyB.length, buf.dataStart, cap);
|
|
54
|
+
if (n == ABSENT) return null;
|
|
55
|
+
if (n == TOO_SMALL) {
|
|
56
|
+
cap = cap * 2;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (n < 0) return null; // unknown negative: fail closed
|
|
60
|
+
return String.UTF8.decodeUnsafe(buf.dataStart, n);
|
|
61
|
+
}
|
|
62
|
+
return null; // value larger than MAX_CAP: treat as absent
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The plain environment variable `key` (a tenant var, set on the dashboard),
|
|
67
|
+
* or `null` if it is not set. NEVER returns a secret — use {@link getSecure}
|
|
68
|
+
* for those.
|
|
69
|
+
*/
|
|
70
|
+
export function get(key: string): string | null {
|
|
71
|
+
return read(key, false);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The secret `key` (a tenant secret, set on the dashboard), or `null` if it
|
|
76
|
+
* is not set. Disjoint from {@link get}: a plain var is never returned here.
|
|
77
|
+
* The value is sensitive — do not log it.
|
|
78
|
+
*/
|
|
79
|
+
export function getSecure(key: string): string | null {
|
|
80
|
+
return read(key, true);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/cli/create.ts
CHANGED
|
@@ -165,7 +165,7 @@ function scaffold(
|
|
|
165
165
|
'.prettierignore':
|
|
166
166
|
'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n',
|
|
167
167
|
'.gitignore':
|
|
168
|
-
'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n',
|
|
168
|
+
'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\n# Local dev env vars/secrets (never commit)\n.env\n.env.secrets\n',
|
|
169
169
|
// Use the project's pinned TypeScript (node_modules) instead of VS Code's bundled version.
|
|
170
170
|
'.vscode/settings.json':
|
|
171
171
|
JSON.stringify({ 'typescript.tsdk': 'node_modules/typescript/lib' }, null, 4) + '\n',
|
|
@@ -304,6 +304,7 @@ declare class RenderedEmail { subject: string; body: string; html: string; const
|
|
|
304
304
|
declare class EmailTemplate { constructor(subject: string, body: string, html?: string); render(vars: Map<string, string>): RenderedEmail; send(to: string, vars: Map<string, string>, purpose?: string): EmailStatus; }
|
|
305
305
|
declare enum RateLimit { FixedWindow, SlidingWindow, TokenBucket }
|
|
306
306
|
declare namespace RateLimitService { function guard(routeId: i32, strategy: i32, limit: i32, window: i32): import('toiljs/server/runtime/response').Response | null; function guardKeyed(routeId: i32, strategy: i32, limit: i32, window: i32, key: string): import('toiljs/server/runtime/response').Response | null; }
|
|
307
|
+
declare namespace Environment { function get(key: string): string | null; function getSecure(key: string): string | null; }
|
|
307
308
|
declare class TwoFactorIssue { code: string; token: string; constructor(code: string, token: string); }
|
|
308
309
|
declare class TwoFactorChallenge { token: string; status: EmailStatus; constructor(token: string, status: EmailStatus); }
|
|
309
310
|
declare namespace TwoFactor { const DEFAULT_TTL_SECS: u64; const DEFAULT_DIGITS: i32; function setSecret(secret: Uint8Array): void; function issue(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorIssue; function send(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorChallenge; function verify(token: string, recipient: string, code: string): bool; }
|
package/src/client/auth.ts
CHANGED
package/src/compiler/generate.ts
CHANGED
|
@@ -117,6 +117,7 @@ export const TOIL_SERVER_ENV_DTS =
|
|
|
117
117
|
`declare class EmailTemplate { constructor(subject: string, body: string, html?: string); render(vars: Map<string, string>): RenderedEmail; send(to: string, vars: Map<string, string>, purpose?: string): EmailStatus; }\n` +
|
|
118
118
|
`declare enum RateLimit { FixedWindow, SlidingWindow, TokenBucket }\n` +
|
|
119
119
|
`declare namespace RateLimitService { function guard(routeId: i32, strategy: i32, limit: i32, window: i32): import('toiljs/server/runtime/response').Response | null; function guardKeyed(routeId: i32, strategy: i32, limit: i32, window: i32, key: string): import('toiljs/server/runtime/response').Response | null; }\n` +
|
|
120
|
+
`declare namespace Environment { function get(key: string): string | null; function getSecure(key: string): string | null; }\n` +
|
|
120
121
|
`declare class TwoFactorIssue { code: string; token: string; constructor(code: string, token: string); }\n` +
|
|
121
122
|
`declare class TwoFactorChallenge { token: string; status: EmailStatus; constructor(token: string, status: EmailStatus); }\n` +
|
|
122
123
|
`declare namespace TwoFactor { const DEFAULT_TTL_SECS: u64; const DEFAULT_DIGITS: i32; function setSecret(secret: Uint8Array): void; function issue(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorIssue; function send(recipient: string, purpose: string, ttlSecs?: u64, digits?: i32): TwoFactorChallenge; function verify(token: string, recipient: string, code: string): bool; }\n` +
|
package/src/devserver/crypto.ts
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-only source for `Environment.get` / `getSecure`, mirroring the edge's
|
|
3
|
+
* per-tenant env store. Reads two optional dotenv files at the project root:
|
|
4
|
+
*
|
|
5
|
+
* - `.env` -> plain vars (`Environment.get`); `process.env` overlays
|
|
6
|
+
* - `.env.secrets` -> secrets (`Environment.getSecure`); keep gitignored
|
|
7
|
+
*
|
|
8
|
+
* DISJOINT like the edge: `get` sees only vars, `getSecure` only secrets, so a
|
|
9
|
+
* secret never comes back through `get`. Framework-reserved `TOIL_*` keys are
|
|
10
|
+
* host-only and stripped from BOTH buckets (a tenant can't read them).
|
|
11
|
+
*
|
|
12
|
+
* Cached after first read; restart `toiljs dev` to pick up edits. The edge
|
|
13
|
+
* resolves this PER TENANT from `$TOIL_ENV_DIR/<host>.env` + `<host>.env.secrets`
|
|
14
|
+
* (and the edge DB later) through a lazy, bounded cache; dev has a single
|
|
15
|
+
* project, so two files are enough and no eviction is needed.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
/** Keys with this prefix are framework-reserved/host-only (never exposed). */
|
|
21
|
+
const RESERVED_PREFIX = 'TOIL_';
|
|
22
|
+
|
|
23
|
+
interface DevEnv {
|
|
24
|
+
vars: Map<string, string>;
|
|
25
|
+
secrets: Map<string, string>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let cache: DevEnv | null = null;
|
|
29
|
+
|
|
30
|
+
/** Parse one dotenv value: take inside matching quotes, else cut an inline ` #`. */
|
|
31
|
+
function parseValue(rest: string): string {
|
|
32
|
+
const q = rest[0];
|
|
33
|
+
if (q === '"' || q === "'") {
|
|
34
|
+
const end = rest.indexOf(q, 1);
|
|
35
|
+
return end < 0 ? rest.slice(1) : rest.slice(1, end);
|
|
36
|
+
}
|
|
37
|
+
const hash = rest.indexOf(' #');
|
|
38
|
+
return (hash < 0 ? rest : rest.slice(0, hash)).trimEnd();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Minimal dotenv parser: `KEY=value`, `#` comments, optional `export`, quotes. */
|
|
42
|
+
function parseDotenv(text: string, into: Map<string, string>): void {
|
|
43
|
+
for (const raw of text.split('\n')) {
|
|
44
|
+
let line = raw.trim();
|
|
45
|
+
if (line.length === 0 || line.startsWith('#')) continue;
|
|
46
|
+
if (line.startsWith('export ')) line = line.slice('export '.length);
|
|
47
|
+
const eq = line.indexOf('=');
|
|
48
|
+
if (eq < 0) continue;
|
|
49
|
+
const key = line.slice(0, eq).trim();
|
|
50
|
+
if (key.length === 0 || key.startsWith(RESERVED_PREFIX)) continue; // reserved/host-only
|
|
51
|
+
into.set(key, parseValue(line.slice(eq + 1).trim()));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readFileInto(file: string, into: Map<string, string>): void {
|
|
56
|
+
try {
|
|
57
|
+
parseDotenv(fs.readFileSync(path.join(process.cwd(), file), 'utf8'), into);
|
|
58
|
+
} catch {
|
|
59
|
+
/* file absent: skip */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function load(): DevEnv {
|
|
64
|
+
if (cache) return cache;
|
|
65
|
+
const vars = new Map<string, string>();
|
|
66
|
+
const secrets = new Map<string, string>();
|
|
67
|
+
// process.env overlays as plain vars (convenient in dev); never as secrets.
|
|
68
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
69
|
+
if (typeof v === 'string' && !k.startsWith(RESERVED_PREFIX)) vars.set(k, v);
|
|
70
|
+
}
|
|
71
|
+
readFileInto('.env', vars);
|
|
72
|
+
readFileInto('.env.secrets', secrets);
|
|
73
|
+
cache = { vars, secrets };
|
|
74
|
+
return cache;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A plain var by exact key, or `null`. Reads ONLY the `.env` (vars) bucket. */
|
|
78
|
+
export function devEnvGet(key: string): string | null {
|
|
79
|
+
const e = load();
|
|
80
|
+
return e.vars.has(key) ? (e.vars.get(key) as string) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A secret by exact key, or `null`. Reads ONLY the `.env.secrets` bucket. */
|
|
84
|
+
export function devEnvGetSecure(key: string): string | null {
|
|
85
|
+
const e = load();
|
|
86
|
+
return e.secrets.has(key) ? (e.secrets.get(key) as string) : null;
|
|
87
|
+
}
|
package/src/devserver/host.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { buildCryptoImports, freshCryptoState, type CryptoState } from './crypto.js';
|
|
21
|
+
import { devEnvGet, devEnvGetSecure } from './env.js';
|
|
21
22
|
import { ratelimitCheck } from './ratelimit.js';
|
|
22
23
|
|
|
23
24
|
/** Limits identical to the edge's `set_header` / `respond_file` bounds. */
|
|
@@ -105,6 +106,32 @@ function readGuestString(ref: MemoryRef, ptr: number): string {
|
|
|
105
106
|
return m.toString('utf16le', ptr, ptr + byteLen);
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Resolve one `Environment.get`/`getSecure` lookup against the dev env source
|
|
111
|
+
* and write it into the guest buffer, with the edge's return protocol: the value
|
|
112
|
+
* byte length (`0` = present-but-empty), `-1` if `outCap` is too small (the guest
|
|
113
|
+
* retries with a bigger buffer), `-2` if the key is absent.
|
|
114
|
+
*/
|
|
115
|
+
function envLookup(
|
|
116
|
+
ref: MemoryRef,
|
|
117
|
+
keyPtr: number,
|
|
118
|
+
keyLen: number,
|
|
119
|
+
outPtr: number,
|
|
120
|
+
outCap: number,
|
|
121
|
+
secure: boolean,
|
|
122
|
+
): number {
|
|
123
|
+
const key = readBytes(ref, keyPtr, keyLen).toString('utf8');
|
|
124
|
+
const val = secure ? devEnvGetSecure(key) : devEnvGet(key);
|
|
125
|
+
if (val === null) return -2; // ABSENT
|
|
126
|
+
const bytes = Buffer.from(val, 'utf8');
|
|
127
|
+
if (bytes.length > outCap) return -1; // TOO_SMALL
|
|
128
|
+
const m = mem(ref);
|
|
129
|
+
if (outPtr < 0 || outPtr + bytes.length > m.length)
|
|
130
|
+
throw new Error('env_get write out of bounds');
|
|
131
|
+
bytes.copy(m, outPtr);
|
|
132
|
+
return bytes.length;
|
|
133
|
+
}
|
|
134
|
+
|
|
108
135
|
/**
|
|
109
136
|
* Build the `env` import object for one instance. `state` collects what the
|
|
110
137
|
* imperative imports produce during a dispatch; bind a fresh state per request.
|
|
@@ -200,6 +227,21 @@ export function buildHostImports(ref: MemoryRef, state: DispatchState): WebAssem
|
|
|
200
227
|
return 0; // EmailStatus.Sent
|
|
201
228
|
},
|
|
202
229
|
|
|
230
|
+
// `Environment.get` / `getSecure`: copy one tenant env value into the
|
|
231
|
+
// guest buffer. Returns the byte length (0 = present-but-empty), -1 if
|
|
232
|
+
// the buffer is too small (the guest retries bigger), -2 if absent.
|
|
233
|
+
// Disjoint buckets: `env_get` reads vars, `env_get_secure` reads
|
|
234
|
+
// secrets. Mirrors the edge's `env_get_import.rs`; the dev source is
|
|
235
|
+
// `.env` (+ process.env vars) and `.env.secrets` (see ./env.ts).
|
|
236
|
+
env_get: (keyPtr: number, keyLen: number, outPtr: number, outCap: number): number =>
|
|
237
|
+
envLookup(ref, keyPtr, keyLen, outPtr, outCap, false),
|
|
238
|
+
env_get_secure: (
|
|
239
|
+
keyPtr: number,
|
|
240
|
+
keyLen: number,
|
|
241
|
+
outPtr: number,
|
|
242
|
+
outCap: number,
|
|
243
|
+
): number => envLookup(ref, keyPtr, keyLen, outPtr, outCap, true),
|
|
244
|
+
|
|
203
245
|
thread_spawn: (_startArg: number): number => -1,
|
|
204
246
|
|
|
205
247
|
// `Date.now()` -> wall-clock milliseconds, matching the edge host.
|
package/src/devserver/module.ts
CHANGED
|
@@ -53,7 +53,7 @@ interface HandleExports {
|
|
|
53
53
|
/** Host functions the dev server provides under `env` (see `host.ts`). */
|
|
54
54
|
const PROVIDED_IMPORTS = new Set([
|
|
55
55
|
'abort', 'set_status', 'set_header', 'respond_file', 'thread_spawn', 'Date.now',
|
|
56
|
-
'client_ip', 'ratelimit_check', 'email_send',
|
|
56
|
+
'client_ip', 'ratelimit_check', 'email_send', 'env_get', 'env_get_secure',
|
|
57
57
|
// Web Crypto host functions (see ./crypto.ts).
|
|
58
58
|
'crypto.fill_random', 'crypto.random_uuid', 'crypto.take_result', 'crypto.digest',
|
|
59
59
|
'crypto.import_key', 'crypto.export_key', 'crypto.encrypt', 'crypto.decrypt',
|