toiljs 0.0.106 → 0.0.108
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 +10 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/auth-emails.d.ts +1 -0
- package/build/compiler/auth-emails.js +14 -10
- package/build/compiler/emails.d.ts +1 -1
- package/build/compiler/emails.js +3 -2
- package/build/compiler/index.js +12 -7
- package/build/devserver/.tsbuildinfo +1 -1
- package/build/devserver/db/catalog.js +7 -3
- package/build/devserver/db/database.d.ts +2 -0
- package/build/devserver/db/database.js +68 -2
- package/build/devserver/db/types.d.ts +7 -0
- package/package.json +3 -3
- package/server/auth/AuthController.ts +26 -19
- package/server/globals/auth.ts +17 -3
- package/server/globals/userid.ts +25 -4
- package/src/compiler/auth-emails.ts +39 -22
- package/src/compiler/emails.ts +7 -2
- package/src/compiler/index.ts +23 -11
- package/src/devserver/db/catalog.ts +8 -3
- package/src/devserver/db/database.ts +73 -4
- package/src/devserver/db/types.ts +10 -0
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.108",
|
|
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": {
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
"nodemailer": "^9.0.3",
|
|
137
137
|
"picocolors": "^1.1.1",
|
|
138
138
|
"sharp": "^0.35.3",
|
|
139
|
-
"toilscript": "^0.1.
|
|
139
|
+
"toilscript": "^0.1.56",
|
|
140
140
|
"typescript-eslint": "^8.62.1",
|
|
141
141
|
"vite": "^8.1.3",
|
|
142
142
|
"vite-imagetools": "^10.0.1",
|
|
@@ -147,7 +147,7 @@
|
|
|
147
147
|
"prettier": ">=3.0.0",
|
|
148
148
|
"react": ">=18.0.0",
|
|
149
149
|
"react-dom": ">=18.0.0",
|
|
150
|
-
"toilscript": ">=0.1.
|
|
150
|
+
"toilscript": ">=0.1.56",
|
|
151
151
|
"typescript": ">=6.0.0"
|
|
152
152
|
},
|
|
153
153
|
"overrides": {
|
|
@@ -488,6 +488,9 @@ class AuthAccount {
|
|
|
488
488
|
// TWOFA_NONE (2FA off), the safe default, so existing accounts stay logged in
|
|
489
489
|
// by password alone until they opt in via /auth/2fa/setup.
|
|
490
490
|
twoFactorMethod: u8 = 0;
|
|
491
|
+
// Append-only: the STABLE ToilUserId, derived once at registration. Reset rotates
|
|
492
|
+
// publicKey but leaves this, so the id is stable across a reset.
|
|
493
|
+
toilUserId: Uint8Array = new Uint8Array(0);
|
|
491
494
|
}
|
|
492
495
|
|
|
493
496
|
@data
|
|
@@ -609,16 +612,6 @@ class Auth {
|
|
|
609
612
|
// on domain A is a DIFFERENT account + index entry from the same on domain B.
|
|
610
613
|
const h = realm(ctx);
|
|
611
614
|
|
|
612
|
-
// Distinguishable statuses (not the generic 401) so the UI can say
|
|
613
|
-
// "username taken" / "email in use". Signup intentionally leaks existence
|
|
614
|
-
// (a product choice, now gated behind the PoP above); reset never does.
|
|
615
|
-
if (AuthDb.accounts.exists(new Username(h, username))) {
|
|
616
|
-
return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
|
|
617
|
-
}
|
|
618
|
-
if (AuthDb.emails.exists(new EmailKey(h, email))) {
|
|
619
|
-
return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
|
|
620
|
-
}
|
|
621
|
-
|
|
622
615
|
// Send the confirm email + store unconfirmed when EITHER flag is on
|
|
623
616
|
// (AUTH_EMAIL_CONFIRMATION or the stricter AUTH_REQUIRE_EMAIL_CONFIRMATION).
|
|
624
617
|
// Whether login is then BLOCKED is a separate check (requireConfirmation,
|
|
@@ -633,14 +626,20 @@ class Auth {
|
|
|
633
626
|
a.memKiB = MEM_KIB;
|
|
634
627
|
a.iterations = ITERS;
|
|
635
628
|
a.parallelism = PAR;
|
|
636
|
-
//
|
|
629
|
+
// Derive + persist the stable id once. Tenant-scoped, and unique WITHOUT a
|
|
630
|
+
// separate index: the framed derive maps distinct (username, domain) to
|
|
631
|
+
// distinct ids, and username is unique per realm (accounts.create below), so
|
|
632
|
+
// two accounts can never share an id.
|
|
633
|
+
a.toilUserId = ToilUserId.derive(pk, username, authDomain(ctx)).toBytes();
|
|
634
|
+
// create-if-absent IS the authoritative, race-safe uniqueness guard
|
|
635
|
+
// (serialized at the key's home): a duplicate username loses here and gets the
|
|
636
|
+
// distinguishable ST_TAKEN. No pre-`exists` read needed -- the create result
|
|
637
|
+
// is the answer.
|
|
637
638
|
if (!AuthDb.accounts.create(new Username(h, username), a)) {
|
|
638
639
|
return Response.bytes(new DataWriter().writeU8(ST_TAKEN).toBytes());
|
|
639
640
|
}
|
|
640
|
-
// Reserve the email -> username index (uniqueness
|
|
641
|
-
//
|
|
642
|
-
// just created so a lost email race can't orphan a username whose email index
|
|
643
|
-
// points at someone else (which would also break reset-by-email for it).
|
|
641
|
+
// Reserve the email -> username index (email uniqueness + reset-by-email). A
|
|
642
|
+
// duplicate email loses here; roll back the account so nothing is orphaned.
|
|
644
643
|
if (!AuthDb.emails.create(new EmailKey(h, email), new EmailOwner(username))) {
|
|
645
644
|
AuthDb.accounts.delete(new Username(h, username));
|
|
646
645
|
return Response.bytes(new DataWriter().writeU8(ST_EMAIL_TAKEN).toBytes());
|
|
@@ -841,8 +840,7 @@ class Auth {
|
|
|
841
840
|
// extended one). `__toilEncodeAuthUser` is injected by `--authUser`: it constructs the `@user`
|
|
842
841
|
// (app fields at their defaults), sets the reserved identity, and encodes it. The stable
|
|
843
842
|
// ToilUserId is derived from the login public key + username + tenant domain.
|
|
844
|
-
const
|
|
845
|
-
const toilUserId = ToilUserId.derive(acct.publicKey, ch.username, domain).toBytes();
|
|
843
|
+
const toilUserId = this.stableUserId(ctx, h, ch.username, acct);
|
|
846
844
|
const userData = __toilEncodeAuthUser(toilUserId, ch.username);
|
|
847
845
|
const w = new DataWriter();
|
|
848
846
|
w.writeU8(ST_OK);
|
|
@@ -958,6 +956,16 @@ class Auth {
|
|
|
958
956
|
return Response.bytes(new DataWriter().writeU8(ST_OK).toBytes());
|
|
959
957
|
}
|
|
960
958
|
|
|
959
|
+
/** The account's STABLE ToilUserId, persisted at registration (so reset never
|
|
960
|
+
* changes it). An empty stored id is derived once, persisted, and indexed. */
|
|
961
|
+
private stableUserId(ctx: RouteContext, h: string, username: string, acct: AuthAccount): Uint8Array {
|
|
962
|
+
if (acct.toilUserId.length > 0) return acct.toilUserId;
|
|
963
|
+
const uid = ToilUserId.derive(acct.publicKey, username, authDomain(ctx)).toBytes();
|
|
964
|
+
acct.toilUserId = uid;
|
|
965
|
+
AuthDb.accounts.patch(new Username(h, username), acct);
|
|
966
|
+
return uid;
|
|
967
|
+
}
|
|
968
|
+
|
|
961
969
|
/** GET /auth/me (@auth: 401 without a valid session) -> the typed user
|
|
962
970
|
* (bytes(toilUserId) str(username)). `AuthService.getUser()` is auto-typed
|
|
963
971
|
* to the built-in `@user` with no type argument. */
|
|
@@ -1037,8 +1045,7 @@ class Auth {
|
|
|
1037
1045
|
AuthDb.twoFaLogins.getDelete(key);
|
|
1038
1046
|
const acct = AuthDb.accounts.get(new Username(h, ch.username));
|
|
1039
1047
|
if (acct == null) return fail();
|
|
1040
|
-
const
|
|
1041
|
-
const toilUserId = ToilUserId.derive(acct.publicKey, ch.username, domain).toBytes();
|
|
1048
|
+
const toilUserId = this.stableUserId(ctx, h, ch.username, acct);
|
|
1042
1049
|
const userData = __toilEncodeAuthUser(toilUserId, ch.username);
|
|
1043
1050
|
const w = new DataWriter();
|
|
1044
1051
|
w.writeU8(ST_OK);
|
package/server/globals/auth.ts
CHANGED
|
@@ -228,8 +228,16 @@ function __labelled(label: string, transcriptHash: Uint8Array): Uint8Array {
|
|
|
228
228
|
function __reqIsSecure(): bool {
|
|
229
229
|
const req = Server.currentRequest;
|
|
230
230
|
if (req == null) return false;
|
|
231
|
+
// Proxied deployments: trust the terminating proxy's scheme header.
|
|
231
232
|
const proto = req.header('x-forwarded-proto');
|
|
232
|
-
|
|
233
|
+
if (proto != null) return proto == 'https';
|
|
234
|
+
// Direct-origin deployments: the edge terminates TLS itself and sends no
|
|
235
|
+
// x-forwarded-proto, so fall back to the configured public origin. An https
|
|
236
|
+
// PUBLIC_BASE_URL means the site is served over TLS, so the auth cookies must
|
|
237
|
+
// be Secure + __Host-/__Secure- prefixed. Without this a direct-origin HTTPS
|
|
238
|
+
// edge sets bare cookies and the client's getUser() misses the __Secure- name.
|
|
239
|
+
const base = Environment.get('PUBLIC_BASE_URL');
|
|
240
|
+
return base != null && base.startsWith('https://');
|
|
233
241
|
}
|
|
234
242
|
|
|
235
243
|
export namespace AuthService {
|
|
@@ -402,7 +410,10 @@ export namespace AuthService {
|
|
|
402
410
|
let cookie = Cookie.create(USER_BASE, base64UrlEncode(userData))
|
|
403
411
|
.sameSite(SameSite.Lax)
|
|
404
412
|
.maxAge(<i64>ttlSecs);
|
|
405
|
-
|
|
413
|
+
// Path=/ in BOTH branches: asSecurePrefixed() sets no path, so without this
|
|
414
|
+
// the cookie is scoped to the /auth request path and getUser() can't read it
|
|
415
|
+
// on /login or /dashboard.
|
|
416
|
+
cookie = secure ? cookie.asSecurePrefixed().path('/') : cookie.path('/');
|
|
406
417
|
return cookie;
|
|
407
418
|
}
|
|
408
419
|
|
|
@@ -412,7 +423,10 @@ export namespace AuthService {
|
|
|
412
423
|
let cookie = Cookie.create(USER_BASE, '')
|
|
413
424
|
.sameSite(SameSite.Lax)
|
|
414
425
|
.maxAge(0);
|
|
415
|
-
|
|
426
|
+
// Path=/ in BOTH branches: asSecurePrefixed() sets no path, so without this
|
|
427
|
+
// the cookie is scoped to the /auth request path and getUser() can't read it
|
|
428
|
+
// on /login or /dashboard.
|
|
429
|
+
cookie = secure ? cookie.asSecurePrefixed().path('/') : cookie.path('/');
|
|
416
430
|
return cookie;
|
|
417
431
|
}
|
|
418
432
|
|
package/server/globals/userid.ts
CHANGED
|
@@ -30,12 +30,18 @@ export class ToilUserId {
|
|
|
30
30
|
* unambiguous, and `domain` is the trusted tail supplied by the host, not the user.
|
|
31
31
|
*/
|
|
32
32
|
static derive(mldsaPublicKey: Uint8Array, identifier: string, domain: string): ToilUserId {
|
|
33
|
+
// Length-framed, domain-separated preimage: a u32 length prefix on every
|
|
34
|
+
// field keeps ids collision-free across tenants; the context tag separates
|
|
35
|
+
// this hash from other sha256 uses.
|
|
36
|
+
const ctx = Uint8Array.wrap(String.UTF8.encode(USERID_CONTEXT));
|
|
33
37
|
const id = Uint8Array.wrap(String.UTF8.encode(identifier));
|
|
34
38
|
const dom = Uint8Array.wrap(String.UTF8.encode(domain));
|
|
35
|
-
const buf = new Uint8Array(mldsaPublicKey.length + id.length + dom.length);
|
|
36
|
-
|
|
37
|
-
buf
|
|
38
|
-
buf
|
|
39
|
+
const buf = new Uint8Array(16 + ctx.length + mldsaPublicKey.length + id.length + dom.length);
|
|
40
|
+
let off = 0;
|
|
41
|
+
off = putField(buf, off, ctx);
|
|
42
|
+
off = putField(buf, off, mldsaPublicKey);
|
|
43
|
+
off = putField(buf, off, id);
|
|
44
|
+
off = putField(buf, off, dom);
|
|
39
45
|
return ToilUserId.fromBytes(crypto.sha256(buf));
|
|
40
46
|
}
|
|
41
47
|
|
|
@@ -105,3 +111,18 @@ export class ToilUserId {
|
|
|
105
111
|
return !this.equals(other);
|
|
106
112
|
}
|
|
107
113
|
}
|
|
114
|
+
|
|
115
|
+
/** Domain-separation tag for {@link ToilUserId.derive}; versioned. */
|
|
116
|
+
const USERID_CONTEXT: string = 'toil.userid.v1';
|
|
117
|
+
|
|
118
|
+
/** Append a u32-LE length prefix + `src` to `buf` at `off`; return the new offset. */
|
|
119
|
+
function putField(buf: Uint8Array, off: i32, src: Uint8Array): i32 {
|
|
120
|
+
const n = src.length;
|
|
121
|
+
buf[off] = <u8>(n & 0xff);
|
|
122
|
+
buf[off + 1] = <u8>((n >> 8) & 0xff);
|
|
123
|
+
buf[off + 2] = <u8>((n >> 16) & 0xff);
|
|
124
|
+
buf[off + 3] = <u8>((n >> 24) & 0xff);
|
|
125
|
+
off += 4;
|
|
126
|
+
buf.set(src, off);
|
|
127
|
+
return off + n;
|
|
128
|
+
}
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
|
|
24
24
|
import fs from 'node:fs';
|
|
25
25
|
import path from 'node:path';
|
|
26
|
-
import { fileURLToPath } from 'node:url';
|
|
27
26
|
|
|
28
27
|
import pc from 'picocolors';
|
|
29
28
|
import { createServer } from 'vite';
|
|
@@ -33,20 +32,19 @@ import { RESERVED_AUTH_EMAIL_NAMES, renderEmailFile, toPascal, type RenderedEmai
|
|
|
33
32
|
import { createViteConfig } from './vite.js';
|
|
34
33
|
|
|
35
34
|
/**
|
|
36
|
-
* The
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
42
|
-
*
|
|
35
|
+
* The APP-LOCAL library directory the auth-email module is generated into,
|
|
36
|
+
* relative to the project root (posix, for the toilscript `--lib` CLI arg). It is
|
|
37
|
+
* passed to the compiler via `--lib` (see runToilscriptPass), which "uses exports
|
|
38
|
+
* of all top-level files at this path as globals". That is what makes the
|
|
39
|
+
* generated `AuthEmail` namespace resolvable from the node_modules-compiled
|
|
40
|
+
* `AuthController` with NO import, the same way `EmailService`/`AuthService` are
|
|
41
|
+
* ambient. Generating HERE (the app's own gitignored `.toil/`) rather than into
|
|
42
|
+
* `node_modules/toiljs/server/globals` keeps it PER-APP: a pnpm/workspace/linked
|
|
43
|
+
* install physically shares that package dir, so writing there would let one app
|
|
44
|
+
* clobber another's templates (or delete the file another app is compiling), and
|
|
45
|
+
* a read-only store would fail the build. `.toil` is always app-writable.
|
|
43
46
|
*/
|
|
44
|
-
|
|
45
|
-
const inApp = path.join(root, 'node_modules', 'toiljs', 'server', 'globals');
|
|
46
|
-
if (fs.existsSync(inApp)) return inApp;
|
|
47
|
-
const pkgDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
48
|
-
return path.join(pkgDir, 'server', 'globals');
|
|
49
|
-
}
|
|
47
|
+
export const AUTH_EMAILS_LIB_SUBDIR = '.toil/authlib';
|
|
50
48
|
|
|
51
49
|
/** The reserved override names (PascalCase), one per built-in auth email. */
|
|
52
50
|
type AuthName = 'AuthConfirm' | 'AuthReset' | 'Auth2fa';
|
|
@@ -204,17 +202,36 @@ function moduleSource(parts: Parts): string {
|
|
|
204
202
|
}
|
|
205
203
|
|
|
206
204
|
/**
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
|
|
205
|
+
* Best-effort removal of a stale copy from the pre-0.0.107 location
|
|
206
|
+
* (`node_modules/toiljs/server/globals/_auth_emails.ts`). Leaving it there would
|
|
207
|
+
* define a SECOND `AuthEmail` namespace alongside the new app-local one and break
|
|
208
|
+
* the compile with a duplicate symbol, so a project upgraded in place (without a
|
|
209
|
+
* fresh `npm i`) is cleaned up here. Never throws (a read-only store just skips).
|
|
210
|
+
*/
|
|
211
|
+
function removeLegacyModule(root: string): void {
|
|
212
|
+
try {
|
|
213
|
+
const legacy = path.join(root, 'node_modules', 'toiljs', 'server', 'globals', GENERATED_BASENAME);
|
|
214
|
+
if (fs.existsSync(legacy)) fs.rmSync(legacy);
|
|
215
|
+
} catch {
|
|
216
|
+
// read-only / missing: nothing to clean, not fatal
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* (Re)write `_auth_emails.ts` into the app-local `.toil/authlib` LIB dir so the
|
|
222
|
+
* built-in auth controller has its email senders, baking any reserved
|
|
223
|
+
* `emails/*.tsx` override over the default. Runs BEFORE the toilscript server
|
|
224
|
+
* build (like {@link renderEmails}) so the module is compiled in via `--lib`. A
|
|
225
|
+
* no-op that removes a stale module when auth is off or there is no server. Only
|
|
226
|
+
* spins up Vite when an override actually exists.
|
|
213
227
|
*/
|
|
214
228
|
export async function renderAuthEmails(cfg: ResolvedToilConfig, authOn: boolean): Promise<void> {
|
|
215
|
-
const generatedPath = path.join(
|
|
229
|
+
const generatedPath = path.join(cfg.root, AUTH_EMAILS_LIB_SUBDIR, GENERATED_BASENAME);
|
|
230
|
+
removeLegacyModule(cfg.root);
|
|
216
231
|
|
|
217
|
-
|
|
232
|
+
// No server to compile into, or auth off: ensure no stale module lingers.
|
|
233
|
+
const hasServer = fs.existsSync(path.join(cfg.root, 'toilconfig.json'));
|
|
234
|
+
if (!authOn || !hasServer) {
|
|
218
235
|
if (fs.existsSync(generatedPath)) fs.rmSync(generatedPath);
|
|
219
236
|
return;
|
|
220
237
|
}
|
package/src/compiler/emails.ts
CHANGED
|
@@ -325,7 +325,7 @@ function warn(msg: string): void {
|
|
|
325
325
|
* there is no `emails/` directory. Removes a stale generated module when the
|
|
326
326
|
* directory becomes empty.
|
|
327
327
|
*/
|
|
328
|
-
export async function renderEmails(cfg: ResolvedToilConfig): Promise<void> {
|
|
328
|
+
export async function renderEmails(cfg: ResolvedToilConfig, authOn = false): Promise<void> {
|
|
329
329
|
const emailsDir = path.join(cfg.root, 'emails');
|
|
330
330
|
const generatedPath = path.join(serverDir(cfg.root), GENERATED_BASENAME);
|
|
331
331
|
|
|
@@ -355,7 +355,12 @@ export async function renderEmails(cfg: ResolvedToilConfig): Promise<void> {
|
|
|
355
355
|
for (const file of files) {
|
|
356
356
|
// Reserved auth-override templates (emails/auth-confirm.tsx, …) are
|
|
357
357
|
// consumed by the auth pipeline, not surfaced as generic `Emails.*`.
|
|
358
|
-
|
|
358
|
+
// Only reserved WHEN AUTH IS ON: a non-auth project keeping an email
|
|
359
|
+
// by that name still gets its normal `Emails.<Name>.send(...)`.
|
|
360
|
+
if (
|
|
361
|
+
authOn &&
|
|
362
|
+
RESERVED_AUTH_EMAIL_NAMES.has(toPascal(path.basename(file).replace(/\.(tsx|jsx)$/, '')))
|
|
363
|
+
)
|
|
359
364
|
continue;
|
|
360
365
|
try {
|
|
361
366
|
const r = await renderEmailFile(
|
package/src/compiler/index.ts
CHANGED
|
@@ -14,7 +14,11 @@ import type { RunningBackend } from 'toiljs/backend';
|
|
|
14
14
|
|
|
15
15
|
import { loadConfig, type ResolvedToilConfig } from './config.js';
|
|
16
16
|
import { renderEmails } from './emails.js';
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
AUTH_EMAILS_GENERATED_BASENAME,
|
|
19
|
+
AUTH_EMAILS_LIB_SUBDIR,
|
|
20
|
+
renderAuthEmails,
|
|
21
|
+
} from './auth-emails.js';
|
|
18
22
|
import { generate, TOIL_SERVER_ENV_DTS } from './generate.js';
|
|
19
23
|
import { prerenderStaticParams } from './ssg.js';
|
|
20
24
|
import {
|
|
@@ -546,6 +550,11 @@ function runToilscriptPass(
|
|
|
546
550
|
if (opts.rpcSurfaceFiles)
|
|
547
551
|
for (const surfaceFile of opts.rpcSurfaceFiles) args.push('--rpcSurfaceFiles', surfaceFile);
|
|
548
552
|
if (opts.authUser) args.push('--authUser');
|
|
553
|
+
// Built-in auth: add the app-local `.toil/authlib` as a library path so the
|
|
554
|
+
// generated `AuthEmail` (see renderAuthEmails) is an ambient global the
|
|
555
|
+
// node_modules-compiled AuthController resolves with no import. `--lib` merges
|
|
556
|
+
// with the toilconfig `lib` (it does not replace it). Written before this pass.
|
|
557
|
+
if (opts.authUser) args.push('--lib', AUTH_EMAILS_LIB_SUBDIR);
|
|
549
558
|
// Each pass is handed its OWN entry subset (the per-tier `files`); suppress the toilconfig
|
|
550
559
|
// `entries` so toilscript does not ALSO append every project entry to every pass (which would
|
|
551
560
|
// pull, e.g., a `@stream` class into the cold daemon pass). serverEntryFiles already folds
|
|
@@ -595,8 +604,9 @@ function watchServer(cfg: ResolvedToilConfig, watcher: ViteDevServer['watcher'])
|
|
|
595
604
|
process.stdout.write(pc.dim(' server changed, rebuilding…') + '\n');
|
|
596
605
|
// Recompile emails/*.tsx -> the generated module before the server build,
|
|
597
606
|
// so editing an email template hot-reloads like any other server change.
|
|
598
|
-
|
|
599
|
-
|
|
607
|
+
const authOn = serverAuthEnabled(root, cfg.auth);
|
|
608
|
+
renderEmails(cfg, authOn)
|
|
609
|
+
.then(() => renderAuthEmails(cfg, authOn))
|
|
600
610
|
.then(() => buildServer(root, cfg.auth))
|
|
601
611
|
.then(() => process.stdout.write(pc.green(' ✓ ') + pc.dim('server rebuilt') + '\n'))
|
|
602
612
|
.catch((e: unknown) =>
|
|
@@ -879,10 +889,11 @@ export async function dev(opts: ToilCommandOptions = {}): Promise<ViteDevServer>
|
|
|
879
889
|
const hasServer = fs.existsSync(path.join(cfg.root, 'toilconfig.json'));
|
|
880
890
|
if (hasServer) process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
|
|
881
891
|
// Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
//
|
|
885
|
-
|
|
892
|
+
const authOnDev = serverAuthEnabled(cfg.root, cfg.auth);
|
|
893
|
+
await renderEmails(cfg, authOnDev);
|
|
894
|
+
// Built-in auth: (re)generate `.toil/authlib/_auth_emails.ts` (defaults + any emails/auth-*.tsx
|
|
895
|
+
// overrides) so the injected AuthController's `AuthEmail.*` senders compile in via --lib.
|
|
896
|
+
await renderAuthEmails(cfg, authOnDev);
|
|
886
897
|
// Generate the client codegen first so the SSR slots pre-pass can load the route graph, then
|
|
887
898
|
// emit the server-importable `<server>/_ssr/<name>.slots.ts` BEFORE the server build so its
|
|
888
899
|
// `render` can import them. Dev reuses the prior build's shell (or the template) for the HASH;
|
|
@@ -1007,10 +1018,11 @@ export async function build(opts: ToilCommandOptions = {}): Promise<void> {
|
|
|
1007
1018
|
if (hasServer && !opts.serverOnly)
|
|
1008
1019
|
process.stdout.write(pc.dim(' building the server (toilscript)…') + '\n');
|
|
1009
1020
|
// Compile emails/*.tsx -> generated server module BEFORE toilscript builds it in.
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
//
|
|
1013
|
-
|
|
1021
|
+
const authOnBuild = serverAuthEnabled(cfg.root, cfg.auth);
|
|
1022
|
+
await renderEmails(cfg, authOnBuild);
|
|
1023
|
+
// Built-in auth: (re)generate `.toil/authlib/_auth_emails.ts` (defaults + any emails/auth-*.tsx
|
|
1024
|
+
// overrides) so the injected AuthController's `AuthEmail.*` senders compile in via --lib.
|
|
1025
|
+
await renderAuthEmails(cfg, authOnBuild);
|
|
1014
1026
|
// Generate the client codegen (`.toil/globals.ts`, `.toil/index.html`, …) NOW — before the
|
|
1015
1027
|
// server build — so the SSR slots pre-pass below can load the route/layout module graph and
|
|
1016
1028
|
// render the opted-in routes.
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type DbCatalogState,
|
|
22
22
|
DEFAULT_FILL_WAIT_MS,
|
|
23
23
|
type DevCollectionHandle,
|
|
24
|
+
type DevField,
|
|
24
25
|
isCollectionFamily,
|
|
25
26
|
MAX_FILL_WAIT_MS,
|
|
26
27
|
} from './types.js';
|
|
@@ -72,10 +73,13 @@ export function parseCatalog(wasm: Buffer): DbCatalogState {
|
|
|
72
73
|
return { kind: 'malformed' };
|
|
73
74
|
const fillAllowStale = fillAllowStaleByte === 1;
|
|
74
75
|
const nFields = r.readU16();
|
|
76
|
+
const fields: DevField[] = [];
|
|
75
77
|
for (let f = 0; f < nFields; f++) {
|
|
76
|
-
r.readString();
|
|
77
|
-
r.readString();
|
|
78
|
-
r.readU8()
|
|
78
|
+
const fName = r.readString();
|
|
79
|
+
const fType = r.readString();
|
|
80
|
+
const isArray = r.readU8() !== 0;
|
|
81
|
+
const unique = r.readU8() !== 0;
|
|
82
|
+
fields.push({ name: fName, typeName: fType, isArray, unique });
|
|
79
83
|
}
|
|
80
84
|
const nMig = r.readU16();
|
|
81
85
|
for (let m = 0; m < nMig; m++) r.readU32(); // migratableFrom versions
|
|
@@ -96,6 +100,7 @@ export function parseCatalog(wasm: Buffer): DbCatalogState {
|
|
|
96
100
|
placement,
|
|
97
101
|
fillMaxWaitMs,
|
|
98
102
|
fillAllowStale,
|
|
103
|
+
fields,
|
|
99
104
|
});
|
|
100
105
|
}
|
|
101
106
|
}
|
|
@@ -685,6 +685,70 @@ export class DevDatabase {
|
|
|
685
685
|
return this.store.has(storeKey(coll.name, readKey(ref, keyPtr, keyLen))) ? 1 : 0;
|
|
686
686
|
}
|
|
687
687
|
|
|
688
|
+
/** Each `@unique` field's raw value in an encoded record, per the collection's
|
|
689
|
+
* field layout. `[]` when there is no layout or no `@unique` field. The
|
|
690
|
+
* compiler restricts `@unique` to top-level scalar/blob records, so the walker
|
|
691
|
+
* only skips fixed scalars and length-prefixed blobs. */
|
|
692
|
+
private uniqueValuesOf(coll: DevCollectionHandle, value: Buffer): { index: number; val: Buffer }[] {
|
|
693
|
+
const fields = coll.fields;
|
|
694
|
+
if (!fields || !fields.some((f) => f.unique)) return [];
|
|
695
|
+
const r = new DataReader(value);
|
|
696
|
+
const out: { index: number; val: Buffer }[] = [];
|
|
697
|
+
for (let i = 0; i < fields.length; i++) {
|
|
698
|
+
const f = fields[i];
|
|
699
|
+
if (f.isArray) break;
|
|
700
|
+
if (f.typeName === 'string') {
|
|
701
|
+
const s = r.readString();
|
|
702
|
+
if (f.unique) out.push({ index: i, val: Buffer.from(s, 'utf8') });
|
|
703
|
+
} else if (f.typeName === 'Uint8Array') {
|
|
704
|
+
const b = r.readBytes();
|
|
705
|
+
if (f.unique) out.push({ index: i, val: Buffer.from(b) });
|
|
706
|
+
} else {
|
|
707
|
+
switch (f.typeName) {
|
|
708
|
+
case 'bool':
|
|
709
|
+
case 'u8':
|
|
710
|
+
case 'i8':
|
|
711
|
+
r.readU8();
|
|
712
|
+
break;
|
|
713
|
+
case 'u16':
|
|
714
|
+
case 'i16':
|
|
715
|
+
r.readU16();
|
|
716
|
+
break;
|
|
717
|
+
case 'u32':
|
|
718
|
+
case 'i32':
|
|
719
|
+
case 'f32':
|
|
720
|
+
r.readU32();
|
|
721
|
+
break;
|
|
722
|
+
case 'u64':
|
|
723
|
+
case 'i64':
|
|
724
|
+
case 'f64':
|
|
725
|
+
r.readU64();
|
|
726
|
+
break;
|
|
727
|
+
default:
|
|
728
|
+
return out; // nested @data — compiler forbids @unique on/after it
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
return out;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** True when another record in `coll` already holds one of `value`'s `@unique`
|
|
736
|
+
* field values (per-tenant uniqueness). `selfSk` is the writing record's store
|
|
737
|
+
* key so it never conflicts with itself; empty values are unique-if-present. */
|
|
738
|
+
private uniqueConflict(coll: DevCollectionHandle, value: Buffer, selfSk: string): boolean {
|
|
739
|
+
const uvals = this.uniqueValuesOf(coll, value).filter((u) => u.val.length > 0);
|
|
740
|
+
if (uvals.length === 0) return false;
|
|
741
|
+
const prefix = coll.name + '\0';
|
|
742
|
+
for (const [otherSk, otherVal] of this.store) {
|
|
743
|
+
if (otherSk === selfSk || !otherSk.startsWith(prefix)) continue;
|
|
744
|
+
const otherUvals = this.uniqueValuesOf(coll, otherVal);
|
|
745
|
+
for (const u of uvals) {
|
|
746
|
+
if (otherUvals.some((o) => o.index === u.index && o.val.equals(u.val))) return true;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return false;
|
|
750
|
+
}
|
|
751
|
+
|
|
688
752
|
create(
|
|
689
753
|
ref: MemoryRef,
|
|
690
754
|
db: DbDevState,
|
|
@@ -706,9 +770,12 @@ export class DevDatabase {
|
|
|
706
770
|
if (!start.fresh)
|
|
707
771
|
return start.outcome ? this.replayRecordOutcome(db, start.outcome) : start.status;
|
|
708
772
|
const sk = storeKey(coll.name, key);
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
773
|
+
// @unique: a value already held by another record blocks the create, exactly
|
|
774
|
+
// like the edge's op_create claim (surfaced as AlreadyExists -> create false).
|
|
775
|
+
const outcome: RecordOutcome =
|
|
776
|
+
this.store.has(sk) || this.uniqueConflict(coll, value, sk)
|
|
777
|
+
? { kind: 'already_exists' }
|
|
778
|
+
: { kind: 'unit' };
|
|
712
779
|
if (outcome.kind === 'unit') {
|
|
713
780
|
this.store.set(sk, value);
|
|
714
781
|
this.stampVersion(coll, sk); // stamp the value type's current schema version
|
|
@@ -740,7 +807,9 @@ export class DevDatabase {
|
|
|
740
807
|
return start.outcome ? this.replayRecordOutcome(db, start.outcome) : start.status;
|
|
741
808
|
const sk = storeKey(coll.name, key);
|
|
742
809
|
const outcome: RecordOutcome = this.store.has(sk)
|
|
743
|
-
?
|
|
810
|
+
? this.uniqueConflict(coll, v, sk)
|
|
811
|
+
? { kind: 'already_exists' } // patch would collide a @unique field
|
|
812
|
+
: { kind: 'value', value: v, schemaVersion: this.currentSchemaVersion(coll) }
|
|
744
813
|
: { kind: 'not_found' };
|
|
745
814
|
if (outcome.kind === 'value') {
|
|
746
815
|
this.store.set(sk, v);
|
|
@@ -28,6 +28,13 @@ export function isCollectionFamily(value: number): value is CollectionFamily {
|
|
|
28
28
|
return value >= CollectionFamily.Record && value <= CollectionFamily.Capacity;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
export interface DevField {
|
|
32
|
+
name: string;
|
|
33
|
+
typeName: string;
|
|
34
|
+
isArray: boolean;
|
|
35
|
+
unique: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
31
38
|
export interface DevCollectionHandle {
|
|
32
39
|
name: string;
|
|
33
40
|
family: CollectionFamily;
|
|
@@ -36,6 +43,9 @@ export interface DevCollectionHandle {
|
|
|
36
43
|
placement: number;
|
|
37
44
|
fillMaxWaitMs: number;
|
|
38
45
|
fillAllowStale: boolean;
|
|
46
|
+
/** The value type's field layout (declaration order); present when the catalog
|
|
47
|
+
* carried one. Drives @unique enforcement. */
|
|
48
|
+
fields?: DevField[];
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
export type DbCatalogState =
|