create-nwire 0.12.1 → 0.13.1

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 (66) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +6 -2
  3. package/package.json +1 -1
  4. package/templates/enterprise/AGENTS.md +11 -11
  5. package/templates/enterprise/README.md +15 -10
  6. package/templates/enterprise/__tests__/auto-moderate.test.ts +22 -6
  7. package/templates/enterprise/__tests__/submit-flow.test.ts +22 -6
  8. package/templates/enterprise/app/api.ts +5 -1
  9. package/templates/enterprise/app/app.ts +23 -15
  10. package/templates/enterprise/app/main.ts +20 -45
  11. package/templates/enterprise/config/app.ts +18 -0
  12. package/templates/enterprise/config/env.ts +32 -0
  13. package/templates/enterprise/config/http.ts +15 -0
  14. package/templates/enterprise/modules/posts/actions/approve-post.ts +4 -3
  15. package/templates/enterprise/modules/posts/actions/reject-post.ts +3 -3
  16. package/templates/enterprise/modules/posts/actions/submit-post.ts +4 -3
  17. package/templates/enterprise/modules/posts/events/post-was-approved.ts +1 -1
  18. package/templates/enterprise/modules/posts/events/post-was-rejected.ts +2 -1
  19. package/templates/enterprise/modules/posts/events/post-was-submitted.ts +2 -1
  20. package/templates/enterprise/modules/posts/projections/queue-dashboard.ts +34 -27
  21. package/templates/enterprise/modules/posts/queries/posts-by-author.ts +7 -5
  22. package/templates/enterprise/modules/posts/routes/approve-post.ts +3 -3
  23. package/templates/enterprise/modules/posts/routes/get-post.ts +3 -3
  24. package/templates/enterprise/modules/posts/routes/list-queue.ts +4 -4
  25. package/templates/enterprise/modules/posts/routes/posts-by-author.ts +28 -0
  26. package/templates/enterprise/modules/posts/routes/reject-post.ts +3 -3
  27. package/templates/enterprise/modules/posts/routes/submit-post.ts +3 -3
  28. package/templates/enterprise/modules/posts/workflows/auto-moderate.ts +3 -3
  29. package/templates/enterprise/package.json +10 -11
  30. package/templates/enterprise/tsconfig.json +1 -1
  31. package/templates/mcp/AGENTS.md +73 -0
  32. package/templates/mcp/__tests__/mcp-server.test.ts +205 -0
  33. package/templates/mcp/_gitignore +5 -0
  34. package/templates/mcp/_npmrc +4 -0
  35. package/templates/mcp/_pnpm-workspace.yaml +12 -0
  36. package/templates/mcp/app/app.ts +24 -0
  37. package/templates/mcp/app/main.ts +197 -0
  38. package/templates/mcp/app/store/facts-store.ts +17 -0
  39. package/templates/mcp/app/tools/add-fact.ts +27 -0
  40. package/templates/mcp/app/tools/list-facts.ts +17 -0
  41. package/templates/mcp/app/tools/lookup-fact.ts +28 -0
  42. package/templates/mcp/app/tools.ts +19 -0
  43. package/templates/mcp/config/app.ts +16 -0
  44. package/templates/mcp/config/env.ts +27 -0
  45. package/templates/mcp/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  46. package/templates/mcp/package.json +25 -0
  47. package/templates/mcp/tsconfig.json +15 -0
  48. package/templates/mcp/vitest.config.ts +8 -0
  49. package/templates/minimal/AGENTS.md +16 -9
  50. package/templates/minimal/README.md +20 -10
  51. package/templates/minimal/__tests__/hello.test.ts +3 -3
  52. package/templates/minimal/app/app.ts +21 -0
  53. package/templates/minimal/app/main.ts +15 -24
  54. package/templates/minimal/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  55. package/templates/minimal/package.json +7 -8
  56. package/templates/service/AGENTS.md +13 -11
  57. package/templates/service/README.md +5 -3
  58. package/templates/service/__tests__/todo-api.test.ts +5 -5
  59. package/templates/service/app/app.ts +25 -0
  60. package/templates/service/app/main.ts +20 -30
  61. package/templates/service/app/store/todo-store.ts +6 -22
  62. package/templates/service/config/app.ts +18 -0
  63. package/templates/service/config/env.ts +32 -0
  64. package/templates/service/config/http.ts +15 -0
  65. package/templates/service/package.json +8 -9
  66. package/templates/service/tsconfig.json +1 -1
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * Usage:
9
9
  * pnpm create nwire <target-dir> [--template minimal|service|enterprise]
10
10
  */
11
- export type TemplateName = "minimal" | "service" | "enterprise";
11
+ export type TemplateName = "minimal" | "service" | "enterprise" | "mcp";
12
12
  interface ScaffoldOptions {
13
13
  target: string;
14
14
  template: TemplateName;
package/dist/index.js CHANGED
@@ -37,6 +37,10 @@ const TEMPLATES = {
37
37
  title: "Enterprise",
38
38
  blurb: "modules/<bc>/{events,actions,workflows,projections,routes}. Full forge shape.",
39
39
  },
40
+ mcp: {
41
+ title: "MCP",
42
+ blurb: "Model Context Protocol server over stdio. AI clients call tool() wires.",
43
+ },
40
44
  };
41
45
  function templatesRoot() {
42
46
  const candidate = resolve(__dirname, "..", "templates");
@@ -142,7 +146,7 @@ const main = defineCommand({
142
146
  },
143
147
  template: {
144
148
  type: "string",
145
- description: "Template: minimal | service | enterprise",
149
+ description: "Template: minimal | service | enterprise | mcp",
146
150
  default: "service",
147
151
  },
148
152
  name: {
@@ -164,7 +168,7 @@ const main = defineCommand({
164
168
  }
165
169
  const template = resolveTemplateName(String(ctx.args.template));
166
170
  if (!template) {
167
- console.error(pc.red(`unknown template "${ctx.args.template}". Use minimal, service, or enterprise.`));
171
+ console.error(pc.red(`unknown template "${ctx.args.template}". Use minimal, service, enterprise, or mcp.`));
168
172
  process.exit(1);
169
173
  }
170
174
  const target = resolve(process.cwd(), targetArg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nwire",
3
- "version": "0.12.1",
3
+ "version": "0.13.1",
4
4
  "description": "Scaffolder for new Nwire projects. Run `pnpm create nwire <name>` or `npm create nwire <name>` to bootstrap.",
5
5
  "keywords": [
6
6
  "nwire",
@@ -10,15 +10,15 @@ gets real, forge adds events, actors, projections, and workflows.
10
10
 
11
11
  The most common mistake is importing a primitive from the wrong package:
12
12
 
13
- | Primitive | Package |
14
- |---|---|
15
- | `createApp`, `appCompose`, `definePlugin` | `@nwire/app` |
16
- | `endpoint` | `@nwire/endpoint` |
17
- | `get` `post` `put` `patch` `del` | `@nwire/wires/http` |
18
- | `httpKoa` | `@nwire/koa` |
19
- | `defineHandler`, `defineResource`, `defineError`, `Unauthorized`/`Forbidden`/`NotFound`/`Conflict`/`BadRequest` | `@nwire/handler` |
20
- | `defineEvent` | `@nwire/messages` |
21
- | `defineAction`, `defineActor`, `defineSchema`, `defineProjection`, `defineQuery`, `defineWorkflow`, `createForgePlugin` | `@nwire/forge` |
13
+ | Primitive | Package |
14
+ | ------------------------------------------------------------------------------------------------------------------ | ------------------- |
15
+ | `createApp`, `appCompose`, `definePlugin` | `@nwire/app` |
16
+ | `endpoint` | `@nwire/endpoint` |
17
+ | `get` `post` `put` `patch` `del` | `@nwire/wires/http` |
18
+ | `httpKoa` | `@nwire/koa` |
19
+ | `defineHandler`, `defineResource`, `defineError`, `Unauthorized`/`Forbidden`/`NotFound`/`Conflict`/`BadRequest` | `@nwire/handler` |
20
+ | `defineEvent` | `@nwire/messages` |
21
+ | `defineAction`, `defineActor`, `defineSchema`, `defineProjection`, `defineQuery`, `defineWorkflow`, `forgePlugins` | `@nwire/forge` |
22
22
 
23
23
  `defineResource` and `defineError` are in **`@nwire/handler`**, not
24
24
  `@nwire/forge` — even though forge re-exports some of them, import from
@@ -31,8 +31,8 @@ The most common mistake is importing a primitive from the wrong package:
31
31
  - **Event** — a past-tense fact (`defineEvent`). Emit with `ctx.emit`;
32
32
  react to it with `when(Event, fn)` in a listener file (`on` is for
33
33
  lifecycle hooks only).
34
- - **Action** — a named command (`defineAction`) a handler fulfils and may
35
- emit events from. Register via `createForgePlugin({ handlers: [act.handler] })`.
34
+ - **Action** — a named command (`defineAction`) that is itself the handler
35
+ and may emit events from. Register via `createApp({ handlers: [act] })`.
36
36
  - **Actor** — a thing with identity that guards a rule. **Projection** — a
37
37
  read model folded from events. **Workflow** — a process that reacts over
38
38
  time.
@@ -16,19 +16,24 @@ pnpm dev
16
16
 
17
17
  ## Try
18
18
 
19
+ `pnpm dev` serves the wire + Studio on http://localhost:4000.
20
+
19
21
  ```bash
20
22
  # submit a post (becomes pending, auto-moderation triggers)
21
- curl -X POST http://localhost:3000/api/posts \
23
+ curl -X POST http://localhost:4000/api/posts \
22
24
  -H "content-type: application/json" \
23
25
  -d '{"authorId":"alice","body":"Hello, world!"}'
24
26
 
25
- # read the dashboard projection
26
- curl http://localhost:3000/api/queue
27
+ # read the dashboard projection — copy a pending postId from here
28
+ curl http://localhost:4000/api/queue
27
29
 
28
- # decide a borderline one manually
29
- curl -X POST http://localhost:3000/api/posts/<id>/approve \
30
+ # decide one manually (paste the postId from /api/queue above)
31
+ curl -X POST http://localhost:4000/api/posts/<postId>/approve \
30
32
  -H "content-type: application/json" \
31
33
  -d '{"moderatorId":"miri"}'
34
+
35
+ # all posts by an author, across statuses
36
+ curl "http://localhost:4000/api/posts/by-author?authorId=alice"
32
37
  ```
33
38
 
34
39
  ## Test
@@ -42,8 +47,8 @@ pnpm test
42
47
  ```
43
48
  {{PROJECT_NAME}}/
44
49
  ├── app/
45
- │ ├── main.ts ← endpoint + app.start + run
46
- │ ├── api.ts ← httpInterfacewires every route
50
+ │ ├── main.ts ← endpoint().use(httpKoa()).mount(app).run()
51
+ │ ├── api.ts ← wires[] — every route + handler pair
47
52
  │ └── app.ts ← createApp({ modules: [...] })
48
53
  ├── modules/
49
54
  │ └── posts/ ← bounded context: posts moderation
@@ -90,11 +95,11 @@ actor (`defineActor`) versus a direct DB write inside a handler.
90
95
 
91
96
  ## Studio
92
97
 
93
- `api.inspect(app)` mounts the `/_nwire/*` introspection surface. Point
94
- Studio at this app:
98
+ `pnpm dev` already serves Studio alongside the wire on http://localhost:4000.
99
+ For the standalone Studio process (multi-project shell, process manager):
95
100
 
96
101
  ```bash
97
- npx -p @nwire/cli nwire studio
102
+ pnpm studio
98
103
  ```
99
104
 
100
105
  You'll get live traces, action / event / workflow browsers, projection
@@ -7,22 +7,38 @@
7
7
  * The workflow runs in-process (same runtime the route dispatches into),
8
8
  * so `await idle()` between submit + dashboard read is enough to observe
9
9
  * the post-decision state.
10
+ *
11
+ * Boots the app from `app.ts` (the pure value) on an ephemeral port.
12
+ * `app` is complete on import — routes are wired in the module body of
13
+ * `app.ts`. Never imports `main.ts` — that file runs a real server and
14
+ * hangs the test runner.
10
15
  */
11
16
 
12
17
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
13
- import { bootstrap } from "../app/main";
18
+ import { endpoint } from "@nwire/endpoint";
19
+ import { httpKoa } from "@nwire/koa";
20
+ import { app } from "../app/app";
14
21
 
15
- let running: Awaited<ReturnType<typeof bootstrap>>;
22
+ let running: Awaited<ReturnType<ReturnType<typeof endpoint>["run"]>>;
23
+ let koa: ReturnType<typeof httpKoa>;
16
24
  let url: string;
17
25
 
18
26
  beforeAll(async () => {
19
- running = await bootstrap({ port: 0, test: true });
20
- url = `http://127.0.0.1:${running.koa.port()}`;
27
+ koa = httpKoa({ port: 0, prefix: "/api" });
28
+ running = await endpoint("auto-moderate-test", {
29
+ exitOnShutdown: false,
30
+ banner: false,
31
+ probes: { enabled: false },
32
+ })
33
+ .use(koa)
34
+ .mount(app)
35
+ .run();
36
+ url = `http://127.0.0.1:${koa.port()}`;
21
37
  });
22
38
 
23
39
  afterAll(async () => {
24
- await running.running.shutdown("test");
25
- await running.app.stop();
40
+ await running.shutdown("test");
41
+ await app.stop();
26
42
  });
27
43
 
28
44
  const idle = () => new Promise<void>((resolve) => setImmediate(resolve));
@@ -1,22 +1,38 @@
1
1
  /**
2
2
  * Happy path — submit a post via HTTP, verify the action runs, the
3
3
  * workflow + projection update the dashboard.
4
+ *
5
+ * Boots the app from `app.ts` (the pure value) on an ephemeral port.
6
+ * `app` is complete on import — routes are wired in the module body of
7
+ * `app.ts`. Never imports `main.ts` — that file runs a real server and
8
+ * hangs the test runner.
4
9
  */
5
10
 
6
11
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
7
- import { bootstrap } from "../app/main";
12
+ import { endpoint } from "@nwire/endpoint";
13
+ import { httpKoa } from "@nwire/koa";
14
+ import { app } from "../app/app";
8
15
 
9
- let running: Awaited<ReturnType<typeof bootstrap>>;
16
+ let running: Awaited<ReturnType<ReturnType<typeof endpoint>["run"]>>;
17
+ let koa: ReturnType<typeof httpKoa>;
10
18
  let url: string;
11
19
 
12
20
  beforeAll(async () => {
13
- running = await bootstrap({ port: 0, test: true });
14
- url = `http://127.0.0.1:${running.koa.port()}`;
21
+ koa = httpKoa({ port: 0, prefix: "/api" });
22
+ running = await endpoint("submit-flow-test", {
23
+ exitOnShutdown: false,
24
+ banner: false,
25
+ probes: { enabled: false },
26
+ })
27
+ .use(koa)
28
+ .mount(app)
29
+ .run();
30
+ url = `http://127.0.0.1:${koa.port()}`;
15
31
  });
16
32
 
17
33
  afterAll(async () => {
18
- await running.running.shutdown("test");
19
- await running.app.stop();
34
+ await running.shutdown("test");
35
+ await app.stop();
20
36
  });
21
37
 
22
38
  const idle = () => new Promise<void>((resolve) => setImmediate(resolve));
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Every route file under `../modules/posts/routes/` exports a
5
5
  * `{name}Route` (binding) + `{name}Handler` (function). The `wires`
6
- * array is what main.ts feeds into `app.wire(...)`.
6
+ * array feeds into `app.wire(...)` in `app.ts`.
7
7
  */
8
8
 
9
9
  import { submitPostRoute, submitPostHandler } from "../modules/posts/routes/submit-post";
@@ -11,11 +11,15 @@ import { approvePostRoute, approvePostHandler } from "../modules/posts/routes/ap
11
11
  import { rejectPostRoute, rejectPostHandler } from "../modules/posts/routes/reject-post";
12
12
  import { listQueueRoute, listQueueHandler } from "../modules/posts/routes/list-queue";
13
13
  import { getPostRoute, getPostHandler } from "../modules/posts/routes/get-post";
14
+ import { postsByAuthorRoute, postsByAuthorHandler } from "../modules/posts/routes/posts-by-author";
14
15
 
15
16
  export const wires = [
16
17
  { binding: submitPostRoute, handler: submitPostHandler },
17
18
  { binding: approvePostRoute, handler: approvePostHandler },
18
19
  { binding: rejectPostRoute, handler: rejectPostHandler },
19
20
  { binding: listQueueRoute, handler: listQueueHandler },
21
+ // Literal `/posts/by-author` must be wired before the `/posts/:postId`
22
+ // param route — otherwise `:postId` captures "by-author" and shadows it.
23
+ { binding: postsByAuthorRoute, handler: postsByAuthorHandler },
20
24
  { binding: getPostRoute, handler: getPostHandler },
21
25
  ] as const;
@@ -1,18 +1,23 @@
1
1
  /**
2
- * `buildApp` composes the posts bounded context into a runnable App.
2
+ * App the posts bounded context as a pure value.
3
3
  *
4
- * Each piece (events, actions, workflows, projections, queries) is
5
- * imported directly; `.with(createForgePlugin(...))` installs forge with
6
- * the handlers, workflows, projections, and queries the BC needs.
4
+ * `createApp` installs the forge plugin set (workflows + projections) and
5
+ * wires all HTTP routes in this module body. The exported `app` is COMPLETE
6
+ * on import no caller needs to wire or start it. No ports are bound here;
7
+ * that is `main.ts`'s job.
7
8
  *
8
- * For multi-BC apps, build each as its own App and compose:
9
+ * Tests import `{ app }` from this file and build an isolated endpoint per
10
+ * suite. Never import `main.ts` from tests: it starts the server and hangs
11
+ * the runner.
12
+ *
13
+ * Multi-BC: build each as its own App and compose at the endpoint:
9
14
  *
10
15
  * const monolith = appCompose(postsApp, ordersApp);
11
- * await endpoint("monolith", { port: 3000 }).use(httpKoa()).mount(monolith).run();
16
+ * await endpoint("monolith", { port }).use(httpKoa()).mount(monolith).run();
12
17
  */
13
18
 
14
19
  import { createApp } from "@nwire/app";
15
- import { createForgePlugin } from "@nwire/forge";
20
+ import { forgePlugins } from "@nwire/forge";
16
21
 
17
22
  import { submitPost } from "../modules/posts/actions/submit-post";
18
23
  import { approvePost } from "../modules/posts/actions/approve-post";
@@ -25,16 +30,19 @@ import {
25
30
  getPost,
26
31
  } from "../modules/posts/projections/queue-dashboard";
27
32
  import { postsByAuthor } from "../modules/posts/queries/posts-by-author";
33
+ import { wires } from "./api";
28
34
 
29
- export function buildApp() {
30
- return createApp({ appName: "{{PROJECT_NAME}}" }).with(
31
- createForgePlugin({
32
- handlers: [submitPost.handler!, approvePost.handler!, rejectPost.handler!],
35
+ export const app = createApp({
36
+ appName: "{{PROJECT_NAME}}",
37
+ handlers: [submitPost, approvePost, rejectPost, listPending, queueTotals, getPost, postsByAuthor],
38
+ plugins: [
39
+ ...forgePlugins({
33
40
  workflows: [autoModerate],
34
41
  projections: [queueDashboard],
35
- queries: [listPending, queueTotals, getPost, postsByAuthor],
36
42
  }),
37
- );
38
- }
43
+ ],
44
+ });
39
45
 
40
- export const app = buildApp();
46
+ for (const { binding, handler } of wires) {
47
+ app.wire(binding, handler);
48
+ }
@@ -1,55 +1,30 @@
1
1
  /**
2
- * Entry — boot the app under HTTP.
2
+ * Entry — the only file that boots a real HTTP server.
3
3
  *
4
- * 1. buildApp composes the App with forge (handlers, workflows,
5
- * projections, queries) via `.with(createForgePlugin(...))`.
6
- * 2. app.wire(...) pairs every route binding with its handler.
7
- * 3. endpoint().use(httpKoa({prefix:"/api"})).mount(app).run() starts
8
- * the HTTP listener + graceful drain + K8s probes.
4
+ * pnpm dev # wire + Studio on http://localhost:4000
9
5
  *
10
- * Once running:
11
- * - HTTP POST /api/posts → submitPost action → PostWasSubmitted event
12
- * - autoModerate workflow picks up the event, runs auto-check, dispatches
13
- * approve/reject if obvious
14
- * - queueDashboard projection folds all three events into state
15
- * - HTTP GET /api/queue reads from the projection
6
+ * curl -X POST http://localhost:4000/api/posts \
7
+ * -H "content-type: application/json" \
8
+ * -d '{"authorId":"alice","body":"Hello, world!"}'
9
+ * curl http://localhost:4000/api/queue
16
10
  *
17
- * Run: pnpm dev
18
- * Try: curl -X POST http://localhost:3000/api/posts \
19
- * -H "content-type: application/json" \
20
- * -d '{"authorId":"alice","body":"Hello, world!"}'
21
- * curl http://localhost:3000/api/queue
11
+ * `app` is a pure value — routes are wired in `./app` on import.
12
+ * This file adds the one side effect: binding a port and serving traffic.
13
+ * Tests import `{ app }` from `./app` and build their own ephemeral
14
+ * endpoint — never import this file from tests or it boots a server and
15
+ * hangs the runner.
22
16
  */
23
17
 
24
18
  import { endpoint } from "@nwire/endpoint";
25
19
  import { httpKoa } from "@nwire/koa";
26
- import { buildApp } from "./app";
27
- import { wires } from "./api";
20
+ import { appConfig } from "../config/app";
21
+ import { httpConfig } from "../config/http";
22
+ import { app } from "./app";
28
23
 
29
- export interface BootstrapOptions {
30
- readonly port?: number;
31
- readonly test?: boolean;
32
- }
24
+ const cfg = appConfig();
25
+ const http = httpConfig();
33
26
 
34
- export async function bootstrap(opts: number | BootstrapOptions = 3000) {
35
- const { port = 3000, test = false } =
36
- typeof opts === "number" ? { port: opts, test: false } : opts;
37
-
38
- const app = buildApp();
39
- for (const { binding, handler } of wires) {
40
- app.wire(binding, handler);
41
- }
42
- await app.start();
43
- const koa = httpKoa({ port, prefix: "/api" });
44
- const running = await endpoint("{{PROJECT_NAME}}", {
45
- exitOnShutdown: !test,
46
- banner: !test,
47
- probes: { enabled: !test },
48
- })
49
- .use(koa)
50
- .mount(app)
51
- .run();
52
- return { app, running, koa };
53
- }
54
-
55
- await bootstrap();
27
+ await endpoint(cfg.name, { port: cfg.port, banner: cfg.banner })
28
+ .use(httpKoa({ prefix: http.prefix }))
29
+ .mount(app)
30
+ .run();
@@ -0,0 +1,18 @@
1
+ /**
2
+ * App-level settings — the process identity and lifecycle budgets the
3
+ * endpoint owns. A function of the typed `env`, so the same config file
4
+ * reads differently per environment without an `if` in sight.
5
+ */
6
+
7
+ import { env, type Env } from "./env";
8
+
9
+ export const appConfig = (e: Env = env) => ({
10
+ /** Endpoint + app name — shows in the banner, logs, and telemetry. */
11
+ name: "{{PROJECT_NAME}}",
12
+ /** Port the HTTP transport binds. */
13
+ port: e.PORT,
14
+ /** Print the boot banner outside of tests. */
15
+ banner: e.NODE_ENV !== "test",
16
+ });
17
+
18
+ export type AppConfig = ReturnType<typeof appConfig>;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Typed environment — read `process.env` once, through a schema, and never
3
+ * touch it again from app code. Missing or malformed values fail loudly at
4
+ * boot with a readable message, not three layers deep at first request.
5
+ *
6
+ * `config/*.ts` files take this typed `env` and shape it into the settings
7
+ * each concern needs.
8
+ */
9
+
10
+ import { z } from "zod";
11
+
12
+ const EnvSchema = z.object({
13
+ NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
14
+ PORT: z.coerce.number().int().min(0).max(65_535).default(3000),
15
+ HTTP_PREFIX: z.string().default("/api"),
16
+ });
17
+
18
+ export type Env = z.output<typeof EnvSchema>;
19
+
20
+ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env {
21
+ const parsed = EnvSchema.safeParse(source);
22
+ if (!parsed.success) {
23
+ const summary = parsed.error.issues
24
+ .map((i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`)
25
+ .join("\n");
26
+ throw new Error(`Invalid environment:\n${summary}`);
27
+ }
28
+ return parsed.data;
29
+ }
30
+
31
+ /** The validated environment, loaded once. */
32
+ export const env: Env = loadEnv();
@@ -0,0 +1,15 @@
1
+ /**
2
+ * HTTP transport settings — prefix and the dev inspect surface Studio reads.
3
+ * A function of the typed `env`, like every config file here.
4
+ */
5
+
6
+ import { env, type Env } from "./env";
7
+
8
+ export const httpConfig = (e: Env = env) => ({
9
+ /** Mounted under this prefix — e.g. `POST /api/posts`. */
10
+ prefix: e.HTTP_PREFIX,
11
+ /** Expose detailed errors in dev; redact in production. */
12
+ exposeErrors: e.NODE_ENV !== "production",
13
+ });
14
+
15
+ export type HttpConfig = ReturnType<typeof httpConfig>;
@@ -13,13 +13,14 @@ import { PostWasApproved } from "../events/post-was-approved";
13
13
 
14
14
  export const approvePost = defineAction({
15
15
  name: "posts.approve-post",
16
- description: "Approve a post — either by a human moderator or the auto-moderation workflow.",
17
- schema: z.object({
16
+ description:
17
+ "Miri reviews the post and approves it — or the auto-check clears it so Alice doesn't have to wait.",
18
+ input: z.object({
18
19
  postId: z.string(),
19
20
  approvedBy: z.string(),
20
21
  }),
21
22
  emits: [PostWasApproved],
22
- handler: async (input) =>
23
+ handler: async ({ input }) =>
23
24
  PostWasApproved({
24
25
  postId: input.postId,
25
26
  approvedBy: input.approvedBy,
@@ -12,14 +12,14 @@ import { PostWasRejected } from "../events/post-was-rejected";
12
12
 
13
13
  export const rejectPost = defineAction({
14
14
  name: "posts.reject-post",
15
- description: "Reject a post with a stated reason.",
16
- schema: z.object({
15
+ description: "Miri (or the auto-check) rejects the post with a reason Alice can see and act on.",
16
+ input: z.object({
17
17
  postId: z.string(),
18
18
  rejectedBy: z.string(),
19
19
  reason: z.string().min(1).max(500),
20
20
  }),
21
21
  emits: [PostWasRejected],
22
- handler: async (input) =>
22
+ handler: async ({ input }) =>
23
23
  PostWasRejected({
24
24
  postId: input.postId,
25
25
  rejectedBy: input.rejectedBy,
@@ -25,13 +25,14 @@ import { PostWasSubmitted } from "../events/post-was-submitted";
25
25
 
26
26
  export const submitPost = defineAction({
27
27
  name: "posts.submit-post",
28
- description: "Author submits a draft to the moderation queue.",
29
- schema: z.object({
28
+ description:
29
+ "Alice drafts a post and submits it for moderation — she's waiting to see if it gets through.",
30
+ input: z.object({
30
31
  authorId: z.string(),
31
32
  body: z.string().min(1).max(2000),
32
33
  }),
33
34
  emits: [PostWasSubmitted],
34
- handler: async (input) =>
35
+ handler: async ({ input }) =>
35
36
  PostWasSubmitted({
36
37
  postId: randomUUID(),
37
38
  authorId: input.authorId,
@@ -11,7 +11,7 @@ import { defineEvent } from "@nwire/messages";
11
11
 
12
12
  export const PostWasApproved = defineEvent({
13
13
  name: "posts.post-was-approved",
14
- description: "A moderator (or the auto-moderation workflow) approves a post.",
14
+ description: "The post cleared moderation Alice's words are now visible to readers.",
15
15
  outcome: "success",
16
16
  audience: ["product", "ops"],
17
17
  schema: z.object({
@@ -8,7 +8,8 @@ import { defineEvent } from "@nwire/messages";
8
8
 
9
9
  export const PostWasRejected = defineEvent({
10
10
  name: "posts.post-was-rejected",
11
- description: "A moderator (or the auto-moderation workflow) rejects a post.",
11
+ description:
12
+ "The post was turned away — Miri or the auto-check flagged it, and Alice sees the reason.",
12
13
  outcome: "failure",
13
14
  audience: ["product", "ops"],
14
15
  schema: z.object({
@@ -15,7 +15,8 @@ import { defineEvent } from "@nwire/messages";
15
15
 
16
16
  export const PostWasSubmitted = defineEvent({
17
17
  name: "posts.post-was-submitted",
18
- description: "An author drafts a post and submits it for moderation.",
18
+ description:
19
+ "Alice drafts a post and submits it for moderation — she's waiting to see if it gets through.",
19
20
  outcome: "milestone",
20
21
  audience: ["product", "ops"],
21
22
  schema: z.object({