@getstrata/starter 1.0.0 → 1.0.2

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.
@@ -1,9 +1,49 @@
1
1
  # API-only scaffold
2
2
 
3
- This template enables `FRONTEND_MODE=api`.
3
+ This scaffold sets `FRONTEND_MODE=api`: no server-rendered views beyond the welcome page, and no SPA assets.
4
4
 
5
- - JSON API routes under `/api/v1/*`
6
- - No server-rendered views or SPA assets
7
- - Use bearer tokens or dev auth headers in tests (`actingAs`, `postJson`)
5
+ ## What exists after migrating
8
6
 
9
- Generate an API app with `bunx create-strata my-app --frontend api --yes`.
7
+ - `GET /health` returns plain text `ok`, or `degraded` (503) until the database ping succeeds and the migrated `notes` table exists. Docker HEALTHCHECK uses `/health`.
8
+ - `GET /` returns the welcome page.
9
+ - A `notes` table, with no routes on it yet.
10
+
11
+ Login endpoints depend on the auth layer you chose. Token auth adds `POST /api/v1/auth/login` and `GET /api/v1/auth/me`; JWT auth adds `POST /api/auth/token`; header auth adds none and expects `x-authenticated-user-id` for local work only.
12
+
13
+ There is no CRUD endpoint for `notes`. Adding your own routes is the first thing you do.
14
+
15
+ ## Adding a route
16
+
17
+ ```typescript
18
+ // src/modules/notes/index.ts
19
+ import type { AppModule } from "@getstrata/bootstrap/contracts";
20
+ import { jsonResponse } from "@getstrata/core/http/response";
21
+ import { getSql } from "../../bootstrap/database.ts";
22
+
23
+ const notesModule: AppModule = {
24
+ name: "notes",
25
+ order: 2,
26
+ routes({ kernel }) {
27
+ return {
28
+ "/api/v1/notes": kernel.wrap("api", async () => {
29
+ const rows = await getSql().unsafe<{ id: number; body: string }>(
30
+ "SELECT id, body FROM notes ORDER BY id DESC",
31
+ );
32
+ return jsonResponse({ data: rows });
33
+ }),
34
+ };
35
+ },
36
+ };
37
+
38
+ export default notesModule;
39
+ ```
40
+
41
+ Import from `@getstrata/core/...` subpaths rather than the package root, so singleton state such as the database pool stays shared.
42
+
43
+ ## Generating an app instead
44
+
45
+ ```bash
46
+ bunx create-strata my-app --frontend api --yes
47
+ ```
48
+
49
+ A generated app writes its own `docs/API.md` listing only the routes its layers actually serve.
@@ -1,10 +1,8 @@
1
1
  import { Link, Navigate, Route, Routes } from "react-router-dom";
2
2
  import { useAuth } from "./auth/AuthContext";
3
+ import HomePage from "./pages/HomePage";
3
4
  import LoginPage from "./pages/LoginPage";
4
5
  import NotFoundPage from "./pages/NotFoundPage";
5
- import OrganizationsPage from "./pages/OrganizationsPage";
6
- import ProjectsPage from "./pages/ProjectsPage";
7
- import TasksPage from "./pages/TasksPage";
8
6
 
9
7
  function ProtectedRoute({ children }: { children: React.ReactNode }) {
10
8
  const { token, loading } = useAuth();
@@ -28,9 +26,7 @@ function AppShell() {
28
26
  <header className="site-header">
29
27
  <strong>Strata SPA</strong>
30
28
  <nav>
31
- <Link to="/organizations">Organizations</Link>
32
- <Link to="/projects">Projects</Link>
33
- <Link to="/tasks">Tasks</Link>
29
+ <Link to="/">Home</Link>
34
30
  {user ? (
35
31
  <>
36
32
  <span className="hint">{user.email}</span>
@@ -43,29 +39,12 @@ function AppShell() {
43
39
  </header>
44
40
 
45
41
  <Routes>
46
- <Route path="/" element={<Navigate to="/organizations" replace />} />
47
42
  <Route path="/login" element={<LoginPage />} />
48
43
  <Route
49
- path="/organizations"
44
+ path="/"
50
45
  element={
51
46
  <ProtectedRoute>
52
- <OrganizationsPage />
53
- </ProtectedRoute>
54
- }
55
- />
56
- <Route
57
- path="/projects"
58
- element={
59
- <ProtectedRoute>
60
- <ProjectsPage />
61
- </ProtectedRoute>
62
- }
63
- />
64
- <Route
65
- path="/tasks"
66
- element={
67
- <ProtectedRoute>
68
- <TasksPage />
47
+ <HomePage />
69
48
  </ProtectedRoute>
70
49
  }
71
50
  />
@@ -0,0 +1,72 @@
1
+ import { useAuth } from "../auth/AuthContext";
2
+
3
+ export default function HomePage() {
4
+ const { user } = useAuth();
5
+
6
+ return (
7
+ <section>
8
+ <h1>Signed in</h1>
9
+ <p className="hint">
10
+ This page reads <code>GET /api/v1/auth/me</code> with the bearer token from the login
11
+ response. It is the only resource the generated backend serves, so it is the only one this
12
+ page shows.
13
+ </p>
14
+
15
+ <div className="card">
16
+ <table>
17
+ <tbody>
18
+ <tr>
19
+ <th>Name</th>
20
+ <td>{user?.name ?? "—"}</td>
21
+ </tr>
22
+ <tr>
23
+ <th>Email</th>
24
+ <td>{user?.email ?? "—"}</td>
25
+ </tr>
26
+ <tr>
27
+ <th>Role</th>
28
+ <td>
29
+ <code>{user?.role ?? "—"}</code>
30
+ </td>
31
+ </tr>
32
+ </tbody>
33
+ </table>
34
+ </div>
35
+
36
+ <h2>Add your first resource</h2>
37
+ <p>
38
+ Create a module under <code>src/modules/</code> and return routes from it. The kernel picks
39
+ it up on the next boot.
40
+ </p>
41
+
42
+ <pre className="card">
43
+ <code>{`// src/modules/notes/index.ts
44
+ import type { AppModule } from "@getstrata/bootstrap/contracts";
45
+ import { jsonResponse } from "@getstrata/core/http/response";
46
+ import { getSql } from "../../bootstrap/database.ts";
47
+
48
+ const notesModule: AppModule = {
49
+ name: "notes",
50
+ order: 2,
51
+ routes({ kernel }) {
52
+ return {
53
+ "/api/v1/notes": kernel.wrap("api", async () => {
54
+ const rows = await getSql().unsafe<{ id: number; body: string }>(
55
+ "SELECT id, body FROM notes ORDER BY id DESC",
56
+ );
57
+ return jsonResponse({ data: rows });
58
+ }),
59
+ };
60
+ },
61
+ };
62
+
63
+ export default notesModule;`}</code>
64
+ </pre>
65
+
66
+ <p className="hint">
67
+ Then fetch it here with <code>apiFetch("/notes", {"{ token }"})</code>. The{" "}
68
+ <code>notes</code> table already exists after <code>bun run db:migrate</code>.
69
+ </p>
70
+ </section>
71
+ );
72
+ }
@@ -12,7 +12,7 @@ export default function LoginPage() {
12
12
  const [submitting, setSubmitting] = useState(false);
13
13
 
14
14
  if (token) {
15
- return <Navigate to="/organizations" replace />;
15
+ return <Navigate to="/" replace />;
16
16
  }
17
17
 
18
18
  async function onSubmit(event: FormEvent<HTMLFormElement>) {
@@ -22,7 +22,7 @@ export default function LoginPage() {
22
22
 
23
23
  try {
24
24
  await login(email, password);
25
- navigate("/organizations");
25
+ navigate("/");
26
26
  } catch (cause) {
27
27
  setError(cause instanceof ApiError ? cause.message : "Login failed");
28
28
  } finally {
@@ -33,7 +33,10 @@ export default function LoginPage() {
33
33
  return (
34
34
  <section className="card">
35
35
  <h1>Sign in</h1>
36
- <p className="hint">Use seeded credentials or your own API user.</p>
36
+ <p className="hint">
37
+ Seeded users are <code>demo@example.com</code> and <code>admin@example.test</code>, password
38
+ <code>password</code>.
39
+ </p>
37
40
 
38
41
  {error ? <p className="error">{error}</p> : null}
39
42
 
@@ -5,7 +5,7 @@ export default function NotFoundPage() {
5
5
  <section className="card">
6
6
  <h1>Not found</h1>
7
7
  <p>
8
- Return to <Link to="/organizations">organizations</Link>.
8
+ Return <Link to="/">home</Link>.
9
9
  </p>
10
10
  </section>
11
11
  );
@@ -11,9 +11,9 @@
11
11
  "check": "tsc --noEmit"
12
12
  },
13
13
  "dependencies": {
14
- "@getstrata/bootstrap": "^1.0.0",
15
- "@getstrata/cli": "^1.0.0",
16
- "@getstrata/core": "^1.0.0"
14
+ "@getstrata/bootstrap": "^1.0.2",
15
+ "@getstrata/cli": "^1.0.2",
16
+ "@getstrata/core": "^1.0.2"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/bun": "^1.4.0",
@@ -16,3 +16,14 @@ export function loadConfig(): AppConfig {
16
16
  databaseUrl,
17
17
  };
18
18
  }
19
+
20
+ /** Cookie sessions and signed cookies are keyed by this; there is no default. */
21
+ export function sessionSecret(): string {
22
+ const secret = process.env.SESSION_SECRET?.trim();
23
+ if (!secret) {
24
+ throw new Error(
25
+ "SESSION_SECRET is required. Copy .env.example to .env and set it (32+ characters).",
26
+ );
27
+ }
28
+ return secret;
29
+ }
@@ -68,7 +68,8 @@ function createAppContext(): AppContext {
68
68
  }
69
69
 
70
70
  export async function bootstrapApp(options: BootstrapOptions = {}): Promise<BootstrappedApp> {
71
- const { migrate: runMigrate = true } = options;
71
+ const isProduction = process.env.APP_ENV === "production";
72
+ const { migrate: runMigrate = !isProduction } = options;
72
73
 
73
74
  const appConfig = loadConfig();
74
75
  const context = createAppContext();
@@ -9,7 +9,7 @@ const { routes, config } = await bootstrapApp();
9
9
 
10
10
  const server = createAppServer(routes, config.port);
11
11
 
12
- console.log(`${config.appUrl} (port ${server.port})`);
12
+ console.log(`Listening on http://localhost:${server.port} (APP_URL ${config.appUrl})`);
13
13
 
14
14
  if (!(await pingDatabase())) {
15
15
  console.warn("Warning: database ping failed.");
@@ -16,6 +16,6 @@ export async function renderPage(
16
16
  return htmlResponse(html);
17
17
  }
18
18
 
19
- export function plainText(body: string): Response {
20
- return new Response(body, { headers: { "content-type": "text/plain; charset=utf-8" } });
19
+ export function plainText(body: string, status = 200): Response {
20
+ return new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
21
21
  }
@@ -19,7 +19,7 @@ const siteModule: AppModule = {
19
19
  "/health": kernel.wrapWeb(
20
20
  withErrorHandling(async () => {
21
21
  const dbOk = await pingDatabase();
22
- return plainText(dbOk ? "ok" : "degraded");
22
+ return plainText(dbOk ? "ok" : "degraded", dbOk ? 200 : 503);
23
23
  }),
24
24
  ),
25
25
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/starter",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Interactive create-strata wizard. Choose each layer; one database engine.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,69 +0,0 @@
1
- import { useEffect, useState } from "react";
2
- import { Link } from "react-router-dom";
3
- import { apiFetch } from "../api/client";
4
- import { useAuth } from "../auth/AuthContext";
5
-
6
- interface Organization {
7
- id: number;
8
- name: string;
9
- slug: string;
10
- }
11
-
12
- interface PaginatedOrganizations {
13
- data: Organization[];
14
- meta: {
15
- total: number;
16
- };
17
- }
18
-
19
- export default function OrganizationsPage() {
20
- const { token } = useAuth();
21
- const [organizations, setOrganizations] = useState<Organization[]>([]);
22
- const [error, setError] = useState<string | null>(null);
23
-
24
- useEffect(() => {
25
- if (!token) {
26
- return;
27
- }
28
-
29
- apiFetch<PaginatedOrganizations>("/organizations", { token })
30
- .then((body) => setOrganizations(body.data))
31
- .catch((cause: unknown) => {
32
- setError(cause instanceof Error ? cause.message : "Failed to load organizations");
33
- });
34
- }, [token]);
35
-
36
- return (
37
- <section>
38
- <h1>Organizations</h1>
39
- <p className="hint">{organizations.length} loaded from the JSON API.</p>
40
-
41
- {error ? <p className="error">{error}</p> : null}
42
-
43
- <div className="card">
44
- <table>
45
- <thead>
46
- <tr>
47
- <th>Name</th>
48
- <th>Slug</th>
49
- </tr>
50
- </thead>
51
- <tbody>
52
- {organizations.map((organization) => (
53
- <tr key={organization.id}>
54
- <td>{organization.name}</td>
55
- <td>
56
- <code>{organization.slug}</code>
57
- </td>
58
- </tr>
59
- ))}
60
- </tbody>
61
- </table>
62
- </div>
63
-
64
- <p>
65
- Continue to <Link to="/projects">projects</Link> or <Link to="/tasks">tasks</Link>.
66
- </p>
67
- </section>
68
- );
69
- }
@@ -1,65 +0,0 @@
1
- import { useEffect, useState } from "react";
2
- import { Link } from "react-router-dom";
3
- import { apiFetch } from "../api/client";
4
- import { useAuth } from "../auth/AuthContext";
5
-
6
- interface Project {
7
- id: number;
8
- name: string;
9
- status: string;
10
- organization_id: number;
11
- }
12
-
13
- interface PaginatedProjects {
14
- data: Project[];
15
- }
16
-
17
- export default function ProjectsPage() {
18
- const { token } = useAuth();
19
- const [projects, setProjects] = useState<Project[]>([]);
20
- const [error, setError] = useState<string | null>(null);
21
-
22
- useEffect(() => {
23
- if (!token) {
24
- return;
25
- }
26
-
27
- apiFetch<PaginatedProjects>("/projects?include=organization", { token })
28
- .then((body) => setProjects(body.data))
29
- .catch((cause: unknown) => {
30
- setError(cause instanceof Error ? cause.message : "Failed to load projects");
31
- });
32
- }, [token]);
33
-
34
- return (
35
- <section>
36
- <h1>Projects</h1>
37
- {error ? <p className="error">{error}</p> : null}
38
-
39
- <div className="card">
40
- <table>
41
- <thead>
42
- <tr>
43
- <th>Name</th>
44
- <th>Status</th>
45
- <th>Organization</th>
46
- </tr>
47
- </thead>
48
- <tbody>
49
- {projects.map((project) => (
50
- <tr key={project.id}>
51
- <td>{project.name}</td>
52
- <td>{project.status}</td>
53
- <td>{project.organization_id}</td>
54
- </tr>
55
- ))}
56
- </tbody>
57
- </table>
58
- </div>
59
-
60
- <p>
61
- Back to <Link to="/organizations">organizations</Link>.
62
- </p>
63
- </section>
64
- );
65
- }
@@ -1,65 +0,0 @@
1
- import { useEffect, useState } from "react";
2
- import { Link } from "react-router-dom";
3
- import { apiFetch } from "../api/client";
4
- import { useAuth } from "../auth/AuthContext";
5
-
6
- interface Task {
7
- id: number;
8
- title: string;
9
- status: string;
10
- project_id: number;
11
- }
12
-
13
- interface PaginatedTasks {
14
- data: Task[];
15
- }
16
-
17
- export default function TasksPage() {
18
- const { token } = useAuth();
19
- const [tasks, setTasks] = useState<Task[]>([]);
20
- const [error, setError] = useState<string | null>(null);
21
-
22
- useEffect(() => {
23
- if (!token) {
24
- return;
25
- }
26
-
27
- apiFetch<PaginatedTasks>("/tasks", { token })
28
- .then((body) => setTasks(body.data))
29
- .catch((cause: unknown) => {
30
- setError(cause instanceof Error ? cause.message : "Failed to load tasks");
31
- });
32
- }, [token]);
33
-
34
- return (
35
- <section>
36
- <h1>Tasks</h1>
37
- {error ? <p className="error">{error}</p> : null}
38
-
39
- <div className="card">
40
- <table>
41
- <thead>
42
- <tr>
43
- <th>Title</th>
44
- <th>Status</th>
45
- <th>Project</th>
46
- </tr>
47
- </thead>
48
- <tbody>
49
- {tasks.map((task) => (
50
- <tr key={task.id}>
51
- <td>{task.title}</td>
52
- <td>{task.status}</td>
53
- <td>{task.project_id}</td>
54
- </tr>
55
- ))}
56
- </tbody>
57
- </table>
58
- </div>
59
-
60
- <p>
61
- Back to <Link to="/organizations">organizations</Link>.
62
- </p>
63
- </section>
64
- );
65
- }