@knowledgefutures/sdk 0.0.0-stage → 0.2.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.
@@ -0,0 +1,249 @@
1
+ /**
2
+ * `@knowledgefutures/sdk/whistle` — what a project that declares
3
+ * `kf/whistle@1` tells Whistle beyond what its Worker's runs already say.
4
+ * On a Worker the console hosts, Whistle tails every run, so uncaught
5
+ * exceptions, `console.error(err)` and cron runs need nothing from here.
6
+ * This is for security events, errors the app handles without logging, and
7
+ * check-ins from jobs that aren't Worker crons:
8
+ *
9
+ * app.use(whistle()) // Hono: denied requests and admin writes (hono.ts)
10
+ * const t = telemetryFromEnv(env, { waitUntil })
11
+ * t.securityEvent('auth.failed', { actor: `user:${id}`, ...requestFields(request) })
12
+ * await checkIn(env.KF_WHISTLE_URL, 'nightly-backup', { every: 1440 })
13
+ *
14
+ * With `KF_WHISTLE_URL` (the console hands it out) events go straight to
15
+ * Whistle; without it, to the console as JSON lines. Moved here from
16
+ * kf-whistle's `@kf/telemetry`, which Whistle keeps for parsing until it
17
+ * reads this package instead: the wire format (wire.ts) must not drift
18
+ * between the two.
19
+ */
20
+ import { scrub, } from './wire.js';
21
+ export * from './wire.js';
22
+ /** Workers (and anything whose stdout is collected): one JSON line per event. */
23
+ export const consoleSink = {
24
+ emit(event) {
25
+ const line = JSON.stringify(event);
26
+ // console.error so Workers Logs, and the OTLP export, mark it as an error.
27
+ if (event.kf === 'error')
28
+ console.error(line);
29
+ else
30
+ console.log(line);
31
+ },
32
+ };
33
+ /** Every event to each sink. */
34
+ export function teeSink(...sinks) {
35
+ return {
36
+ emit(event) {
37
+ for (const s of sinks) {
38
+ try {
39
+ s.emit(event);
40
+ }
41
+ catch {
42
+ // One sink failing never stops the others.
43
+ }
44
+ }
45
+ },
46
+ };
47
+ }
48
+ /** `KF_WHISTLE_URL` (`https://<instance>:<key>@<host>/`) as an origin and a key, or null. */
49
+ export function parseWhistleUrl(url) {
50
+ if (!url)
51
+ return null;
52
+ try {
53
+ const u = new URL(url);
54
+ const key = decodeURIComponent(u.password);
55
+ if (!key || !/^https?:$/.test(u.protocol))
56
+ return null;
57
+ return { origin: `${u.protocol}//${u.host}`, key };
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /**
64
+ * A Sentry DSN that sends to Whistle, from `KF_WHISTLE_URL`: how an app on a
65
+ * Sentry SDK moves without code changes beyond `dsn` (and `release:
66
+ * KF_DEPLOYMENT_ID`, so errors group by deployment). Null without a URL, or
67
+ * when the key isn't one a DSN can carry (Sentry takes only [A-Za-z0-9_]).
68
+ * The project id is a placeholder: Sentry wants a number, Whistle goes by the key.
69
+ */
70
+ export function sentryDsn(url) {
71
+ const target = parseWhistleUrl(url);
72
+ if (!target || !/^\w+$/.test(target.key))
73
+ return null;
74
+ const [scheme, host] = target.origin.split('//');
75
+ return `${scheme}//${target.key}@${host}/1`;
76
+ }
77
+ /**
78
+ * Reports one run of a scheduled job to Whistle: `every` (minutes) says how
79
+ * often it should run, so Whistle can say when it stops. Never throws; false
80
+ * when there's no KF_WHISTLE_URL or the send failed.
81
+ *
82
+ * await checkIn(env.KF_WHISTLE_URL, 'nightly-backup', { every: 1440 })
83
+ * await checkIn(env.KF_WHISTLE_URL, 'nightly-backup', { status: 'error', message: String(e) })
84
+ */
85
+ export async function checkIn(url, name, run = {}, doFetch = fetch) {
86
+ const target = parseWhistleUrl(url);
87
+ if (!target)
88
+ return false;
89
+ try {
90
+ const res = await doFetch(`${target.origin}/v1/checkins/${encodeURIComponent(name)}`, {
91
+ method: 'POST',
92
+ headers: { authorization: `Bearer ${target.key}`, 'content-type': 'application/json' },
93
+ body: JSON.stringify(run),
94
+ });
95
+ return res.ok;
96
+ }
97
+ catch {
98
+ return false;
99
+ }
100
+ }
101
+ /**
102
+ * Posts events to Whistle's `/v1/events` as NDJSON, batched per tick. `flush()`
103
+ * sends what's queued now; failures are dropped, never thrown.
104
+ */
105
+ export function whistleSink(target, options = {}) {
106
+ const doFetch = options.fetch ?? fetch;
107
+ let queue = [];
108
+ let scheduled = false;
109
+ const flush = async () => {
110
+ scheduled = false;
111
+ if (queue.length === 0)
112
+ return;
113
+ const body = queue.map((e) => JSON.stringify(e)).join('\n');
114
+ queue = [];
115
+ try {
116
+ await doFetch(`${target.origin}/v1/events`, {
117
+ method: 'POST',
118
+ headers: { authorization: `Bearer ${target.key}`, 'content-type': 'application/x-ndjson' },
119
+ body,
120
+ });
121
+ }
122
+ catch {
123
+ // Telemetry never breaks the request it describes.
124
+ }
125
+ };
126
+ return {
127
+ emit(event) {
128
+ queue.push(event);
129
+ if (scheduled)
130
+ return;
131
+ scheduled = true;
132
+ const sent = new Promise((resolve) => queueMicrotask(() => void flush().then(resolve)));
133
+ options.waitUntil?.(sent);
134
+ },
135
+ flush,
136
+ };
137
+ }
138
+ /**
139
+ * Telemetry set up from what the console hands a project: `KF_WHISTLE_URL`
140
+ * (sends straight to Whistle), `KF_WHISTLE_PROJECT` and `KF_WHISTLE_ENVIRONMENT`
141
+ * (what Whistle files events under), `KF_DEPLOYMENT_ID` and `KF_COMMIT`.
142
+ * Without `KF_WHISTLE_URL`, console output. Not both: on Workers, Cloudflare's
143
+ * export would deliver the console line as well, and every event would count twice.
144
+ */
145
+ export function telemetryFromEnv(env, options = {}) {
146
+ const read = (k) => typeof env[k] === 'string' && env[k] ? env[k] : undefined;
147
+ const target = parseWhistleUrl(read('KF_WHISTLE_URL'));
148
+ const sink = options.sink ??
149
+ (target
150
+ ? whistleSink(target, defined({ fetch: options.fetch, waitUntil: options.waitUntil }))
151
+ : consoleSink);
152
+ return createTelemetry(defined({
153
+ // Whistle refuses events for a project their key isn't for.
154
+ project: options.project ?? read('KF_WHISTLE_PROJECT') ?? 'unknown',
155
+ environment: options.environment ?? read('KF_WHISTLE_ENVIRONMENT') ?? 'production',
156
+ sink,
157
+ now: options.now,
158
+ deployment: read('KF_DEPLOYMENT_ID'),
159
+ commit: read('KF_COMMIT'),
160
+ }));
161
+ }
162
+ /** `{ ...o }` without the undefined values, so events stay small and exact. */
163
+ export function defined(o) {
164
+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
165
+ }
166
+ /** An Error, or whatever was thrown, as the three exception fields. */
167
+ export function describeError(error) {
168
+ if (error instanceof Error) {
169
+ return defined({ type: error.name || 'Error', message: error.message, stack: error.stack });
170
+ }
171
+ if (typeof error === 'string')
172
+ return { type: 'string', message: error };
173
+ try {
174
+ return { type: typeof error, message: JSON.stringify(error) ?? String(error) };
175
+ }
176
+ catch {
177
+ return { type: typeof error, message: String(error) };
178
+ }
179
+ }
180
+ export function createTelemetry(options) {
181
+ const sink = options.sink ?? consoleSink;
182
+ const now = options.now ?? (() => new Date());
183
+ const base = () => defined({
184
+ v: 1,
185
+ project: options.project,
186
+ environment: options.environment,
187
+ at: now().toISOString(),
188
+ deployment: options.deployment,
189
+ commit: options.commit,
190
+ });
191
+ // Building the event is guarded too: `detail` is whatever the app passed.
192
+ const emit = (build) => {
193
+ try {
194
+ sink.emit(build());
195
+ }
196
+ catch {
197
+ // Telemetry never breaks the request it describes.
198
+ }
199
+ };
200
+ const captureError = (error, context = {}) => emit(() => defined({
201
+ kf: 'error',
202
+ ...base(),
203
+ ...describeError(error),
204
+ route: context.route,
205
+ rayId: context.rayId,
206
+ traceId: context.traceId,
207
+ detail: scrub(context.detail),
208
+ }));
209
+ return {
210
+ securityEvent(kind, fields = {}) {
211
+ emit(() => defined({
212
+ kf: 'security',
213
+ ...base(),
214
+ kind,
215
+ actor: fields.actor,
216
+ ip: fields.ip,
217
+ country: fields.country,
218
+ role: fields.role,
219
+ outcome: fields.outcome,
220
+ rayId: fields.rayId,
221
+ traceId: fields.traceId,
222
+ detail: scrub(fields.detail),
223
+ }));
224
+ },
225
+ captureError,
226
+ async span(name, fn, context = {}) {
227
+ try {
228
+ return await fn();
229
+ }
230
+ catch (error) {
231
+ captureError(error, { route: name, ...context });
232
+ throw error;
233
+ }
234
+ },
235
+ };
236
+ }
237
+ /**
238
+ * What a request carries that events want, on Cloudflare: the client IP, its
239
+ * country, and the Ray ID that joins an event to its edge log row. Elsewhere,
240
+ * pass them yourself.
241
+ */
242
+ export function requestFields(request) {
243
+ return defined({
244
+ ip: request.headers.get('cf-connecting-ip') ?? undefined,
245
+ country: request.headers.get('cf-ipcountry') ?? undefined,
246
+ rayId: request.headers.get('cf-ray')?.split('-')[0],
247
+ });
248
+ }
249
+ export { whistle } from './hono.js';
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The wire format: one JSON object per event, the same on every runtime.
3
+ *
4
+ * Sent to Whistle's `/v1/events` as NDJSON, or written as the body of a
5
+ * `console.log`/`console.error` line. Box agents send the same objects.
6
+ * Whistle parses it with `parseWire`, so this file is the contract between
7
+ * apps and Whistle; kf-whistle's `packages/telemetry/src/wire.ts` must say
8
+ * the same until Whistle imports it from here.
9
+ */
10
+ /** Security events: what a careful attacker can't avoid causing. Add kinds here, nowhere else. */
11
+ export declare const SECURITY_KINDS: readonly ["auth.failed", "auth.success", "auth.mfa_failed", "auth.reset_requested", "token.created", "token.used_new_ip", "access.denied", "admin.action", "secret.read", "member.added", "export.large", "ssh.failed", "ssh.success", "sudo"];
12
+ export type SecurityKind = (typeof SECURITY_KINDS)[number];
13
+ interface Common {
14
+ v: 1;
15
+ /** `<account>/<project>`, the console key. */
16
+ project: string;
17
+ environment: string;
18
+ /** ISO 8601. */
19
+ at: string;
20
+ /** The edge request this happened in, when there is one: joins to the edge log lake. */
21
+ rayId?: string;
22
+ traceId?: string;
23
+ /** The console's deployment id (KF_DEPLOYMENT_ID) and its commit (KF_COMMIT). */
24
+ deployment?: string;
25
+ commit?: string;
26
+ detail?: Record<string, unknown>;
27
+ }
28
+ export interface SecurityEvent extends Common {
29
+ kf: 'security';
30
+ kind: SecurityKind;
31
+ /** Who: `user:<id>`, `token:<id>`, `ip`, or a box user. Never an email or a name. */
32
+ actor?: string;
33
+ ip?: string;
34
+ /** ISO 3166 alpha-2, from `cf-ipcountry` on Cloudflare. */
35
+ country?: string;
36
+ /** Staff, admin, member: lets rules treat privileged sign-ins differently. */
37
+ role?: string;
38
+ outcome?: 'success' | 'failure' | 'denied';
39
+ }
40
+ export interface ErrorEvent extends Common {
41
+ kf: 'error';
42
+ /** `exception.type`: TypeError, D1_ERROR… */
43
+ type: string;
44
+ /** `exception.message`. */
45
+ message: string;
46
+ /** `exception.stacktrace`. */
47
+ stack?: string;
48
+ /** The route pattern (`/api/projects/:id`), not the URL: keeps ids out of grouping. */
49
+ route?: string;
50
+ }
51
+ export type WireEvent = SecurityEvent | ErrorEvent;
52
+ /**
53
+ * `detail` without anything that looks like a credential, and bounded in size.
54
+ * Apps pass objects they didn't fully build (a request's params, an error's
55
+ * cause), so this is the last line before a secret ends up in the lake.
56
+ */
57
+ export declare function scrub(detail: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
58
+ /**
59
+ * A wire event from a log body (a JSON string or an already-parsed object), or
60
+ * null when it isn't one. Tolerant of extra fields; strict about the ones rules
61
+ * depend on, so a malformed event never matches a rule by accident.
62
+ */
63
+ export declare function parseWire(input: unknown): WireEvent | null;
64
+ export {};
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The wire format: one JSON object per event, the same on every runtime.
3
+ *
4
+ * Sent to Whistle's `/v1/events` as NDJSON, or written as the body of a
5
+ * `console.log`/`console.error` line. Box agents send the same objects.
6
+ * Whistle parses it with `parseWire`, so this file is the contract between
7
+ * apps and Whistle; kf-whistle's `packages/telemetry/src/wire.ts` must say
8
+ * the same until Whistle imports it from here.
9
+ */
10
+ /** Security events: what a careful attacker can't avoid causing. Add kinds here, nowhere else. */
11
+ export const SECURITY_KINDS = [
12
+ 'auth.failed',
13
+ 'auth.success',
14
+ 'auth.mfa_failed',
15
+ 'auth.reset_requested',
16
+ 'token.created',
17
+ 'token.used_new_ip',
18
+ 'access.denied',
19
+ 'admin.action',
20
+ 'secret.read',
21
+ 'member.added',
22
+ 'export.large',
23
+ // From box agents (vector/vector.toml), not app code.
24
+ 'ssh.failed',
25
+ 'ssh.success',
26
+ 'sudo',
27
+ ];
28
+ const MAX_DETAIL_BYTES = 8_000;
29
+ const SENSITIVE_KEY = /pass(word)?|secret|token|cookie|authorization|api[-_]?key|session/i;
30
+ /**
31
+ * `detail` without anything that looks like a credential, and bounded in size.
32
+ * Apps pass objects they didn't fully build (a request's params, an error's
33
+ * cause), so this is the last line before a secret ends up in the lake.
34
+ */
35
+ export function scrub(detail) {
36
+ if (!detail)
37
+ return undefined;
38
+ const walk = (value, depth) => {
39
+ if (depth > 4)
40
+ return '[depth]';
41
+ if (Array.isArray(value))
42
+ return value.slice(0, 50).map((v) => walk(v, depth + 1));
43
+ if (value && typeof value === 'object') {
44
+ const out = {};
45
+ for (const [k, v] of Object.entries(value)) {
46
+ out[k] = SENSITIVE_KEY.test(k) ? '[redacted]' : walk(v, depth + 1);
47
+ }
48
+ return out;
49
+ }
50
+ if (typeof value === 'string' && value.length > 2_000)
51
+ return `${value.slice(0, 2_000)}…`;
52
+ // JSON.stringify throws on a bigint.
53
+ if (typeof value === 'bigint')
54
+ return value.toString();
55
+ return value;
56
+ };
57
+ const out = walk(detail, 0);
58
+ if (JSON.stringify(out).length > MAX_DETAIL_BYTES)
59
+ return { truncated: true };
60
+ return out;
61
+ }
62
+ const isString = (v) => typeof v === 'string' && v.length > 0;
63
+ const KINDS = new Set(SECURITY_KINDS);
64
+ /**
65
+ * A wire event from a log body (a JSON string or an already-parsed object), or
66
+ * null when it isn't one. Tolerant of extra fields; strict about the ones rules
67
+ * depend on, so a malformed event never matches a rule by accident.
68
+ */
69
+ export function parseWire(input) {
70
+ let value = input;
71
+ if (typeof value === 'string') {
72
+ const s = value.trim();
73
+ if (!s.startsWith('{') || !s.includes('"kf"'))
74
+ return null;
75
+ try {
76
+ value = JSON.parse(s);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ if (!value || typeof value !== 'object')
83
+ return null;
84
+ const o = value;
85
+ if (o.v !== 1 || !isString(o.project) || !isString(o.environment) || !isString(o.at))
86
+ return null;
87
+ if (o.kf === 'security') {
88
+ if (!isString(o.kind) || !KINDS.has(o.kind))
89
+ return null;
90
+ return o;
91
+ }
92
+ if (o.kf === 'error') {
93
+ if (!isString(o.type) || typeof o.message !== 'string')
94
+ return null;
95
+ return o;
96
+ }
97
+ return null;
98
+ }
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "@knowledgefutures/sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.1",
4
+ "description": "Zero-config clients for Knowledge Futures platform services, configured by the console.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/knowledgefutures/kf-console.git",
9
+ "directory": "packages/sdk"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "exports": {
17
+ "./storage": {
18
+ "types": "./dist/storage/index.d.ts",
19
+ "default": "./dist/storage/index.js"
20
+ },
21
+ "./storage/testing": {
22
+ "types": "./dist/storage/testing.d.ts",
23
+ "default": "./dist/storage/testing.js"
24
+ },
25
+ "./whistle": {
26
+ "types": "./dist/whistle/index.d.ts",
27
+ "default": "./dist/whistle/index.js"
28
+ }
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.build.json",
35
+ "typecheck": "tsc --noEmit -p tsconfig.json"
36
+ }
6
37
  }