toiljs 0.0.84 → 0.0.86

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/compiler/.tsbuildinfo +1 -1
  4. package/build/compiler/config.d.ts +2 -0
  5. package/build/compiler/config.js +1 -0
  6. package/build/compiler/generate.js +3 -2
  7. package/build/compiler/index.d.ts +1 -1
  8. package/build/compiler/index.js +44 -5
  9. package/build/compiler/toil-docs.generated.js +6 -1
  10. package/build/devserver/.tsbuildinfo +1 -1
  11. package/build/devserver/analytics/index.js +113 -33
  12. package/build/devserver/runtime/module.js +1 -0
  13. package/docs/auth/configuration.md +94 -0
  14. package/docs/auth/extending.md +170 -0
  15. package/docs/auth/how-it-works.md +138 -0
  16. package/docs/auth/index.md +102 -0
  17. package/docs/auth/usage.md +188 -0
  18. package/docs/auth.md +6 -0
  19. package/examples/basic/client/routes/analytics.tsx +164 -26
  20. package/examples/basic/server/models/SiteAnalytics.ts +139 -11
  21. package/examples/basic/server/routes/Analytics.ts +89 -13
  22. package/examples/basic/server/routes/UserId.ts +22 -0
  23. package/package.json +5 -1
  24. package/scripts/gen-toil-docs.mjs +15 -6
  25. package/server/auth/AuthController.ts +335 -0
  26. package/server/auth/AuthUser.ts +49 -0
  27. package/server/auth/index.ts +16 -0
  28. package/server/globals/auth.ts +31 -0
  29. package/server/globals/userid.ts +107 -0
  30. package/src/compiler/config.ts +13 -0
  31. package/src/compiler/generate.ts +3 -2
  32. package/src/compiler/index.ts +74 -5
  33. package/src/compiler/toil-docs.generated.ts +6 -1
  34. package/src/devserver/analytics/index.ts +139 -38
  35. package/src/devserver/runtime/module.ts +1 -0
  36. package/test/analytics-dev.test.ts +76 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `ToilUserId` — the stable, tenant-scoped 256-bit user identity.
3
+ *
4
+ * Derived once as `sha256(mldsaPublicKey || identifier || domain)`, so the SAME user (same ML-DSA login
5
+ * public key + the same email/username) on the SAME tenant domain always maps to the SAME id — independent
6
+ * of session, cookie, or device. It is opaque and one-way (a hash), so it is safe to store, log, or use as
7
+ * a stable database key without leaking the underlying key or address.
8
+ *
9
+ * Backed by four 64-bit words (a 256-bit value), NOT a byte array, so equality is four word compares with
10
+ * early-out — no allocation, no byte loop. `==` / `!=` are overloaded to value equality, and `equals()` is
11
+ * the explicit form. (`===` in AssemblyScript is reference identity and is NOT overloadable; use `==` for
12
+ * value equality.)
13
+ *
14
+ * A global (no import), like `crypto` / `AuthService`. `AuthService` mints the current user's id from the
15
+ * login key + identifier + the request's tenant domain, so `@auth` handlers can key on a stable user.
16
+ */
17
+ export class ToilUserId {
18
+ constructor(
19
+ public w0: u64 = 0,
20
+ public w1: u64 = 0,
21
+ public w2: u64 = 0,
22
+ public w3: u64 = 0,
23
+ ) {}
24
+
25
+ /**
26
+ * Derive the id from the user's ML-DSA public key, their identifier (email or username), and the
27
+ * tenant `domain` (the site on dacely). Deterministic: identical inputs → identical id. The three
28
+ * inputs are concatenated in order and SHA-256'd; length-framing is unnecessary because the ML-DSA
29
+ * public key is a FIXED length (1312 bytes for ML-DSA-44), so the boundary before `identifier` is
30
+ * unambiguous, and `domain` is the trusted tail supplied by the host, not the user.
31
+ */
32
+ static derive(mldsaPublicKey: Uint8Array, identifier: string, domain: string): ToilUserId {
33
+ const id = Uint8Array.wrap(String.UTF8.encode(identifier));
34
+ const dom = Uint8Array.wrap(String.UTF8.encode(domain));
35
+ const buf = new Uint8Array(mldsaPublicKey.length + id.length + dom.length);
36
+ buf.set(mldsaPublicKey, 0);
37
+ buf.set(id, mldsaPublicKey.length);
38
+ buf.set(dom, mldsaPublicKey.length + id.length);
39
+ return ToilUserId.fromBytes(crypto.sha256(buf));
40
+ }
41
+
42
+ /** Build from a 32-byte digest. Bytes past 32 are ignored; a shorter array zero-pads the tail. */
43
+ static fromBytes(b: Uint8Array): ToilUserId {
44
+ if (b.length >= 32) {
45
+ const p = b.dataStart;
46
+ return new ToilUserId(load<u64>(p, 0), load<u64>(p, 8), load<u64>(p, 16), load<u64>(p, 24));
47
+ }
48
+ const tmp = new Uint8Array(32);
49
+ tmp.set(b, 0);
50
+ const p = tmp.dataStart;
51
+ return new ToilUserId(load<u64>(p, 0), load<u64>(p, 8), load<u64>(p, 16), load<u64>(p, 24));
52
+ }
53
+
54
+ /** The 32-byte identity (the original SHA-256 digest bytes; wasm is little-endian, so this round-trips). */
55
+ toBytes(): Uint8Array {
56
+ const out = new Uint8Array(32);
57
+ const p = out.dataStart;
58
+ store<u64>(p, this.w0, 0);
59
+ store<u64>(p, this.w1, 8);
60
+ store<u64>(p, this.w2, 16);
61
+ store<u64>(p, this.w3, 24);
62
+ return out;
63
+ }
64
+
65
+ /** Lowercase hex, 64 chars. */
66
+ toHex(): string {
67
+ const b = this.toBytes();
68
+ const HEX = '0123456789abcdef';
69
+ let s = '';
70
+ for (let i = 0; i < 32; i++) {
71
+ const v = <i32>b[i];
72
+ s += HEX.charAt(v >> 4);
73
+ s += HEX.charAt(v & 0xf);
74
+ }
75
+ return s;
76
+ }
77
+
78
+ /** True when unset (the all-zero id, e.g. an absent / anonymous user). */
79
+ isZero(): bool {
80
+ return (this.w0 | this.w1 | this.w2 | this.w3) == 0;
81
+ }
82
+
83
+ /** Value equality: four u64 compares, short-circuiting. O(1), no allocation. */
84
+ @inline
85
+ equals(other: ToilUserId): bool {
86
+ return (
87
+ this.w0 == other.w0 &&
88
+ this.w1 == other.w1 &&
89
+ this.w2 == other.w2 &&
90
+ this.w3 == other.w3
91
+ );
92
+ }
93
+
94
+ /** `a == b` value equality (overloaded). */
95
+ @operator('==')
96
+ @inline
97
+ eq(other: ToilUserId): bool {
98
+ return this.equals(other);
99
+ }
100
+
101
+ /** `a != b` value inequality (overloaded). */
102
+ @operator('!=')
103
+ @inline
104
+ ne(other: ToilUserId): bool {
105
+ return !this.equals(other);
106
+ }
107
+ }
@@ -140,6 +140,16 @@ export interface ServerConfig {
140
140
  readonly email?: EmailBackendConfig;
141
141
  /** Which layer the dev process emulates. Default `all`. */
142
142
  readonly nodeMode?: DevNodeMode;
143
+ /**
144
+ * Built-in auth. `true` opts into the framework's post-quantum login: the
145
+ * build appends a shipped `@rest('auth')` controller + its `@user` shape to
146
+ * the toilscript entry set, so the app gets the full `/auth/register|login`
147
+ * API + sessions (`/auth/me`, `/auth/logout`) with no hand-written boilerplate.
148
+ * Default `false`. (The escape hatch `import 'toiljs/server/auth'` in
149
+ * `server/main.ts` does the same without this flag.) An app that opts in must
150
+ * NOT declare its own `@user` — the built-in owns the single per-program one.
151
+ */
152
+ readonly auth?: boolean;
143
153
  /** Daemon (L4) config mirror (dev / self-host). */
144
154
  readonly daemon?: DaemonConfig;
145
155
  /**
@@ -202,6 +212,8 @@ export interface ResolvedToilConfig {
202
212
  readonly email: EmailBackendConfig | null;
203
213
  /** Which layer the dev process emulates (dev / self-host). Default `all`. */
204
214
  readonly nodeMode: DevNodeMode;
215
+ /** Whether the built-in `/auth/*` PQ-login controller is compiled + mounted. */
216
+ readonly auth: boolean;
205
217
  /** Daemon (L4) config mirror (dev / self-host), every field resolved. */
206
218
  readonly daemon: ResolvedDaemonConfig;
207
219
  /** Self-host HTTP worker count for `toiljs start`. */
@@ -279,6 +291,7 @@ export async function loadConfig(
279
291
  seo: client.seo ?? null,
280
292
  email: user.server?.email ?? null,
281
293
  nodeMode: resolveNodeMode(user.server?.nodeMode),
294
+ auth: user.server?.auth === true,
282
295
  daemon: resolveDaemonConfig(user.server?.daemon),
283
296
  threads: resolveThreads(user.server?.threads),
284
297
  runtimePath: resolveRuntimePath(),
@@ -125,8 +125,9 @@ export const TOIL_SERVER_ENV_DTS =
125
125
  `declare class TwoFactorIssue { code: string; token: string; constructor(code: string, token: string); }\n` +
126
126
  `declare class TwoFactorChallenge { token: string; status: EmailStatus; constructor(token: string, status: EmailStatus); }\n` +
127
127
  `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` +
128
- `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, ciphertext: Uint8Array, memKiB: u32, iterations: u32, parallelism: u32, serverKemKeyId: Uint8Array): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; const KEM_CIPHERTEXT_LEN: i32; const KEM_SECRET_KEY_LEN: i32; const KEM_PUBLIC_KEY_LEN: i32; const SHARED_SECRET_LEN: i32; const OPRF_ELEMENT_LEN: i32; const OPRF_SEED_LEN: i32; const SESSION_KEY_LABEL: string; const SERVER_CONFIRM_LABEL: string; function setOprfSeed(seed: Uint8Array): void; function setServerKemSecretKey(secretKey: Uint8Array): void; function setServerKemPublicKey(publicKey: Uint8Array): void; function serverKemKeyId(): Uint8Array; function oprfEvaluate(username: string, blinded: Uint8Array): Uint8Array; function mlkemDecapsulate(ciphertext: Uint8Array): Uint8Array; function sha256(data: Uint8Array): Uint8Array; function deriveSessionKey(sharedSecret: Uint8Array, transcriptHash: Uint8Array): Uint8Array; function serverConfirmTag(sessionKey: Uint8Array, transcriptHash: Uint8Array): Uint8Array; const REGISTER_CONTEXT: string; function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array; function verifyRegister(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }\n` +
129
- `interface __ToilAuthUser {}\n`;
128
+ `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 userId(): ToilUserId | null; function enable(): void; 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, ciphertext: Uint8Array, memKiB: u32, iterations: u32, parallelism: u32, serverKemKeyId: Uint8Array): Uint8Array; function verifyLogin(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; const KEM_CIPHERTEXT_LEN: i32; const KEM_SECRET_KEY_LEN: i32; const KEM_PUBLIC_KEY_LEN: i32; const SHARED_SECRET_LEN: i32; const OPRF_ELEMENT_LEN: i32; const OPRF_SEED_LEN: i32; const SESSION_KEY_LABEL: string; const SERVER_CONFIRM_LABEL: string; function setOprfSeed(seed: Uint8Array): void; function setServerKemSecretKey(secretKey: Uint8Array): void; function setServerKemPublicKey(publicKey: Uint8Array): void; function serverKemKeyId(): Uint8Array; function oprfEvaluate(username: string, blinded: Uint8Array): Uint8Array; function mlkemDecapsulate(ciphertext: Uint8Array): Uint8Array; function sha256(data: Uint8Array): Uint8Array; function deriveSessionKey(sharedSecret: Uint8Array, transcriptHash: Uint8Array): Uint8Array; function serverConfirmTag(sessionKey: Uint8Array, transcriptHash: Uint8Array): Uint8Array; const REGISTER_CONTEXT: string; function buildRegisterMessage(username: string, publicKey: Uint8Array): Uint8Array; function verifyRegister(publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array): bool; }\n` +
129
+ `interface __ToilAuthUser {}\n` +
130
+ `declare class ToilUserId { w0: u64; w1: u64; w2: u64; w3: u64; constructor(w0?: u64, w1?: u64, w2?: u64, w3?: u64); static derive(mldsaPublicKey: Uint8Array, identifier: string, domain: string): ToilUserId; static fromBytes(b: Uint8Array): ToilUserId; toBytes(): Uint8Array; toHex(): string; isZero(): bool; equals(other: ToilUserId): bool; }\n`;
130
131
 
131
132
  /**
132
133
  * Returns a `./`-prefixed, **extensionless** POSIX module specifier from `.toil` to `abs`, for use
@@ -3,6 +3,7 @@ import fs from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
4
  import net from 'node:net';
5
5
  import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
6
7
 
7
8
  import pc from 'picocolors';
8
9
  import { build as viteBuild, createServer, mergeConfig, type ViteDevServer } from 'vite';
@@ -106,6 +107,56 @@ function serverEntryFiles(root: string): string[] {
106
107
  return [...result].sort();
107
108
  }
108
109
 
110
+ /**
111
+ * The framework-shipped built-in-auth entry files (root-relative), appended to the toilscript entry
112
+ * set when `server.auth` is on. `AuthUser.ts` (the `@user` shape) is FIRST because `AuthController.ts`
113
+ * (the `@rest('auth')` controller) imports it. Primary lookup is the conventional install location
114
+ * under the app's `node_modules/toiljs` (which transparently follows a symlinked/linked toiljs); the
115
+ * fallback resolves the running toiljs package (`…/build/compiler/index.js` -> package root) for a
116
+ * hoisted install. Throws a clear error if the shipped files are missing.
117
+ */
118
+ function authEntryFiles(root: string): string[] {
119
+ const names = ['AuthUser.ts', 'AuthController.ts'];
120
+ const primary = names.map((n) => path.posix.join('node_modules/toiljs/server/auth', n));
121
+ if (primary.every((rel) => fs.existsSync(path.join(root, rel)))) return primary;
122
+ // Fallback: locate this running toiljs package dir and make the paths root-relative.
123
+ try {
124
+ const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
125
+ const abs = names.map((n) => path.join(pkgDir, 'server', 'auth', n));
126
+ if (abs.every((p) => fs.existsSync(p)))
127
+ return abs.map((p) => path.relative(root, p).replace(/\\/g, '/'));
128
+ } catch {
129
+ // fall through to the error below
130
+ }
131
+ throw new Error(
132
+ 'toiljs: server.auth is enabled but the built-in auth controller ' +
133
+ '(server/auth/AuthController.ts) was not found under node_modules/toiljs. ' +
134
+ 'Reinstall toiljs (npm i toiljs), or remove server.auth from toil.config.ts.',
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Whether any server entry declares the escape-hatch `import 'toiljs/server/auth'` (a bare side-effect
140
+ * import) — the lighter opt-in that turns on the built-in auth surface without the `server.auth` config
141
+ * flag. Framework-shipped decorator sources under node_modules only WEAVE as explicit toilscript entries
142
+ * (a transitive import lands them under the `~lib/` LIBRARY prefix, where `@data`/`@rest`/`@user` do not
143
+ * weave), so the bare import is only a SIGNAL: it triggers the same entry injection as the flag. `import
144
+ * type` is skipped (erased). The marker module `server/auth/index.ts` is intentionally empty so it never
145
+ * pulls a conflicting `~lib/` copy of the controller into the compile.
146
+ */
147
+ function serverImportsAuth(root: string, files: string[]): boolean {
148
+ // Bare side-effect import only (`import 'toiljs/server/auth'`); not `import type`, not a subpath.
149
+ const re = /(^|[^.\w])import\s+['"]toiljs\/server\/auth['"]/m;
150
+ for (const rel of files) {
151
+ try {
152
+ if (re.test(fs.readFileSync(path.join(root, rel), 'utf8'))) return true;
153
+ } catch {
154
+ // unreadable: skip
155
+ }
156
+ }
157
+ return false;
158
+ }
159
+
109
160
  /**
110
161
  * Builds the toilscript server target (which also regenerates `shared/server.ts` via
111
162
  * `--rpcModule`) when the project has one, signalled by a `toilconfig.json` at the root. This
@@ -115,7 +166,7 @@ function serverEntryFiles(root: string): string[] {
115
166
  * toilconfig entries) so dropped-in `@data`/`@rest` files are picked up. Runs the locally
116
167
  * installed `toilscript`, resolved + invoked via Node (no `.bin` shim / PATH assumptions).
117
168
  */
118
- export async function buildServer(root: string): Promise<void> {
169
+ export async function buildServer(root: string, auth: boolean = false): Promise<void> {
119
170
  if (!fs.existsSync(path.join(root, 'toilconfig.json'))) return;
120
171
 
121
172
  // Regenerate the editor-only server-globals d.ts each build (the same way
@@ -137,6 +188,24 @@ export async function buildServer(root: string): Promise<void> {
137
188
  // (optimization, features, runtime) still come from the toilconfig's `release` target.
138
189
  const files = serverEntryFiles(root);
139
190
 
191
+ // Built-in auth opt-in: APPEND the framework-shipped auth surface to the entry set BEFORE the tier
192
+ // split, so its `@user`/`@data`/`@database`/`@rest('auth')` decorators weave (a source under the
193
+ // `server/globals` LIB set — or transitively imported from node_modules under the `~lib/` prefix —
194
+ // would NOT weave) and the controller self-mounts at `/auth/*` with no hand-written boilerplate.
195
+ // AuthUser is ordered first (the controller imports it); AuthController lands in the REQUEST tier (it
196
+ // declares `@rest`) and AuthUser is shared into every pass (only `@user`/`@data`). They live under
197
+ // node_modules, so serverEntryFiles never scanned them. Opt-in is either the `server.auth` config flag
198
+ // OR the escape-hatch `import 'toiljs/server/auth'` in a server entry (which only MARKS the intent —
199
+ // it cannot weave on its own, being a `~lib/` import — so the build turns it into the same injection).
200
+ if (auth || serverImportsAuth(root, files)) {
201
+ const authFiles = authEntryFiles(root);
202
+ const authSet = new Set(authFiles);
203
+ files.unshift(...authFiles);
204
+ for (let i = files.length - 1; i >= authFiles.length; i--) {
205
+ if (authSet.has(files[i])) files.splice(i, 1); // defensive dedup (normally none)
206
+ }
207
+ }
208
+
140
209
  // A project that declares a `@daemon` (L4 cold surface) and/or a `@stream` (L2/L3 stream
141
210
  // surface) compiles the ONE source tree into SEPARATE artifacts, one per deployment tier, via
142
211
  // one toilscript pass each; a project with only the request surface keeps the default
@@ -473,7 +542,7 @@ function watchServer(cfg: ResolvedToilConfig, watcher: ViteDevServer['watcher'])
473
542
  // Recompile emails/*.tsx -> the generated module before the server build,
474
543
  // so editing an email template hot-reloads like any other server change.
475
544
  renderEmails(cfg)
476
- .then(() => buildServer(root))
545
+ .then(() => buildServer(root, cfg.auth))
477
546
  .then(() => process.stdout.write(pc.green(' ✓ ') + pc.dim('server rebuilt') + '\n'))
478
547
  .catch((e: unknown) =>
479
548
  process.stdout.write(pc.red(` ✗ server rebuild failed: ${String(e)}`) + '\n'),
@@ -751,7 +820,7 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
751
820
  // documented fail-safe 500 until the next full `build`. A no-op without an `ssr = true` route.
752
821
  generate(cfg);
753
822
  if (hasServer) await extractServerSlots(cfg);
754
- await buildServer(cfg.root);
823
+ await buildServer(cfg.root, cfg.auth);
755
824
  if (hasServer) process.stdout.write(pc.green(' ✓ ') + pc.dim('server built') + '\n');
756
825
 
757
826
  if (!hasServer) {
@@ -874,7 +943,7 @@ export async function build(opts: ToilCommandOptions = {}): Promise<void> {
874
943
  // server compiles. (The `HASH` is finalized by the post-Vite `extractTemplates` below, which
875
944
  // recompiles the server only if it rotated.) A no-op for a project with no `ssr = true` route.
876
945
  const priorServerSlots = hasServer ? await extractServerSlots(cfg) : new Map<string, string>();
877
- await buildServer(cfg.root);
946
+ await buildServer(cfg.root, cfg.auth);
878
947
  if (opts.serverOnly) return;
879
948
  if (hasServer)
880
949
  process.stdout.write(
@@ -909,7 +978,7 @@ export async function build(opts: ToilCommandOptions = {}): Promise<void> {
909
978
  process.stdout.write(
910
979
  pc.dim(' SSR template changed; recompiling the server with the new hash…') + '\n',
911
980
  );
912
- await buildServer(cfg.root);
981
+ await buildServer(cfg.root, cfg.auth);
913
982
  }
914
983
  }
915
984