@sigrun/sigrun 0.0.0-stage → 0.1.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/CHANGELOG.md +214 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +89 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The §5.4/§5.8 auth routes (core hardening W1 Tasks 3 + 4): browser
|
|
3
|
+
* login (API token → session cookie), logout (cookie expiry), whoami
|
|
4
|
+
* (the resolved principal), and the token management surface (`GET/POST
|
|
5
|
+
* /v1/auth/tokens`, `DELETE /v1/auth/tokens/:id`). The pipeline marks
|
|
6
|
+
* login/logout public (stage 2); whoami and the token routes are
|
|
7
|
+
* authenticated routes like any other.
|
|
8
|
+
*
|
|
9
|
+
* Login's contract: a valid token replies 200 `{user}` and sets
|
|
10
|
+
* `sigrun_session=<token>; HttpOnly; SameSite=Strict; Path=/` (plus
|
|
11
|
+
* `; Secure` when the request is https directly or via
|
|
12
|
+
* `X-Forwarded-Proto: https` from a trusted proxy peer). An invalid
|
|
13
|
+
* token waits 1 000 ms (blunting online guessing) before 401; the
|
|
14
|
+
* per-IP limiter's 429 is checked FIRST, before the credential and the
|
|
15
|
+
* wait.
|
|
16
|
+
*
|
|
17
|
+
* Token routes (Task 4): creation is SELF-ONLY — a body carrying a `user`
|
|
18
|
+
* field is 403 `forbidden` (minting credentials for another user is
|
|
19
|
+
* never a delegated act); listing serves the caller's tokens, with
|
|
20
|
+
* `?user=` the admin-only window into another's; revocation deletes the
|
|
21
|
+
* caller's own tokens, or any for an admin.
|
|
22
|
+
*/
|
|
23
|
+
import { ipAllowed } from "../auth/ip.js";
|
|
24
|
+
import { LoginLimiter } from "../auth/login-limiter.js";
|
|
25
|
+
/** The session cookie name (spec §5.4). */
|
|
26
|
+
export const SESSION_COOKIE = "sigrun_session";
|
|
27
|
+
/** The failed-login delay (ms), per the brief. */
|
|
28
|
+
const LOGIN_FAILURE_DELAY_MS = 1_000;
|
|
29
|
+
/**
|
|
30
|
+
* Build the `sigrun_session` cookie for `token`. `Secure` only on https —
|
|
31
|
+
* direct TLS or a trusted proxy's X-Forwarded-Proto (an untrusted peer's
|
|
32
|
+
* header is ignored). Shared by login and the onboarding claim's
|
|
33
|
+
* `issueToken` (Task 4).
|
|
34
|
+
*/
|
|
35
|
+
export function buildSessionCookie(token, request, ctx) {
|
|
36
|
+
const trusted = ipAllowed(request.socket.remoteAddress, ctx.config.auth.proxy.trustedProxies);
|
|
37
|
+
const forwarded = request.headers["x-forwarded-proto"];
|
|
38
|
+
const forwardedProto = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
|
39
|
+
const secure = request.protocol === "https" || (trusted && forwardedProto === "https");
|
|
40
|
+
return (`${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/` +
|
|
41
|
+
(secure ? "; Secure" : ""));
|
|
42
|
+
}
|
|
43
|
+
/** The wire form of one token record — never the digest. */
|
|
44
|
+
function tokenWire(record) {
|
|
45
|
+
return {
|
|
46
|
+
id: record.id,
|
|
47
|
+
name: record.name,
|
|
48
|
+
user: record.user,
|
|
49
|
+
createdAt: record.createdAt,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Register the auth routes: login, logout, whoami, and token management. */
|
|
53
|
+
export function registerAuthRoutes(app, ctx) {
|
|
54
|
+
const limiter = new LoginLimiter();
|
|
55
|
+
app.post("/v1/auth/login", async (request, reply) => {
|
|
56
|
+
const source = request.socket.remoteAddress ?? "unknown";
|
|
57
|
+
// The limiter answers BEFORE the credential is touched — a locked
|
|
58
|
+
// source never learns whether its token was right.
|
|
59
|
+
if (!limiter.allowed(source)) {
|
|
60
|
+
return await reply.code(429).send({ error: "rate_limited" });
|
|
61
|
+
}
|
|
62
|
+
const body = request.body;
|
|
63
|
+
const presented = typeof body === "object" && body !== null && "token" in body
|
|
64
|
+
? body.token
|
|
65
|
+
: undefined;
|
|
66
|
+
const token = typeof presented === "string" ? presented : undefined;
|
|
67
|
+
const record = token === undefined
|
|
68
|
+
? undefined
|
|
69
|
+
: ctx.opened.identity.tokens.verify(token);
|
|
70
|
+
const user = record === undefined
|
|
71
|
+
? undefined
|
|
72
|
+
: ctx.opened.identity.users.get(record.user);
|
|
73
|
+
if (record === undefined || user === undefined || token === undefined) {
|
|
74
|
+
limiter.recordFailure(source);
|
|
75
|
+
await new Promise((resolve) => setTimeout(resolve, LOGIN_FAILURE_DELAY_MS));
|
|
76
|
+
return await reply.code(401).send({ error: "invalid_token" });
|
|
77
|
+
}
|
|
78
|
+
limiter.recordSuccess(source);
|
|
79
|
+
return await reply
|
|
80
|
+
.code(200)
|
|
81
|
+
.header("set-cookie", buildSessionCookie(token, request, ctx))
|
|
82
|
+
.send({ user: user.user });
|
|
83
|
+
});
|
|
84
|
+
app.post("/v1/auth/logout", async (_request, reply) => {
|
|
85
|
+
return await reply
|
|
86
|
+
.code(204)
|
|
87
|
+
.header("set-cookie", `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`)
|
|
88
|
+
.send();
|
|
89
|
+
});
|
|
90
|
+
app.get("/v1/auth/whoami", async (request) => {
|
|
91
|
+
const principal = request.principal;
|
|
92
|
+
return {
|
|
93
|
+
...(principal.user === undefined ? {} : { user: principal.user }),
|
|
94
|
+
...(principal.name === undefined ? {} : { name: principal.name }),
|
|
95
|
+
...(principal.email === undefined ? {} : { email: principal.email }),
|
|
96
|
+
admin: principal.admin,
|
|
97
|
+
service: principal.service,
|
|
98
|
+
method: principal.method,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
// ─── Token management (Task 4) ─────────────────────────────────────
|
|
102
|
+
app.get("/v1/auth/tokens", async (request, reply) => {
|
|
103
|
+
const principal = request.principal;
|
|
104
|
+
const query = request.query;
|
|
105
|
+
// `?user=` is the admin-only window into another's tokens; a
|
|
106
|
+
// non-admin may pass their OWN name (the same payload as no query —
|
|
107
|
+
// the identity is theirs either way) but anyone ELSE's is refused
|
|
108
|
+
// before the store is read.
|
|
109
|
+
if (query.user !== undefined) {
|
|
110
|
+
if (!principal.admin && query.user !== principal.user) {
|
|
111
|
+
return await reply.code(403).send({ error: "forbidden" });
|
|
112
|
+
}
|
|
113
|
+
return { tokens: ctx.opened.identity.tokens.list(query.user).map(tokenWire) };
|
|
114
|
+
}
|
|
115
|
+
// The caller's own list — undefined (the naive single-operator
|
|
116
|
+
// superuser, userless service principal) means "no directory user to
|
|
117
|
+
// filter by": the whole store, which for that posture IS the
|
|
118
|
+
// operator's own (single-operator deploy).
|
|
119
|
+
return {
|
|
120
|
+
tokens: ctx.opened.identity.tokens.list(principal.user).map(tokenWire),
|
|
121
|
+
};
|
|
122
|
+
});
|
|
123
|
+
app.post("/v1/auth/tokens", async (request, reply) => {
|
|
124
|
+
const body = request.body;
|
|
125
|
+
if (typeof body !== "object" ||
|
|
126
|
+
body === null ||
|
|
127
|
+
Array.isArray(body)) {
|
|
128
|
+
return await reply.code(400).send({ error: "invalid_body" });
|
|
129
|
+
}
|
|
130
|
+
const record = body;
|
|
131
|
+
for (const key of Object.keys(record)) {
|
|
132
|
+
if (key !== "name" && key !== "user") {
|
|
133
|
+
return await reply.code(400).send({ error: "invalid_field", field: key });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Self only, ever: an authenticated principal never mints for
|
|
137
|
+
// another user — the body's `user` is refused, not honored.
|
|
138
|
+
if (record.user !== undefined) {
|
|
139
|
+
return await reply.code(403).send({ error: "forbidden" });
|
|
140
|
+
}
|
|
141
|
+
if (typeof record.name !== "string" || record.name.trim() === "") {
|
|
142
|
+
return await reply.code(400).send({ error: "invalid_body" });
|
|
143
|
+
}
|
|
144
|
+
const owner = request.principal.user;
|
|
145
|
+
// The naive superuser with no header has no directory identity to
|
|
146
|
+
// mint for — a mintable token needs a user whose rights it carries.
|
|
147
|
+
if (owner === undefined) {
|
|
148
|
+
return await reply.code(400).send({ error: "no_user" });
|
|
149
|
+
}
|
|
150
|
+
const minted = await ctx.opened.identity.tokens.create(record.name, owner);
|
|
151
|
+
const response = {
|
|
152
|
+
token: minted.token,
|
|
153
|
+
record: tokenWire(minted.record),
|
|
154
|
+
};
|
|
155
|
+
return await reply.code(201).send(response);
|
|
156
|
+
});
|
|
157
|
+
app.delete("/v1/auth/tokens/:id", async (request, reply) => {
|
|
158
|
+
const { id } = request.params;
|
|
159
|
+
const record = ctx.opened.identity.tokens
|
|
160
|
+
.list()
|
|
161
|
+
.find((entry) => entry.id === id);
|
|
162
|
+
if (record === undefined) {
|
|
163
|
+
return await reply.code(404).send({ error: "unknown_token" });
|
|
164
|
+
}
|
|
165
|
+
const principal = request.principal;
|
|
166
|
+
if (record.user !== principal.user && !principal.admin) {
|
|
167
|
+
return await reply.code(403).send({ error: "forbidden" });
|
|
168
|
+
}
|
|
169
|
+
await ctx.opened.identity.tokens.revoke(id);
|
|
170
|
+
return await reply.code(204).send();
|
|
171
|
+
});
|
|
172
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation lifecycle routes: create, list, get, fork, export, import,
|
|
3
|
+
* and the session-meta sidebar resource — plus the JSONL body parser the
|
|
4
|
+
* import route reads its raw text through. Every `:id` route resolves its
|
|
5
|
+
* conversation through the `loadConversation` pre-handler (404 for an
|
|
6
|
+
* unknown id, before any body validation); the no-`id` routes (create,
|
|
7
|
+
* list, import) take no pre-handler.
|
|
8
|
+
*/
|
|
9
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
10
|
+
import { isSuperuser } from "../auth/principal.js";
|
|
11
|
+
import { createConversationOp, listConversationsOp, readMetaOp, } from "../conversation-ops.js";
|
|
12
|
+
import { loadConversation } from "../conversation-handler.js";
|
|
13
|
+
import { ID_PARAMS_SCHEMA, readView, stampSessionOwner, storeAgentChange, } from "../helpers.js";
|
|
14
|
+
import { applySessionMetaPatch, DEFAULT_SESSION_META, SessionMetaDoc, validateSessionMetaPatch, } from "../session-meta.js";
|
|
15
|
+
import { JSONL_CONTENT_TYPE, } from "../views.js";
|
|
16
|
+
import { base64BodyLimit } from "../../config.js";
|
|
17
|
+
/** Register the conversation lifecycle + session-meta routes. */
|
|
18
|
+
export function registerConversationRoutes(app, ctx) {
|
|
19
|
+
const { opened } = ctx;
|
|
20
|
+
app.post("/v1/conversations", async (request, reply) => {
|
|
21
|
+
// Create takes no fields (model/thinking changes arrive with
|
|
22
|
+
// the configure endpoint): an absent body is valid, an
|
|
23
|
+
// object with any field is a fail-loud 400.
|
|
24
|
+
const body = request.body;
|
|
25
|
+
if (body !== undefined &&
|
|
26
|
+
(typeof body !== "object" ||
|
|
27
|
+
Object.keys(body).length > 0)) {
|
|
28
|
+
return await reply.code(400).send({ error: "invalid_body" });
|
|
29
|
+
}
|
|
30
|
+
// The shared create op (W5 Task 5): the SAME code path the
|
|
31
|
+
// extension host bridge drives — ownerless ownership, the boot
|
|
32
|
+
// default model + store agent settings, and the identity seam's
|
|
33
|
+
// owner stamp. The source is the HTTP-create label ("user").
|
|
34
|
+
const created = await createConversationOp(ctx, {
|
|
35
|
+
...(request.principal.user === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: { owner: request.principal.user }),
|
|
38
|
+
source: "user",
|
|
39
|
+
});
|
|
40
|
+
return await reply.code(201).send(created);
|
|
41
|
+
});
|
|
42
|
+
app.get("/v1/conversations", async (request, reply) => {
|
|
43
|
+
// Durable enumeration: the storage scan is authoritative —
|
|
44
|
+
// conversations this process never created (before a restart, in
|
|
45
|
+
// another process, or by tasks/tools inside pi-durable) all list.
|
|
46
|
+
// §5.9: the list is PER-CALLER — conversations the caller may not
|
|
47
|
+
// see (accessOf "none") are filtered out entirely (hidden is absent
|
|
48
|
+
// from lists, not just 404s), and every served row carries the
|
|
49
|
+
// caller's resolved access.
|
|
50
|
+
//
|
|
51
|
+
// The shared list op (W5 Task 5): the SAME code path the extension
|
|
52
|
+
// host bridge drives — lean by construction (spec §7.2 / W3 Task 2:
|
|
53
|
+
// scan record + SessionMetaDoc snapshot alone, no per-row view
|
|
54
|
+
// read, so a sidebar poll never re-reads transcripts).
|
|
55
|
+
//
|
|
56
|
+
// Query filters: `archived=true|false` (absent = all) and
|
|
57
|
+
// `mine=true` (owner === the caller). Strict booleans — anything
|
|
58
|
+
// else is a fail-loud 400, the same posture as body validation.
|
|
59
|
+
const query = request.query;
|
|
60
|
+
for (const field of ["archived", "mine"]) {
|
|
61
|
+
const value = query[field];
|
|
62
|
+
if (value !== undefined &&
|
|
63
|
+
value !== "true" &&
|
|
64
|
+
value !== "false") {
|
|
65
|
+
return await reply.code(400).send({ error: "invalid_field", field });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const filters = {
|
|
69
|
+
...(query.archived === undefined
|
|
70
|
+
? {}
|
|
71
|
+
: { archivedOnly: query.archived === "true" }),
|
|
72
|
+
...(query.mine === "true" ? { mine: true } : {}),
|
|
73
|
+
};
|
|
74
|
+
const conversations = await listConversationsOp(ctx, {
|
|
75
|
+
user: request.principal.user,
|
|
76
|
+
superuser: isSuperuser(request.principal),
|
|
77
|
+
}, filters);
|
|
78
|
+
return { conversations };
|
|
79
|
+
});
|
|
80
|
+
app.get("/v1/conversations/:id", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx) }, async (request) => {
|
|
81
|
+
const conversation = request.conversation;
|
|
82
|
+
return readView(conversation);
|
|
83
|
+
});
|
|
84
|
+
// Fork: a history fork at one visible entry (spec §5 session
|
|
85
|
+
// management). `at` is the LAST entry id the fork keeps; absent = the
|
|
86
|
+
// newest entry (resolved through the entries query — never 0, which is
|
|
87
|
+
// not a real entry id). The fork inherits the parent's agent model and
|
|
88
|
+
// the same ownerless ownership the create route uses; the response is
|
|
89
|
+
// the create shape: 201 `{id, view}`.
|
|
90
|
+
app.post("/v1/conversations/:id/fork", {
|
|
91
|
+
schema: { params: ID_PARAMS_SCHEMA },
|
|
92
|
+
preHandler: loadConversation(ctx, "write"),
|
|
93
|
+
}, async (request, reply) => {
|
|
94
|
+
const conversation = request.conversation;
|
|
95
|
+
// Absent body is valid (`at` defaults to the newest entry); the
|
|
96
|
+
// object may carry ONLY `at` — the compact/configure convention.
|
|
97
|
+
const body = request.body;
|
|
98
|
+
let at;
|
|
99
|
+
if (body !== undefined && body !== null) {
|
|
100
|
+
if (typeof body !== "object" || Array.isArray(body)) {
|
|
101
|
+
return await reply.code(400).send({ error: "invalid_body" });
|
|
102
|
+
}
|
|
103
|
+
const record = body;
|
|
104
|
+
const unknownKey = Object.keys(record).find((key) => key !== "at");
|
|
105
|
+
if (unknownKey !== undefined) {
|
|
106
|
+
return await reply
|
|
107
|
+
.code(400)
|
|
108
|
+
.send({ error: "invalid_field", field: unknownKey });
|
|
109
|
+
}
|
|
110
|
+
if (record.at !== undefined &&
|
|
111
|
+
typeof record.at !== "number") {
|
|
112
|
+
return await reply
|
|
113
|
+
.code(400)
|
|
114
|
+
.send({ error: "invalid_field", field: "at" });
|
|
115
|
+
}
|
|
116
|
+
at = record.at;
|
|
117
|
+
}
|
|
118
|
+
if (at === undefined) {
|
|
119
|
+
// The entries query is newest-first: the first item of the first
|
|
120
|
+
// page is the newest entry. An empty page means there is nothing
|
|
121
|
+
// to fork — the parent conversation has no history at all.
|
|
122
|
+
const page = await conversation.entries({}, 1, undefined, BACKGROUND_CONTEXT);
|
|
123
|
+
if (page.items.length === 0) {
|
|
124
|
+
return await reply.code(400).send({ error: "empty_conversation" });
|
|
125
|
+
}
|
|
126
|
+
at = page.items[0].id;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
// Validate `at` against the parent's own history: `fork()` accepts
|
|
130
|
+
// any EntryId, but an id this conversation never had is a client
|
|
131
|
+
// error, not a silent empty fork.
|
|
132
|
+
const page = await conversation.entries({ maxEntryId: at }, 1, undefined, BACKGROUND_CONTEXT);
|
|
133
|
+
const newest = page.items[0];
|
|
134
|
+
if (newest === undefined || newest.id !== at) {
|
|
135
|
+
return await reply
|
|
136
|
+
.code(400)
|
|
137
|
+
.send({ error: "invalid_field", field: "at" });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// The fork inherits the parent's agent model — read the resolved
|
|
141
|
+
// agent, fall back to the boot default like create.
|
|
142
|
+
const agent = await conversation.agent(BACKGROUND_CONTEXT);
|
|
143
|
+
const fork = await conversation.fork(at, {
|
|
144
|
+
ownership: { kind: "ownerless" },
|
|
145
|
+
agent: { model: agent.model ?? opened.defaultModel },
|
|
146
|
+
}, BACKGROUND_CONTEXT);
|
|
147
|
+
// The fork's sidebar identity: the copied meta doc
|
|
148
|
+
// carries the source's name/autoName and the namer's attempt count;
|
|
149
|
+
// ONE commit drops all three (a fork is a new conversation, not the
|
|
150
|
+
// source's name — and it starts naming-fresh: inheriting an exhausted
|
|
151
|
+
// attempt count would make the fork forever unnameable), re-seeds
|
|
152
|
+
// it with "<source display name> (fork)"
|
|
153
|
+
// as its AUTO name when the source had one, and stamps activity so
|
|
154
|
+
// the fresh fork sorts where it happened.
|
|
155
|
+
// §5.9 (Review Focus 3): the doc copied the source's `owner` and
|
|
156
|
+
// `sharing` (fork: "current") — the SAME commit resets them: a fork
|
|
157
|
+
// is the FORKER's private copy (owner = the caller, sharing deleted;
|
|
158
|
+
// a header-less caller means no owner at all — the key is removed).
|
|
159
|
+
const sourceMeta = await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
|
|
160
|
+
const sourceName = sourceMeta?.name ?? sourceMeta?.autoName;
|
|
161
|
+
await fork.commit(async (tx) => {
|
|
162
|
+
const draft = await tx.doc(SessionMetaDoc, fork.id);
|
|
163
|
+
delete draft.name;
|
|
164
|
+
delete draft.autoName;
|
|
165
|
+
delete draft.autoNameAttempts;
|
|
166
|
+
delete draft.sharing;
|
|
167
|
+
if (request.principal.user === undefined)
|
|
168
|
+
delete draft.owner;
|
|
169
|
+
else
|
|
170
|
+
draft.owner = request.principal.user;
|
|
171
|
+
if (sourceName !== undefined)
|
|
172
|
+
draft.autoName = `${sourceName} (fork)`;
|
|
173
|
+
draft.lastActivityAt = opened.now().toISOString();
|
|
174
|
+
}, BACKGROUND_CONTEXT);
|
|
175
|
+
const view = await readView(fork);
|
|
176
|
+
return await reply.code(201).send({ id: fork.id, view });
|
|
177
|
+
});
|
|
178
|
+
// Export: the conversation's history as JSONL — pi's portable
|
|
179
|
+
// format. One entry JSON object per line (the same per-entry
|
|
180
|
+
// serialization the view serves), OLDEST-first (the reverse of the
|
|
181
|
+
// newest-first entries query), so an import replays the transcript in
|
|
182
|
+
// order. All history pages, not just the active view.
|
|
183
|
+
app.get("/v1/conversations/:id/export", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx) }, async (request, reply) => {
|
|
184
|
+
const conversation = request.conversation;
|
|
185
|
+
const lines = [];
|
|
186
|
+
let cursor;
|
|
187
|
+
// Newest-first pages accumulate reversed; emit oldest-first.
|
|
188
|
+
const collected = [];
|
|
189
|
+
do {
|
|
190
|
+
const page = await conversation.entries({}, 200, cursor, BACKGROUND_CONTEXT);
|
|
191
|
+
collected.push(...page.items);
|
|
192
|
+
cursor = page.next;
|
|
193
|
+
} while (cursor !== undefined);
|
|
194
|
+
for (let i = collected.length - 1; i >= 0; i--) {
|
|
195
|
+
lines.push(JSON.stringify(collected[i]));
|
|
196
|
+
}
|
|
197
|
+
return await reply
|
|
198
|
+
.header("content-type", JSONL_CONTENT_TYPE)
|
|
199
|
+
.send(lines.join("\n") + "\n");
|
|
200
|
+
});
|
|
201
|
+
// Import: create a new conversation from JSONL. The body is
|
|
202
|
+
// the RAW JSONL text (the export format, round-tripped); parse line by
|
|
203
|
+
// line, create the conversation the create-route way, then append each
|
|
204
|
+
// parsed entry IN ORDER (oldest-first) inside one commit — fresh entry
|
|
205
|
+
// ids are assigned by storage (`EntryDraft` carries no id).
|
|
206
|
+
app.post("/v1/conversations/import",
|
|
207
|
+
// Same knob-derived ceiling as the base64 upload route (spec §6.2):
|
|
208
|
+
// the JSONL text may be as large as the knob-derived body without
|
|
209
|
+
// hitting Fastify's 1 MiB default.
|
|
210
|
+
{ bodyLimit: base64BodyLimit(ctx.config.maxUploadBytes) }, async (request, reply) => {
|
|
211
|
+
const raw = request.body;
|
|
212
|
+
if (typeof raw !== "string") {
|
|
213
|
+
return await reply.code(400).send({ error: "invalid_body" });
|
|
214
|
+
}
|
|
215
|
+
const textLines = raw.split("\n");
|
|
216
|
+
// A trailing newline is the export terminator, not an empty line.
|
|
217
|
+
const lines = textLines[textLines.length - 1] === ""
|
|
218
|
+
? textLines.slice(0, -1)
|
|
219
|
+
: textLines;
|
|
220
|
+
if (lines.length === 0) {
|
|
221
|
+
return await reply.code(400).send({ error: "invalid_body" });
|
|
222
|
+
}
|
|
223
|
+
const drafts = [];
|
|
224
|
+
for (const [index, line] of lines.entries()) {
|
|
225
|
+
if (line.trim() === "") {
|
|
226
|
+
return await reply.code(400).send({ error: "invalid_body" });
|
|
227
|
+
}
|
|
228
|
+
let parsed;
|
|
229
|
+
try {
|
|
230
|
+
parsed = JSON.parse(line);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return await reply
|
|
234
|
+
.code(400)
|
|
235
|
+
.send({ error: "invalid_body", line: index + 1 });
|
|
236
|
+
}
|
|
237
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
238
|
+
return await reply
|
|
239
|
+
.code(400)
|
|
240
|
+
.send({ error: "invalid_body", line: index + 1 });
|
|
241
|
+
}
|
|
242
|
+
const record = parsed;
|
|
243
|
+
// An entry line carries `kind` plus the optional model/data/
|
|
244
|
+
// edits payloads — the EntryDraft shape. Stale identity fields
|
|
245
|
+
// (id/conversationId/byTaskId) are dropped: storage assigns
|
|
246
|
+
// fresh ones. A stale `head` EntryId would point at the OLD
|
|
247
|
+
// numbering — drop it too; the default active-context pointer
|
|
248
|
+
// starts at the newest appended entry.
|
|
249
|
+
const draft = {
|
|
250
|
+
kind: String(record.kind),
|
|
251
|
+
...(Array.isArray(record.model)
|
|
252
|
+
? { model: record.model }
|
|
253
|
+
: {}),
|
|
254
|
+
...("data" in record
|
|
255
|
+
? { data: record.data }
|
|
256
|
+
: {}),
|
|
257
|
+
...(Array.isArray(record.edits)
|
|
258
|
+
? { edits: record.edits }
|
|
259
|
+
: {}),
|
|
260
|
+
};
|
|
261
|
+
if (draft.kind === "") {
|
|
262
|
+
return await reply
|
|
263
|
+
.code(400)
|
|
264
|
+
.send({ error: "invalid_body", line: index + 1 });
|
|
265
|
+
}
|
|
266
|
+
drafts.push(draft);
|
|
267
|
+
}
|
|
268
|
+
const conversation = await opened.harness.createConversation({
|
|
269
|
+
ownership: { kind: "ownerless" },
|
|
270
|
+
agent: storeAgentChange(opened, opened.defaultModel),
|
|
271
|
+
}, BACKGROUND_CONTEXT);
|
|
272
|
+
// One commit appends every entry in order; the conversation's own
|
|
273
|
+
// commit binds the transaction to this conversation.
|
|
274
|
+
await conversation.commit(async (tx) => {
|
|
275
|
+
for (const draft of drafts) {
|
|
276
|
+
await tx.appendEntry(conversation.id, draft);
|
|
277
|
+
}
|
|
278
|
+
}, BACKGROUND_CONTEXT);
|
|
279
|
+
// The imported conversation is the IMPORTER's, not the source's
|
|
280
|
+
// (the JSONL never carries meta — the stamp is the only owner).
|
|
281
|
+
await stampSessionOwner(conversation, request.principal.user);
|
|
282
|
+
const view = await readView(conversation);
|
|
283
|
+
return await reply.code(201).send({ id: conversation.id, view });
|
|
284
|
+
});
|
|
285
|
+
// JSONL bodies (the import route above): raw text, never JSON-parsed —
|
|
286
|
+
// the route itself parses line by line so a bad line is a 400
|
|
287
|
+
// invalid_body, not Fastify's 415 Unsupported Media Type. Content-type-
|
|
288
|
+
// only: onRequest hooks (deploy auth etc.) keep their hook semantics.
|
|
289
|
+
for (const type of [JSONL_CONTENT_TYPE, "text/plain"]) {
|
|
290
|
+
app.addContentTypeParser(type, { parseAs: "string" }, (_request, body, done) => done(null, body));
|
|
291
|
+
}
|
|
292
|
+
// Session meta: the conversation-scoped sidebar resource (spec §5).
|
|
293
|
+
// GET serves the doc — or the defaults when it was never written; a read
|
|
294
|
+
// NEVER materializes the doc (defaults are free; a write-per-read would
|
|
295
|
+
// churn a doc revision on every sidebar poll). PATCH merges per the spec
|
|
296
|
+
// table (null clears, draft sub-merges, tagged replaces wholesale) inside
|
|
297
|
+
// ONE commit callback — the read-modify-write pattern keeps concurrent
|
|
298
|
+
// patches from losing each other's fields (the mutation line serializes
|
|
299
|
+
// commits, mirroring the decide pattern).
|
|
300
|
+
app.get("/v1/conversations/:id/meta", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx) }, async (request) => {
|
|
301
|
+
const conversation = request.conversation;
|
|
302
|
+
// The shared read op (W5 Task 5): absent doc → defaults, served
|
|
303
|
+
// (never written — a read that wrote would churn a doc revision
|
|
304
|
+
// on every sidebar poll).
|
|
305
|
+
return await readMetaOp(opened, conversation.id);
|
|
306
|
+
});
|
|
307
|
+
app.patch("/v1/conversations/:id/meta", {
|
|
308
|
+
schema: { params: ID_PARAMS_SCHEMA },
|
|
309
|
+
preHandler: loadConversation(ctx, "write"),
|
|
310
|
+
}, async (request, reply) => {
|
|
311
|
+
const conversation = request.conversation;
|
|
312
|
+
// Validate FIRST — a rejected patch never touches the doc.
|
|
313
|
+
const validated = validateSessionMetaPatch(request.body);
|
|
314
|
+
if ("error" in validated) {
|
|
315
|
+
return await reply.code(400).send(validated);
|
|
316
|
+
}
|
|
317
|
+
// §5.9: `sharing` additionally requires the OWNER or a superuser —
|
|
318
|
+
// write access (granted by a share) must not let a recipient
|
|
319
|
+
// re-share the resource.
|
|
320
|
+
if (validated.sharing !== undefined) {
|
|
321
|
+
const meta = await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT);
|
|
322
|
+
const isOwner = request.principal.user !== undefined &&
|
|
323
|
+
request.principal.user === meta?.owner;
|
|
324
|
+
if (!isOwner && !isSuperuser(request.principal)) {
|
|
325
|
+
return await reply.code(403).send({ error: "forbidden" });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// Read-modify-write inside ONE commit: tx.doc materializes the doc
|
|
329
|
+
// (initial()) when absent and the patch mutates the tracked draft.
|
|
330
|
+
await conversation.commit(async (tx) => {
|
|
331
|
+
const draft = await tx.doc(SessionMetaDoc, conversation.id);
|
|
332
|
+
applySessionMetaPatch(draft, validated);
|
|
333
|
+
}, BACKGROUND_CONTEXT);
|
|
334
|
+
// The response is a FRESH snapshot, not a draft projection:
|
|
335
|
+
// reading a newly-SET nested draft value (`sharing`, §5.9) trips
|
|
336
|
+
// chord's settled-overlay rule, so the reply must never read the
|
|
337
|
+
// mutated draft — a post-commit snapshot is exactly what was
|
|
338
|
+
// stored.
|
|
339
|
+
return ((await opened.harness.snapshot(SessionMetaDoc, conversation.id, BACKGROUND_CONTEXT)) ?? DEFAULT_SESSION_META);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The global events stream route (core hardening W3 Task 4, spec §7.4):
|
|
3
|
+
* `GET /v1/events` — ONE Server-Sent Events stream per client carrying
|
|
4
|
+
* the hub's `HostEventWire` events, framed `event: <type>\ndata: <json>\n\n`
|
|
5
|
+
* with a `: ping` heartbeat every 15s (the /stream heartbeat pattern).
|
|
6
|
+
* No replay: the stream only nudges; a reconnecting client refetches the
|
|
7
|
+
* list (the events carry no transcript state).
|
|
8
|
+
*
|
|
9
|
+
* Filtering (Review Focus 3 — per-event, never a connect-time snapshot):
|
|
10
|
+
* the visibility decision is an EXHAUSTIVE per-type switch (hardening
|
|
11
|
+
* review Task 4): a conversation-scoped event (conversation.* / run.* /
|
|
12
|
+
* interaction.* / ambient.*) is delivered iff `accessOf(principal, meta)`
|
|
13
|
+
* ≠ `"none"` with the SessionMeta RE-SNAPSHOTTED per event — a share
|
|
14
|
+
* makes the NEXT event arrive, an un-share withholds the one after.
|
|
15
|
+
* `providers.changed` reaches every authenticated principal. `job.changed`
|
|
16
|
+
* (W4 Task 4) resolves `accessOf` against the EVENT's own owner/sharing
|
|
17
|
+
* (the hub's copy is access-neutral) and is delivered with the caller's
|
|
18
|
+
* `access` ADDED to the job row — a hidden job's event is withheld
|
|
19
|
+
* entirely, no extra read. `taskSettled` is never delivered. Any type the
|
|
20
|
+
* switch does not know is NEVER delivered (deny by construction — the
|
|
21
|
+
* old fall-through snapshotted a missing conversationId and broadcast
|
|
22
|
+
* the ownerless result).
|
|
23
|
+
*
|
|
24
|
+
* The async re-snapshot runs in the route's own side lane (the bus/hub
|
|
25
|
+
* listener contract is no-blocking-work): each stream keeps a per-event
|
|
26
|
+
* serial promise chain, so frames for one connection stay in publish
|
|
27
|
+
* order without ever blocking the hub. A snapshot failure (a retired
|
|
28
|
+
* conversation raced the event) withholds THAT event — the honest
|
|
29
|
+
* read-side posture.
|
|
30
|
+
*/
|
|
31
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
32
|
+
import { accessOf } from "@sigrun/shared/access";
|
|
33
|
+
import { SessionMetaDoc } from "../session-meta.js";
|
|
34
|
+
/** The heartbeat cadence (the /stream pattern: proxy idle-timeout survival). */
|
|
35
|
+
const HEARTBEAT_MS = 15_000;
|
|
36
|
+
/** Register the global events stream route. */
|
|
37
|
+
export function registerEventRoutes(app, ctx) {
|
|
38
|
+
app.get("/v1/events", async (request, reply) => {
|
|
39
|
+
const principal = request.principal;
|
|
40
|
+
const { opened } = ctx;
|
|
41
|
+
reply.hijack();
|
|
42
|
+
const raw = reply.raw;
|
|
43
|
+
raw.writeHead(200, {
|
|
44
|
+
"content-type": "text/event-stream",
|
|
45
|
+
"cache-control": "no-cache",
|
|
46
|
+
connection: "keep-alive",
|
|
47
|
+
"x-accel-buffering": "no",
|
|
48
|
+
});
|
|
49
|
+
raw.flushHeaders();
|
|
50
|
+
// Socket-level write errors after teardown have no consumer; swallow
|
|
51
|
+
// them so a mid-write disconnect cannot crash the process.
|
|
52
|
+
raw.on("error", () => { });
|
|
53
|
+
const send = (event) => {
|
|
54
|
+
raw.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
55
|
+
};
|
|
56
|
+
let finished = false;
|
|
57
|
+
const heartbeat = setInterval(() => {
|
|
58
|
+
raw.write(": ping\n\n");
|
|
59
|
+
}, HEARTBEAT_MS);
|
|
60
|
+
/**
|
|
61
|
+
* Whether THIS principal may see one event (Review Focus 3): an
|
|
62
|
+
* EXHAUSTIVE switch over the HostEventWire members — a new member
|
|
63
|
+
* added without a case fails the typecheck (Review Focus 4;
|
|
64
|
+
* test/events-exhaustive.typecheck.ts mirrors this dispatch) and
|
|
65
|
+
* never falls through to the old ownerless-snapshot broadcast
|
|
66
|
+
* (accessOf(principal, {}) === "write")). Conversation-scoped
|
|
67
|
+
* events re-snapshot the SessionMeta PER EVENT; providers.changed is
|
|
68
|
+
* broadcast; job.changed (W4 Task 4) resolves against the event's own
|
|
69
|
+
* owner/sharing and, when visible, ENRICHES the row with the caller's
|
|
70
|
+
* `access` (the hub's copy is access-neutral by contract — the stream
|
|
71
|
+
* adds it per principal); taskSettled is host machinery (never
|
|
72
|
+
* principal-facing).
|
|
73
|
+
*/
|
|
74
|
+
const visible = async (event) => {
|
|
75
|
+
switch (event.type) {
|
|
76
|
+
// Conversation-scoped: SessionMeta access, re-snapshotted per
|
|
77
|
+
// event (share → the NEXT event arrives; un-share withholds the
|
|
78
|
+
// one after).
|
|
79
|
+
case "conversation.created":
|
|
80
|
+
case "conversation.changed":
|
|
81
|
+
case "run.admitted":
|
|
82
|
+
case "run.settled":
|
|
83
|
+
case "interaction.pending":
|
|
84
|
+
case "interaction.settled":
|
|
85
|
+
case "ambient.changed": {
|
|
86
|
+
let meta;
|
|
87
|
+
try {
|
|
88
|
+
meta = await opened.harness.snapshot(SessionMetaDoc, event.conversationId, BACKGROUND_CONTEXT);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
console.error("events route: SessionMeta snapshot failed:", error);
|
|
92
|
+
return undefined; // a raced read withholds that one event
|
|
93
|
+
}
|
|
94
|
+
return accessOf(principal, meta ?? {}) !== "none" ? event : undefined;
|
|
95
|
+
}
|
|
96
|
+
// Broadcast: every authenticated principal.
|
|
97
|
+
case "providers.changed":
|
|
98
|
+
return event;
|
|
99
|
+
// The event's own owner/sharing decides; the delivered row
|
|
100
|
+
// carries the caller's access (the wire adds what the hub's
|
|
101
|
+
// access-neutral copy omits).
|
|
102
|
+
case "job.changed": {
|
|
103
|
+
const access = accessOf(principal, {
|
|
104
|
+
...(event.job.owner === undefined
|
|
105
|
+
? {}
|
|
106
|
+
: { owner: event.job.owner }),
|
|
107
|
+
...(event.job.sharing === undefined
|
|
108
|
+
? {}
|
|
109
|
+
: { sharing: event.job.sharing }),
|
|
110
|
+
});
|
|
111
|
+
if (access === "none")
|
|
112
|
+
return undefined;
|
|
113
|
+
const enriched = { ...event.job, access };
|
|
114
|
+
return { ...event, job: enriched };
|
|
115
|
+
}
|
|
116
|
+
// Host machinery (the jobs observer's input), never a
|
|
117
|
+
// principal-facing wire event.
|
|
118
|
+
case "taskSettled":
|
|
119
|
+
return undefined;
|
|
120
|
+
// An unknown member is NEVER delivered — deny by construction,
|
|
121
|
+
// not by enumerating the leak shapes. The satisfies check keeps
|
|
122
|
+
// this arm exhaustive: a new HostEventWire member without a
|
|
123
|
+
// case fails the typecheck here (and in the mirror file).
|
|
124
|
+
default:
|
|
125
|
+
event;
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
// The per-connection side lane: serial promise chain, so frames for
|
|
130
|
+
// one connection stay in publish order while the hub listener itself
|
|
131
|
+
// returns immediately (the no-blocking-work contract).
|
|
132
|
+
let lane = Promise.resolve();
|
|
133
|
+
const unsubscribe = opened.events.subscribe((event) => {
|
|
134
|
+
lane = lane.then(async () => {
|
|
135
|
+
if (finished)
|
|
136
|
+
return;
|
|
137
|
+
const deliverable = await visible(event);
|
|
138
|
+
if (deliverable === undefined || finished)
|
|
139
|
+
return;
|
|
140
|
+
send(deliverable);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
const finish = () => {
|
|
144
|
+
if (finished)
|
|
145
|
+
return;
|
|
146
|
+
finished = true;
|
|
147
|
+
clearInterval(heartbeat);
|
|
148
|
+
unsubscribe();
|
|
149
|
+
raw.end();
|
|
150
|
+
};
|
|
151
|
+
// Client disconnect: `response` close (socket termination) — NOT
|
|
152
|
+
// `request.raw` close (Node 26 fires it at request-message completion,
|
|
153
|
+
// i.e. immediately for a bodyless GET).
|
|
154
|
+
raw.on("close", finish);
|
|
155
|
+
// A client may have disconnected before the close listener existed.
|
|
156
|
+
if (raw.destroyed) {
|
|
157
|
+
finish();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|