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 +1 -1
- package/template/frontend/package.json +1 -1
- package/template/frontend/src/auth/AuthContext.jsx +17 -0
- package/template/frontend/src/platform/agentStreamTransport.js +168 -0
- package/template/frontend/src/platform/agentStreamTransport.test.mjs +273 -0
- package/template/server/package.json +6 -6
- package/template/server/src/main.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-astram-app",
|
|
3
|
-
"version": "0.14.
|
|
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",
|
|
@@ -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.
|
|
25
|
-
"@astram/engine": "0.
|
|
26
|
-
"@astram/guard": "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.
|
|
29
|
-
"@astram/messaging": "0.1.
|
|
30
|
-
"@astram/migrate": "0.7.
|
|
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
|
-
|
|
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
|