blogwright-analytics 0.3.3

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 (131) hide show
  1. package/README.md +162 -0
  2. package/dist/adapters/duckdb-ingest.d.ts +76 -0
  3. package/dist/adapters/duckdb-ingest.js +173 -0
  4. package/dist/adapters/duckdb-query.d.ts +56 -0
  5. package/dist/adapters/duckdb-query.js +80 -0
  6. package/dist/adapters/duckdb-session.d.ts +168 -0
  7. package/dist/adapters/duckdb-session.js +330 -0
  8. package/dist/app/_app/immutable/assets/0.BTQrrh5B.css +1 -0
  9. package/dist/app/_app/immutable/assets/2.CZSK3rT8.css +1 -0
  10. package/dist/app/_app/immutable/assets/BrushContext.D7c8UPey.css +1 -0
  11. package/dist/app/_app/immutable/assets/ChartAnnotations.CPxIG7Mw.css +1 -0
  12. package/dist/app/_app/immutable/assets/Circle.C5MKzgk2.css +1 -0
  13. package/dist/app/_app/immutable/assets/DefaultTooltip.C5-uctZ7.css +1 -0
  14. package/dist/app/_app/immutable/assets/Group.DV48xipa.css +1 -0
  15. package/dist/app/_app/immutable/assets/Labels.BxZ4NUVz.css +1 -0
  16. package/dist/app/_app/immutable/assets/Legend.CxnrE4Ye.css +1 -0
  17. package/dist/app/_app/immutable/assets/Line.fkmsECm9.css +1 -0
  18. package/dist/app/_app/immutable/assets/Path.CvpwNZ6g.css +1 -0
  19. package/dist/app/_app/immutable/assets/Rect.CtRaGMmQ.css +1 -0
  20. package/dist/app/_app/immutable/assets/Text.j9l35qB0.css +1 -0
  21. package/dist/app/_app/immutable/assets/TransformContext.Bs_HkpAk.css +1 -0
  22. package/dist/app/_app/immutable/assets/Voronoi.ce7atosu.css +1 -0
  23. package/dist/app/_app/immutable/chunks/-aNGNaBT.js +1 -0
  24. package/dist/app/_app/immutable/chunks/6djn-yLs.js +1 -0
  25. package/dist/app/_app/immutable/chunks/B1amyutE.js +1 -0
  26. package/dist/app/_app/immutable/chunks/B3vZDoek.js +1 -0
  27. package/dist/app/_app/immutable/chunks/B5KRA4hC.js +1 -0
  28. package/dist/app/_app/immutable/chunks/BClnVG6H.js +1 -0
  29. package/dist/app/_app/immutable/chunks/BID1NNRh.js +1 -0
  30. package/dist/app/_app/immutable/chunks/BR2LaRms.js +1 -0
  31. package/dist/app/_app/immutable/chunks/Bd1gDe3Y.js +1 -0
  32. package/dist/app/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  33. package/dist/app/_app/immutable/chunks/Bl052uUt.js +1 -0
  34. package/dist/app/_app/immutable/chunks/Bye3lL0c.js +1 -0
  35. package/dist/app/_app/immutable/chunks/C58PZtCD.js +4 -0
  36. package/dist/app/_app/immutable/chunks/CAzydqEO.js +1 -0
  37. package/dist/app/_app/immutable/chunks/CCch3uox.js +1 -0
  38. package/dist/app/_app/immutable/chunks/CIlSMUH9.js +1 -0
  39. package/dist/app/_app/immutable/chunks/CO1vUXfR.js +1 -0
  40. package/dist/app/_app/immutable/chunks/CPbD8C65.js +5 -0
  41. package/dist/app/_app/immutable/chunks/CRTcXoMo.js +1 -0
  42. package/dist/app/_app/immutable/chunks/CjjyIQAO.js +1 -0
  43. package/dist/app/_app/immutable/chunks/CuXAxjvF.js +1 -0
  44. package/dist/app/_app/immutable/chunks/CvyVA_jC.js +1 -0
  45. package/dist/app/_app/immutable/chunks/CxGCFVdy.js +1 -0
  46. package/dist/app/_app/immutable/chunks/D0Ty6LN0.js +1 -0
  47. package/dist/app/_app/immutable/chunks/D2AaQUUW.js +1 -0
  48. package/dist/app/_app/immutable/chunks/D2BnX0Uk.js +3 -0
  49. package/dist/app/_app/immutable/chunks/DJc8C0NK.js +1 -0
  50. package/dist/app/_app/immutable/chunks/DKMlMI4a.js +1 -0
  51. package/dist/app/_app/immutable/chunks/DVXZkpbf.js +1 -0
  52. package/dist/app/_app/immutable/chunks/DVt8ukQ_.js +1 -0
  53. package/dist/app/_app/immutable/chunks/DZPlYdq_.js +1 -0
  54. package/dist/app/_app/immutable/chunks/Db0q5_zr.js +1 -0
  55. package/dist/app/_app/immutable/chunks/Dfvzj6n2.js +1 -0
  56. package/dist/app/_app/immutable/chunks/Dh958be7.js +1 -0
  57. package/dist/app/_app/immutable/chunks/DjKLLdnY.js +15 -0
  58. package/dist/app/_app/immutable/chunks/Doz7YX1W.js +1 -0
  59. package/dist/app/_app/immutable/chunks/DthYhn6Y.js +2 -0
  60. package/dist/app/_app/immutable/chunks/DtuTIrAM.js +1 -0
  61. package/dist/app/_app/immutable/chunks/HclGiUj8.js +1 -0
  62. package/dist/app/_app/immutable/chunks/Hx0TNsV3.js +1 -0
  63. package/dist/app/_app/immutable/chunks/RobXhXPM.js +1 -0
  64. package/dist/app/_app/immutable/chunks/V9ZjaxiY.js +1 -0
  65. package/dist/app/_app/immutable/chunks/Y5urAfNy.js +1 -0
  66. package/dist/app/_app/immutable/chunks/caXkbKD3.js +1 -0
  67. package/dist/app/_app/immutable/chunks/devYm2ud.js +1 -0
  68. package/dist/app/_app/immutable/chunks/mtZWP0zR.js +1 -0
  69. package/dist/app/_app/immutable/chunks/vDgBJUjM.js +1 -0
  70. package/dist/app/_app/immutable/chunks/xIq_fFFM.js +1 -0
  71. package/dist/app/_app/immutable/chunks/xihTtKlq.js +1 -0
  72. package/dist/app/_app/immutable/chunks/z05MoCFz.js +1 -0
  73. package/dist/app/_app/immutable/entry/app.CLAerUAN.js +2 -0
  74. package/dist/app/_app/immutable/entry/start.D3MqnNci.js +1 -0
  75. package/dist/app/_app/immutable/nodes/0.UTMEigHJ.js +1 -0
  76. package/dist/app/_app/immutable/nodes/1.Cn4f11bT.js +1 -0
  77. package/dist/app/_app/immutable/nodes/2.B39cIcr2.js +6 -0
  78. package/dist/app/_app/version.json +1 -0
  79. package/dist/app/index.html +82 -0
  80. package/dist/aws/clients.d.ts +70 -0
  81. package/dist/aws/clients.js +52 -0
  82. package/dist/aws/errors.d.ts +41 -0
  83. package/dist/aws/errors.js +70 -0
  84. package/dist/aws/firehose.d.ts +228 -0
  85. package/dist/aws/firehose.js +347 -0
  86. package/dist/aws/glue.d.ts +103 -0
  87. package/dist/aws/glue.js +225 -0
  88. package/dist/aws/lambda.d.ts +132 -0
  89. package/dist/aws/lambda.js +339 -0
  90. package/dist/aws/s3tables.d.ts +120 -0
  91. package/dist/aws/s3tables.js +281 -0
  92. package/dist/backfill.d.ts +100 -0
  93. package/dist/backfill.js +294 -0
  94. package/dist/commands.d.ts +124 -0
  95. package/dist/commands.js +336 -0
  96. package/dist/config.d.ts +162 -0
  97. package/dist/config.js +317 -0
  98. package/dist/fixture-ingest.d.ts +49 -0
  99. package/dist/fixture-ingest.js +43 -0
  100. package/dist/fixture-query.d.ts +39 -0
  101. package/dist/fixture-query.js +70 -0
  102. package/dist/index.d.ts +35 -0
  103. package/dist/index.js +35 -0
  104. package/dist/nodes.d.ts +404 -0
  105. package/dist/nodes.js +2708 -0
  106. package/dist/paths.d.ts +45 -0
  107. package/dist/paths.js +47 -0
  108. package/dist/plugin.d.ts +102 -0
  109. package/dist/plugin.js +248 -0
  110. package/dist/ports.d.ts +113 -0
  111. package/dist/ports.js +35 -0
  112. package/dist/queries.d.ts +301 -0
  113. package/dist/queries.js +414 -0
  114. package/dist/schema.d.ts +240 -0
  115. package/dist/schema.js +154 -0
  116. package/dist/server.d.ts +150 -0
  117. package/dist/server.js +499 -0
  118. package/dist/transform/bots.d.ts +47 -0
  119. package/dist/transform/bots.js +73 -0
  120. package/dist/transform/handler.d.ts +135 -0
  121. package/dist/transform/handler.js +177 -0
  122. package/dist/transform/map-record.d.ts +110 -0
  123. package/dist/transform/map-record.js +275 -0
  124. package/dist/transform/visitor-key.d.ts +83 -0
  125. package/dist/transform/visitor-key.js +120 -0
  126. package/dist/transform-bundle/index.mjs +21456 -0
  127. package/dist/transform-bundle/transform-manifest.json +4 -0
  128. package/dist/transform-hash.d.ts +135 -0
  129. package/dist/transform-hash.js +186 -0
  130. package/dist/write-transform-manifest.mjs +365 -0
  131. package/package.json +59 -0
package/dist/server.js ADDED
@@ -0,0 +1,499 @@
1
+ /**
2
+ * The package's **edge**: the one module that imports `node:http`, and the one
3
+ * place an HTTP request becomes a call on the {@link AnalyticsQuery} port.
4
+ * Everything below the port - `queries.ts`, `schema.ts`, the transform, the
5
+ * nodes - is reached only through {@link createDashboardServer}, so a reviewer
6
+ * who wants to know what a browser can make this package do reads this file
7
+ * and stops. See [the change spec's §Analytics dashboard → Local
8
+ * server](../../../.specs/changes/merged/2026-07-26-analytics_plugin.md).
9
+ *
10
+ * **Loopback, always.** The listener binds {@link LOOPBACK_ADDRESS} - a named
11
+ * constant, never `0.0.0.0` and never a wildcard - because the dashboard reads
12
+ * the Iceberg table over the *operator's own* AWS credentials
13
+ * (`adapters/duckdb-query.ts`). A wildcard bind would put an unauthenticated
14
+ * read of the site's traffic, signed as the operator, on every interface the
15
+ * machine has. The port is whatever the caller resolved from
16
+ * `config.analytics.dashboard.port` (task 44); this module reads no
17
+ * environment variable and states no port of its own, so
18
+ * `DEFAULT_DASHBOARD_PORT` keeps the single home task 44 gave it. Contrast
19
+ * the build agent (`packages/build-agent/src/server.ts`), which binds
20
+ * `0.0.0.0` from `process.env.PORT` - correct for a MicroVM behind a security
21
+ * group, wrong for a program holding an operator's session.
22
+ *
23
+ * **There is no route that accepts SQL, and that is structural rather than
24
+ * filtered.** The request surface is exactly two families:
25
+ *
26
+ * - `GET {@link QUERY_ROUTE_PREFIX}<name>` - one of the seven names
27
+ * `queries.ts` declares, resolved through that module's own
28
+ * {@link queryDefinition} lookup, which is `Object.hasOwn`-guarded so
29
+ * `constructor`, `toString` and `__proto__` are unknown names and not
30
+ * inherited functions. An unknown name answers 404 listing the ones that
31
+ * resolve.
32
+ * - everything else - a static file from `appDir`, served only if it is on
33
+ * the allow-list `FileSystem.listFiles(appDir)` returned.
34
+ *
35
+ * A caller therefore has nowhere to put a statement. The query string is
36
+ * checked against {@link QUERY_STRING_PARAMS} and an unrecognised key is
37
+ * *rejected*, not ignored, so `?sql=...` is a 400 and never a silently
38
+ * dropped parameter; the only methods answered are `GET` and `HEAD` - the two
39
+ * RFC 9110 §9.1 makes mandatory - and everything else is 405 naming them; and
40
+ * no handler in this module attaches a `data` listener to a request, so a
41
+ * request body is never a value this program holds. The type system says the
42
+ * same thing one layer down - `AnalyticsQuery.run` takes a `QueryName`, and
43
+ * `queries.ts` mints SQL only through a module-private tagged template - and
44
+ * this module is where that guarantee would have been thrown away, because
45
+ * here the compiler's knowledge of the name has already been erased to
46
+ * `string`.
47
+ *
48
+ * **Nothing this module holds is ever concatenated into a statement.** The
49
+ * date range and the bot-inclusion flag are parsed here, validated here
50
+ * through task 45's own {@link prepareQuery}, and handed to
51
+ * `AnalyticsQuery.run(name, params)` **unmodified**. The `PreparedQuery` that
52
+ * validation returns is used for exactly one field, its `name` - the
53
+ * `QueryName` the lookup just proved - and its `sql` is deliberately untouched:
54
+ * a statement is the adapter's business, and an edge module that held one
55
+ * would be one refactor away from letting a request shape it.
56
+ *
57
+ * **Loopback is not an origin, so the `Host` header is checked too.** Binding
58
+ * {@link LOOPBACK_ADDRESS} keeps other *machines* out; it does nothing about
59
+ * other *origins* in the operator's own browser. A page the operator visits
60
+ * can point a hostname it controls at 127.0.0.1 and fetch this server through
61
+ * that name - the DNS-rebinding shape - and because a browser judges
62
+ * same-origin by hostname, that page reads the responses. This module sends no
63
+ * CORS headers, and their absence is what refuses an ordinary cross-origin
64
+ * read; the rebinding case defeats that precisely by making the origin match,
65
+ * so no response header can answer it. What answers it is
66
+ * {@link ALLOWED_HOST_NAMES}: a request whose `Host` is not this listener's own
67
+ * address is refused with a 403 before anything else about it is read. The
68
+ * exposure that closes is bounded - the site's own traffic figures, nothing
69
+ * writable, no route that takes SQL - which is why it is three lines here
70
+ * rather than a design.
71
+ *
72
+ * {@link COMMON_HEADERS} carries `X-Content-Type-Options: nosniff` on every
73
+ * response for a neighbouring reason: `dist/app` is a build output whose file
74
+ * names this module does not choose, and an extension {@link CONTENT_TYPES}
75
+ * does not know is served as {@link DEFAULT_CONTENT_TYPE}, which a sniffing
76
+ * browser is otherwise free to re-read as something it will execute.
77
+ */
78
+ import { createServer } from 'node:http';
79
+ import { join, sep } from 'node:path';
80
+ import { FileNotFoundError } from 'blogwright-core';
81
+ import { prepareQuery, queryDefinition } from './queries.js';
82
+ /**
83
+ * The address the listener binds. A named constant because the assertion that
84
+ * matters is on this exact value: a dashboard reachable from another host is
85
+ * an unauthenticated read of the site's traffic under the operator's
86
+ * credentials. `localhost` would not do - it resolves through the host's name
87
+ * service, so it can be `::1`, both, or whatever a `/etc/hosts` entry says.
88
+ */
89
+ const LOOPBACK_ADDRESS = '127.0.0.1';
90
+ /**
91
+ * The host names a request may be addressed to, each paired with the bound
92
+ * port to make the `Host` values this server answers. Two spellings because
93
+ * both reach a loopback listener from a browser's address bar and the
94
+ * dashboard prints one of them; anything else - a name that merely *resolves*
95
+ * to 127.0.0.1 - is a different origin wearing this server's address, and is
96
+ * refused. See this module's doc comment for why the bind alone does not cover
97
+ * it.
98
+ *
99
+ * No entry is portless. Task 44 floors `dashboard.port` at 1024, so this
100
+ * listener never holds a default port and a browser therefore always sends the
101
+ * port in `Host`.
102
+ */
103
+ const ALLOWED_HOST_NAMES = ['127.0.0.1', 'localhost'];
104
+ /**
105
+ * Headers on every response this module writes, whatever it is answering.
106
+ * `nosniff` is here rather than beside one route because the reason for it -
107
+ * a served file whose extension nothing in this module recognised - reaches
108
+ * both the asset route and the refusals written about it.
109
+ */
110
+ const COMMON_HEADERS = {
111
+ 'x-content-type-options': 'nosniff',
112
+ };
113
+ /** Path prefix under which the named queries - and nothing else - are answered. */
114
+ const QUERY_ROUTE_PREFIX = '/api/queries/';
115
+ /**
116
+ * Every query-string key a named-query route accepts. An unrecognised key is
117
+ * refused rather than ignored: ignoring it would mean `?sql=DROP TABLE …`
118
+ * returned 200 with the fake having run a real query, which is a weaker
119
+ * property than the one this server claims. `from` and `to` are the range
120
+ * `queries.ts` binds; `includeBots` is the bot-inclusion flag, and is optional
121
+ * because its default is `config.analytics.bots`, applied inside the port -
122
+ * this module must not restate it.
123
+ */
124
+ const QUERY_STRING_PARAMS = ['from', 'to', 'includeBots'];
125
+ /** The two spellings the bot-inclusion flag takes, mapped to what it means. */
126
+ const INCLUDE_BOTS_VALUES = { true: true, false: false };
127
+ /**
128
+ * The methods every route answers. `HEAD` is here because RFC 9110 §9.1 makes
129
+ * it mandatory beside `GET`: no browser needs it, but `curl -I` and an uptime
130
+ * probe both use it, and a 405 there reads as a broken server rather than a
131
+ * deliberate one.
132
+ *
133
+ * It is answered *as* `GET` with the body suppressed - same status, same
134
+ * headers, `content-length` included - which is why no handler below branches
135
+ * on the method and why the asset route still reads the bytes it would have
136
+ * sent. Node drops the payload itself once the request method is `HEAD`, so
137
+ * the headers a probe reads are computed by the same code that serves a `GET`
138
+ * rather than by a second path that could drift from it.
139
+ */
140
+ const ALLOWED_METHODS = ['GET', 'HEAD'];
141
+ /**
142
+ * What a 405 reports as allowed. RFC 9110 §10.2.1 asks for the whole supported
143
+ * set, so this is derived from {@link ALLOWED_METHODS} rather than restated.
144
+ */
145
+ const ALLOW_HEADER = ALLOWED_METHODS.join(', ');
146
+ /** The file a directory-shaped request path resolves to, the way a static host resolves one. */
147
+ const DIRECTORY_INDEX = 'index.html';
148
+ /**
149
+ * Content types for the extensions a built SvelteKit application emits. A
150
+ * narrower copy of the build agent's map (`packages/build-agent/src/build.ts`)
151
+ * rather than an import of it: that package is a deployed artifact, not a
152
+ * dependency of this one, and this server hands out one prebuilt directory
153
+ * rather than a user's arbitrary site.
154
+ */
155
+ const CONTENT_TYPES = {
156
+ html: 'text/html; charset=utf-8',
157
+ css: 'text/css; charset=utf-8',
158
+ js: 'text/javascript; charset=utf-8',
159
+ mjs: 'text/javascript; charset=utf-8',
160
+ json: 'application/json',
161
+ map: 'application/json',
162
+ webmanifest: 'application/manifest+json',
163
+ svg: 'image/svg+xml',
164
+ png: 'image/png',
165
+ jpg: 'image/jpeg',
166
+ jpeg: 'image/jpeg',
167
+ webp: 'image/webp',
168
+ avif: 'image/avif',
169
+ gif: 'image/gif',
170
+ ico: 'image/x-icon',
171
+ woff: 'font/woff',
172
+ woff2: 'font/woff2',
173
+ txt: 'text/plain; charset=utf-8',
174
+ wasm: 'application/wasm',
175
+ };
176
+ /** Served for any extension {@link CONTENT_TYPES} does not cover. */
177
+ const DEFAULT_CONTENT_TYPE = 'application/octet-stream';
178
+ /** What an error says for itself, whether or not it is an `Error`. */
179
+ function describeError(err) {
180
+ return err instanceof Error ? err.message : String(err);
181
+ }
182
+ /**
183
+ * A request this server refuses, carrying the status it refuses with. Private
184
+ * to this module: it is how a rejection travels from the place that can name
185
+ * the offending value to the place that writes a response, never a type a
186
+ * caller observes.
187
+ */
188
+ class RequestRejected extends Error {
189
+ status;
190
+ constructor(status, message) {
191
+ super(message);
192
+ this.status = status;
193
+ }
194
+ }
195
+ /**
196
+ * Whether the response can still be written to. A client that dropped its
197
+ * socket - or a shutdown that destroyed it out from under an in-flight
198
+ * handler - leaves a response nothing can be sent on, and writing to it raises
199
+ * from inside a handler that has nowhere to report.
200
+ */
201
+ function writable(res) {
202
+ return !res.writableEnded && !res.destroyed;
203
+ }
204
+ /** Write a JSON response. Every route in this module answers through here or {@link sendAsset}. */
205
+ function sendJson(res, status, body) {
206
+ if (!writable(res))
207
+ return;
208
+ const text = JSON.stringify(body);
209
+ res.writeHead(status, {
210
+ ...COMMON_HEADERS,
211
+ 'content-type': 'application/json; charset=utf-8',
212
+ 'content-length': Buffer.byteLength(text),
213
+ // Traffic figures change under the reader; a cached chart is a wrong chart.
214
+ 'cache-control': 'no-store',
215
+ });
216
+ res.end(text);
217
+ }
218
+ /**
219
+ * Write a static file's bytes. Unconditional: on a `HEAD` request Node keeps
220
+ * the headers and discards the payload, so the `content-length` a probe reads
221
+ * is the one a `GET` of the same path would have carried.
222
+ */
223
+ function sendAsset(res, contentType, bytes) {
224
+ if (!writable(res))
225
+ return;
226
+ res.writeHead(200, {
227
+ ...COMMON_HEADERS,
228
+ 'content-type': contentType,
229
+ 'content-length': bytes.byteLength,
230
+ });
231
+ res.end(Buffer.from(bytes));
232
+ }
233
+ /**
234
+ * The lowercase extension of a path, or `undefined` when it has none - a
235
+ * leading dot is a dotfile, not an extension. Mirrors `extensionOf`
236
+ * (`packages/build-agent/src/build.ts`).
237
+ */
238
+ function extensionOf(path) {
239
+ const base = path.split('/').pop() ?? '';
240
+ const dot = base.lastIndexOf('.');
241
+ if (dot <= 0 || dot === base.length - 1)
242
+ return undefined;
243
+ return base.slice(dot + 1).toLowerCase();
244
+ }
245
+ /**
246
+ * Content type for a served file. Guarded with `Object.hasOwn` for the reason
247
+ * `contentType` (`packages/build-agent/src/build.ts`) is: an unguarded index
248
+ * answers every `Object.prototype` key, so a file named `x.constructor` would
249
+ * resolve to a truthy function and be written into a header.
250
+ */
251
+ function contentTypeOf(path) {
252
+ const extension = extensionOf(path);
253
+ if (extension === undefined)
254
+ return DEFAULT_CONTENT_TYPE;
255
+ const type = Object.hasOwn(CONTENT_TYPES, extension) ? CONTENT_TYPES[extension] : undefined;
256
+ return type ?? DEFAULT_CONTENT_TYPE;
257
+ }
258
+ /**
259
+ * The name a query route asks for. Deliberately **not** percent-decoded: every
260
+ * name `queries.ts` declares is an ASCII identifier, so decoding would only
261
+ * mint second spellings of the same name (`%63ountries`), and anything that
262
+ * needs decoding to look like a name is not one. What resolves is exactly what
263
+ * {@link queryDefinition} holds as an own key.
264
+ */
265
+ function requestedQueryName(pathname) {
266
+ return pathname.slice(QUERY_ROUTE_PREFIX.length);
267
+ }
268
+ /**
269
+ * Read one required query-string value, refusing an absent one rather than
270
+ * standing in a window the server picked - a chart over a range the reader did
271
+ * not choose is indexed by nothing. `searchParams.get` answers `null` for an
272
+ * absent key, which never becomes a value here: absence raises.
273
+ */
274
+ function requiredParam(params, key) {
275
+ const value = params.get(key);
276
+ if (value === null) {
277
+ throw new RequestRejected(400, `missing required query parameter "${key}" - this route takes ${QUERY_STRING_PARAMS.join(', ')}`);
278
+ }
279
+ return value;
280
+ }
281
+ /**
282
+ * Parse the request's parameters into the {@link QueryParams} the port takes.
283
+ * An unrecognised key is refused here, which is what makes "no route accepts
284
+ * SQL" a property of the routing layer rather than of what the port happens to
285
+ * ignore.
286
+ */
287
+ function parseQueryParams(search) {
288
+ for (const key of search.keys()) {
289
+ if (!QUERY_STRING_PARAMS.some((known) => known === key)) {
290
+ throw new RequestRejected(400, `unknown query parameter "${key}" - this route takes ${QUERY_STRING_PARAMS.join(', ')} and nothing else`);
291
+ }
292
+ }
293
+ const range = { from: requiredParam(search, 'from'), to: requiredParam(search, 'to') };
294
+ const flag = search.get('includeBots');
295
+ if (flag === null) {
296
+ // Absent on purpose: `config.analytics.bots` decides, inside the port.
297
+ return { range };
298
+ }
299
+ if (!Object.hasOwn(INCLUDE_BOTS_VALUES, flag)) {
300
+ throw new RequestRejected(400, `query parameter "includeBots" must be ${Object.keys(INCLUDE_BOTS_VALUES).join(' or ')}, got "${flag}"`);
301
+ }
302
+ return { range, includeBots: INCLUDE_BOTS_VALUES[flag] };
303
+ }
304
+ /**
305
+ * Start the dashboard's HTTP listener on {@link LOOPBACK_ADDRESS}. Resolves
306
+ * once the socket is bound, with the address read back off it, so a caller
307
+ * prints where the server *is* rather than where it was asked to be.
308
+ */
309
+ export async function createDashboardServer(opts) {
310
+ /**
311
+ * The files `appDir` holds, keyed by the URL path that serves each one. An
312
+ * **allow-list**, and that is the whole traversal defence: a request path is
313
+ * looked up in this map and a miss is a 404, so `..`, `%2e%2e`, an absolute
314
+ * path and a symlink alike name nothing that can be served. No path
315
+ * arithmetic decides what escapes the directory, because none is done -
316
+ * contrast `resolveWithin` (`packages/build-agent/src/build.ts`), which has
317
+ * to get a containment check right because it resolves a caller's string.
318
+ *
319
+ * Read once, lazily, and cached on success only, following the DuckDB
320
+ * adapter's session: the listener therefore binds before any filesystem is
321
+ * consulted, and a run started before task 57's `dist/app` existed picks the
322
+ * directory up once it does rather than repeating the first failure forever.
323
+ */
324
+ let listing;
325
+ /**
326
+ * The `Host` values this listener answers - {@link ALLOWED_HOST_NAMES}, each
327
+ * with the port the socket actually bound. Built from the *bound* port and
328
+ * not from `opts.port`, so a caller that asks for port 0 gets an allow-list
329
+ * naming the port it really got; assigned inside the `listen` callback,
330
+ * before the promise below resolves, so it is already set by the time the
331
+ * listener can be connected to at all. `undefined` therefore refuses rather
332
+ * than admitting: a request this server cannot name its own address for is
333
+ * not one it should answer.
334
+ */
335
+ let allowedHostHeaders;
336
+ /**
337
+ * Refuse a request addressed to a name that is not this listener's own. The
338
+ * body is drained rather than left unread, for the reason the 405 path
339
+ * drains it: nothing here reads a body, and an undrained socket stalls.
340
+ */
341
+ function rejectForeignHost(req) {
342
+ const host = req.headers.host?.toLowerCase();
343
+ if (host !== undefined && allowedHostHeaders?.has(host) === true)
344
+ return;
345
+ req.resume();
346
+ throw new RequestRejected(403, `this dashboard answers only ${[...(allowedHostHeaders ?? [])].join(' and ')} - a request for host ${host === undefined ? '<absent>' : `"${host}"`} is addressed to some other origin that resolved to this address`);
347
+ }
348
+ async function readAppFiles() {
349
+ const entries = await opts.fs.listFiles(opts.appDir);
350
+ // `listFiles` answers platform-separated relative paths; a URL path is
351
+ // always `/`-separated, so the key is normalised and the value keeps the
352
+ // form the port will be asked to read back.
353
+ return new Map(entries.map((entry) => [entry.split(sep).join('/'), entry]));
354
+ }
355
+ function appFiles() {
356
+ const pending = (listing ??= readAppFiles());
357
+ return pending.catch((err) => {
358
+ if (listing === pending)
359
+ listing = undefined;
360
+ throw err;
361
+ });
362
+ }
363
+ async function handleQuery(pathname, search) {
364
+ const name = requestedQueryName(pathname);
365
+ // The unknown-name guard. `queryDefinition` raises listing every name that
366
+ // resolves, and its `Object.hasOwn` lookup is why an inherited key
367
+ // (`constructor`, `toString`, `__proto__`) is an unknown name here rather
368
+ // than a truthy definition that fails deeper in.
369
+ let definition;
370
+ try {
371
+ definition = queryDefinition(name);
372
+ }
373
+ catch (err) {
374
+ throw new RequestRejected(404, describeError(err));
375
+ }
376
+ const params = parseQueryParams(search);
377
+ // Task 45's own parameter validation, run at the boundary so a malformed
378
+ // or inverted range is a 400 naming the offending value rather than a 500
379
+ // raised from inside the port. Exactly one field of the result is used -
380
+ // `name`, the `QueryName` the lookup above proved - and `sql` is left
381
+ // untouched: see this module's doc comment.
382
+ let resolved;
383
+ try {
384
+ resolved = prepareQuery(name, params, opts.config);
385
+ }
386
+ catch (err) {
387
+ throw new RequestRejected(400, describeError(err));
388
+ }
389
+ // `params` goes through unmodified. Nothing between the request and this
390
+ // call reshapes a caller's value, and nothing concatenates one.
391
+ const rows = await opts.query.run(resolved.name, params);
392
+ return {
393
+ name: resolved.name,
394
+ rowMeaning: definition.rowMeaning,
395
+ resultColumns: definition.resultColumns,
396
+ rows,
397
+ };
398
+ }
399
+ async function handleAsset(pathname, res) {
400
+ let decoded;
401
+ try {
402
+ decoded = decodeURIComponent(pathname);
403
+ }
404
+ catch {
405
+ throw new RequestRejected(400, `path "${pathname}" is not valid percent-encoding`);
406
+ }
407
+ const trimmed = decoded.replace(/^\/+/, '');
408
+ const key = trimmed === '' || trimmed.endsWith('/') ? `${trimmed}${DIRECTORY_INDEX}` : trimmed;
409
+ let files;
410
+ try {
411
+ files = await appFiles();
412
+ }
413
+ catch (err) {
414
+ if (err instanceof FileNotFoundError) {
415
+ throw new RequestRejected(503, `the dashboard application has not been built: no directory at ${opts.appDir}`);
416
+ }
417
+ throw err;
418
+ }
419
+ const relative = files.get(key);
420
+ if (relative === undefined)
421
+ throw new RequestRejected(404, `no such file "${key}"`);
422
+ sendAsset(res, contentTypeOf(key), await opts.fs.readBytes(join(opts.appDir, relative)));
423
+ }
424
+ async function route(req, res) {
425
+ // First, and before the method: "is this addressed to me" precedes "may
426
+ // you do that here", and a page on some other origin should learn nothing
427
+ // about this server - not even which methods it answers.
428
+ rejectForeignHost(req);
429
+ if (!ALLOWED_METHODS.some((allowed) => allowed === req.method)) {
430
+ // Refused on the method alone, before anything about the request is
431
+ // read. No handler in this module attaches a `data` listener, so a body
432
+ // - SQL or otherwise - is never a value this program holds; `resume`
433
+ // drains and discards it so the socket does not stall.
434
+ req.resume();
435
+ res.setHeader('allow', ALLOW_HEADER);
436
+ throw new RequestRejected(405, `${String(req.method)} is not allowed - this server answers ${ALLOW_HEADER}`);
437
+ }
438
+ let url;
439
+ try {
440
+ url = new URL(req.url ?? '/', `http://${LOOPBACK_ADDRESS}`);
441
+ }
442
+ catch {
443
+ throw new RequestRejected(400, 'the request target is not a URL');
444
+ }
445
+ if (url.pathname.startsWith(QUERY_ROUTE_PREFIX)) {
446
+ sendJson(res, 200, await handleQuery(url.pathname, url.searchParams));
447
+ return;
448
+ }
449
+ await handleAsset(url.pathname, res);
450
+ }
451
+ const server = createServer((req, res) => {
452
+ void route(req, res).catch((err) => {
453
+ if (!writable(res))
454
+ return;
455
+ if (res.headersSent) {
456
+ res.destroy();
457
+ return;
458
+ }
459
+ const status = err instanceof RequestRejected ? err.status : 500;
460
+ sendJson(res, status, { error: describeError(err) });
461
+ });
462
+ });
463
+ // The build agent's precedent: a malformed request line kills its own socket
464
+ // rather than the process.
465
+ server.on('clientError', (_err, socket) => socket.destroy());
466
+ const address = await new Promise((resolve, reject) => {
467
+ const onError = (err) => {
468
+ reject(new Error(`analytics dashboard cannot bind ${LOOPBACK_ADDRESS}:${opts.port}: ${err.message}`));
469
+ };
470
+ server.once('error', onError);
471
+ server.listen(opts.port, LOOPBACK_ADDRESS, () => {
472
+ server.off('error', onError);
473
+ const bound = server.address();
474
+ if (bound === null || typeof bound === 'string') {
475
+ reject(new Error(`analytics dashboard bound no TCP address on port ${opts.port}`));
476
+ return;
477
+ }
478
+ allowedHostHeaders = new Set(ALLOWED_HOST_NAMES.map((name) => `${name}:${bound.port}`));
479
+ resolve({ host: bound.address, port: bound.port });
480
+ });
481
+ });
482
+ let closed;
483
+ return {
484
+ address,
485
+ url: `http://${address.host}:${address.port}/`,
486
+ close() {
487
+ // Idempotent: a caller that stops on a signal and a caller that stops
488
+ // explicitly may both arrive, and `Server.close` calls back with
489
+ // ERR_SERVER_NOT_RUNNING the second time.
490
+ closed ??= new Promise((resolve, reject) => {
491
+ server.close((err) => (err ? reject(err) : resolve()));
492
+ // Without this a keep-alive connection holds the port after `close`
493
+ // resolves nothing, and the caller's next bind fails with EADDRINUSE.
494
+ server.closeAllConnections();
495
+ });
496
+ return closed;
497
+ },
498
+ };
499
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `is_bot`: the user-agent heuristic behind step 4 of
3
+ * [§Record transformation](../../../../.specs/changes/merged/2026-07-26-analytics_plugin.md).
4
+ *
5
+ * A matched record is **flagged, never dropped**. `is_bot` is a column and
6
+ * every dashboard query takes a bot-inclusion flag, so a wrong match costs a
7
+ * wrong boolean that a query can ignore, while a dropped record is a page view
8
+ * nobody can get back. That asymmetry is the whole reason this returns a
9
+ * boolean rather than a verdict the transform acts on.
10
+ *
11
+ * The patterns are a deliberately small, self-declaring set rather than an
12
+ * exhaustive registry of crawlers. Every one of them matches text an agent
13
+ * puts in its own user agent to say what it is - `Googlebot/2.1`,
14
+ * `python-requests/2.32`, `curl/8.7.1` - so a match is evidence from the
15
+ * request itself, not a fingerprint. Bots that lie about their user agent are
16
+ * out of reach of any user-agent test and are not what this is for.
17
+ *
18
+ * Each pattern anchors on a word boundary rather than matching a bare
19
+ * substring, because the substrings are short enough to appear inside
20
+ * unrelated words: `bot` alone matches `CUBOT_X30`, an Android phone. The
21
+ * boundary is not a guarantee against every false positive - it cannot be -
22
+ * but it removes the class of them that a plain `includes` walks straight
23
+ * into, and the flag-don't-drop rule bounds the cost of the rest.
24
+ *
25
+ * Absent and empty user agents are **not** flagged. `is_bot` records that the
26
+ * agent named itself as a known bot; a request that named no agent has named
27
+ * nothing, and flagging it would assert something the record does not say -
28
+ * on a column an operator filters their traffic by. It is also the wrong
29
+ * guess in practice: no-user-agent requests are as often a stripped-down
30
+ * client as a crawler.
31
+ *
32
+ * Pure data and pure functions: no clock, no `node:` builtin, no vendor SDK.
33
+ */
34
+ /**
35
+ * The user-agent patterns that set `is_bot`, each with an agent it is here
36
+ * for. All are case-insensitive: agents spell these tokens every way
37
+ * (`GPTBot`, `bingbot`, `Baiduspider`). None carries the `g` flag - a global
38
+ * regex carries `lastIndex` between `test` calls, which would make the answer
39
+ * depend on how many records had been matched before it.
40
+ */
41
+ export declare const BOT_USER_AGENT_PATTERNS: readonly RegExp[];
42
+ /**
43
+ * Whether this user agent names a known bot. Total: every input answers,
44
+ * including an empty user agent and a request that carried none at all, both
45
+ * of which answer `false` for the reason in the module comment above.
46
+ */
47
+ export declare function isBotUserAgent(userAgent: string | undefined): boolean;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `is_bot`: the user-agent heuristic behind step 4 of
3
+ * [§Record transformation](../../../../.specs/changes/merged/2026-07-26-analytics_plugin.md).
4
+ *
5
+ * A matched record is **flagged, never dropped**. `is_bot` is a column and
6
+ * every dashboard query takes a bot-inclusion flag, so a wrong match costs a
7
+ * wrong boolean that a query can ignore, while a dropped record is a page view
8
+ * nobody can get back. That asymmetry is the whole reason this returns a
9
+ * boolean rather than a verdict the transform acts on.
10
+ *
11
+ * The patterns are a deliberately small, self-declaring set rather than an
12
+ * exhaustive registry of crawlers. Every one of them matches text an agent
13
+ * puts in its own user agent to say what it is - `Googlebot/2.1`,
14
+ * `python-requests/2.32`, `curl/8.7.1` - so a match is evidence from the
15
+ * request itself, not a fingerprint. Bots that lie about their user agent are
16
+ * out of reach of any user-agent test and are not what this is for.
17
+ *
18
+ * Each pattern anchors on a word boundary rather than matching a bare
19
+ * substring, because the substrings are short enough to appear inside
20
+ * unrelated words: `bot` alone matches `CUBOT_X30`, an Android phone. The
21
+ * boundary is not a guarantee against every false positive - it cannot be -
22
+ * but it removes the class of them that a plain `includes` walks straight
23
+ * into, and the flag-don't-drop rule bounds the cost of the rest.
24
+ *
25
+ * Absent and empty user agents are **not** flagged. `is_bot` records that the
26
+ * agent named itself as a known bot; a request that named no agent has named
27
+ * nothing, and flagging it would assert something the record does not say -
28
+ * on a column an operator filters their traffic by. It is also the wrong
29
+ * guess in practice: no-user-agent requests are as often a stripped-down
30
+ * client as a crawler.
31
+ *
32
+ * Pure data and pure functions: no clock, no `node:` builtin, no vendor SDK.
33
+ */
34
+ /**
35
+ * The user-agent patterns that set `is_bot`, each with an agent it is here
36
+ * for. All are case-insensitive: agents spell these tokens every way
37
+ * (`GPTBot`, `bingbot`, `Baiduspider`). None carries the `g` flag - a global
38
+ * regex carries `lastIndex` between `test` calls, which would make the answer
39
+ * depend on how many records had been matched before it.
40
+ */
41
+ export const BOT_USER_AGENT_PATTERNS = [
42
+ /bot\b/i, // Googlebot/2.1, bingbot, GPTBot, ClaudeBot, Twitterbot, UptimeRobot
43
+ /crawler\b/i, // Barkrowler, MJ12Crawler - crawlers that avoid the word "bot"
44
+ /spider\b/i, // Baiduspider/2.0, Sogou Spider
45
+ /\bslurp\b/i, // Yahoo! Slurp
46
+ /\bscrapy\b/i, // Scrapy/2.11 (+https://scrapy.org)
47
+ /feedfetcher/i, // Feedfetcher-Google
48
+ /facebookexternalhit/i, // link-preview fetchers that carry no bot token
49
+ /\bheadlesschrome\b/i, // HeadlessChrome/121.0 - a real browser, driven by a script
50
+ /\blighthouse\b/i, // Chrome-Lighthouse, the page-audit runner
51
+ /\bcurl\//i, // curl/8.7.1
52
+ /\bwget\b/i, // Wget/1.21.4
53
+ /python-requests\b/i, // python-requests/2.32.3
54
+ /python-urllib\b/i, // Python-urllib/3.12
55
+ /\bgo-http-client\b/i, // Go-http-client/2.0
56
+ /\bokhttp\b/i, // okhttp/4.12.0
57
+ /\bjava\//i, // Java/21.0.2
58
+ /\bapache-httpclient\b/i, // Apache-HttpClient/5.3
59
+ /libwww-perl/i, // libwww-perl/6.77
60
+ ];
61
+ /**
62
+ * Whether this user agent names a known bot. Total: every input answers,
63
+ * including an empty user agent and a request that carried none at all, both
64
+ * of which answer `false` for the reason in the module comment above.
65
+ */
66
+ export function isBotUserAgent(userAgent) {
67
+ if (userAgent === undefined)
68
+ return false;
69
+ const candidate = userAgent.trim();
70
+ if (candidate === '')
71
+ return false;
72
+ return BOT_USER_AGENT_PATTERNS.some((pattern) => pattern.test(candidate));
73
+ }