toiljs 0.0.42 → 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.
@@ -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,40 @@
1
+ import { useCallback, useRef, useSyncExternalStore } from 'react';
2
+
3
+ /**
4
+ * Read a browser-only value (e.g. `document.cookie`) hydration-safely.
5
+ *
6
+ * Returns `server` during SSR and the first client paint, then the live `read()`
7
+ * value after mount, so the server and client markup always match. Call the
8
+ * returned `refresh()` after an action that changed the underlying source (a
9
+ * login, a Set-Cookie response) to re-read on demand.
10
+ *
11
+ * This is the `useSyncExternalStore` form of "sync a browser value into state on
12
+ * mount", which avoids the synchronous `setState`-in-`useEffect` pattern the React
13
+ * Compiler lint rules flag (`react-hooks/set-state-in-effect`).
14
+ */
15
+ export function useBrowserValue<T>(read: () => T, server: T): readonly [T, () => void] {
16
+ const subscribers = useRef(new Set<() => void>());
17
+ // Cache the snapshot so `getSnapshot` is referentially stable between refreshes
18
+ // (a fresh object each call would loop `useSyncExternalStore`).
19
+ const snapshot = useRef<{ live: boolean; value: T }>({ live: false, value: server });
20
+
21
+ const subscribe = useCallback((onStoreChange: () => void) => {
22
+ subscribers.current.add(onStoreChange);
23
+ return () => {
24
+ subscribers.current.delete(onStoreChange);
25
+ };
26
+ }, []);
27
+
28
+ const getSnapshot = useCallback(() => {
29
+ if (!snapshot.current.live) snapshot.current = { live: true, value: read() };
30
+ return snapshot.current.value;
31
+ }, [read]);
32
+
33
+ const refresh = useCallback(() => {
34
+ snapshot.current = { live: true, value: read() };
35
+ subscribers.current.forEach((onStoreChange) => onStoreChange());
36
+ }, [read]);
37
+
38
+ const value = useSyncExternalStore(subscribe, getSnapshot, () => server);
39
+ return [value, refresh];
40
+ }
@@ -8,10 +8,12 @@
8
8
  // `GET /session/me` is the server re-verifying the signed session (trusted).
9
9
  // The full post-quantum register/login (ML-DSA-44) needs an account store and is
10
10
  // stubbed in server/routes/Auth.ts; see docs/auth.md.
11
- import { useCallback, useEffect, useState } from 'react';
11
+ import { useCallback, useState } from 'react';
12
12
 
13
13
  import { Account } from 'shared/server';
14
14
 
15
+ import { useBrowserValue } from '../lib/useBrowserValue';
16
+
15
17
  /** Read one cookie value from `document.cookie`, or null. */
16
18
  function readCookie(name: string): string | null {
17
19
  const pairs = (document.cookie || '').split('; ');
@@ -71,13 +73,13 @@ interface VerifiedUser {
71
73
 
72
74
  export default function Auth(): React.JSX.Element {
73
75
  const [username, setUsername] = useState('ada');
74
- const [companion, setCompanion] = useState<Account | null>(null);
75
76
  const [verified, setVerified] = useState<VerifiedUser | null | 'none'>(null);
76
77
  const [busy, setBusy] = useState(false);
77
78
  const [log, setLog] = useState<string>('');
78
79
 
79
- const refreshCompanion = useCallback(() => setCompanion(readCompanion()), []);
80
- useEffect(refreshCompanion, [refreshCompanion]);
80
+ // Hydration-safe: null on the server and first paint, the live companion
81
+ // cookie after mount; `refreshCompanion()` re-reads after a login/logout.
82
+ const [companion, refreshCompanion] = useBrowserValue(readCompanion, null);
81
83
 
82
84
  const devLogin = useCallback(async () => {
83
85
  setBusy(true);
@@ -3,7 +3,9 @@
3
3
  // surface plus HMAC signing and AES-256-GCM encryption, running in the server wasm.
4
4
  // These controls call the `/api/cookies/*` routes in `server/core/AppHandler.ts`.
5
5
  // Needs the server running to respond.
6
- import { useEffect, useState, type CSSProperties } from 'react';
6
+ import { useState, type CSSProperties } from 'react';
7
+
8
+ import { useBrowserValue } from '../lib/useBrowserValue';
7
9
 
8
10
  export const metadata: Toil.Metadata = {
9
11
  title: 'Cookies',
@@ -41,6 +43,11 @@ const card: CSSProperties = {
41
43
  };
42
44
  const label: CSSProperties = { opacity: 0.7, fontSize: '0.8rem', marginTop: 6 };
43
45
 
46
+ /** The cookies JS can read (HttpOnly cookies are absent from `document.cookie`). */
47
+ function readJsCookies(): string {
48
+ return document.cookie || '(nothing visible to JS)';
49
+ }
50
+
44
51
  export default function CookiesDemo() {
45
52
  const [gallery, setGallery] = useState<Record<string, string> | null>(null);
46
53
  const [setResp, setSetResp] = useState<SetResp | null>(null);
@@ -48,11 +55,11 @@ export default function CookiesDemo() {
48
55
  const [cleared, setCleared] = useState<string[] | null>(null);
49
56
  const [seal, setSeal] = useState<SealResp | null>(null);
50
57
  const [sealInput, setSealInput] = useState('hello toiljs');
51
- const [jsCookies, setJsCookies] = useState('');
52
58
  const [err, setErr] = useState('');
53
59
 
54
- const readJs = (): void => setJsCookies(document.cookie || '(nothing visible to JS)');
55
- useEffect(readJs, []);
60
+ // Hydration-safe: '' on the server and first paint, the live `document.cookie`
61
+ // after mount; `readJs()` re-reads after a Set/Clear/Seal action.
62
+ const [jsCookies, readJs] = useBrowserValue(readJsCookies, '');
56
63
 
57
64
  const guard = async (fn: () => Promise<void>): Promise<void> => {
58
65
  setErr('');
@@ -10,11 +10,13 @@
10
10
  // swap in its own. It still isn't the full production login (no single-use
11
11
  // consume -> within the TTL a captured proof could be replayed; that needs a
12
12
  // store) -- see Auth.login / server/routes/Auth.ts and docs/auth.md.
13
- import { useCallback, useEffect, useState } from 'react';
13
+ import { useCallback, useState } from 'react';
14
14
 
15
15
  import { Auth, type IdentityProof } from 'toiljs/client';
16
16
  import { Account } from 'shared/server';
17
17
 
18
+ import { useBrowserValue } from '../lib/useBrowserValue';
19
+
18
20
  /** Read the readable companion cookie under either name (HTTP `toil_user` or
19
21
  * HTTPS `__Secure-toil_user`) and decode it. Display-only / untrusted. */
20
22
  function readCompanion(): Account | null {
@@ -82,11 +84,11 @@ export default function Pq(): React.JSX.Element {
82
84
  const [busy, setBusy] = useState(false);
83
85
  const [proof, setProof] = useState<IdentityProof | null>(null);
84
86
  const [result, setResult] = useState<Result | null>(null);
85
- const [companion, setCompanion] = useState<Account | null>(null);
86
87
  const [verified, setVerified] = useState<VerifiedUser | null | 'none'>(null);
87
88
 
88
- const refreshCompanion = useCallback(() => setCompanion(readCompanion()), []);
89
- useEffect(refreshCompanion, [refreshCompanion]);
89
+ // Hydration-safe: null on the server and first paint, the live companion
90
+ // cookie after mount; `refreshCompanion()` re-reads after a PQ login.
91
+ const [companion, refreshCompanion] = useBrowserValue(readCompanion, null);
90
92
 
91
93
  const prove = useCallback(
92
94
  async (doTamper: boolean) => {
@@ -11,6 +11,7 @@ import './routes/Players';
11
11
  import './routes/Leaderboard';
12
12
  import './routes/Session';
13
13
  import './routes/PqDemo';
14
+ import './routes/EnvDemo';
14
15
  import './services/Stats';
15
16
  import './services/remotes';
16
17
 
@@ -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.42",
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",
@@ -129,7 +129,7 @@
129
129
  "juice": "^12.1.0",
130
130
  "picocolors": "^1.1.1",
131
131
  "sharp": "^0.35.0",
132
- "toilscript": "^0.1.24",
132
+ "toilscript": "^0.1.25",
133
133
  "typescript-eslint": "^8.60.0",
134
134
  "vite": "^8.0.14",
135
135
  "vite-imagetools": "^10.0.0",
@@ -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',
@@ -303,10 +303,13 @@ declare namespace EmailService { function send(to: string, subject: string, body
303
303
  declare class RenderedEmail { subject: string; body: string; html: string; constructor(subject: string, body: string, html: string); }
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
+ 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; }
306
308
  declare class TwoFactorIssue { code: string; token: string; constructor(code: string, token: string); }
307
309
  declare class TwoFactorChallenge { token: string; status: EmailStatus; constructor(token: string, status: EmailStatus); }
308
- declare namespace TwoFactor { 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; }
309
- declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }
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; }
311
+ declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function getUser(): __ToilAuthUser | null; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }
312
+ interface __ToilAuthUser {}
310
313
  `;
311
314
 
312
315
  /**
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { argon2id, sha256 } from 'hash-wasm';
15
- import { ml_dsa44 } from '@btc-vision/post-quantum/ml-dsa.js';
15
+ import { ml_dsa44 } from '@dacely/noble-post-quantum/ml-dsa.js';
16
16
 
17
17
  import { DataReader, DataWriter } from 'toiljs/io';
18
18
 
@@ -116,10 +116,13 @@ export const TOIL_SERVER_ENV_DTS =
116
116
  `declare class RenderedEmail { subject: string; body: string; html: string; constructor(subject: string, body: string, html: string); }\n` +
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
+ `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` +
119
121
  `declare class TwoFactorIssue { code: string; token: string; constructor(code: string, token: string); }\n` +
120
122
  `declare class TwoFactorChallenge { token: string; status: EmailStatus; constructor(token: string, status: EmailStatus); }\n` +
121
- `declare namespace TwoFactor { 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` +
122
- `declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }\n`;
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` +
124
+ `declare namespace AuthService { const SESSION_COOKIE: string; const USER_COOKIE: string; const LOGIN_CONTEXT: string; const PUBLIC_KEY_LEN: i32; const SIGNATURE_LEN: i32; const DEFAULT_SESSION_TTL_SECS: u64; function setSecret(secret: Uint8Array): void; function hasSession(): bool; function getSessionBytes(): Uint8Array | null; function getUser(): __ToilAuthUser | null; function mintSession(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearSession(): Cookie; function userCookie(userData: Uint8Array, ttlSecs?: u64): Cookie; function clearUserCookie(): Cookie; function buildLoginMessage(sub: string, aud: string, cid: Uint8Array, nonce: Uint8Array, iat: u64, exp: u64): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }\n` +
125
+ `interface __ToilAuthUser {}\n`;
123
126
 
124
127
  /**
125
128
  * Returns a `./`-prefixed, **extensionless** POSIX module specifier from `.toil` to `abs`, for use
@@ -17,7 +17,7 @@
17
17
 
18
18
  import * as nodeCrypto from 'node:crypto';
19
19
 
20
- import { ml_dsa44 } from '@btc-vision/post-quantum/ml-dsa.js';
20
+ import { ml_dsa44 } from '@dacely/noble-post-quantum/ml-dsa.js';
21
21
 
22
22
  import type { MemoryRef } from './host.js';
23
23
 
@@ -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
+ }
@@ -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.
@@ -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',