create-zerotal 1.0.4 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/package.json +1 -1
  3. package/src/index.ts +3 -1
  4. package/src/scaffold.ts +10 -4
  5. package/templates/admin/app/models/Product.ts +4 -4
  6. package/templates/admin/app/models/Setting.ts +2 -2
  7. package/templates/admin/app/models/User.ts +2 -2
  8. package/templates/admin/bootstrap/providers.ts +14 -0
  9. package/templates/admin/package.json +1 -0
  10. package/templates/api/app/models/User.ts +1 -1
  11. package/templates/api/bootstrap/app.ts +14 -0
  12. package/templates/api/package.json +1 -0
  13. package/templates/flow/app/flow/pages/register.tsx +9 -1
  14. package/templates/flow/app/models/User.ts +16 -3
  15. package/templates/flow/bootstrap/providers.ts +21 -1
  16. package/templates/flow/config/database.ts +6 -4
  17. package/templates/flow/package.json +1 -0
  18. package/templates/flow/tests/{smoke.test.ts → smoke.test.ts.tmpl} +49 -1
  19. package/templates/minimal/bootstrap/app.ts +11 -1
  20. package/templates/minimal/package.json +1 -0
  21. package/templates/react/app/models/User.ts +16 -3
  22. package/templates/react/app/routes/register.ts +10 -1
  23. package/templates/react/bootstrap/providers.ts +21 -1
  24. package/templates/react/config/database.ts +6 -4
  25. package/templates/react/package.json +1 -0
  26. package/templates/react/tests/{smoke.test.ts → smoke.test.ts.tmpl} +7 -1
  27. package/templates/vue/app/models/User.ts +1 -1
  28. package/templates/vue/bootstrap/providers.ts +1 -0
  29. package/templates/vue/config/database.ts +6 -4
  30. package/templates/vue/tests/{smoke.test.ts → smoke.test.ts.tmpl} +7 -1
  31. /package/templates/admin/tests/{admin_test.ts → admin.test.ts.tmpl} +0 -0
  32. /package/templates/api/tests/{auth.test.ts → auth.test.ts.tmpl} +0 -0
  33. /package/templates/minimal/tests/{smoke.test.ts → smoke.test.ts.tmpl} +0 -0
package/CHANGELOG.md CHANGED
@@ -1,4 +1,4 @@
1
- # Changelog — @zerotal/create-zerotal
1
+ # Changelog @zerotal/create-zerotal
2
2
 
3
3
  All notable changes to this package are documented here. The format is
4
4
  based on [Keep a Changelog](https://keepachangelog.com/); this package
@@ -8,6 +8,15 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.1.0] — 2026-08-08
12
+
13
+ ### Fixed
14
+
15
+ - **`StorageProvider` is registered in the scaffold.** `Storage.disk(...)` is used directly in the docs but the facade was unbound, so the first call failed with "Facade [storage] is not bound in the container". The error names the fix precisely; the cost is *when* it appears — a storage call sits behind auth, permission and upload checks, so simpler probes return 401/403 long before reaching it, and the first real upload is often in production. The provider falls back to `StorageConfig()` defaults and mounts no middleware when no disk is servable, so registering it unused costs nothing.
16
+ - **A fresh scaffold can run `migrate`.** The flow, react and vue templates shipped `synchronize` on *and* baseline migrations, so boot-time sync created `users` from the model and the first `bun zt migrate` collided with it — on the very first command a new user types. Migrations are now the single source of truth in those templates, as they already were in `api`, and `bun zt migrate` is listed in the scaffold's next steps.
17
+ - **The scaffolded test suite builds its schema.** With `synchronize` off, `createTestApp()` hands back a `:memory:` database with no tables; the smoke test now calls `migrateDatabase()`, which also means the schema under test is the schema that ships.
18
+ - **Nullable model fields can be cleared.** The templates declared them `?: T` under `exactOptionalPropertyTypes: true`, which means "may be absent, but never `undefined`" — so assigning `undefined` to clear the field, which is the point of a nullable column, did not typecheck. They are now `?: T | undefined`.
19
+
11
20
  ## [1.0.4] — 2026-08-07
12
21
 
13
22
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-zerotal",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "description": "Create a new Zerotal application",
5
5
  "license": "MIT",
6
6
  "maturity": "stable",
package/src/index.ts CHANGED
@@ -110,7 +110,9 @@ async function main(): Promise<void> {
110
110
  log('');
111
111
  log(`${c.bold} Next steps:${c.reset}`);
112
112
  dim(`cd ${name}`);
113
- if (template === 'api') {
113
+ // Every template that ships baseline migrations builds its schema from them
114
+ // (synchronize is off), so the first migrate is a required step, not a tip.
115
+ if (template === 'api' || template === 'flow' || template === 'react' || template === 'vue') {
114
116
  if (db !== 'sqlite') {
115
117
  dim(`# Set DATABASE_URL in .env`);
116
118
  }
package/src/scaffold.ts CHANGED
@@ -13,7 +13,7 @@ export type Template = 'minimal' | 'api' | 'admin' | 'flow' | 'react' | 'vue';
13
13
  // "^1.1.0" found for specifier "zerotal"` — the first thing anyone trying the
14
14
  // framework saw. `scaffold.test.ts` now asserts the two agree, so CI fails rather
15
15
  // than the user's install.
16
- export const ZT_VERSION = '^1.0.4';
16
+ export const ZT_VERSION = '^1.1.0';
17
17
 
18
18
  export interface ScaffoldOptions {
19
19
  name: string;
@@ -73,12 +73,18 @@ export async function scaffold(opts: ScaffoldOptions): Promise<void> {
73
73
  const src = join(templateDir, rel);
74
74
  // Rename underscore-prefixed dotfiles — npm and Bun glob both skip real dotfiles
75
75
  // inside published/scanned directories. _gitignore → .gitignore, etc.
76
- // Also strip the .tmpl infix from test files so Bun doesn't discover them
77
- // as tests while the package is being developed in the monorepo.
76
+ //
77
+ // Test files carry a trailing `.tmpl` that is stripped here, so Bun does not
78
+ // discover them while this package is developed inside the monorepo — where their
79
+ // `zerotal/testing` imports cannot resolve, because they are written for the app
80
+ // being scaffolded rather than for this workspace. The previous spelling was
81
+ // `_test.ts`, which does not work: Bun's matcher globs `_test.ts` as readily as
82
+ // `.test.ts` (and `.spec.ts` / `_spec.ts`), so those files were collected and failed
83
+ // on every root `bun test`. A trailing suffix is matched by none of them.
78
84
  const dest = join(opts.target, rel
79
85
  .replace('_gitignore', '.gitignore')
80
86
  .replace('_env.example', '.env.example')
81
- .replace('_test.ts', '.test.ts'),
87
+ .replace(/\.tmpl$/, ''),
82
88
  );
83
89
 
84
90
  await mkdir(dirname(dest), { recursive: true });
@@ -11,11 +11,11 @@ export class Product extends BaseModel {
11
11
 
12
12
  @column() name!: string;
13
13
  @column() sku!: string;
14
- @column({ nullable: true }) description?: string;
14
+ @column({ nullable: true }) description?: string | undefined;
15
15
  /** Price in minor units, so money never rides on a float. */
16
16
  @column("integer") price!: number;
17
- @column({ cast: "integer", nullable: true }) stock?: number;
17
+ @column({ cast: "integer", nullable: true }) stock?: number | undefined;
18
18
  /** draft | active | discontinued — drives the filter tabs and a select column. */
19
- @column({ nullable: true }) status?: string;
20
- @column({ cast: "boolean", nullable: true }) featured?: boolean;
19
+ @column({ nullable: true }) status?: string | undefined;
20
+ @column({ cast: "boolean", nullable: true }) featured?: boolean | undefined;
21
21
  }
@@ -9,6 +9,6 @@ export class Setting extends BaseModel {
9
9
  static override fillable = ["siteName", "supportEmail", "ordersOpen"];
10
10
 
11
11
  @column() siteName!: string;
12
- @column({ nullable: true }) supportEmail?: string;
13
- @column({ cast: "boolean", nullable: true }) ordersOpen?: boolean;
12
+ @column({ nullable: true }) supportEmail?: string | undefined;
13
+ @column({ cast: "boolean", nullable: true }) ordersOpen?: boolean | undefined;
14
14
  }
@@ -14,7 +14,7 @@ export class User extends BaseModelWith(Authenticatable) {
14
14
  @column() email!: string;
15
15
  @column() password!: string;
16
16
  /** Assigned roles — drives a multi-select field and a badge column. */
17
- @column({ cast: "json", nullable: true }) roles?: string[];
17
+ @column({ cast: "json", nullable: true }) roles?: string[] | undefined;
18
18
  /** Profile picture, shown as a circular image entry on the view page. */
19
- @column({ nullable: true }) avatarUrl?: string;
19
+ @column({ nullable: true }) avatarUrl?: string | undefined;
20
20
  }
@@ -1,23 +1,37 @@
1
1
  import { LogProvider } from "zerotal/logger";
2
2
  import { DatabaseProvider } from "zerotal/orm";
3
+ import { StorageProvider } from "zerotal/storage";
3
4
  import { CacheProvider } from "zerotal/cache";
4
5
  import { SessionProvider } from "zerotal/session";
5
6
  import { AuthProvider } from "zerotal/auth";
6
7
  import { FlowProvider } from "@zerotal/flow";
7
8
  import { AdminProvider } from "@zerotal/admin";
9
+ import { DevtoolsProvider } from "@zerotal/devtools";
8
10
 
9
11
  // Order matters: the database backs every resource, the cache holds the panel's
10
12
  // navigation-badge counts, and the session underpins auth. FlowProvider is
11
13
  // listed explicitly (before AdminProvider, which also `dependsOn` it) so its CLI
12
14
  // tooling boots in `bun zt`, where the admin panel itself does not run.
15
+ //
16
+ // `DevtoolsProvider` is last and needs no configuration. It activates only when
17
+ // APP_ENV is one of development/dev/local/test/testing — it fails *closed*, so
18
+ // an unset or `staging` APP_ENV leaves it inert rather than exposing the trace
19
+ // inspector. It also injects its own in-page panel, so nothing has to be added
20
+ // to the panel's assets. Press Alt+D (Cmd+D on Mac) to open it.
21
+ //
22
+ // It is a real dependency rather than a devDependency on purpose: this import
23
+ // runs in every environment, so a `--production` install that dropped the
24
+ // package would fail at boot instead of simply skipping the panel.
13
25
  const providers = [
14
26
  LogProvider,
15
27
  DatabaseProvider,
28
+ StorageProvider,
16
29
  CacheProvider,
17
30
  SessionProvider,
18
31
  AuthProvider,
19
32
  FlowProvider,
20
33
  AdminProvider,
34
+ DevtoolsProvider,
21
35
  ];
22
36
 
23
37
  export default providers;
@@ -12,6 +12,7 @@
12
12
  "typecheck": "tsc --noEmit"
13
13
  },
14
14
  "dependencies": {
15
+ "@zerotal/devtools": "{{zerotal_version}}",
15
16
  "@zerotal/admin": "{{zerotal_version}}",
16
17
  "@zerotal/flow": "{{zerotal_version}}",
17
18
  "zerotal": "{{zerotal_version}}"
@@ -12,5 +12,5 @@ export class User extends BaseModel {
12
12
  @column({ type: 'string' }) name!: string;
13
13
  @column({ type: 'string' }) email!: string;
14
14
  @column({ type: 'string' }) password!: string;
15
- @column({ type: 'string', nullable: true, default: 'user' }) role?: string;
15
+ @column({ type: 'string', nullable: true, default: 'user' }) role?: string | undefined;
16
16
  }
@@ -5,7 +5,20 @@ import { AuthProvider } from 'zerotal/auth';
5
5
  import { NotificationProvider } from '@zerotal/notifications';
6
6
  import { QueueProvider } from 'zerotal/queue';
7
7
  import { LogProvider } from 'zerotal/logger';
8
+ import { DevtoolsProvider } from '@zerotal/devtools';
8
9
 
10
+ // `DevtoolsProvider` needs no configuration. It activates only when APP_ENV is
11
+ // one of development/dev/local/test/testing — it fails *closed*, so an unset or
12
+ // `staging` APP_ENV leaves it inert rather than exposing the trace inspector.
13
+ //
14
+ // This API answers with JSON, so there is no HTML for the floating panel to
15
+ // attach itself to. Open `http://localhost:3000/__zerotal/devtools` instead —
16
+ // the dashboard is served as its own page, and every request this API handles
17
+ // still records its SQL, N+1 warnings, jobs, mail and logs there.
18
+ //
19
+ // It is a real dependency rather than a devDependency on purpose: this import
20
+ // runs in every environment, so a `--production` install that dropped the
21
+ // package would fail at boot instead of simply skipping the panel.
9
22
  export default Application.create({
10
23
  providers: [
11
24
  DatabaseProvider,
@@ -14,6 +27,7 @@ export default Application.create({
14
27
  NotificationProvider,
15
28
  QueueProvider,
16
29
  LogProvider,
30
+ DevtoolsProvider,
17
31
  ],
18
32
  })
19
33
  .routing({ web: `${import.meta.dir}/../routes/index.ts` });
@@ -11,6 +11,7 @@
11
11
  "typecheck": "tsc --noEmit"
12
12
  },
13
13
  "dependencies": {
14
+ "@zerotal/devtools": "{{zerotal_version}}",
14
15
  "@zerotal/notifications": "{{zerotal_version}}",
15
16
  "zerotal": "{{zerotal_version}}"
16
17
  },
@@ -48,7 +48,15 @@ export class RegisterPage extends Component {
48
48
  user.role = "user";
49
49
  await user.save();
50
50
 
51
- await Auth.attempt({ email: this.email, password: this.password });
51
+ // Check the result. Redirecting to a guarded page on a failed attempt sends
52
+ // the visitor to /profile, where AuthMiddleware turns them straight back to
53
+ // /login — so the account is created and they are bounced to a sign-in form
54
+ // with no error shown anywhere. Report it instead of redirecting into it.
55
+ if (!(await Auth.attempt({ email: this.email, password: this.password }))) {
56
+ this.error = "Your account was created, but signing you in failed. Please sign in.";
57
+ return;
58
+ }
59
+
52
60
  this.redirect("/profile").withSuccess("Welcome aboard.");
53
61
  }
54
62
 
@@ -1,7 +1,20 @@
1
- import { BaseModel, column, table } from "zerotal/orm";
1
+ import { BaseModelWith, column, table } from "zerotal/orm";
2
+ import { Authenticatable } from "zerotal/auth";
2
3
 
4
+ /**
5
+ * `BaseModelWith(Authenticatable)`, not a plain `BaseModel`.
6
+ *
7
+ * The mixin is what brands the class so `authUserModel()` can find it. Without
8
+ * the brand nothing errors and nothing warns — the model saves, queries and
9
+ * hashes exactly as before — but `Auth.attempt()` resolves *no* user model at
10
+ * all and therefore returns `false` for every correct password. Registration
11
+ * appears to work while login silently never succeeds.
12
+ *
13
+ * It also supplies `getAuthId()` / `getAuthPassword()`, which the auth flow
14
+ * reads, and registers the `rememberToken` column that "remember me" needs.
15
+ */
3
16
  @table("users")
4
- export class User extends BaseModel {
17
+ export class User extends BaseModelWith(Authenticatable) {
5
18
  // Models guard every attribute by default. Only these may be mass-assigned
6
19
  // from a request body — `role` is deliberately absent, so no amount of extra
7
20
  // fields in a form post can promote the account that submitted it.
@@ -13,5 +26,5 @@ export class User extends BaseModel {
13
26
  @column({ type: "string" }) name!: string;
14
27
  @column({ type: "string" }) email!: string;
15
28
  @column({ type: "string" }) password!: string;
16
- @column({ type: "string", nullable: true, default: "user" }) role?: string;
29
+ @column({ type: "string", nullable: true, default: "user" }) role?: string | undefined;
17
30
  }
@@ -1,11 +1,31 @@
1
1
  import { LogProvider } from "zerotal/logger";
2
2
  import { DatabaseProvider } from "zerotal/orm";
3
+ import { StorageProvider } from "zerotal/storage";
3
4
  import { SessionProvider } from "zerotal/session";
4
5
  import { AuthProvider } from "zerotal/auth";
5
6
  import { FlowProvider } from "@zerotal/flow";
7
+ import { DevtoolsProvider } from "@zerotal/devtools";
6
8
 
7
9
  // Order matters: the database backs the user model, and the session underpins
8
10
  // auth — AuthProvider needs both already registered when it boots.
9
- const providers = [LogProvider, DatabaseProvider, SessionProvider, AuthProvider, FlowProvider];
11
+ //
12
+ // `DevtoolsProvider` is last and needs no configuration. It activates only when
13
+ // APP_ENV is one of development/dev/local/test/testing — it fails *closed*, so
14
+ // an unset or `staging` APP_ENV leaves it inert rather than exposing the trace
15
+ // inspector. It also injects its own in-page panel, so nothing has to be added
16
+ // to the frontend bundle. Press Alt+D (Cmd+D on Mac) to open it.
17
+ //
18
+ // It is a real dependency rather than a devDependency on purpose: this import
19
+ // runs in every environment, so a `--production` install that dropped the
20
+ // package would fail at boot instead of simply skipping the panel.
21
+ const providers = [
22
+ LogProvider,
23
+ DatabaseProvider,
24
+ StorageProvider,
25
+ SessionProvider,
26
+ AuthProvider,
27
+ FlowProvider,
28
+ DevtoolsProvider,
29
+ ];
10
30
 
11
31
  export default providers;
@@ -11,8 +11,10 @@ export default DatabaseConfig({
11
11
  // against your development database and leave its rows behind.
12
12
  url: env("ZT_DB_URL", env("DATABASE_URL", "./database/db.sqlite")),
13
13
 
14
- // Builds the schema from the models at boot, so `bun run dev` works with no
15
- // migration step. Hard-off in production, where you run the migrations in
16
- // database/migrations instead.
17
- synchronize: env("APP_ENV", "development") !== "production",
14
+ // Off deliberately. This template ships baseline migrations in
15
+ // database/migrations, and boot-time schema sync would create the same tables
16
+ // from the models first — so the very first `bun zt migrate` would fail with
17
+ // "table users already exists". Migrations are the single source of truth, in
18
+ // development exactly as in production; run `bun zt migrate` after scaffolding.
19
+ synchronize: false,
18
20
  });
@@ -11,6 +11,7 @@
11
11
  "typecheck": "tsc --noEmit"
12
12
  },
13
13
  "dependencies": {
14
+ "@zerotal/devtools": "{{zerotal_version}}",
14
15
  "@zerotal/flow": "{{zerotal_version}}",
15
16
  "zerotal": "{{zerotal_version}}",
16
17
  "tailwindcss": "^4.0.0",
@@ -8,9 +8,11 @@
8
8
  * does rather than that it exists.
9
9
  */
10
10
  import { beforeAll, afterAll, describe, test, expect } from 'bun:test';
11
- import { createTestApp, type TestApp } from 'zerotal/testing';
11
+ import { createTestApp, migrateDatabase, type TestApp } from 'zerotal/testing';
12
12
  import { FlowTest } from '@zerotal/flow/testing';
13
+ import { Auth, Hash, isAuthenticatable } from 'zerotal/auth';
13
14
  import { ContactPage } from '../app/flow/pages/contact.tsx';
15
+ import { User } from '../app/models/User.ts';
14
16
 
15
17
  let app: TestApp;
16
18
 
@@ -22,6 +24,12 @@ beforeAll(async () => {
22
24
  // Each run gets its own throwaway schema rather than the dev database.
23
25
  Bun.env.ZT_DB_URL ??= ':memory:';
24
26
  app = await createTestApp(() => import('../bootstrap/app.ts').then((m) => m.default));
27
+ // Build the schema from the project's own migrations. config/database.ts keeps
28
+ // synchronize off — migrations are the single source of truth — so without this
29
+ // the :memory: database above has no tables and every test fails on 'no such
30
+ // table'. Running the real migrations also means the schema under test is the
31
+ // schema that ships, rather than a second definition that drifts from it.
32
+ await migrateDatabase();
25
33
  });
26
34
 
27
35
  afterAll(() => app.close());
@@ -73,6 +81,46 @@ describe('auth', () => {
73
81
 
74
82
  expect(res.status).toBe(302);
75
83
  });
84
+
85
+ /*
86
+ * Credentials must actually verify. Asserting that /login and /register
87
+ * *render* proves nothing about whether anyone can get in, and that gap let a
88
+ * User model ship that the auth layer could not resolve at all: every correct
89
+ * password was rejected, and the only symptom was being returned to the
90
+ * sign-in form. Nothing threw, and every other test still passed.
91
+ */
92
+ /*
93
+ * `validate` rather than `attempt`: it runs the same user lookup and password
94
+ * check, but stops short of `Auth.login`, which needs a live request context
95
+ * that a plain unit test has no reason to build. The bug this guards against
96
+ * lives entirely in the lookup half.
97
+ */
98
+ test('a registered user can actually sign in', async () => {
99
+ const user = new User();
100
+ user.name = 'Test Person';
101
+ user.email = 'signin-check@example.com';
102
+ user.password = await Hash.make('correct-horse-battery');
103
+ user.role = 'user';
104
+ await user.save();
105
+
106
+ expect(await Auth.validate({
107
+ email: 'signin-check@example.com',
108
+ password: 'correct-horse-battery',
109
+ })).toBe(true);
110
+ });
111
+
112
+ test('the User model is resolvable by the auth layer', () => {
113
+ // The root cause, asserted directly: `Auth.attempt` finds the user model
114
+ // through this brand, so a plain `BaseModel` makes every login fail.
115
+ expect(isAuthenticatable(User)).toBe(true);
116
+ });
117
+
118
+ test('a wrong password is still refused', async () => {
119
+ expect(await Auth.validate({
120
+ email: 'signin-check@example.com',
121
+ password: 'not-the-password',
122
+ })).toBe(false);
123
+ });
76
124
  });
77
125
 
78
126
  /**
@@ -1,7 +1,17 @@
1
1
  import { Application, basePath } from 'zerotal';
2
2
  import { LogProvider } from 'zerotal/logger';
3
+ import { DevtoolsProvider } from '@zerotal/devtools';
3
4
 
4
- const app = Application.create({ providers: [LogProvider] })
5
+ // `DevtoolsProvider` needs no configuration. It activates only when APP_ENV is
6
+ // one of development/dev/local/test/testing — it fails *closed*, so an unset or
7
+ // `staging` APP_ENV leaves it inert rather than exposing the trace inspector. It
8
+ // injects its own in-page panel, so nothing has to be added to the frontend
9
+ // bundle. Press Alt+D (Cmd+D on Mac) to open it.
10
+ //
11
+ // It is a real dependency rather than a devDependency on purpose: this import
12
+ // runs in every environment, so a `--production` install that dropped the
13
+ // package would fail at boot instead of simply skipping the panel.
14
+ const app = Application.create({ providers: [LogProvider, DevtoolsProvider] })
5
15
  .routing({
6
16
  web: basePath("routes/index.ts"),
7
17
  });
@@ -11,6 +11,7 @@
11
11
  "typecheck": "tsc --noEmit"
12
12
  },
13
13
  "dependencies": {
14
+ "@zerotal/devtools": "{{zerotal_version}}",
14
15
  "zerotal": "{{zerotal_version}}",
15
16
  "tailwindcss": "^4.0.0",
16
17
  "bun-plugin-tailwind": "latest"
@@ -1,7 +1,20 @@
1
- import { BaseModel, column, table } from "zerotal/orm";
1
+ import { BaseModelWith, column, table } from "zerotal/orm";
2
+ import { Authenticatable } from "zerotal/auth";
2
3
 
4
+ /**
5
+ * `BaseModelWith(Authenticatable)`, not a plain `BaseModel`.
6
+ *
7
+ * The mixin is what brands the class so `authUserModel()` can find it. Without
8
+ * the brand nothing errors and nothing warns — the model saves, queries and
9
+ * hashes exactly as before — but `Auth.attempt()` resolves *no* user model at
10
+ * all and therefore returns `false` for every correct password, so no one can
11
+ * ever sign in.
12
+ *
13
+ * It also supplies `getAuthId()` / `getAuthPassword()`, which the auth flow
14
+ * reads, and registers the `rememberToken` column that "remember me" needs.
15
+ */
3
16
  @table("users")
4
- export class User extends BaseModel {
17
+ export class User extends BaseModelWith(Authenticatable) {
5
18
  // Models guard every attribute by default. Only these may be mass-assigned
6
19
  // from a request body — `role` is deliberately absent, so no amount of extra
7
20
  // fields in a form post can promote the account that submitted it.
@@ -13,5 +26,5 @@ export class User extends BaseModel {
13
26
  @column({ type: "string" }) name!: string;
14
27
  @column({ type: "string" }) email!: string;
15
28
  @column({ type: "string" }) password!: string;
16
- @column({ type: "string", nullable: true, default: "user" }) role?: string;
29
+ @column({ type: "string", nullable: true, default: "user" }) role?: string | undefined;
17
30
  }
@@ -33,7 +33,16 @@ export async function POST(http: HttpContext): Promise<void> {
33
33
  user.role = "user";
34
34
  await user.save();
35
35
 
36
- await Auth.attempt({ email, password });
36
+ // Check the result. Redirecting to a guarded page on a failed attempt sends
37
+ // the visitor to /profile, where the auth guard turns them straight back to
38
+ // /login — so the account is created and they land on a sign-in form with no
39
+ // error shown anywhere. Say what happened instead of redirecting into it.
40
+ if (!(await Auth.attempt({ email, password }))) {
41
+ http.flash("error", "Your account was created, but signing you in failed. Please sign in.");
42
+ http.redirect("/login", 303);
43
+ return;
44
+ }
45
+
37
46
  http.flash("success", "Welcome aboard.");
38
47
  http.redirect("/profile", 303);
39
48
  }
@@ -1,13 +1,33 @@
1
1
  import { LogProvider } from "zerotal/logger";
2
2
  import { DatabaseProvider } from "zerotal/orm";
3
+ import { StorageProvider } from "zerotal/storage";
3
4
  import { SessionProvider } from "zerotal/session";
4
5
  import { AuthProvider } from "zerotal/auth";
5
6
  import { InertiaProvider } from "@zerotal/inertia";
7
+ import { DevtoolsProvider } from "@zerotal/devtools";
6
8
 
7
9
  // Order matters: the database backs the user model and the session underpins
8
10
  // auth, so both are registered before AuthProvider boots. AuthProvider installs
9
11
  // the middleware that puts the signed-in user on the request — which is what
10
12
  // makes `auth.user` appear in every Inertia page's props.
11
- const providers = [LogProvider, DatabaseProvider, SessionProvider, AuthProvider, InertiaProvider];
13
+ //
14
+ // `DevtoolsProvider` is last and needs no configuration. It activates only when
15
+ // APP_ENV is one of development/dev/local/test/testing — it fails *closed*, so
16
+ // an unset or `staging` APP_ENV leaves it inert rather than exposing the trace
17
+ // inspector. It also injects its own in-page panel, so nothing has to be added
18
+ // to the React bundle. Press Alt+D (Cmd+D on Mac) to open it.
19
+ //
20
+ // It is a real dependency rather than a devDependency on purpose: this import
21
+ // runs in every environment, so a `--production` install that dropped the
22
+ // package would fail at boot instead of simply skipping the panel.
23
+ const providers = [
24
+ LogProvider,
25
+ DatabaseProvider,
26
+ StorageProvider,
27
+ SessionProvider,
28
+ AuthProvider,
29
+ InertiaProvider,
30
+ DevtoolsProvider,
31
+ ];
12
32
 
13
33
  export default providers;
@@ -11,8 +11,10 @@ export default DatabaseConfig({
11
11
  // against your development database and leave its rows behind.
12
12
  url: env("ZT_DB_URL", env("DATABASE_URL", "./database/db.sqlite")),
13
13
 
14
- // Builds the schema from the models at boot, so `bun run dev` works with no
15
- // migration step. Hard-off in production, where you run the migrations in
16
- // database/migrations instead.
17
- synchronize: env("APP_ENV", "development") !== "production",
14
+ // Off deliberately. This template ships baseline migrations in
15
+ // database/migrations, and boot-time schema sync would create the same tables
16
+ // from the models first — so the very first `bun zt migrate` would fail with
17
+ // "table users already exists". Migrations are the single source of truth, in
18
+ // development exactly as in production; run `bun zt migrate` after scaffolding.
19
+ synchronize: false,
18
20
  });
@@ -12,6 +12,7 @@
12
12
  "typecheck": "tsc --noEmit"
13
13
  },
14
14
  "dependencies": {
15
+ "@zerotal/devtools": "{{zerotal_version}}",
15
16
  "@inertiajs/react": "^2.0.0",
16
17
  "@zerotal/inertia": "{{zerotal_version}}",
17
18
  "react": "^19.0.0",
@@ -7,7 +7,7 @@
7
7
  * you have real tests, or keep it as the shape to copy.
8
8
  */
9
9
  import { beforeAll, afterAll, describe, test, expect } from 'bun:test';
10
- import { createTestApp, type TestApp } from 'zerotal/testing';
10
+ import { createTestApp, migrateDatabase, type TestApp } from 'zerotal/testing';
11
11
 
12
12
  let app: TestApp;
13
13
 
@@ -18,6 +18,12 @@ beforeAll(async () => {
18
18
  // Each run gets its own throwaway schema rather than the dev database.
19
19
  Bun.env.ZT_DB_URL ??= ':memory:';
20
20
  app = await createTestApp(() => import('../bootstrap/app.ts').then((m) => m.default));
21
+ // Build the schema from the project's own migrations. config/database.ts keeps
22
+ // synchronize off — migrations are the single source of truth — so without this
23
+ // the :memory: database above has no tables and every test fails on 'no such
24
+ // table'. Running the real migrations also means the schema under test is the
25
+ // schema that ships, rather than a second definition that drifts from it.
26
+ await migrateDatabase();
21
27
  });
22
28
 
23
29
  afterAll(() => app.close());
@@ -13,5 +13,5 @@ export class User extends BaseModel {
13
13
  @column({ type: "string" }) name!: string;
14
14
  @column({ type: "string" }) email!: string;
15
15
  @column({ type: "string" }) password!: string;
16
- @column({ type: "string", nullable: true, default: "user" }) role?: string;
16
+ @column({ type: "string", nullable: true, default: "user" }) role?: string | undefined;
17
17
  }
@@ -1,5 +1,6 @@
1
1
  import { LogProvider } from "zerotal/logger";
2
2
  import { DatabaseProvider } from "zerotal/orm";
3
+ import { StorageProvider } from "zerotal/storage";
3
4
  import { SessionProvider } from "zerotal/session";
4
5
  import { AuthProvider } from "zerotal/auth";
5
6
  import { InertiaProvider } from "@zerotal/inertia";
@@ -11,8 +11,10 @@ export default DatabaseConfig({
11
11
  // against your development database and leave its rows behind.
12
12
  url: env("ZT_DB_URL", env("DATABASE_URL", "./database/db.sqlite")),
13
13
 
14
- // Builds the schema from the models at boot, so `bun run dev` works with no
15
- // migration step. Hard-off in production, where you run the migrations in
16
- // database/migrations instead.
17
- synchronize: env("APP_ENV", "development") !== "production",
14
+ // Off deliberately. This template ships baseline migrations in
15
+ // database/migrations, and boot-time schema sync would create the same tables
16
+ // from the models first — so the very first `bun zt migrate` would fail with
17
+ // "table users already exists". Migrations are the single source of truth, in
18
+ // development exactly as in production; run `bun zt migrate` after scaffolding.
19
+ synchronize: false,
18
20
  });
@@ -7,7 +7,7 @@
7
7
  * you have real tests, or keep it as the shape to copy.
8
8
  */
9
9
  import { beforeAll, afterAll, describe, test, expect } from 'bun:test';
10
- import { createTestApp, type TestApp } from 'zerotal/testing';
10
+ import { createTestApp, migrateDatabase, type TestApp } from 'zerotal/testing';
11
11
 
12
12
  let app: TestApp;
13
13
 
@@ -18,6 +18,12 @@ beforeAll(async () => {
18
18
  // Each run gets its own throwaway schema rather than the dev database.
19
19
  Bun.env.ZT_DB_URL ??= ':memory:';
20
20
  app = await createTestApp(() => import('../bootstrap/app.ts').then((m) => m.default));
21
+ // Build the schema from the project's own migrations. config/database.ts keeps
22
+ // synchronize off — migrations are the single source of truth — so without this
23
+ // the :memory: database above has no tables and every test fails on 'no such
24
+ // table'. Running the real migrations also means the schema under test is the
25
+ // schema that ships, rather than a second definition that drifts from it.
26
+ await migrateDatabase();
21
27
  });
22
28
 
23
29
  afterAll(() => app.close());