@homeflare/kit 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Timothy Schneider <tim@taslabs.net>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # @homeflare/kit
2
+
3
+ Shared primitives for the HomeFlare estate. Bun-native to author, runtime-neutral to
4
+ consume — the published bundle runs on Cloudflare Workers, Node and Bun alike.
5
+
6
+ ```sh
7
+ bun add @homeflare/kit # or: pnpm add @homeflare/kit
8
+ ```
9
+
10
+ ## Usage
11
+
12
+ ```ts
13
+ import { parseEnv } from '@homeflare/kit';
14
+
15
+ const config = parseEnv(
16
+ {
17
+ API_URL: { type: 'string' },
18
+ PORT: { type: 'number', default: 8787 },
19
+ DEBUG: { type: 'boolean', default: false },
20
+ },
21
+ env, // a Worker's env binding, or process.env
22
+ );
23
+
24
+ config.PORT; // number
25
+ config.DEBUG; // boolean — 'false' parses as false, not as a truthy string
26
+ ```
27
+
28
+ `parseEnv` throws `EnvError` on the first key that is missing or malformed. The error
29
+ names the key and never the value.
30
+
31
+ ## Development
32
+
33
+ ```sh
34
+ bun install
35
+ bun run check # lint + types + build + tests
36
+ bun run smoke # pack, install and use the real tarball under bun AND node
37
+ bun run verify # check + smoke — the full pre-publish gate
38
+ ```
39
+
40
+ Changes are versioned with [changesets](https://github.com/changesets/changesets):
41
+
42
+ ```sh
43
+ bun run changeset # describe the change; commit the file with your PR
44
+ ```
45
+
46
+ ## Contributing
47
+
48
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the workflow and the gates, and
49
+ [AGENTS.md](./AGENTS.md) for the toolchain rationale behind them.
50
+
51
+ Found a vulnerability? Please don't open a public issue — see
52
+ [SECURITY.md](./SECURITY.md).
53
+
54
+ ## License
55
+
56
+ MIT © Timothy Schneider
package/SECURITY.md ADDED
@@ -0,0 +1,44 @@
1
+ # Security Policy
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Email **security@homeflare.dev**. Please do not open a public issue for a security
6
+ vulnerability — a public report is a disclosure, and it reaches attackers at the same
7
+ moment it reaches us.
8
+
9
+ Include what you have:
10
+
11
+ - a description of the issue
12
+ - steps to reproduce
13
+ - affected version(s) or commit SHAs
14
+ - your assessment of severity
15
+
16
+ We acknowledge reports within 72 hours and aim to ship fixes for high-severity issues
17
+ within 30 days.
18
+
19
+ ## Supported versions
20
+
21
+ The latest published release on npm is the only supported version. Older tags are
22
+ point-in-time references; fixes are not backported.
23
+
24
+ ## What is in scope
25
+
26
+ This package is a **library**. It holds no credentials, opens no sockets, and reads no
27
+ files — `parseEnv` is handed a plain object by its caller and returns a plain object.
28
+ The realistic vulnerability classes here are:
29
+
30
+ - a parsing result that misrepresents its input (for example, a value that should have
31
+ been rejected being returned as valid)
32
+ - a supply-chain issue in what we publish: the built `dist/`, the tarball's contents, or
33
+ the release workflow that produces them
34
+
35
+ ⚠️ **Configuration values are the caller's secrets, not ours.** Nothing in this package
36
+ logs a parsed value, and `EnvError` names only the offending key. If you find any path
37
+ where a value reaches a log, a message, or a stack trace, treat it as a vulnerability and
38
+ report it here.
39
+
40
+ ## Disclosure
41
+
42
+ We follow coordinated disclosure. Once a fix ships, reporters are welcome to publish
43
+ their findings, and we are happy to credit you in the release notes — tell us how you
44
+ would like to be named, or if you would rather not be.
package/dist/env.d.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Environment parsing — the one thing every HomeFlare app does before anything else.
3
+ *
4
+ * ★ WHY THIS EXISTS RATHER THAN zod. A Worker reads its config from a plain object
5
+ * (`env`), not from `process.env`, and it does so on every request. Pulling a schema
6
+ * library in for that costs bundle size on a hot path to validate perhaps six keys.
7
+ * This is deliberately small and dependency-free; when a shape genuinely needs
8
+ * unions, refinement or codecs, reach for zod in the consuming app instead of
9
+ * growing this.
10
+ *
11
+ * ⚠️ THIS VALIDATES SHAPE, NOT SECRECY. A value arriving here has already been handed
12
+ * over by the platform — a Worker binding, an OpenBao-minted lease. Nothing in this
13
+ * file should ever log a value, and callers should not either: a thrown error names
14
+ * the KEY that was wrong and never what it contained.
15
+ */
16
+ /** How one environment key is read, and what it becomes. */
17
+ export type EnvSpec = {
18
+ readonly type: 'string';
19
+ readonly optional?: boolean;
20
+ readonly default?: string;
21
+ } | {
22
+ readonly type: 'number';
23
+ readonly optional?: boolean;
24
+ readonly default?: number;
25
+ } | {
26
+ readonly type: 'boolean';
27
+ readonly optional?: boolean;
28
+ readonly default?: boolean;
29
+ };
30
+ /** A description of every key an app needs, keyed by the name it appears under. */
31
+ export type EnvSchema = Readonly<Record<string, EnvSpec>>;
32
+ /** What a schema produces once parsed: each key narrowed to its declared type. */
33
+ type Parsed<S extends EnvSchema> = {
34
+ readonly [K in keyof S]: S[K] extends {
35
+ type: 'number';
36
+ } ? number : S[K] extends {
37
+ type: 'boolean';
38
+ } ? boolean : string;
39
+ };
40
+ /** Raised when a key is missing or cannot be read as its declared type. */
41
+ export declare class EnvError extends Error {
42
+ /** The offending key. Never the value — see the file header. */
43
+ readonly key: string;
44
+ constructor(key: string, reason: string);
45
+ }
46
+ /**
47
+ * ⛔ THE THREE VALUES THAT MEAN "ABSENT", AND WHY IT IS NOT JUST THE EMPTY STRING.
48
+ * An unbound workerd binding does NOT arrive as undefined — `fromEnvironment` produces
49
+ * the four-character STRING "null", because the binding is present and String(null) is
50
+ * truthy. Code that checks only for '' therefore accepts it, and the request goes out
51
+ * as `Authorization: Bearer null`.
52
+ * 🔴 MEASURED IN THIS ESTATE, 2026-09-02. Twenty hand-rolled clients; this guard was in
53
+ * four of them. The other sixteen surfaced a bare 401 from upstream — which reads as
54
+ * "the token is wrong" and sends an operator to rotate a perfectly good credential.
55
+ * The litellm server spent a day authenticating with nothing while /health answered 200.
56
+ * Ported from house/mcp-servers/packages/kit/src/http.ts, where it was paid for once.
57
+ */
58
+ export declare function isUnset(value: string): boolean;
59
+ /**
60
+ * Read `source` against `schema`, throwing `EnvError` on the first key that is missing
61
+ * or malformed.
62
+ *
63
+ * ★ THROWS RATHER THAN RETURNING A RESULT, deliberately: this runs once at startup or
64
+ * at the top of a fetch handler, where a misconfigured deployment should fail loudly
65
+ * and immediately rather than produce a half-configured app that fails later somewhere
66
+ * less obvious.
67
+ */
68
+ export declare function parseEnv<const S extends EnvSchema>(schema: S, source: Readonly<Record<string, unknown>>): Parsed<S>;
69
+ export {};
70
+ //# sourceMappingURL=env.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../src/env.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,4DAA4D;AAC5D,MAAM,MAAM,OAAO,GACf;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GACnF;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GACnF;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAE1F,mFAAmF;AACnF,MAAM,MAAM,SAAS,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAE1D,kFAAkF;AAClF,KAAK,MAAM,CAAC,CAAC,SAAS,SAAS,IAAI;IACjC,QAAQ,EAAE,CAAC,IAAI,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS;QAAE,IAAI,EAAE,QAAQ,CAAA;KAAE,GACpD,MAAM,GACN,CAAC,CAAC,CAAC,CAAC,SAAS;QAAE,IAAI,EAAE,SAAS,CAAA;KAAE,GAC9B,OAAO,GACP,MAAM;CACb,CAAC;AAEF,2EAA2E;AAC3E,qBAAa,QAAS,SAAQ,KAAK;IACjC,gEAAgE;IAChE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAErB,YAAY,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAItC;CACF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAE9C;AAWD;;;;;;;;GAQG;AACH,wBAAgB,QAAQ,CAAC,KAAK,CAAC,CAAC,SAAS,SAAS,EAChD,MAAM,EAAE,CAAC,EACT,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GACxC,MAAM,CAAC,CAAC,CAAC,CAuCX"}
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Errors a CALLER can act on — an agent, a retry loop, or a person reading a log.
3
+ *
4
+ * ★ PORTED FROM house/mcp-servers/packages/kit/src/errors.ts, where 34 packages proved
5
+ * the shape. Generalised: the estate version is MCP-specific (`HouseToolError`), this
6
+ * one is not, so a Worker or a script can use the same vocabulary.
7
+ *
8
+ * ⛔ THE RULE THIS ENFORCES: a failure must never be silent, and never ambiguous with a
9
+ * DIFFERENT failure. Both halves were learned from incidents, not from principle:
10
+ * - An Access provider read its token at module scope and, when that failed, silently
11
+ * omitted the auth header. Cloudflare answered a bare 401 — indistinguishable from a
12
+ * bad credential — and sent an operator to debug the wrong subsystem. Omitting is
13
+ * worse than throwing.
14
+ * - A gate reported success on a corrupted file because it compared the file to itself.
15
+ * A check that cannot fail is worse than no check, because it is trusted.
16
+ *
17
+ * ⚠️ THE AUDIENCE MAY BE AN AGENT, not a human with a debugger. An agent cannot open a
18
+ * dashboard or ask a colleague, so everything it needs to pick its next action must be
19
+ * in the error. One that reads "permission denied" retries forever; one that reads
20
+ * "the token lacks Grafana Read; ask an operator to widen the grant" stops and reports.
21
+ */
22
+ /** Which class of failure this is. Callers branch on it, so the set stays small. */
23
+ export type ErrorKind =
24
+ /** Not who it claims, or claims nothing. Retrying unchanged fails. */
25
+ 'unauthenticated'
26
+ /** Known, but lacks the grant. ⛔ NEVER retry — a human must widen a scope. */
27
+ | 'forbidden'
28
+ /** The upstream said no. The caller may be able to fix its arguments and retry. */
29
+ | 'upstream_rejected'
30
+ /**
31
+ * Over a rate limit. The IDENTICAL call succeeds after the stated delay.
32
+ * 🔴 A DISTINCT KIND BECAUSE IT WAS LANDING AS `bad_arguments`, measured 2026-09-07 in
33
+ * the estate: a 429 mapped to "your arguments are wrong", so a throttled agent
34
+ * rewrote a correct call, was throttled again, and concluded the tool was broken.
35
+ * This is the refusal agents meet most — Cloudflare frontier models are 20 rpm.
36
+ */
37
+ | 'rate_limited'
38
+ /** Could not be reached at all. Usually transient; a retry may work. */
39
+ | 'upstream_unreachable'
40
+ /** Called with arguments it cannot accept. Correct them and retry. */
41
+ | 'bad_arguments'
42
+ /** We are misconfigured. ⛔ A caller cannot fix this; it must report it. */
43
+ | 'misconfigured';
44
+ /**
45
+ * What each kind means, for anything that has to explain them.
46
+ *
47
+ * ⛔ TYPED AS Record<ErrorKind, string>, WHICH IS THE POINT: adding a kind above without
48
+ * describing it here is a COMPILE ERROR. A hand-kept list beside a union goes stale,
49
+ * and a documented error contract with a missing case is worse than none — it is
50
+ * trusted.
51
+ */
52
+ export declare const KIND_DESCRIPTIONS: Record<ErrorKind, string>;
53
+ export interface KitErrorOptions {
54
+ /** Which system failed. ⛔ Name it — "request failed" tells a caller nothing. */
55
+ readonly system: string;
56
+ /** What would make it work. This is what stops an agent retrying forever. */
57
+ readonly remedy?: string;
58
+ /** For `rate_limited`: how long to wait, in milliseconds. */
59
+ readonly retryAfterMs?: number;
60
+ readonly cause?: unknown;
61
+ }
62
+ /** A failure with enough shape for a caller to decide what to do next. */
63
+ export declare class KitError extends Error {
64
+ readonly kind: ErrorKind;
65
+ readonly system: string;
66
+ readonly remedy: string | undefined;
67
+ readonly retryAfterMs: number | undefined;
68
+ constructor(kind: ErrorKind, message: string, options: KitErrorOptions);
69
+ /** True when retrying the identical call could plausibly succeed. */
70
+ get retryable(): boolean;
71
+ }
72
+ /**
73
+ * Map an HTTP status to a kind.
74
+ *
75
+ * ⚠️ 429 IS CHECKED EXPLICITLY, not left to fall through. It used to reach
76
+ * `upstream_rejected` here and `bad_arguments` elsewhere — both of which tell a caller
77
+ * to CHANGE ITS ARGUMENTS, which is precisely wrong for a throttle.
78
+ */
79
+ export declare function kindForStatus(status: number): ErrorKind;
80
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,oFAAoF;AACpF,MAAM,MAAM,SAAS;AACnB,sEAAsE;AACpE,iBAAiB;AACnB,8EAA8E;GAC5E,WAAW;AACb,mFAAmF;GACjF,mBAAmB;AACrB;;;;;;GAMG;GACD,cAAc;AAChB,wEAAwE;GACtE,sBAAsB;AACxB,sEAAsE;GACpE,eAAe;AACjB,2EAA2E;GACzE,eAAe,CAAC;AAEpB;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,SAAS,EAAE,MAAM,CAQvD,CAAC;AAEF,MAAM,WAAW,eAAe;IAC9B,gFAAgF;IAChF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,6EAA6E;IAC7E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,6DAA6D;IAC7D,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,0EAA0E;AAC1E,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,CAAC;IAE1C,YAAY,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,EAOrE;IAED,qEAAqE;IACrE,IAAI,SAAS,IAAI,OAAO,CAEvB;CACF;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAMvD"}
package/dist/http.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * HTTP client — a configured `ky`, not a wrapper around it.
3
+ *
4
+ * ★ WHY ky AND NOT A HAND-ROLLED fetch+retry. Retry, timeout and backoff are each easy
5
+ * to write and easy to write subtly wrong (retrying a POST, retrying a 4xx, a timeout
6
+ * that leaks the AbortController). ky is zero-dependency, TypeScript-native and
7
+ * fetch-based — measured 2026-09-15: `npm view ky dependencies` is empty — so it adds
8
+ * nothing to a consumer's tree.
9
+ * ⚠️ ky is described as browser-first, which matters only in that it assumes a global
10
+ * `fetch`. workerd, Node 18+ and Bun all provide one, so there is nothing to polyfill.
11
+ * ⛔ NOT ofetch (pulls destr + node-fetch-native) and NOT wretch, which is BROKEN on
12
+ * Workers out of the box: Cloudflare's Response has no `.type` and wretch throws
13
+ * reading it, needing a middleware patch to work at all.
14
+ *
15
+ * ★ THIS MODULE IS DELIBERATELY THIN. It re-exports ky's own types rather than inventing
16
+ * parallel ones, so a caller who needs something not covered here uses ky directly and
17
+ * loses nothing.
18
+ */
19
+ import { HTTPError, TimeoutError, type KyInstance, type Options } from 'ky';
20
+ export { HTTPError, TimeoutError };
21
+ export type { KyInstance, Options };
22
+ /**
23
+ * The house defaults.
24
+ *
25
+ * ⚠️ ky retries only IDEMPOTENT methods by default (GET/PUT/HEAD/DELETE/OPTIONS/TRACE) —
26
+ * POST is excluded, which is the behaviour you want and the one a hand-rolled retry
27
+ * usually gets wrong. Retried status codes are 408/413/429/500/502/503/504.
28
+ * ⚠️ `timeout` is PER ATTEMPT, not for the whole call. With limit 3 and a 10s timeout the
29
+ * worst case is ~30s plus backoff — shorten both for a Worker on a request path, where
30
+ * the CPU-time limit is unforgiving.
31
+ */
32
+ export declare const http: KyInstance;
33
+ /**
34
+ * A client bound to one base URL, for talking to a specific service.
35
+ *
36
+ * const linear = client('https://api.linear.app', { headers: { authorization } });
37
+ * const issues = await linear.get('/graphql').json<Issues>();
38
+ *
39
+ * ⚠️ `baseUrl`, NOT `prefixUrl`. ky 2.x renamed the option and changed its semantics:
40
+ * `baseUrl` resolves per the URL standard, so a leading slash is fine and an absolute
41
+ * input bypasses the base. (ky 1.x had `prefixUrl`, which threw on a leading slash —
42
+ * measured 2026-09-15, the old name is a type error, not a silent no-op.)
43
+ */
44
+ export declare function client(baseUrl: string, options?: Options): KyInstance;
45
+ //# sourceMappingURL=http.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAW,EAAE,SAAS,EAAE,YAAY,EAAE,KAAK,UAAU,EAAE,KAAK,OAAO,EAAE,MAAM,IAAI,CAAC;AAEhF,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,CAAC;AACnC,YAAY,EAAE,UAAU,EAAE,OAAO,EAAE,CAAC;AAEpC;;;;;;;;;GASG;AACH,eAAO,MAAM,IAAI,EAAE,UAGjB,CAAC;AAEH;;;;;;;;;;GAUG;AACH,wBAAgB,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,OAAO,GAAG,UAAU,CAErE"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @homeflare/kit — the estate's shared primitives.
3
+ *
4
+ * ⛔ THIS ENTRYPOINT STAYS RUNTIME-NEUTRAL. Bun is how the kit is authored, built and
5
+ * tested; it is not what consumers run. The monorepo ships this code to Cloudflare
6
+ * Workers, so anything reaching for `bun:*`, `node:fs` or a filesystem belongs behind
7
+ * its own subpath export (`@homeflare/kit/<area>`), never here.
8
+ * The failure this prevents is a deploy-time one: a `bun:sqlite` import that resolves
9
+ * fine on a laptop and fails only in workerd, where the stack trace names the bundler
10
+ * rather than this file.
11
+ *
12
+ * ★ isolatedDeclarations is on, so every exported symbol below carries an explicit
13
+ * return type. That is not style — `bun build` cannot emit .d.ts, so declarations come
14
+ * from a separate tsc pass, and explicit types are what keep the two in agreement.
15
+ */
16
+ export { VERSION } from './version.ts';
17
+ export { EnvError, isUnset, parseEnv, type EnvSchema, type EnvSpec } from './env.ts';
18
+ export { KIND_DESCRIPTIONS, KitError, kindForStatus, type ErrorKind, type KitErrorOptions, } from './errors.ts';
19
+ export { client, http, HTTPError, TimeoutError, type KyInstance, type Options } from './http.ts';
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,SAAS,EAAE,KAAK,OAAO,EAAE,MAAM,UAAU,CAAC;AACrF,OAAO,EACL,iBAAiB,EACjB,QAAQ,EACR,aAAa,EACb,KAAK,SAAS,EACd,KAAK,eAAe,GACrB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,YAAY,EAAE,KAAK,UAAU,EAAE,KAAK,OAAO,EAAE,MAAM,WAAW,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,114 @@
1
+ // src/version.ts
2
+ var VERSION = "0.1.0";
3
+ // src/env.ts
4
+ class EnvError extends Error {
5
+ key;
6
+ constructor(key, reason) {
7
+ super(`env ${key}: ${reason}`);
8
+ this.name = "EnvError";
9
+ this.key = key;
10
+ }
11
+ }
12
+ function isUnset(value) {
13
+ return value === "" || value === "null" || value === "undefined";
14
+ }
15
+ var TRUE = new Set(["1", "true", "yes", "on"]);
16
+ var FALSE = new Set(["0", "false", "no", "off"]);
17
+ function parseEnv(schema, source) {
18
+ const out = {};
19
+ for (const key of Object.keys(schema)) {
20
+ const spec = schema[key];
21
+ const raw = source[key];
22
+ if (raw === undefined || raw === null || isUnset(String(raw).trim())) {
23
+ if (spec.default !== undefined) {
24
+ out[key] = spec.default;
25
+ continue;
26
+ }
27
+ if (spec.optional === true)
28
+ continue;
29
+ throw new EnvError(key, "required but not set");
30
+ }
31
+ const text = String(raw).trim();
32
+ if (spec.type === "number") {
33
+ const n = Number(text);
34
+ if (!Number.isFinite(n))
35
+ throw new EnvError(key, "expected a number");
36
+ out[key] = n;
37
+ } else if (spec.type === "boolean") {
38
+ const lowered = text.toLowerCase();
39
+ if (TRUE.has(lowered))
40
+ out[key] = true;
41
+ else if (FALSE.has(lowered))
42
+ out[key] = false;
43
+ else
44
+ throw new EnvError(key, "expected one of 1/true/yes/on or 0/false/no/off");
45
+ } else {
46
+ out[key] = text;
47
+ }
48
+ }
49
+ return out;
50
+ }
51
+ // src/errors.ts
52
+ var KIND_DESCRIPTIONS = {
53
+ unauthenticated: "The credential was missing, malformed, or rejected.",
54
+ forbidden: "The credential is valid but lacks the required grant. Do not retry.",
55
+ upstream_rejected: "The upstream refused the request as made.",
56
+ rate_limited: "Throttled. The identical call succeeds after the stated delay.",
57
+ upstream_unreachable: "The upstream could not be reached. A retry may succeed.",
58
+ bad_arguments: "The arguments were not acceptable. Correct them and retry.",
59
+ misconfigured: "This service is misconfigured and cannot serve the request."
60
+ };
61
+
62
+ class KitError extends Error {
63
+ kind;
64
+ system;
65
+ remedy;
66
+ retryAfterMs;
67
+ constructor(kind, message, options) {
68
+ super(`${options.system}: ${message}`, { cause: options.cause });
69
+ this.name = "KitError";
70
+ this.kind = kind;
71
+ this.system = options.system;
72
+ this.remedy = options.remedy;
73
+ this.retryAfterMs = options.retryAfterMs;
74
+ }
75
+ get retryable() {
76
+ return this.kind === "rate_limited" || this.kind === "upstream_unreachable";
77
+ }
78
+ }
79
+ function kindForStatus(status) {
80
+ if (status === 401)
81
+ return "unauthenticated";
82
+ if (status === 403)
83
+ return "forbidden";
84
+ if (status === 429)
85
+ return "rate_limited";
86
+ if (status >= 500)
87
+ return "upstream_unreachable";
88
+ return "upstream_rejected";
89
+ }
90
+ // src/http.ts
91
+ import ky, { HTTPError, TimeoutError } from "ky";
92
+ var http = ky.extend({
93
+ retry: { limit: 3, backoffLimit: 3000 },
94
+ timeout: 1e4
95
+ });
96
+ function client(baseUrl, options) {
97
+ return http.extend({ ...options, baseUrl });
98
+ }
99
+ export {
100
+ EnvError,
101
+ HTTPError,
102
+ KIND_DESCRIPTIONS,
103
+ KitError,
104
+ TimeoutError,
105
+ VERSION,
106
+ client,
107
+ http,
108
+ isUnset,
109
+ kindForStatus,
110
+ parseEnv
111
+ };
112
+
113
+ //# debugId=A318350C97DF929364756E2164756E21
114
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/version.ts", "../src/env.ts", "../src/errors.ts", "../src/http.ts"],
4
+ "sourcesContent": [
5
+ "// ⛔ GENERATED by scripts/sync-versions.ts — do not edit. Source: package.json.\nexport const VERSION: string = '0.1.0';\n",
6
+ "/**\n * Environment parsing — the one thing every HomeFlare app does before anything else.\n *\n * ★ WHY THIS EXISTS RATHER THAN zod. A Worker reads its config from a plain object\n * (`env`), not from `process.env`, and it does so on every request. Pulling a schema\n * library in for that costs bundle size on a hot path to validate perhaps six keys.\n * This is deliberately small and dependency-free; when a shape genuinely needs\n * unions, refinement or codecs, reach for zod in the consuming app instead of\n * growing this.\n *\n * ⚠️ THIS VALIDATES SHAPE, NOT SECRECY. A value arriving here has already been handed\n * over by the platform — a Worker binding, an OpenBao-minted lease. Nothing in this\n * file should ever log a value, and callers should not either: a thrown error names\n * the KEY that was wrong and never what it contained.\n */\n\n/** How one environment key is read, and what it becomes. */\nexport type EnvSpec =\n | { readonly type: 'string'; readonly optional?: boolean; readonly default?: string }\n | { readonly type: 'number'; readonly optional?: boolean; readonly default?: number }\n | { readonly type: 'boolean'; readonly optional?: boolean; readonly default?: boolean };\n\n/** A description of every key an app needs, keyed by the name it appears under. */\nexport type EnvSchema = Readonly<Record<string, EnvSpec>>;\n\n/** What a schema produces once parsed: each key narrowed to its declared type. */\ntype Parsed<S extends EnvSchema> = {\n readonly [K in keyof S]: S[K] extends { type: 'number' }\n ? number\n : S[K] extends { type: 'boolean' }\n ? boolean\n : string;\n};\n\n/** Raised when a key is missing or cannot be read as its declared type. */\nexport class EnvError extends Error {\n /** The offending key. Never the value — see the file header. */\n readonly key: string;\n\n constructor(key: string, reason: string) {\n super(`env ${key}: ${reason}`);\n this.name = 'EnvError';\n this.key = key;\n }\n}\n\n/**\n * ⛔ THE THREE VALUES THAT MEAN \"ABSENT\", AND WHY IT IS NOT JUST THE EMPTY STRING.\n * An unbound workerd binding does NOT arrive as undefined — `fromEnvironment` produces\n * the four-character STRING \"null\", because the binding is present and String(null) is\n * truthy. Code that checks only for '' therefore accepts it, and the request goes out\n * as `Authorization: Bearer null`.\n * 🔴 MEASURED IN THIS ESTATE, 2026-09-02. Twenty hand-rolled clients; this guard was in\n * four of them. The other sixteen surfaced a bare 401 from upstream — which reads as\n * \"the token is wrong\" and sends an operator to rotate a perfectly good credential.\n * The litellm server spent a day authenticating with nothing while /health answered 200.\n * Ported from house/mcp-servers/packages/kit/src/http.ts, where it was paid for once.\n */\nexport function isUnset(value: string): boolean {\n return value === '' || value === 'null' || value === 'undefined';\n}\n\n/**\n * ⚠️ 'false' IS TRUTHY AS A STRING, and that is the bug this function exists to stop.\n * `Boolean(env.FEATURE)` on the string 'false' is `true`, so a flag turned off in a\n * dashboard stays on in production and nothing in the logs says why. Only the listed\n * spellings are accepted; anything else throws rather than guessing.\n */\nconst TRUE = new Set(['1', 'true', 'yes', 'on']);\nconst FALSE = new Set(['0', 'false', 'no', 'off']);\n\n/**\n * Read `source` against `schema`, throwing `EnvError` on the first key that is missing\n * or malformed.\n *\n * ★ THROWS RATHER THAN RETURNING A RESULT, deliberately: this runs once at startup or\n * at the top of a fetch handler, where a misconfigured deployment should fail loudly\n * and immediately rather than produce a half-configured app that fails later somewhere\n * less obvious.\n */\nexport function parseEnv<const S extends EnvSchema>(\n schema: S,\n source: Readonly<Record<string, unknown>>,\n): Parsed<S> {\n const out: Record<string, string | number | boolean> = {};\n\n for (const key of Object.keys(schema)) {\n const spec = schema[key] as EnvSpec;\n const raw = source[key];\n\n // ⛔ isUnset, not `=== ''`. See its comment: \"null\" is what an unbound workerd\n // binding actually looks like, and treating it as a value is a day-long debug.\n if (raw === undefined || raw === null || isUnset(String(raw).trim())) {\n if (spec.default !== undefined) {\n out[key] = spec.default;\n continue;\n }\n if (spec.optional === true) continue;\n throw new EnvError(key, 'required but not set');\n }\n\n // ⚠️ Trimmed: a rendered env file with a trailing newline is the common case, and an\n // untrimmed token fails upstream with the same unhelpful 401.\n const text = String(raw).trim();\n\n if (spec.type === 'number') {\n const n = Number(text);\n // ⚠️ Number('') is 0 and Number(' ') is 0; the empty check above is what makes\n // this safe. NaN is the only remaining failure and it is caught here.\n if (!Number.isFinite(n)) throw new EnvError(key, 'expected a number');\n out[key] = n;\n } else if (spec.type === 'boolean') {\n const lowered = text.toLowerCase();\n if (TRUE.has(lowered)) out[key] = true;\n else if (FALSE.has(lowered)) out[key] = false;\n else throw new EnvError(key, 'expected one of 1/true/yes/on or 0/false/no/off');\n } else {\n out[key] = text;\n }\n }\n\n return out as Parsed<S>;\n}\n",
7
+ "/**\n * Errors a CALLER can act on — an agent, a retry loop, or a person reading a log.\n *\n * ★ PORTED FROM house/mcp-servers/packages/kit/src/errors.ts, where 34 packages proved\n * the shape. Generalised: the estate version is MCP-specific (`HouseToolError`), this\n * one is not, so a Worker or a script can use the same vocabulary.\n *\n * ⛔ THE RULE THIS ENFORCES: a failure must never be silent, and never ambiguous with a\n * DIFFERENT failure. Both halves were learned from incidents, not from principle:\n * - An Access provider read its token at module scope and, when that failed, silently\n * omitted the auth header. Cloudflare answered a bare 401 — indistinguishable from a\n * bad credential — and sent an operator to debug the wrong subsystem. Omitting is\n * worse than throwing.\n * - A gate reported success on a corrupted file because it compared the file to itself.\n * A check that cannot fail is worse than no check, because it is trusted.\n *\n * ⚠️ THE AUDIENCE MAY BE AN AGENT, not a human with a debugger. An agent cannot open a\n * dashboard or ask a colleague, so everything it needs to pick its next action must be\n * in the error. One that reads \"permission denied\" retries forever; one that reads\n * \"the token lacks Grafana Read; ask an operator to widen the grant\" stops and reports.\n */\n\n/** Which class of failure this is. Callers branch on it, so the set stays small. */\nexport type ErrorKind =\n /** Not who it claims, or claims nothing. Retrying unchanged fails. */\n | 'unauthenticated'\n /** Known, but lacks the grant. ⛔ NEVER retry — a human must widen a scope. */\n | 'forbidden'\n /** The upstream said no. The caller may be able to fix its arguments and retry. */\n | 'upstream_rejected'\n /**\n * Over a rate limit. The IDENTICAL call succeeds after the stated delay.\n * 🔴 A DISTINCT KIND BECAUSE IT WAS LANDING AS `bad_arguments`, measured 2026-09-07 in\n * the estate: a 429 mapped to \"your arguments are wrong\", so a throttled agent\n * rewrote a correct call, was throttled again, and concluded the tool was broken.\n * This is the refusal agents meet most — Cloudflare frontier models are 20 rpm.\n */\n | 'rate_limited'\n /** Could not be reached at all. Usually transient; a retry may work. */\n | 'upstream_unreachable'\n /** Called with arguments it cannot accept. Correct them and retry. */\n | 'bad_arguments'\n /** We are misconfigured. ⛔ A caller cannot fix this; it must report it. */\n | 'misconfigured';\n\n/**\n * What each kind means, for anything that has to explain them.\n *\n * ⛔ TYPED AS Record<ErrorKind, string>, WHICH IS THE POINT: adding a kind above without\n * describing it here is a COMPILE ERROR. A hand-kept list beside a union goes stale,\n * and a documented error contract with a missing case is worse than none — it is\n * trusted.\n */\nexport const KIND_DESCRIPTIONS: Record<ErrorKind, string> = {\n unauthenticated: 'The credential was missing, malformed, or rejected.',\n forbidden: 'The credential is valid but lacks the required grant. Do not retry.',\n upstream_rejected: 'The upstream refused the request as made.',\n rate_limited: 'Throttled. The identical call succeeds after the stated delay.',\n upstream_unreachable: 'The upstream could not be reached. A retry may succeed.',\n bad_arguments: 'The arguments were not acceptable. Correct them and retry.',\n misconfigured: 'This service is misconfigured and cannot serve the request.',\n};\n\nexport interface KitErrorOptions {\n /** Which system failed. ⛔ Name it — \"request failed\" tells a caller nothing. */\n readonly system: string;\n /** What would make it work. This is what stops an agent retrying forever. */\n readonly remedy?: string;\n /** For `rate_limited`: how long to wait, in milliseconds. */\n readonly retryAfterMs?: number;\n readonly cause?: unknown;\n}\n\n/** A failure with enough shape for a caller to decide what to do next. */\nexport class KitError extends Error {\n readonly kind: ErrorKind;\n readonly system: string;\n readonly remedy: string | undefined;\n readonly retryAfterMs: number | undefined;\n\n constructor(kind: ErrorKind, message: string, options: KitErrorOptions) {\n super(`${options.system}: ${message}`, { cause: options.cause });\n this.name = 'KitError';\n this.kind = kind;\n this.system = options.system;\n this.remedy = options.remedy;\n this.retryAfterMs = options.retryAfterMs;\n }\n\n /** True when retrying the identical call could plausibly succeed. */\n get retryable(): boolean {\n return this.kind === 'rate_limited' || this.kind === 'upstream_unreachable';\n }\n}\n\n/**\n * Map an HTTP status to a kind.\n *\n * ⚠️ 429 IS CHECKED EXPLICITLY, not left to fall through. It used to reach\n * `upstream_rejected` here and `bad_arguments` elsewhere — both of which tell a caller\n * to CHANGE ITS ARGUMENTS, which is precisely wrong for a throttle.\n */\nexport function kindForStatus(status: number): ErrorKind {\n if (status === 401) return 'unauthenticated';\n if (status === 403) return 'forbidden';\n if (status === 429) return 'rate_limited';\n if (status >= 500) return 'upstream_unreachable';\n return 'upstream_rejected';\n}\n",
8
+ "/**\n * HTTP client — a configured `ky`, not a wrapper around it.\n *\n * ★ WHY ky AND NOT A HAND-ROLLED fetch+retry. Retry, timeout and backoff are each easy\n * to write and easy to write subtly wrong (retrying a POST, retrying a 4xx, a timeout\n * that leaks the AbortController). ky is zero-dependency, TypeScript-native and\n * fetch-based — measured 2026-09-15: `npm view ky dependencies` is empty — so it adds\n * nothing to a consumer's tree.\n * ⚠️ ky is described as browser-first, which matters only in that it assumes a global\n * `fetch`. workerd, Node 18+ and Bun all provide one, so there is nothing to polyfill.\n * ⛔ NOT ofetch (pulls destr + node-fetch-native) and NOT wretch, which is BROKEN on\n * Workers out of the box: Cloudflare's Response has no `.type` and wretch throws\n * reading it, needing a middleware patch to work at all.\n *\n * ★ THIS MODULE IS DELIBERATELY THIN. It re-exports ky's own types rather than inventing\n * parallel ones, so a caller who needs something not covered here uses ky directly and\n * loses nothing.\n */\nimport ky, { HTTPError, TimeoutError, type KyInstance, type Options } from 'ky';\n\nexport { HTTPError, TimeoutError };\nexport type { KyInstance, Options };\n\n/**\n * The house defaults.\n *\n * ⚠️ ky retries only IDEMPOTENT methods by default (GET/PUT/HEAD/DELETE/OPTIONS/TRACE) —\n * POST is excluded, which is the behaviour you want and the one a hand-rolled retry\n * usually gets wrong. Retried status codes are 408/413/429/500/502/503/504.\n * ⚠️ `timeout` is PER ATTEMPT, not for the whole call. With limit 3 and a 10s timeout the\n * worst case is ~30s plus backoff — shorten both for a Worker on a request path, where\n * the CPU-time limit is unforgiving.\n */\nexport const http: KyInstance = ky.extend({\n retry: { limit: 3, backoffLimit: 3_000 },\n timeout: 10_000,\n});\n\n/**\n * A client bound to one base URL, for talking to a specific service.\n *\n * const linear = client('https://api.linear.app', { headers: { authorization } });\n * const issues = await linear.get('/graphql').json<Issues>();\n *\n * ⚠️ `baseUrl`, NOT `prefixUrl`. ky 2.x renamed the option and changed its semantics:\n * `baseUrl` resolves per the URL standard, so a leading slash is fine and an absolute\n * input bypasses the base. (ky 1.x had `prefixUrl`, which threw on a leading slash —\n * measured 2026-09-15, the old name is a type error, not a silent no-op.)\n */\nexport function client(baseUrl: string, options?: Options): KyInstance {\n return http.extend({ ...options, baseUrl });\n}\n"
9
+ ],
10
+ "mappings": ";AACO,IAAM,UAAkB;;ACkCxB,MAAM,iBAAiB,MAAM;AAAA,EAEzB;AAAA,EAET,WAAW,CAAC,KAAa,QAAgB;AAAA,IACvC,MAAM,OAAO,QAAQ,QAAQ;AAAA,IAC7B,KAAK,OAAO;AAAA,IACZ,KAAK,MAAM;AAAA;AAEf;AAcO,SAAS,OAAO,CAAC,OAAwB;AAAA,EAC9C,OAAO,UAAU,MAAM,UAAU,UAAU,UAAU;AAAA;AASvD,IAAM,OAAO,IAAI,IAAI,CAAC,KAAK,QAAQ,OAAO,IAAI,CAAC;AAC/C,IAAM,QAAQ,IAAI,IAAI,CAAC,KAAK,SAAS,MAAM,KAAK,CAAC;AAW1C,SAAS,QAAmC,CACjD,QACA,QACW;AAAA,EACX,MAAM,MAAiD,CAAC;AAAA,EAExD,WAAW,OAAO,OAAO,KAAK,MAAM,GAAG;AAAA,IACrC,MAAM,OAAO,OAAO;AAAA,IACpB,MAAM,MAAM,OAAO;AAAA,IAInB,IAAI,QAAQ,aAAa,QAAQ,QAAQ,QAAQ,OAAO,GAAG,EAAE,KAAK,CAAC,GAAG;AAAA,MACpE,IAAI,KAAK,YAAY,WAAW;AAAA,QAC9B,IAAI,OAAO,KAAK;AAAA,QAChB;AAAA,MACF;AAAA,MACA,IAAI,KAAK,aAAa;AAAA,QAAM;AAAA,MAC5B,MAAM,IAAI,SAAS,KAAK,sBAAsB;AAAA,IAChD;AAAA,IAIA,MAAM,OAAO,OAAO,GAAG,EAAE,KAAK;AAAA,IAE9B,IAAI,KAAK,SAAS,UAAU;AAAA,MAC1B,MAAM,IAAI,OAAO,IAAI;AAAA,MAGrB,IAAI,CAAC,OAAO,SAAS,CAAC;AAAA,QAAG,MAAM,IAAI,SAAS,KAAK,mBAAmB;AAAA,MACpE,IAAI,OAAO;AAAA,IACb,EAAO,SAAI,KAAK,SAAS,WAAW;AAAA,MAClC,MAAM,UAAU,KAAK,YAAY;AAAA,MACjC,IAAI,KAAK,IAAI,OAAO;AAAA,QAAG,IAAI,OAAO;AAAA,MAC7B,SAAI,MAAM,IAAI,OAAO;AAAA,QAAG,IAAI,OAAO;AAAA,MACnC;AAAA,cAAM,IAAI,SAAS,KAAK,iDAAiD;AAAA,IAChF,EAAO;AAAA,MACL,IAAI,OAAO;AAAA;AAAA,EAEf;AAAA,EAEA,OAAO;AAAA;;ACpEF,IAAM,oBAA+C;AAAA,EAC1D,iBAAiB;AAAA,EACjB,WAAW;AAAA,EACX,mBAAmB;AAAA,EACnB,cAAc;AAAA,EACd,sBAAsB;AAAA,EACtB,eAAe;AAAA,EACf,eAAe;AACjB;AAAA;AAaO,MAAM,iBAAiB,MAAM;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EAET,WAAW,CAAC,MAAiB,SAAiB,SAA0B;AAAA,IACtE,MAAM,GAAG,QAAQ,WAAW,WAAW,EAAE,OAAO,QAAQ,MAAM,CAAC;AAAA,IAC/D,KAAK,OAAO;AAAA,IACZ,KAAK,OAAO;AAAA,IACZ,KAAK,SAAS,QAAQ;AAAA,IACtB,KAAK,SAAS,QAAQ;AAAA,IACtB,KAAK,eAAe,QAAQ;AAAA;AAAA,MAI1B,SAAS,GAAY;AAAA,IACvB,OAAO,KAAK,SAAS,kBAAkB,KAAK,SAAS;AAAA;AAEzD;AASO,SAAS,aAAa,CAAC,QAA2B;AAAA,EACvD,IAAI,WAAW;AAAA,IAAK,OAAO;AAAA,EAC3B,IAAI,WAAW;AAAA,IAAK,OAAO;AAAA,EAC3B,IAAI,WAAW;AAAA,IAAK,OAAO;AAAA,EAC3B,IAAI,UAAU;AAAA,IAAK,OAAO;AAAA,EAC1B,OAAO;AAAA;;ACzFT;AAeO,IAAM,OAAmB,GAAG,OAAO;AAAA,EACxC,OAAO,EAAE,OAAO,GAAG,cAAc,KAAM;AAAA,EACvC,SAAS;AACX,CAAC;AAaM,SAAS,MAAM,CAAC,SAAiB,SAA+B;AAAA,EACrE,OAAO,KAAK,OAAO,KAAK,SAAS,QAAQ,CAAC;AAAA;",
11
+ "debugId": "A318350C97DF929364756E2164756E21",
12
+ "names": []
13
+ }
@@ -0,0 +1,2 @@
1
+ export declare const VERSION: string;
2
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,OAAO,EAAE,MAAgB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@homeflare/kit",
3
+ "version": "0.1.0",
4
+ "description": "Runtime-neutral HomeFlare primitives: env parsing, errors, logging, HTTP.",
5
+ "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/kit#readme",
6
+ "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
7
+ "license": "MIT",
8
+ "author": "Timothy Schneider",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/taslabs-net/homeflare-kit.git",
12
+ "directory": "packages/kit"
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src",
17
+ "README.md",
18
+ "LICENSE",
19
+ "SECURITY.md"
20
+ ],
21
+ "type": "module",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ },
28
+ "./package.json": "./package.json"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "bun run build:js && bun run build:types",
35
+ "build:js": "bun build ./src/index.ts --outdir dist --target node --format esm --packages external --sourcemap=linked",
36
+ "build:types": "tsc --project tsconfig.build.json",
37
+ "clean": "rm -rf dist",
38
+ "test": "bun test",
39
+ "smoke": "bun run scripts/smoke.ts",
40
+ "prepublishOnly": "bun run clean && bun run build",
41
+ "types": "tsc --noEmit"
42
+ },
43
+ "dependencies": {
44
+ "ky": "2.1.0"
45
+ }
46
+ }
package/src/env.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Environment parsing — the one thing every HomeFlare app does before anything else.
3
+ *
4
+ * ★ WHY THIS EXISTS RATHER THAN zod. A Worker reads its config from a plain object
5
+ * (`env`), not from `process.env`, and it does so on every request. Pulling a schema
6
+ * library in for that costs bundle size on a hot path to validate perhaps six keys.
7
+ * This is deliberately small and dependency-free; when a shape genuinely needs
8
+ * unions, refinement or codecs, reach for zod in the consuming app instead of
9
+ * growing this.
10
+ *
11
+ * ⚠️ THIS VALIDATES SHAPE, NOT SECRECY. A value arriving here has already been handed
12
+ * over by the platform — a Worker binding, an OpenBao-minted lease. Nothing in this
13
+ * file should ever log a value, and callers should not either: a thrown error names
14
+ * the KEY that was wrong and never what it contained.
15
+ */
16
+
17
+ /** How one environment key is read, and what it becomes. */
18
+ export type EnvSpec =
19
+ | { readonly type: 'string'; readonly optional?: boolean; readonly default?: string }
20
+ | { readonly type: 'number'; readonly optional?: boolean; readonly default?: number }
21
+ | { readonly type: 'boolean'; readonly optional?: boolean; readonly default?: boolean };
22
+
23
+ /** A description of every key an app needs, keyed by the name it appears under. */
24
+ export type EnvSchema = Readonly<Record<string, EnvSpec>>;
25
+
26
+ /** What a schema produces once parsed: each key narrowed to its declared type. */
27
+ type Parsed<S extends EnvSchema> = {
28
+ readonly [K in keyof S]: S[K] extends { type: 'number' }
29
+ ? number
30
+ : S[K] extends { type: 'boolean' }
31
+ ? boolean
32
+ : string;
33
+ };
34
+
35
+ /** Raised when a key is missing or cannot be read as its declared type. */
36
+ export class EnvError extends Error {
37
+ /** The offending key. Never the value — see the file header. */
38
+ readonly key: string;
39
+
40
+ constructor(key: string, reason: string) {
41
+ super(`env ${key}: ${reason}`);
42
+ this.name = 'EnvError';
43
+ this.key = key;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * ⛔ THE THREE VALUES THAT MEAN "ABSENT", AND WHY IT IS NOT JUST THE EMPTY STRING.
49
+ * An unbound workerd binding does NOT arrive as undefined — `fromEnvironment` produces
50
+ * the four-character STRING "null", because the binding is present and String(null) is
51
+ * truthy. Code that checks only for '' therefore accepts it, and the request goes out
52
+ * as `Authorization: Bearer null`.
53
+ * 🔴 MEASURED IN THIS ESTATE, 2026-09-02. Twenty hand-rolled clients; this guard was in
54
+ * four of them. The other sixteen surfaced a bare 401 from upstream — which reads as
55
+ * "the token is wrong" and sends an operator to rotate a perfectly good credential.
56
+ * The litellm server spent a day authenticating with nothing while /health answered 200.
57
+ * Ported from house/mcp-servers/packages/kit/src/http.ts, where it was paid for once.
58
+ */
59
+ export function isUnset(value: string): boolean {
60
+ return value === '' || value === 'null' || value === 'undefined';
61
+ }
62
+
63
+ /**
64
+ * ⚠️ 'false' IS TRUTHY AS A STRING, and that is the bug this function exists to stop.
65
+ * `Boolean(env.FEATURE)` on the string 'false' is `true`, so a flag turned off in a
66
+ * dashboard stays on in production and nothing in the logs says why. Only the listed
67
+ * spellings are accepted; anything else throws rather than guessing.
68
+ */
69
+ const TRUE = new Set(['1', 'true', 'yes', 'on']);
70
+ const FALSE = new Set(['0', 'false', 'no', 'off']);
71
+
72
+ /**
73
+ * Read `source` against `schema`, throwing `EnvError` on the first key that is missing
74
+ * or malformed.
75
+ *
76
+ * ★ THROWS RATHER THAN RETURNING A RESULT, deliberately: this runs once at startup or
77
+ * at the top of a fetch handler, where a misconfigured deployment should fail loudly
78
+ * and immediately rather than produce a half-configured app that fails later somewhere
79
+ * less obvious.
80
+ */
81
+ export function parseEnv<const S extends EnvSchema>(
82
+ schema: S,
83
+ source: Readonly<Record<string, unknown>>,
84
+ ): Parsed<S> {
85
+ const out: Record<string, string | number | boolean> = {};
86
+
87
+ for (const key of Object.keys(schema)) {
88
+ const spec = schema[key] as EnvSpec;
89
+ const raw = source[key];
90
+
91
+ // ⛔ isUnset, not `=== ''`. See its comment: "null" is what an unbound workerd
92
+ // binding actually looks like, and treating it as a value is a day-long debug.
93
+ if (raw === undefined || raw === null || isUnset(String(raw).trim())) {
94
+ if (spec.default !== undefined) {
95
+ out[key] = spec.default;
96
+ continue;
97
+ }
98
+ if (spec.optional === true) continue;
99
+ throw new EnvError(key, 'required but not set');
100
+ }
101
+
102
+ // ⚠️ Trimmed: a rendered env file with a trailing newline is the common case, and an
103
+ // untrimmed token fails upstream with the same unhelpful 401.
104
+ const text = String(raw).trim();
105
+
106
+ if (spec.type === 'number') {
107
+ const n = Number(text);
108
+ // ⚠️ Number('') is 0 and Number(' ') is 0; the empty check above is what makes
109
+ // this safe. NaN is the only remaining failure and it is caught here.
110
+ if (!Number.isFinite(n)) throw new EnvError(key, 'expected a number');
111
+ out[key] = n;
112
+ } else if (spec.type === 'boolean') {
113
+ const lowered = text.toLowerCase();
114
+ if (TRUE.has(lowered)) out[key] = true;
115
+ else if (FALSE.has(lowered)) out[key] = false;
116
+ else throw new EnvError(key, 'expected one of 1/true/yes/on or 0/false/no/off');
117
+ } else {
118
+ out[key] = text;
119
+ }
120
+ }
121
+
122
+ return out as Parsed<S>;
123
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Errors a CALLER can act on — an agent, a retry loop, or a person reading a log.
3
+ *
4
+ * ★ PORTED FROM house/mcp-servers/packages/kit/src/errors.ts, where 34 packages proved
5
+ * the shape. Generalised: the estate version is MCP-specific (`HouseToolError`), this
6
+ * one is not, so a Worker or a script can use the same vocabulary.
7
+ *
8
+ * ⛔ THE RULE THIS ENFORCES: a failure must never be silent, and never ambiguous with a
9
+ * DIFFERENT failure. Both halves were learned from incidents, not from principle:
10
+ * - An Access provider read its token at module scope and, when that failed, silently
11
+ * omitted the auth header. Cloudflare answered a bare 401 — indistinguishable from a
12
+ * bad credential — and sent an operator to debug the wrong subsystem. Omitting is
13
+ * worse than throwing.
14
+ * - A gate reported success on a corrupted file because it compared the file to itself.
15
+ * A check that cannot fail is worse than no check, because it is trusted.
16
+ *
17
+ * ⚠️ THE AUDIENCE MAY BE AN AGENT, not a human with a debugger. An agent cannot open a
18
+ * dashboard or ask a colleague, so everything it needs to pick its next action must be
19
+ * in the error. One that reads "permission denied" retries forever; one that reads
20
+ * "the token lacks Grafana Read; ask an operator to widen the grant" stops and reports.
21
+ */
22
+
23
+ /** Which class of failure this is. Callers branch on it, so the set stays small. */
24
+ export type ErrorKind =
25
+ /** Not who it claims, or claims nothing. Retrying unchanged fails. */
26
+ | 'unauthenticated'
27
+ /** Known, but lacks the grant. ⛔ NEVER retry — a human must widen a scope. */
28
+ | 'forbidden'
29
+ /** The upstream said no. The caller may be able to fix its arguments and retry. */
30
+ | 'upstream_rejected'
31
+ /**
32
+ * Over a rate limit. The IDENTICAL call succeeds after the stated delay.
33
+ * 🔴 A DISTINCT KIND BECAUSE IT WAS LANDING AS `bad_arguments`, measured 2026-09-07 in
34
+ * the estate: a 429 mapped to "your arguments are wrong", so a throttled agent
35
+ * rewrote a correct call, was throttled again, and concluded the tool was broken.
36
+ * This is the refusal agents meet most — Cloudflare frontier models are 20 rpm.
37
+ */
38
+ | 'rate_limited'
39
+ /** Could not be reached at all. Usually transient; a retry may work. */
40
+ | 'upstream_unreachable'
41
+ /** Called with arguments it cannot accept. Correct them and retry. */
42
+ | 'bad_arguments'
43
+ /** We are misconfigured. ⛔ A caller cannot fix this; it must report it. */
44
+ | 'misconfigured';
45
+
46
+ /**
47
+ * What each kind means, for anything that has to explain them.
48
+ *
49
+ * ⛔ TYPED AS Record<ErrorKind, string>, WHICH IS THE POINT: adding a kind above without
50
+ * describing it here is a COMPILE ERROR. A hand-kept list beside a union goes stale,
51
+ * and a documented error contract with a missing case is worse than none — it is
52
+ * trusted.
53
+ */
54
+ export const KIND_DESCRIPTIONS: Record<ErrorKind, string> = {
55
+ unauthenticated: 'The credential was missing, malformed, or rejected.',
56
+ forbidden: 'The credential is valid but lacks the required grant. Do not retry.',
57
+ upstream_rejected: 'The upstream refused the request as made.',
58
+ rate_limited: 'Throttled. The identical call succeeds after the stated delay.',
59
+ upstream_unreachable: 'The upstream could not be reached. A retry may succeed.',
60
+ bad_arguments: 'The arguments were not acceptable. Correct them and retry.',
61
+ misconfigured: 'This service is misconfigured and cannot serve the request.',
62
+ };
63
+
64
+ export interface KitErrorOptions {
65
+ /** Which system failed. ⛔ Name it — "request failed" tells a caller nothing. */
66
+ readonly system: string;
67
+ /** What would make it work. This is what stops an agent retrying forever. */
68
+ readonly remedy?: string;
69
+ /** For `rate_limited`: how long to wait, in milliseconds. */
70
+ readonly retryAfterMs?: number;
71
+ readonly cause?: unknown;
72
+ }
73
+
74
+ /** A failure with enough shape for a caller to decide what to do next. */
75
+ export class KitError extends Error {
76
+ readonly kind: ErrorKind;
77
+ readonly system: string;
78
+ readonly remedy: string | undefined;
79
+ readonly retryAfterMs: number | undefined;
80
+
81
+ constructor(kind: ErrorKind, message: string, options: KitErrorOptions) {
82
+ super(`${options.system}: ${message}`, { cause: options.cause });
83
+ this.name = 'KitError';
84
+ this.kind = kind;
85
+ this.system = options.system;
86
+ this.remedy = options.remedy;
87
+ this.retryAfterMs = options.retryAfterMs;
88
+ }
89
+
90
+ /** True when retrying the identical call could plausibly succeed. */
91
+ get retryable(): boolean {
92
+ return this.kind === 'rate_limited' || this.kind === 'upstream_unreachable';
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Map an HTTP status to a kind.
98
+ *
99
+ * ⚠️ 429 IS CHECKED EXPLICITLY, not left to fall through. It used to reach
100
+ * `upstream_rejected` here and `bad_arguments` elsewhere — both of which tell a caller
101
+ * to CHANGE ITS ARGUMENTS, which is precisely wrong for a throttle.
102
+ */
103
+ export function kindForStatus(status: number): ErrorKind {
104
+ if (status === 401) return 'unauthenticated';
105
+ if (status === 403) return 'forbidden';
106
+ if (status === 429) return 'rate_limited';
107
+ if (status >= 500) return 'upstream_unreachable';
108
+ return 'upstream_rejected';
109
+ }
package/src/http.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * HTTP client — a configured `ky`, not a wrapper around it.
3
+ *
4
+ * ★ WHY ky AND NOT A HAND-ROLLED fetch+retry. Retry, timeout and backoff are each easy
5
+ * to write and easy to write subtly wrong (retrying a POST, retrying a 4xx, a timeout
6
+ * that leaks the AbortController). ky is zero-dependency, TypeScript-native and
7
+ * fetch-based — measured 2026-09-15: `npm view ky dependencies` is empty — so it adds
8
+ * nothing to a consumer's tree.
9
+ * ⚠️ ky is described as browser-first, which matters only in that it assumes a global
10
+ * `fetch`. workerd, Node 18+ and Bun all provide one, so there is nothing to polyfill.
11
+ * ⛔ NOT ofetch (pulls destr + node-fetch-native) and NOT wretch, which is BROKEN on
12
+ * Workers out of the box: Cloudflare's Response has no `.type` and wretch throws
13
+ * reading it, needing a middleware patch to work at all.
14
+ *
15
+ * ★ THIS MODULE IS DELIBERATELY THIN. It re-exports ky's own types rather than inventing
16
+ * parallel ones, so a caller who needs something not covered here uses ky directly and
17
+ * loses nothing.
18
+ */
19
+ import ky, { HTTPError, TimeoutError, type KyInstance, type Options } from 'ky';
20
+
21
+ export { HTTPError, TimeoutError };
22
+ export type { KyInstance, Options };
23
+
24
+ /**
25
+ * The house defaults.
26
+ *
27
+ * ⚠️ ky retries only IDEMPOTENT methods by default (GET/PUT/HEAD/DELETE/OPTIONS/TRACE) —
28
+ * POST is excluded, which is the behaviour you want and the one a hand-rolled retry
29
+ * usually gets wrong. Retried status codes are 408/413/429/500/502/503/504.
30
+ * ⚠️ `timeout` is PER ATTEMPT, not for the whole call. With limit 3 and a 10s timeout the
31
+ * worst case is ~30s plus backoff — shorten both for a Worker on a request path, where
32
+ * the CPU-time limit is unforgiving.
33
+ */
34
+ export const http: KyInstance = ky.extend({
35
+ retry: { limit: 3, backoffLimit: 3_000 },
36
+ timeout: 10_000,
37
+ });
38
+
39
+ /**
40
+ * A client bound to one base URL, for talking to a specific service.
41
+ *
42
+ * const linear = client('https://api.linear.app', { headers: { authorization } });
43
+ * const issues = await linear.get('/graphql').json<Issues>();
44
+ *
45
+ * ⚠️ `baseUrl`, NOT `prefixUrl`. ky 2.x renamed the option and changed its semantics:
46
+ * `baseUrl` resolves per the URL standard, so a leading slash is fine and an absolute
47
+ * input bypasses the base. (ky 1.x had `prefixUrl`, which threw on a leading slash —
48
+ * measured 2026-09-15, the old name is a type error, not a silent no-op.)
49
+ */
50
+ export function client(baseUrl: string, options?: Options): KyInstance {
51
+ return http.extend({ ...options, baseUrl });
52
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @homeflare/kit — the estate's shared primitives.
3
+ *
4
+ * ⛔ THIS ENTRYPOINT STAYS RUNTIME-NEUTRAL. Bun is how the kit is authored, built and
5
+ * tested; it is not what consumers run. The monorepo ships this code to Cloudflare
6
+ * Workers, so anything reaching for `bun:*`, `node:fs` or a filesystem belongs behind
7
+ * its own subpath export (`@homeflare/kit/<area>`), never here.
8
+ * The failure this prevents is a deploy-time one: a `bun:sqlite` import that resolves
9
+ * fine on a laptop and fails only in workerd, where the stack trace names the bundler
10
+ * rather than this file.
11
+ *
12
+ * ★ isolatedDeclarations is on, so every exported symbol below carries an explicit
13
+ * return type. That is not style — `bun build` cannot emit .d.ts, so declarations come
14
+ * from a separate tsc pass, and explicit types are what keep the two in agreement.
15
+ */
16
+
17
+ export { VERSION } from './version.ts';
18
+ export { EnvError, isUnset, parseEnv, type EnvSchema, type EnvSpec } from './env.ts';
19
+ export {
20
+ KIND_DESCRIPTIONS,
21
+ KitError,
22
+ kindForStatus,
23
+ type ErrorKind,
24
+ type KitErrorOptions,
25
+ } from './errors.ts';
26
+ export { client, http, HTTPError, TimeoutError, type KyInstance, type Options } from './http.ts';
package/src/version.ts ADDED
@@ -0,0 +1,2 @@
1
+ // ⛔ GENERATED by scripts/sync-versions.ts — do not edit. Source: package.json.
2
+ export const VERSION: string = '0.1.0';