@getfamiliar/shared 0.1.13 → 0.1.16

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,166 @@
1
+ /**
2
+ * Trusted-proxy contract of the core webserver.
3
+ *
4
+ * A reverse proxy in front of the webserver (a user-run Authelia /
5
+ * oauth2-proxy, or a plugin's own proxy such as the tailscale sidecar)
6
+ * proves that a request went through it by sending
7
+ * {@link PROXY_SECRET_HEADER} with a shared secret. Only then does the
8
+ * webserver read the proxy's user header; without a matching secret,
9
+ * identity headers are ignored. Shared because plugins register such
10
+ * proxies through `ctx.web.registerTrustedProxy`.
11
+ *
12
+ * Also the contract of a web UI plugin's root controller
13
+ * ({@link WebUiEnv}, see `PluginHostManifest.webUi`).
14
+ */
15
+ import type { IncomingMessage, ServerResponse } from "node:http";
16
+ import type { Hono } from "hono";
17
+ import type { WebMenuEntry } from "./WebMenu.js";
18
+ import type { WebPageEntry } from "./WebPlugin.js";
19
+ /** Request header carrying a trusted proxy's shared secret (lowercase, as Hono / node normalize it). */
20
+ export declare const PROXY_SECRET_HEADER = "x-familiar-proxy-secret";
21
+ /** Minimum length of a trusted-proxy secret; shorter ones are rejected. */
22
+ export declare const MIN_PROXY_SECRET_LENGTH = 32;
23
+ /** A reverse proxy whose user header the webserver trusts once its secret matches. */
24
+ export interface TrustedProxyRegistration {
25
+ /** Shared secret the proxy sends in {@link PROXY_SECRET_HEADER}; at least {@link MIN_PROXY_SECRET_LENGTH} characters. */
26
+ readonly secret: string;
27
+ /** Header holding the authenticated login (e.g. `Tailscale-User-Login`, `Remote-User`). Empty or absent means "no identity". */
28
+ readonly userHeader: string;
29
+ /** Optional header holding a display name (e.g. `Tailscale-User-Name`). */
30
+ readonly nameHeader?: string;
31
+ /**
32
+ * Optional: where browsers reach the webserver through this proxy right
33
+ * now (e.g. `https://familiar.<tailnet>.ts.net`), or null while it is
34
+ * not serving. Used to build login links that work from the user's
35
+ * other devices. Called on demand; must be cheap and must not throw.
36
+ */
37
+ readonly publicBaseUrl?: () => string | null;
38
+ /**
39
+ * Whether the proxy signs users in by itself: an allowed user who opens
40
+ * {@link publicBaseUrl} is already authenticated (Tailscale identity,
41
+ * an Authelia login page, …), so a login link is pointless there and is
42
+ * not offered under that URL; the URL is listed as "signs in
43
+ * automatically" instead. Default `false`: login links are built for
44
+ * the proxy's URL.
45
+ */
46
+ readonly hasBuiltInAuth?: boolean;
47
+ }
48
+ /** Webserver hooks exposed to plugins as `ctx.web`. */
49
+ export interface WebApi {
50
+ /**
51
+ * Register this plugin as a trusted proxy of the webserver; its id is
52
+ * the plugin id. Call it from **`prepare()`**. The host drops the
53
+ * registration when the plugin restarts.
54
+ *
55
+ * @throws {Error} When the secret is shorter than
56
+ * {@link MIN_PROXY_SECRET_LENGTH}, the user header is empty, or the
57
+ * plugin already registered a proxy.
58
+ */
59
+ registerTrustedProxy(registration: TrustedProxyRegistration): void;
60
+ /**
61
+ * The web UI menu: every running plugin's `webMenu(ctx)` items, sorted
62
+ * by plugin weight (then plugin id), each plugin's items in its own
63
+ * order. Read it per request; it changes when plugins restart. For
64
+ * the web UI plugin; others contribute through their `webMenu` hook.
65
+ *
66
+ * @returns The flat, sorted item list (empty when nobody contributes).
67
+ */
68
+ listMenuItems(): readonly WebMenuEntry[];
69
+ /**
70
+ * The plugin pages (`webPlugin(ctx)` hooks with a `page`), one per
71
+ * plugin, sorted by plugin id. Read it per request. For the web UI
72
+ * plugin, which loads the page owning the current route.
73
+ *
74
+ * @returns The pages.
75
+ */
76
+ listPages(): readonly WebPageEntry[];
77
+ }
78
+ /** Who a request belongs to, as resolved by the core auth middleware. */
79
+ export interface WebPrincipal {
80
+ /** Login as listed in `webserver.auth.users`. */
81
+ readonly login: string;
82
+ /** Display name from the proxy's name header, else the login. */
83
+ readonly displayName: string;
84
+ /** `session`, or the id of the trusted proxy that vouched for the login (`plugin:tailscale`, `config:authelia`). */
85
+ readonly via: string;
86
+ }
87
+ /**
88
+ * Node bindings the webserver passes on every request (`c.env`), the same
89
+ * shape as `HttpBindings` of `@hono/node-server`. A UI controller may
90
+ * hand them to node-style middleware (e.g. Vite's dev server) and answer
91
+ * with `RESPONSE_ALREADY_SENT`.
92
+ */
93
+ export interface WebUiBindings {
94
+ readonly incoming: IncomingMessage;
95
+ readonly outgoing: ServerResponse;
96
+ }
97
+ /**
98
+ * Hono environment of a web UI plugin's root controller. Every request
99
+ * reaching the controller passed the core auth middleware, so
100
+ * `c.get("principal")` is always set.
101
+ */
102
+ export interface WebUiEnv {
103
+ Bindings: WebUiBindings;
104
+ Variables: {
105
+ /** The signed-in, allowlisted user. */
106
+ principal: WebPrincipal;
107
+ /** Whether the browser talks HTTPS (directly, or to a trusted proxy that says so). */
108
+ isHttps: boolean;
109
+ };
110
+ }
111
+ /**
112
+ * One of the core login-flow pages, described as data. The core decides
113
+ * which page is shown, with which status code, and handles every form
114
+ * submission itself; a web UI only turns the view into markup
115
+ * ({@link WebUi.renderAuthPage}).
116
+ */
117
+ export type WebAuthPageView =
118
+ /** `GET /login` with an allowed identity: who is signed in, link to the app. */
119
+ {
120
+ readonly kind: "signedIn";
121
+ readonly principal: WebPrincipal;
122
+ /** Where "continue" leads (`/`). */
123
+ readonly continueUrl: string;
124
+ /** Form action of the sign-out button (`POST`, `/logout`). */
125
+ readonly logoutAction: string;
126
+ }
127
+ /** `GET /login` without identity: how to get a login link. */
128
+ | {
129
+ readonly kind: "signedOut";
130
+ /** CLI command that prints a login link (`familiar web login`). */
131
+ readonly loginCommand: string;
132
+ }
133
+ /** `GET /login/:token`: confirm before the one-time token is redeemed. */
134
+ | {
135
+ readonly kind: "confirmLoginLink";
136
+ /** Form action redeeming the token (`POST`, `/login/<token>`). */
137
+ readonly formAction: string;
138
+ }
139
+ /** `POST /login/:token` with an invalid, used or expired token. */
140
+ | {
141
+ readonly kind: "invalidLoginLink";
142
+ /** CLI command that prints a new login link. */
143
+ readonly loginCommand: string;
144
+ };
145
+ /**
146
+ * What a web UI plugin hands the core webserver from its `webUi(ctx)` hook.
147
+ */
148
+ export interface WebUi {
149
+ /**
150
+ * Root controller mounted at `/`, behind the core auth middleware and
151
+ * after the core login routes.
152
+ */
153
+ readonly controller: Hono<WebUiEnv>;
154
+ /**
155
+ * Optional: render a login-flow page as a complete HTML document.
156
+ * The page is served under a strict Content-Security-Policy (no
157
+ * scripts, no external resources; inline styles and `data:` fonts /
158
+ * images only; forms may post to the same origin only), so it must
159
+ * be self-contained. Text content must be escaped by the renderer.
160
+ * When it is absent or throws, the core serves its plain page.
161
+ *
162
+ * @param view The page the core decided to show.
163
+ * @returns The HTML document.
164
+ */
165
+ readonly renderAuthPage?: (view: WebAuthPageView) => Promise<string>;
166
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Trusted-proxy contract of the core webserver.
3
+ *
4
+ * A reverse proxy in front of the webserver (a user-run Authelia /
5
+ * oauth2-proxy, or a plugin's own proxy such as the tailscale sidecar)
6
+ * proves that a request went through it by sending
7
+ * {@link PROXY_SECRET_HEADER} with a shared secret. Only then does the
8
+ * webserver read the proxy's user header; without a matching secret,
9
+ * identity headers are ignored. Shared because plugins register such
10
+ * proxies through `ctx.web.registerTrustedProxy`.
11
+ *
12
+ * Also the contract of a web UI plugin's root controller
13
+ * ({@link WebUiEnv}, see `PluginHostManifest.webUi`).
14
+ */
15
+ /** Request header carrying a trusted proxy's shared secret (lowercase, as Hono / node normalize it). */
16
+ export const PROXY_SECRET_HEADER = "x-familiar-proxy-secret";
17
+ /** Minimum length of a trusted-proxy secret; shorter ones are rejected. */
18
+ export const MIN_PROXY_SECRET_LENGTH = 32;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Navigation entries plugins contribute to the web UI's sidebar.
3
+ *
4
+ * A plugin returns one {@link WebMenuContribution} from its `webMenu(ctx)`
5
+ * hook; the core collects them, sorts them by weight and hands the flat
6
+ * list to the web UI plugin (`ctx.web.listMenuItems()`), which renders it.
7
+ */
8
+ /** Lucide icon id, kebab-case (`mail`, `calendar-days`), see https://lucide.dev/icons. */
9
+ export declare const WEB_MENU_ICON_PATTERN: RegExp;
10
+ /** A sub-entry of a collapsible menu item. */
11
+ export interface WebMenuSubItem {
12
+ /** Text shown in the menu. */
13
+ readonly label: string;
14
+ /** Path inside the web UI, starting with `/` (e.g. `/plugins/mail/inbox`). */
15
+ readonly route: string;
16
+ }
17
+ /**
18
+ * One top-level menu item: a direct link (`route` without `children`) or a
19
+ * collapsible group (`children`, optionally with its own `route`).
20
+ */
21
+ export interface WebMenuItem {
22
+ /** Lucide icon id (see {@link WEB_MENU_ICON_PATTERN}). */
23
+ readonly icon: string;
24
+ /** Text shown in the menu. */
25
+ readonly label: string;
26
+ /** Path inside the web UI, starting with `/`. Required when there are no children. */
27
+ readonly route?: string;
28
+ /** Sub-entries; makes the item collapsible. */
29
+ readonly children?: readonly WebMenuSubItem[];
30
+ }
31
+ /** What one plugin contributes to the menu. */
32
+ export interface WebMenuContribution {
33
+ /**
34
+ * Position of the plugin's items among all plugins: lower weights come
35
+ * first (default 0; ties are ordered by plugin id). The plugin's items
36
+ * keep their own order.
37
+ */
38
+ readonly weight?: number;
39
+ /** The plugin's items, in display order. */
40
+ readonly items: readonly WebMenuItem[];
41
+ }
42
+ /** A menu item as the core hands it to the web UI. */
43
+ export interface WebMenuEntry extends WebMenuItem {
44
+ /** Plugin that contributed the item. */
45
+ readonly pluginId: string;
46
+ }
47
+ /**
48
+ * The first route of a menu: the first item's own route, else its first
49
+ * child's route. The web UI opens it instead of an empty start page.
50
+ *
51
+ * @param items Sorted menu items.
52
+ * @returns The route, or null for an empty menu.
53
+ */
54
+ export declare function firstMenuRoute(items: readonly WebMenuItem[]): string | null;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Navigation entries plugins contribute to the web UI's sidebar.
3
+ *
4
+ * A plugin returns one {@link WebMenuContribution} from its `webMenu(ctx)`
5
+ * hook; the core collects them, sorts them by weight and hands the flat
6
+ * list to the web UI plugin (`ctx.web.listMenuItems()`), which renders it.
7
+ */
8
+ /** Lucide icon id, kebab-case (`mail`, `calendar-days`), see https://lucide.dev/icons. */
9
+ export const WEB_MENU_ICON_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
10
+ /**
11
+ * The first route of a menu: the first item's own route, else its first
12
+ * child's route. The web UI opens it instead of an empty start page.
13
+ *
14
+ * @param items Sorted menu items.
15
+ * @returns The route, or null for an empty menu.
16
+ */
17
+ export function firstMenuRoute(items) {
18
+ const first = items[0];
19
+ if (first === undefined) {
20
+ return null;
21
+ }
22
+ return first.route ?? first.children?.[0]?.route ?? null;
23
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Plugin pages and controllers of the web UI.
3
+ *
4
+ * A plugin's `webPlugin(ctx)` hook returns a {@link WebPluginUi}: a Hono
5
+ * controller the core webserver mounts at `/plugins/<plugin-id>/`
6
+ * (behind its authentication) and, optionally, a page module the web UI
7
+ * plugin loads into its content area for every browser route under
8
+ * `/plugins/<plugin-id>`.
9
+ */
10
+ import type { Hono } from "hono";
11
+ import type { WebPrincipal, WebUiEnv } from "./WebAuth.js";
12
+ /** Where a plugin page's ES module comes from. */
13
+ export interface WebPageModule {
14
+ /**
15
+ * Path of the built module relative to the plugin's controller mount,
16
+ * e.g. `page/page.js` → served as `/plugins/<id>/page/page.js` by the
17
+ * plugin's own controller.
18
+ */
19
+ readonly module: string;
20
+ /**
21
+ * Dev mode only: absolute path of the page's source entry (`.tsx`).
22
+ * A web UI with a dev server may load it from source instead (hot
23
+ * reload, one React); omit it in production.
24
+ */
25
+ readonly sourceFile?: string;
26
+ }
27
+ /** What a plugin hands the core from its `webPlugin(ctx)` hook. */
28
+ export interface WebPluginUi {
29
+ /**
30
+ * Controller mounted at `/plugins/<plugin-id>/`, behind the core auth
31
+ * middleware (`c.get("principal")` is set). Register concrete routes
32
+ * only (e.g. `/api/*`, `/page/*`) and **no catch-all**: every path the
33
+ * controller does not match falls through to the web UI, which is how
34
+ * the browser routes of the plugin's page (`/plugins/<id>`, …) reach it.
35
+ */
36
+ readonly controller: Hono<WebUiEnv>;
37
+ /** Optional page shown by the web UI for routes under `/plugins/<plugin-id>`. */
38
+ readonly page?: WebPageModule;
39
+ /**
40
+ * Optional WebSocket endpoints, served at `/plugins/<plugin-id><path>`.
41
+ * The core authenticates every handshake like an ordinary request
42
+ * (proxy login or session, allowlist) and additionally requires a
43
+ * same-origin `Origin` header; only then is `onConnection` called.
44
+ * All sockets of a plugin are closed when it restarts.
45
+ */
46
+ readonly sockets?: readonly WebPluginSocket[];
47
+ }
48
+ /** A WebSocket endpoint of a plugin (see {@link WebPluginUi.sockets}). */
49
+ export interface WebPluginSocket {
50
+ /**
51
+ * Path below the plugin's mount, starting with `/` (e.g. `/socket`
52
+ * → `/plugins/<plugin-id>/socket`). Matched exactly, query ignored.
53
+ */
54
+ readonly path: string;
55
+ /**
56
+ * Called once per authenticated connection. Must not throw; errors
57
+ * of the connection's own work belong on the peer (`close`).
58
+ *
59
+ * @param peer The open socket.
60
+ * @param info Who connected and how.
61
+ */
62
+ onConnection(peer: WebSocketPeer, info: WebSocketConnectionInfo): void;
63
+ }
64
+ /** Who opened a plugin WebSocket. */
65
+ export interface WebSocketConnectionInfo {
66
+ /** The signed-in, allowlisted user. */
67
+ readonly principal: WebPrincipal;
68
+ /** Whether the browser reached the server over HTTPS (directly or via a trusted proxy). */
69
+ readonly isHttps: boolean;
70
+ }
71
+ /**
72
+ * Minimal, library-independent view of an open WebSocket, so plugins do
73
+ * not depend on the server's WebSocket implementation.
74
+ */
75
+ export interface WebSocketPeer {
76
+ /**
77
+ * Send a text (string) or binary (bytes) frame. Silently dropped once
78
+ * the socket is closing.
79
+ *
80
+ * @param data Frame payload.
81
+ */
82
+ send(data: string | Uint8Array): void;
83
+ /**
84
+ * Close the socket.
85
+ *
86
+ * @param code WebSocket close code (default 1000; 4000–4999 are free for the application).
87
+ * @param reason Short human-readable reason (≤ 123 bytes).
88
+ */
89
+ close(code?: number, reason?: string): void;
90
+ /**
91
+ * Register the frame handler (one per peer; a second call replaces it).
92
+ *
93
+ * @param handler Receives text frames as string, binary frames as bytes.
94
+ */
95
+ onMessage(handler: (data: string | Uint8Array) => void): void;
96
+ /**
97
+ * Register the close handler (one per peer; a second call replaces
98
+ * it). Called exactly once, also when the server closed the socket.
99
+ *
100
+ * @param handler Receives the close code and reason.
101
+ */
102
+ onClose(handler: (code: number, reason: string) => void): void;
103
+ /** Bytes queued but not yet sent (for backpressure). */
104
+ readonly bufferedAmount: number;
105
+ /** Whether the socket is open (not connecting, closing or closed). */
106
+ readonly isOpen: boolean;
107
+ }
108
+ /** A plugin page as the core lists it for the web UI (`ctx.web.listPages()`). */
109
+ export interface WebPageEntry {
110
+ /** Plugin providing the page. */
111
+ readonly pluginId: string;
112
+ /** Browser route prefix the page owns: `/plugins/<plugin-id>`. */
113
+ readonly basePath: string;
114
+ /** URL of the built module (`/plugins/<plugin-id>/<module>`). */
115
+ readonly moduleUrl: string;
116
+ /** Dev mode: absolute source entry (see {@link WebPageModule.sourceFile}). */
117
+ readonly sourceFile?: string;
118
+ }
119
+ /** What the web UI passes to a page module's `mount`. */
120
+ export interface WebPageContext {
121
+ /** Route prefix of the page (`/plugins/<plugin-id>`). */
122
+ readonly basePath: string;
123
+ /** Current browser path (at or below `basePath`). */
124
+ readonly pathname: string;
125
+ /** The signed-in user. */
126
+ readonly user: WebPrincipal;
127
+ /** Navigate the web UI to another route. */
128
+ navigate(to: string): void;
129
+ }
130
+ /**
131
+ * Default export of a page module: render the page into `container`
132
+ * (the web UI's content area) and return a function that unmounts it.
133
+ * Framework-agnostic; a React page creates its own root.
134
+ */
135
+ export type MountWebPage = (container: HTMLElement, context: WebPageContext) => () => void;
136
+ /** Route prefix of a plugin's controller and page. */
137
+ export declare function webPluginBasePath(pluginId: string): string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Plugin pages and controllers of the web UI.
3
+ *
4
+ * A plugin's `webPlugin(ctx)` hook returns a {@link WebPluginUi}: a Hono
5
+ * controller the core webserver mounts at `/plugins/<plugin-id>/`
6
+ * (behind its authentication) and, optionally, a page module the web UI
7
+ * plugin loads into its content area for every browser route under
8
+ * `/plugins/<plugin-id>`.
9
+ */
10
+ /** Route prefix of a plugin's controller and page. */
11
+ export function webPluginBasePath(pluginId) {
12
+ return `/plugins/${pluginId}`;
13
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Defaults of the core `webserver.*` config keys. Shared because both
3
+ * the host's `WebServer` and plugins that front it (tailscale) must
4
+ * resolve the same effective port and bind address.
5
+ */
6
+ /** Port the webserver listens on when `webserver.port` is unset. */
7
+ export declare const DEFAULT_WEBSERVER_PORT = 8080;
8
+ /** Address the webserver binds on when `webserver.host` is unset. */
9
+ export declare const DEFAULT_WEBSERVER_HOST = "0.0.0.0";
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Defaults of the core `webserver.*` config keys. Shared because both
3
+ * the host's `WebServer` and plugins that front it (tailscale) must
4
+ * resolve the same effective port and bind address.
5
+ */
6
+ /** Port the webserver listens on when `webserver.port` is unset. */
7
+ export const DEFAULT_WEBSERVER_PORT = 8080;
8
+ /** Address the webserver binds on when `webserver.host` is unset. */
9
+ export const DEFAULT_WEBSERVER_HOST = "0.0.0.0";
@@ -0,0 +1,72 @@
1
+ import type { Context } from "hono";
2
+ /**
3
+ * Content type for a file name, by extension.
4
+ *
5
+ * @param fileName File name or path.
6
+ * @returns The media type, `application/octet-stream` when unknown.
7
+ */
8
+ export declare function contentTypeForFile(fileName: string): string;
9
+ /** Options of {@link WebStaticAssets}. */
10
+ export interface WebStaticAssetsOptions {
11
+ /**
12
+ * URL prefix the directory is served under (e.g. `/plugins/web-chat/page`);
13
+ * stripped from the request path before mapping it onto a file.
14
+ * Default: the site root.
15
+ */
16
+ readonly urlPrefix?: string;
17
+ /**
18
+ * Answer unknown GET paths without a file extension with `index.html`,
19
+ * so client-side routes of a single-page app survive a reload.
20
+ */
21
+ readonly spaFallback: boolean;
22
+ /** Hint added to the 500 answer when the directory was never built (e.g. "run `npm run build` in plugins/web-chat"). */
23
+ readonly missingBuildHint?: string;
24
+ }
25
+ /**
26
+ * Serve a directory of built web assets (a Vite build output) from a
27
+ * Hono route, for web UI plugins and plugin pages.
28
+ *
29
+ * A request path maps onto a file below the directory; paths escaping it
30
+ * are refused. Files under `assets/` (content-hashed) are cached forever,
31
+ * everything else is revalidated (`no-cache`).
32
+ */
33
+ export declare class WebStaticAssets {
34
+ private readonly rootDir;
35
+ private readonly options;
36
+ /**
37
+ * @param rootDir Absolute path of the build output.
38
+ * @param options URL prefix, SPA fallback, build hint.
39
+ */
40
+ constructor(rootDir: string, options: WebStaticAssetsOptions);
41
+ /**
42
+ * Answer one request.
43
+ *
44
+ * @param c Request context.
45
+ * @returns The file, the SPA fallback, 404, or a 500 page when the
46
+ * directory was never built.
47
+ */
48
+ handle(c: Context): Promise<Response>;
49
+ /**
50
+ * The 500 answer for a directory that was never built.
51
+ *
52
+ * @param c Request context.
53
+ * @returns The response.
54
+ */
55
+ private missingBuild;
56
+ /**
57
+ * Map a URL path onto a file path below {@link rootDir}.
58
+ *
59
+ * @param urlPath Request path.
60
+ * @returns The absolute file path, or null when it lies outside the
61
+ * prefix, escapes the directory or is not decodable.
62
+ */
63
+ private resolveRequestPath;
64
+ /**
65
+ * Send one file with content type and cache headers.
66
+ *
67
+ * @param c Request context.
68
+ * @param file Absolute file path below the root.
69
+ * @returns The response.
70
+ */
71
+ private sendFile;
72
+ }