create-astram-app 0.14.0 → 0.14.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-astram-app",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "license": "MIT",
5
5
  "description": "Scaffolder for a new Astram app — emits a running, framework-enforced NestJS server (deny-by-default, built-in auth/identity to switch on) and, with --with-frontend, a React admin/auth UI, over the immutable @astram/* framework packages.",
6
6
  "type": "module",
@@ -12,7 +12,7 @@
12
12
  "test": "node scripts/run-tests.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "@astram/frontend-core": "^0.11.0",
15
+ "@astram/frontend-core": "^0.12.0",
16
16
  "@astram/sdk": "^0.2.1",
17
17
  "@radix-ui/react-alert-dialog": "^1.1.19",
18
18
  "@radix-ui/react-avatar": "^1.1.10",
@@ -13,6 +13,7 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState } from
13
13
  import { createShieldClient, systemClock } from '@astram/sdk';
14
14
  import {
15
15
  createAccountService,
16
+ createAgentService,
16
17
  createAuditService,
17
18
  createAuthService,
18
19
  createCrossTenantService,
@@ -29,6 +30,7 @@ import {
29
30
  import { createWebHttpClient } from '../platform/httpClient';
30
31
  import { createWebSessionStore } from '../platform/sessionStore';
31
32
  import { createWebShieldTransport } from '../platform/shieldTransport';
33
+ import { createAgentStreamTransport } from '../platform/agentStreamTransport';
32
34
  import { saveReturnPath } from '../platform/returnPathStore';
33
35
  import {
34
36
  afterDeviceConfirmed,
@@ -121,6 +123,16 @@ export function AuthProvider({ children }) {
121
123
  // In-app notices (G-027): the header bell and Announcements menus and their unread counts
122
124
  // (`/account/notifications`; shell/useNotificationCounts.js polls the counts).
123
125
  notifications: createNotificationService(http),
126
+ // The AI assistant client (G-036): JSON calls (cancel, task view) over `http`; the SSE
127
+ // streams over the web stream transport, which reads the live token and, on a 401 before
128
+ // the first byte only, shares `http`'s single-flight refresh. No chat UI ships yet.
129
+ agent: createAgentService(
130
+ http,
131
+ createAgentStreamTransport({
132
+ getToken: () => tokenRef.current,
133
+ refreshAfterUnauthorized: (sentToken) => http.refreshAfterUnauthorized(sentToken),
134
+ }),
135
+ ),
124
136
  // The @astram/sdk ShieldClient (Phase E slice 1) — a UX-convenience projection of
125
137
  // the caller's OWN coarse grants, fetched once per session via `getAuthFacts`.
126
138
  // Built once, so every session boundary calls `shield.reset()` (establishSession,
@@ -152,6 +164,7 @@ export function AuthProvider({ children }) {
152
164
  serviceAccounts,
153
165
  account,
154
166
  notifications,
167
+ agent,
155
168
  shield,
156
169
  store,
157
170
  rawAuth,
@@ -457,6 +470,9 @@ export function AuthProvider({ children }) {
457
470
  account,
458
471
  // The shared notification service (list / unread count / mark read; G-027) — same client.
459
472
  notifications,
473
+ // The AI assistant client (open/continue/cancel/view/replay; G-036). Gate any UI on
474
+ // `can('identity:agent:chat')`; the server's decide() is the real check.
475
+ agent,
460
476
  // The @astram/sdk ShieldClient (Phase E slice 1) — exposed for any page that needs
461
477
  // direct access (e.g. a forced re-fetch). Most call sites should prefer `useCan`.
462
478
  shield,
@@ -491,6 +507,7 @@ export function AuthProvider({ children }) {
491
507
  serviceAccounts,
492
508
  account,
493
509
  notifications,
510
+ agent,
494
511
  shield,
495
512
  profile,
496
513
  mustReenroll,
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The WEB agent stream transport — the platform half of @astram/frontend-core's injected
3
+ * `AgentStreamTransport` (G-036 M1). `fetch` with a streamed body (never `EventSource`, which
4
+ * cannot send a bearer token or a POST body) to the same `/api` base as `httpClient.js`,
5
+ * decoded with `TextDecoder` (stream mode, so a UTF-8 sequence split across reads is kept
6
+ * whole). The service in frontend-core parses the SSE frames, folds them and runs the stall
7
+ * watchdog; this file only moves bytes.
8
+ *
9
+ * Auth: `Authorization: Bearer <getToken()>`. A `401` BEFORE THE FIRST BYTE (the response status,
10
+ * so no stream byte has been read) runs `refreshAfterUnauthorized(sentToken)` once — the
11
+ * refreshing http client's shared single-flight refresh, so the stream never spends a refresh
12
+ * token in parallel with a JSON call — then retries once with the rotated token. `expired` /
13
+ * `superseded` ⇒ status 401, `unavailable` ⇒ status 0, no retry. Once a `200` has arrived there is
14
+ * never a refresh: the stream is bound to the session, not the access token (SPEC §10), so token
15
+ * expiry mid-stream does not cut it.
16
+ *
17
+ * `open()` never throws: a network failure or abort is `{ status: 0 }`. A refusal (any non-200)
18
+ * carries its parsed `{ code, retryable }` body, else `error: null`.
19
+ */
20
+
21
+ const BASE = '/api'; // the same base as httpClient.js (Vite proxies /api to the server)
22
+
23
+ function refusalBody(data) {
24
+ if (data === null || typeof data !== 'object' || Array.isArray(data)) return null;
25
+ return typeof data.code === 'string' && data.code !== '' && typeof data.retryable === 'boolean'
26
+ ? { code: data.code, retryable: data.retryable }
27
+ : null;
28
+ }
29
+
30
+ async function readJson(res) {
31
+ try {
32
+ return await res.json();
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /** Release a response body we will not read (best-effort). */
39
+ function discard(res) {
40
+ try {
41
+ const p = res.body && typeof res.body.cancel === 'function' ? res.body.cancel() : null;
42
+ if (p && typeof p.catch === 'function') p.catch(() => {});
43
+ } catch {
44
+ /* nothing to release */
45
+ }
46
+ }
47
+
48
+ const noop = () => {};
49
+
50
+ /**
51
+ * @param {{ getToken: () => (string | null),
52
+ * refreshAfterUnauthorized?: (sentToken: string) => Promise<
53
+ * { kind: 'rotated', accessToken: string } | { kind: 'expired' | 'superseded' | 'unavailable' }>,
54
+ * fetchImpl?: typeof fetch }} deps
55
+ * `getToken` — the current access token (AuthContext's token ref).
56
+ * `refreshAfterUnauthorized` — the refreshing http client's method of the same name.
57
+ * `fetchImpl` — for tests; defaults to the global `fetch`.
58
+ * @returns {import('@astram/frontend-core').AgentStreamTransport}
59
+ */
60
+ export function createAgentStreamTransport({ getToken, refreshAfterUnauthorized, fetchImpl } = {}) {
61
+ const doFetch = (...args) => (fetchImpl ?? globalThis.fetch)(...args);
62
+
63
+ return {
64
+ async open({ method, path, body, lastEventId, signal }) {
65
+ // Our own controller, so close() can stop the read; the caller's signal feeds it.
66
+ const ctl = new AbortController();
67
+ const onAbort = () => ctl.abort();
68
+ if (signal) {
69
+ if (signal.aborted) ctl.abort();
70
+ else signal.addEventListener('abort', onAbort);
71
+ }
72
+ const detach = () => {
73
+ if (signal) signal.removeEventListener('abort', onAbort);
74
+ };
75
+ const failed = (status, error = null) => {
76
+ detach();
77
+ return { status, error, chunks: null, close: noop };
78
+ };
79
+
80
+ const send = (token) => {
81
+ const headers = { accept: 'text/event-stream' };
82
+ if (token) headers.authorization = `Bearer ${token}`;
83
+ if (method === 'POST') headers['content-type'] = 'application/json';
84
+ if (typeof lastEventId === 'number') headers['last-event-id'] = String(lastEventId);
85
+ return doFetch(`${BASE}${path}`, {
86
+ method,
87
+ headers,
88
+ body: method === 'POST' ? JSON.stringify(body ?? {}) : undefined,
89
+ cache: 'no-store',
90
+ signal: ctl.signal,
91
+ });
92
+ };
93
+
94
+ let res;
95
+ const sent = getToken ? getToken() : null;
96
+ try {
97
+ res = await send(sent);
98
+ } catch {
99
+ return failed(0);
100
+ }
101
+
102
+ // 401 before the first byte: one shared refresh, then one retry.
103
+ if (res.status === 401 && sent && typeof refreshAfterUnauthorized === 'function') {
104
+ discard(res);
105
+ let outcome;
106
+ try {
107
+ outcome = await refreshAfterUnauthorized(sent);
108
+ } catch {
109
+ outcome = { kind: 'unavailable' };
110
+ }
111
+ if (outcome && outcome.kind === 'rotated' && outcome.accessToken) {
112
+ try {
113
+ res = await send(outcome.accessToken);
114
+ } catch {
115
+ return failed(0);
116
+ }
117
+ } else if (outcome && (outcome.kind === 'expired' || outcome.kind === 'superseded')) {
118
+ return failed(401);
119
+ } else {
120
+ return failed(0);
121
+ }
122
+ }
123
+
124
+ if (res.status !== 200) {
125
+ const error = refusalBody(await readJson(res));
126
+ return failed(res.status, error);
127
+ }
128
+ if (!res.body || typeof res.body.getReader !== 'function') {
129
+ return failed(0);
130
+ }
131
+
132
+ const reader = res.body.getReader();
133
+ const decoder = new TextDecoder();
134
+ let closed = false;
135
+ const close = () => {
136
+ if (closed) return;
137
+ closed = true;
138
+ detach();
139
+ try {
140
+ const p = reader.cancel();
141
+ if (p && typeof p.catch === 'function') p.catch(() => {});
142
+ } catch {
143
+ /* already released */
144
+ }
145
+ ctl.abort();
146
+ };
147
+
148
+ async function* chunks() {
149
+ try {
150
+ for (;;) {
151
+ const { done, value } = await reader.read();
152
+ if (done) {
153
+ const tail = decoder.decode();
154
+ if (tail) yield tail;
155
+ return;
156
+ }
157
+ const text = decoder.decode(value, { stream: true });
158
+ if (text) yield text;
159
+ }
160
+ } finally {
161
+ close();
162
+ }
163
+ }
164
+
165
+ return { status: 200, error: null, chunks: chunks(), close };
166
+ },
167
+ };
168
+ }
@@ -0,0 +1,273 @@
1
+ // agentStreamTransport.test.mjs — the web agent stream transport (G-036 M1, SPEC
2
+ // agent-read-capabilities §5 frontend-core, §14 item 7): fetch with a streamed body, bearer token,
3
+ // and a refresh ONLY before the first byte (one shared refresh, one retry); never after a 200.
4
+ //
5
+ // node --test src/platform/agentStreamTransport.test.mjs
6
+ //
7
+ // `fetch` is injected (`fetchImpl`), so this runs under plain Node (>= 20: Response,
8
+ // ReadableStream, TextEncoder/TextDecoder are globals).
9
+ import { test, describe } from 'node:test';
10
+ import assert from 'node:assert/strict';
11
+ import { createAgentStreamTransport } from './agentStreamTransport.js';
12
+
13
+ const enc = new TextEncoder();
14
+
15
+ /** A 200 SSE response whose body yields `parts` (strings or byte arrays), then optionally fails. */
16
+ function sseResponse(parts, { failAfter = false } = {}) {
17
+ let i = 0;
18
+ const body = new ReadableStream({
19
+ pull(controller) {
20
+ if (i < parts.length) {
21
+ const p = parts[i++];
22
+ controller.enqueue(typeof p === 'string' ? enc.encode(p) : p);
23
+ } else if (failAfter) {
24
+ controller.error(new Error('connection reset'));
25
+ } else {
26
+ controller.close();
27
+ }
28
+ },
29
+ });
30
+ return new Response(body, { status: 200, headers: { 'content-type': 'text/event-stream' } });
31
+ }
32
+
33
+ const json = (status, data) =>
34
+ new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
35
+
36
+ /** fetch stub: answers in order, records each call. */
37
+ function fetchSeq(...answers) {
38
+ const calls = [];
39
+ const fetchImpl = async (url, init) => {
40
+ calls.push({ url, init });
41
+ const a = answers.shift();
42
+ if (a instanceof Error) throw a;
43
+ return typeof a === 'function' ? a() : a;
44
+ };
45
+ return { fetchImpl, calls };
46
+ }
47
+
48
+ async function readAll(chunks) {
49
+ let text = '';
50
+ for await (const c of chunks) text += c;
51
+ return text;
52
+ }
53
+
54
+ const signal = () => new AbortController().signal;
55
+ const FRAME = 'id: 1\nevent: step\ndata: {}\n\n';
56
+
57
+ describe('createAgentStreamTransport', () => {
58
+ test('POST to /api with bearer, JSON body and SSE accept; 200 streams decoded text', async () => {
59
+ const { fetchImpl, calls } = fetchSeq(sseResponse([': hb\n\n', FRAME]));
60
+ const t = createAgentStreamTransport({ getToken: () => 'tok-1', fetchImpl });
61
+ const res = await t.open({
62
+ method: 'POST',
63
+ path: '/agent/tasks',
64
+ body: { message: 'Hi' },
65
+ signal: signal(),
66
+ });
67
+ assert.equal(res.status, 200);
68
+ assert.equal(res.error, null);
69
+ assert.equal(await readAll(res.chunks), `: hb\n\n${FRAME}`);
70
+ assert.equal(calls.length, 1);
71
+ assert.equal(calls[0].url, '/api/agent/tasks');
72
+ assert.equal(calls[0].init.method, 'POST');
73
+ assert.equal(calls[0].init.headers.authorization, 'Bearer tok-1');
74
+ assert.equal(calls[0].init.headers.accept, 'text/event-stream');
75
+ assert.equal(calls[0].init.headers['content-type'], 'application/json');
76
+ assert.equal(calls[0].init.body, '{"message":"Hi"}');
77
+ });
78
+
79
+ test('GET replay sends Last-Event-ID and no body', async () => {
80
+ const { fetchImpl, calls } = fetchSeq(sseResponse([]));
81
+ const t = createAgentStreamTransport({ getToken: () => 'tok-1', fetchImpl });
82
+ const res = await t.open({
83
+ method: 'GET',
84
+ path: '/agent/tasks/x/events',
85
+ body: null,
86
+ lastEventId: 7,
87
+ signal: signal(),
88
+ });
89
+ assert.equal(res.status, 200);
90
+ assert.equal(calls[0].init.headers['last-event-id'], '7');
91
+ assert.equal(calls[0].init.body, undefined);
92
+ assert.equal(calls[0].init.headers['content-type'], undefined);
93
+ });
94
+
95
+ test('a UTF-8 sequence split across reads is decoded whole', async () => {
96
+ const bytes = enc.encode('data: "— 𝔸"\n\n');
97
+ const { fetchImpl } = fetchSeq(
98
+ sseResponse([bytes.subarray(0, 8), bytes.subarray(8, 10), bytes.subarray(10)]),
99
+ );
100
+ const res = await createAgentStreamTransport({ getToken: () => 't', fetchImpl }).open({
101
+ method: 'GET',
102
+ path: '/p',
103
+ body: null,
104
+ signal: signal(),
105
+ });
106
+ assert.equal(await readAll(res.chunks), 'data: "— 𝔸"\n\n');
107
+ });
108
+
109
+ test('401 before the first byte: one refresh, one retry with the rotated token', async () => {
110
+ const { fetchImpl, calls } = fetchSeq(
111
+ json(401, { message: 'Unauthorized' }),
112
+ sseResponse([FRAME]),
113
+ );
114
+ const refreshes = [];
115
+ const t = createAgentStreamTransport({
116
+ getToken: () => 'old',
117
+ refreshAfterUnauthorized: async (sent) => {
118
+ refreshes.push(sent);
119
+ return { kind: 'rotated', accessToken: 'new' };
120
+ },
121
+ fetchImpl,
122
+ });
123
+ const res = await t.open({
124
+ method: 'POST',
125
+ path: '/agent/tasks',
126
+ body: { message: 'Hi' },
127
+ signal: signal(),
128
+ });
129
+ assert.deepEqual(refreshes, ['old']);
130
+ assert.equal(calls.length, 2);
131
+ assert.equal(calls[1].init.headers.authorization, 'Bearer new');
132
+ assert.equal(calls[1].init.body, '{"message":"Hi"}');
133
+ assert.equal(res.status, 200);
134
+ assert.equal(await readAll(res.chunks), FRAME);
135
+ });
136
+
137
+ test('a second 401 after the retry is returned: no second refresh', async () => {
138
+ const { fetchImpl, calls } = fetchSeq(json(401, null), json(401, null));
139
+ let refreshes = 0;
140
+ const t = createAgentStreamTransport({
141
+ getToken: () => 'old',
142
+ refreshAfterUnauthorized: async () => {
143
+ refreshes += 1;
144
+ return { kind: 'rotated', accessToken: 'new' };
145
+ },
146
+ fetchImpl,
147
+ });
148
+ const res = await t.open({ method: 'POST', path: '/p', body: {}, signal: signal() });
149
+ assert.equal(res.status, 401);
150
+ assert.equal(res.chunks, null);
151
+ assert.equal(refreshes, 1);
152
+ assert.equal(calls.length, 2);
153
+ });
154
+
155
+ test('expired / superseded ⇒ 401, unavailable ⇒ 0, a throwing refresh ⇒ 0; never a retry', async () => {
156
+ for (const [outcome, status] of [
157
+ [{ kind: 'expired' }, 401],
158
+ [{ kind: 'superseded' }, 401],
159
+ [{ kind: 'unavailable' }, 0],
160
+ [new Error('boom'), 0],
161
+ ]) {
162
+ const { fetchImpl, calls } = fetchSeq(json(401, null));
163
+ const t = createAgentStreamTransport({
164
+ getToken: () => 'old',
165
+ refreshAfterUnauthorized: async () => {
166
+ if (outcome instanceof Error) throw outcome;
167
+ return outcome;
168
+ },
169
+ fetchImpl,
170
+ });
171
+ const res = await t.open({ method: 'POST', path: '/p', body: {}, signal: signal() });
172
+ assert.equal(res.status, status);
173
+ assert.equal(res.chunks, null);
174
+ assert.equal(calls.length, 1);
175
+ }
176
+ });
177
+
178
+ test('after a 200 there is never a refresh: a failing stream or a token change mid-stream', async () => {
179
+ const { fetchImpl, calls } = fetchSeq(sseResponse([FRAME], { failAfter: true }));
180
+ let token = 'tok-1';
181
+ let refreshes = 0;
182
+ const t = createAgentStreamTransport({
183
+ getToken: () => token,
184
+ refreshAfterUnauthorized: async () => {
185
+ refreshes += 1;
186
+ return { kind: 'rotated', accessToken: 'x' };
187
+ },
188
+ fetchImpl,
189
+ });
190
+ const res = await t.open({ method: 'POST', path: '/p', body: {}, signal: signal() });
191
+ assert.equal(res.status, 200);
192
+ const it = res.chunks[Symbol.asyncIterator]();
193
+ assert.equal((await it.next()).value, FRAME);
194
+ token = 'tok-2'; // another tab refreshed: the open stream is unaffected
195
+ await assert.rejects(it.next());
196
+ assert.equal(refreshes, 0);
197
+ assert.equal(calls.length, 1);
198
+ });
199
+
200
+ test('no token ⇒ no Authorization header and no refresh on 401', async () => {
201
+ const { fetchImpl, calls } = fetchSeq(json(401, null));
202
+ let refreshes = 0;
203
+ const t = createAgentStreamTransport({
204
+ getToken: () => null,
205
+ refreshAfterUnauthorized: async () => {
206
+ refreshes += 1;
207
+ return { kind: 'rotated', accessToken: 'x' };
208
+ },
209
+ fetchImpl,
210
+ });
211
+ const res = await t.open({ method: 'POST', path: '/p', body: {}, signal: signal() });
212
+ assert.equal(res.status, 401);
213
+ assert.equal(calls[0].init.headers.authorization, undefined);
214
+ assert.equal(refreshes, 0);
215
+ });
216
+
217
+ test('a refusal carries its parsed { code, retryable } body; other bodies ⇒ null', async () => {
218
+ const { fetchImpl } = fetchSeq(
219
+ json(409, { code: 'task_busy', retryable: true, extra: 'ignored' }),
220
+ json(503, { message: 'server text' }),
221
+ );
222
+ const t = createAgentStreamTransport({ getToken: () => 't', fetchImpl });
223
+ const a = await t.open({ method: 'POST', path: '/p', body: {}, signal: signal() });
224
+ assert.deepEqual(a.error, { code: 'task_busy', retryable: true });
225
+ assert.equal(a.status, 409);
226
+ const b = await t.open({ method: 'POST', path: '/p', body: {}, signal: signal() });
227
+ assert.equal(b.status, 503);
228
+ assert.equal(b.error, null);
229
+ });
230
+
231
+ test('network failure ⇒ status 0; an aborted signal aborts the fetch', async () => {
232
+ const { fetchImpl } = fetchSeq(new TypeError('Failed to fetch'));
233
+ const t = createAgentStreamTransport({ getToken: () => 't', fetchImpl });
234
+ assert.equal(
235
+ (await t.open({ method: 'POST', path: '/p', body: {}, signal: signal() })).status,
236
+ 0,
237
+ );
238
+
239
+ let seenSignal;
240
+ const t2 = createAgentStreamTransport({
241
+ getToken: () => 't',
242
+ fetchImpl: async (_u, init) => {
243
+ seenSignal = init.signal;
244
+ throw new DOMException('aborted', 'AbortError');
245
+ },
246
+ });
247
+ const ctl = new AbortController();
248
+ ctl.abort();
249
+ assert.equal(
250
+ (await t2.open({ method: 'POST', path: '/p', body: {}, signal: ctl.signal })).status,
251
+ 0,
252
+ );
253
+ assert.equal(seenSignal.aborted, true);
254
+ });
255
+
256
+ test('close() ends the stream and aborts the request', async () => {
257
+ let seenSignal;
258
+ const t = createAgentStreamTransport({
259
+ getToken: () => 't',
260
+ fetchImpl: async (_u, init) => {
261
+ seenSignal = init.signal;
262
+ return sseResponse([FRAME, FRAME, FRAME]);
263
+ },
264
+ });
265
+ const res = await t.open({ method: 'GET', path: '/p', body: null, signal: signal() });
266
+ const it = res.chunks[Symbol.asyncIterator]();
267
+ await it.next();
268
+ res.close();
269
+ assert.equal(seenSignal.aborted, true);
270
+ const next = await it.next().catch(() => ({ done: true }));
271
+ assert.equal(next.done, true);
272
+ });
273
+ });
@@ -21,13 +21,13 @@
21
21
  "postinstall": "node scripts/sync-astram-guide.mjs"
22
22
  },
23
23
  "dependencies": {
24
- "@astram/core": "1.14.0",
25
- "@astram/engine": "0.16.2",
26
- "@astram/guard": "0.27.0",
24
+ "@astram/core": "1.15.0",
25
+ "@astram/engine": "0.17.0",
26
+ "@astram/guard": "0.28.0",
27
27
  "@astram/guide": "^0.2.8",
28
- "@astram/identity": "0.25.0",
29
- "@astram/messaging": "0.1.13",
30
- "@astram/migrate": "0.7.4",
28
+ "@astram/identity": "0.26.0",
29
+ "@astram/messaging": "0.1.14",
30
+ "@astram/migrate": "0.7.5",
31
31
  "@astram/sdk": "^0.2.0",
32
32
  "@nestjs/common": "^11.2.7",
33
33
  "@nestjs/core": "^11.2.7",
@@ -22,7 +22,13 @@ export async function bootstrap(): Promise<void> {
22
22
  await resolveBootSecrets();
23
23
  const { AppModule } = await import('./app.module');
24
24
  const config = loadAppConfig();
25
- const app = await NestFactory.create<NestExpressApplication>(AppModule, { bufferLogs: false });
25
+ // `rawBody: true` keeps the request bytes in `req.rawBody` beside the parsed JSON. `@astram/agent`
26
+ // parses its own bodies from those bytes (strict JSON, duplicate keys refused); without it every
27
+ // agent POST answers 503 agent_unavailable. Other routes are unaffected.
28
+ const app = await NestFactory.create<NestExpressApplication>(AppModule, {
29
+ bufferLogs: false,
30
+ rawBody: true,
31
+ });
26
32
  // On SIGTERM/SIGINT (PM2 restart, deploy) run the framework's shutdown hooks: background jobs
27
33
  // stop, the HTTP server finishes its in-flight requests, then every DB pool and Redis client
28
34
  // closes. Without this line Node exits with no hook run. Give your process manager a stop