ollanet 0.6.23 → 0.7.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.
@@ -0,0 +1,52 @@
1
+ export interface ServeToken {
2
+ name: string;
3
+ /** Hex SHA-256 of the token. */
4
+ sha256: string;
5
+ created_at: string;
6
+ expires_at: string | null;
7
+ /** Last successful auth by `ollanet serve` (ISO), written at most once a minute. */
8
+ last_used?: string | null;
9
+ }
10
+ export interface ServeStore {
11
+ tokens: ServeToken[];
12
+ /** Opaque catalog label per machine (key: hostname, MagicDNS name, or IP). */
13
+ labels: Record<string, string>;
14
+ }
15
+ export declare function serveStorePath(): string;
16
+ /** Token names and labels: 1–40 of letters, digits, `.`, `_`, `-`. */
17
+ export declare function isValidServeName(name: string): boolean;
18
+ export declare function loadServeStore(): Promise<ServeStore>;
19
+ export declare function saveServeStore(store: ServeStore): Promise<string>;
20
+ export declare function hashToken(token: string): string;
21
+ export declare function newToken(): string;
22
+ /**
23
+ * `30d`, `12h`, `10m`, `45s`, `500ms`; a bare number is seconds.
24
+ * Returns milliseconds, or undefined when the text is not a duration.
25
+ */
26
+ export declare function parseDuration(raw: string): number | undefined;
27
+ export declare function addToken(name: string, expiresInMs?: number): Promise<{
28
+ token: string;
29
+ record: ServeToken;
30
+ file: string;
31
+ }>;
32
+ export declare function removeToken(name: string): Promise<boolean>;
33
+ /** Remove several tokens in one write. Returns the names actually removed. */
34
+ export declare function removeTokens(names: string[]): Promise<string[]>;
35
+ /**
36
+ * Merge last-used times into serve.json. Re-reads the file first and touches
37
+ * only `last_used` on tokens matched by hash, so token add/rm from the CLI in
38
+ * the meantime is kept (a removed token simply is not written back). Never
39
+ * moves a time backwards. Atomic via temp file + rename.
40
+ */
41
+ export declare function recordLastUsed(usedBySha: Map<string, string>): Promise<void>;
42
+ /** Last use, else creation: the clock for `token prune --unused-days`. */
43
+ export declare function lastActivity(token: ServeToken): number;
44
+ export declare function setLabel(machine: string, label: string): Promise<void>;
45
+ export declare function removeLabel(machine: string): Promise<boolean>;
46
+ export declare function isExpired(token: ServeToken, now?: number): boolean;
47
+ /**
48
+ * Find the token matching `presented`. Compares the SHA-256 digest against
49
+ * every stored hash with timingSafeEqual (no early exit). Expired tokens never
50
+ * match.
51
+ */
52
+ export declare function matchToken(tokens: ServeToken[], presented: string, now?: number): ServeToken | undefined;
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Token + label store for `ollanet serve`.
3
+ *
4
+ * Lives in `~/.ollanet/serve.json` (override with OLLANET_SERVE_FILE), never in
5
+ * a checkout. Only SHA-256 hashes of tokens are stored; the token itself is
6
+ * printed once by `ollanet serve token add`.
7
+ */
8
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
9
+ import { mkdir, open, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
10
+ import path from "node:path";
11
+ import { USER_DIR } from "./paths.js";
12
+ const NAME_RE = /^[a-z0-9][a-z0-9_.-]{0,39}$/i;
13
+ const TOKEN_PREFIX = "olns_";
14
+ export function serveStorePath() {
15
+ return process.env.OLLANET_SERVE_FILE ?? path.join(USER_DIR, "serve.json");
16
+ }
17
+ /** Token names and labels: 1–40 of letters, digits, `.`, `_`, `-`. */
18
+ export function isValidServeName(name) {
19
+ return NAME_RE.test(name);
20
+ }
21
+ export async function loadServeStore() {
22
+ const file = serveStorePath();
23
+ let text;
24
+ try {
25
+ text = await readFile(file, "utf8");
26
+ }
27
+ catch (err) {
28
+ if (err.code === "ENOENT")
29
+ return { tokens: [], labels: {} };
30
+ throw err;
31
+ }
32
+ const parsed = JSON.parse(text);
33
+ const tokens = Array.isArray(parsed.tokens)
34
+ ? parsed.tokens.filter((t) => !!t && typeof t.name === "string" && typeof t.sha256 === "string" && /^[0-9a-f]{64}$/.test(t.sha256))
35
+ : [];
36
+ const labels = {};
37
+ if (parsed.labels && typeof parsed.labels === "object") {
38
+ for (const [k, v] of Object.entries(parsed.labels)) {
39
+ if (typeof v === "string" && isValidServeName(v))
40
+ labels[k.toLowerCase()] = v;
41
+ }
42
+ }
43
+ return { tokens, labels };
44
+ }
45
+ export async function saveServeStore(store) {
46
+ const file = serveStorePath();
47
+ await mkdir(path.dirname(file), { recursive: true });
48
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
49
+ await writeFile(tmp, `${JSON.stringify(store, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
50
+ await rename(tmp, file);
51
+ return file;
52
+ }
53
+ /**
54
+ * Serialize read-modify-write of serve.json across processes (a running
55
+ * `serve` recording last use, and CLI token add/rm/prune). A plain lock file
56
+ * created with O_EXCL; a lock older than 10s is treated as stale.
57
+ */
58
+ async function withStoreLock(fn) {
59
+ const file = serveStorePath();
60
+ await mkdir(path.dirname(file), { recursive: true });
61
+ const lock = `${file}.lock`;
62
+ const deadline = Date.now() + 5000;
63
+ for (;;) {
64
+ try {
65
+ const handle = await open(lock, "wx");
66
+ try {
67
+ return await fn();
68
+ }
69
+ finally {
70
+ await handle.close();
71
+ await unlink(lock).catch(() => { });
72
+ }
73
+ }
74
+ catch (err) {
75
+ if (err.code !== "EEXIST")
76
+ throw err;
77
+ const age = await stat(lock).then((st) => Date.now() - st.mtimeMs, () => 0);
78
+ if (age > 10_000) {
79
+ await unlink(lock).catch(() => { });
80
+ continue;
81
+ }
82
+ if (Date.now() > deadline)
83
+ throw new Error(`serve.json is locked (${lock}). Try again.`);
84
+ await new Promise((r) => setTimeout(r, 25));
85
+ }
86
+ }
87
+ }
88
+ export function hashToken(token) {
89
+ return createHash("sha256").update(token, "utf8").digest("hex");
90
+ }
91
+ export function newToken() {
92
+ return `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
93
+ }
94
+ /**
95
+ * `30d`, `12h`, `10m`, `45s`, `500ms`; a bare number is seconds.
96
+ * Returns milliseconds, or undefined when the text is not a duration.
97
+ */
98
+ export function parseDuration(raw) {
99
+ const m = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/i.exec(raw.trim());
100
+ if (!m)
101
+ return undefined;
102
+ const n = Number(m[1]);
103
+ const unit = (m[2] ?? "s").toLowerCase();
104
+ const scale = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
105
+ const ms = n * (scale[unit] ?? 1000);
106
+ return Number.isFinite(ms) ? Math.round(ms) : undefined;
107
+ }
108
+ export async function addToken(name, expiresInMs) {
109
+ if (!isValidServeName(name)) {
110
+ throw new Error(`Invalid token name "${name}" (1–40 of letters, digits, ".", "_", "-").`);
111
+ }
112
+ return withStoreLock(async () => {
113
+ const store = await loadServeStore();
114
+ if (store.tokens.some((t) => t.name.toLowerCase() === name.toLowerCase())) {
115
+ throw new Error(`A token named "${name}" already exists. Remove it first: ollanet serve token rm ${name}`);
116
+ }
117
+ const token = newToken();
118
+ const now = Date.now();
119
+ const record = {
120
+ name,
121
+ sha256: hashToken(token),
122
+ created_at: new Date(now).toISOString(),
123
+ expires_at: expiresInMs ? new Date(now + expiresInMs).toISOString() : null,
124
+ };
125
+ store.tokens.push(record);
126
+ const file = await saveServeStore(store);
127
+ return { token, record, file };
128
+ });
129
+ }
130
+ export async function removeToken(name) {
131
+ return (await removeTokens([name])).length > 0;
132
+ }
133
+ /** Remove several tokens in one write. Returns the names actually removed. */
134
+ export async function removeTokens(names) {
135
+ const wanted = new Set(names.map((n) => n.toLowerCase()));
136
+ return withStoreLock(async () => {
137
+ const store = await loadServeStore();
138
+ const removed = store.tokens.filter((t) => wanted.has(t.name.toLowerCase())).map((t) => t.name);
139
+ if (removed.length === 0)
140
+ return [];
141
+ store.tokens = store.tokens.filter((t) => !wanted.has(t.name.toLowerCase()));
142
+ await saveServeStore(store);
143
+ return removed;
144
+ });
145
+ }
146
+ /**
147
+ * Merge last-used times into serve.json. Re-reads the file first and touches
148
+ * only `last_used` on tokens matched by hash, so token add/rm from the CLI in
149
+ * the meantime is kept (a removed token simply is not written back). Never
150
+ * moves a time backwards. Atomic via temp file + rename.
151
+ */
152
+ export async function recordLastUsed(usedBySha) {
153
+ if (usedBySha.size === 0)
154
+ return;
155
+ await withStoreLock(async () => {
156
+ const store = await loadServeStore();
157
+ let changed = false;
158
+ for (const t of store.tokens) {
159
+ const used = usedBySha.get(t.sha256);
160
+ if (!used)
161
+ continue;
162
+ if (!t.last_used || Date.parse(used) > Date.parse(t.last_used)) {
163
+ t.last_used = used;
164
+ changed = true;
165
+ }
166
+ }
167
+ if (changed)
168
+ await saveServeStore(store);
169
+ });
170
+ }
171
+ /** Last use, else creation: the clock for `token prune --unused-days`. */
172
+ export function lastActivity(token) {
173
+ return Date.parse(token.last_used ?? token.created_at);
174
+ }
175
+ export async function setLabel(machine, label) {
176
+ if (!isValidServeName(label)) {
177
+ throw new Error(`Invalid label "${label}" (1–40 of letters, digits, ".", "_", "-").`);
178
+ }
179
+ await withStoreLock(async () => {
180
+ const store = await loadServeStore();
181
+ store.labels[machine.trim().toLowerCase()] = label;
182
+ await saveServeStore(store);
183
+ });
184
+ }
185
+ export async function removeLabel(machine) {
186
+ const key = machine.trim().toLowerCase();
187
+ return withStoreLock(async () => {
188
+ const store = await loadServeStore();
189
+ if (!(key in store.labels))
190
+ return false;
191
+ delete store.labels[key];
192
+ await saveServeStore(store);
193
+ return true;
194
+ });
195
+ }
196
+ export function isExpired(token, now = Date.now()) {
197
+ return token.expires_at != null && Date.parse(token.expires_at) <= now;
198
+ }
199
+ /**
200
+ * Find the token matching `presented`. Compares the SHA-256 digest against
201
+ * every stored hash with timingSafeEqual (no early exit). Expired tokens never
202
+ * match.
203
+ */
204
+ export function matchToken(tokens, presented, now = Date.now()) {
205
+ const digest = createHash("sha256").update(presented, "utf8").digest();
206
+ let hit;
207
+ for (const t of tokens) {
208
+ const stored = Buffer.from(t.sha256, "hex");
209
+ if (stored.length !== digest.length)
210
+ continue;
211
+ if (timingSafeEqual(stored, digest) && !hit)
212
+ hit = t;
213
+ }
214
+ return hit && !isExpired(hit, now) ? hit : undefined;
215
+ }
@@ -0,0 +1,108 @@
1
+ import { type ModelAlias } from "./config.js";
2
+ import { type ScannedServer } from "./scan.js";
3
+ import { type ServeToken } from "./serve-store.js";
4
+ export declare function helpText(): string;
5
+ export interface CatalogModel {
6
+ name: string;
7
+ size?: number;
8
+ parameter_size?: string;
9
+ quantization_level?: string;
10
+ family?: string;
11
+ capabilities?: string[];
12
+ tuned: boolean;
13
+ }
14
+ export interface CatalogHost {
15
+ label: string;
16
+ models: CatalogModel[];
17
+ }
18
+ export interface CatalogAlias {
19
+ name: string;
20
+ /** Host label the alias points at, or null if that machine was not in the scan. */
21
+ host: string | null;
22
+ model: string;
23
+ }
24
+ export interface Catalog {
25
+ generated_at: string;
26
+ scanned_at: string | null;
27
+ hosts: CatalogHost[];
28
+ aliases: CatalogAlias[];
29
+ }
30
+ /** Capabilities cache key (internal only; never serialized). */
31
+ export declare function capsKey(server: ScannedServer, model: string): string;
32
+ /**
33
+ * Pick an opaque label per server: serve label, else the first alias name
34
+ * (sorted) pointing at it, else host-N. Labels are unique.
35
+ */
36
+ export declare function hostLabels(servers: ScannedServer[], aliases: Record<string, ModelAlias>, labels: Record<string, string>): string[];
37
+ /**
38
+ * Build the sanitized catalog. Only allow-listed fields are copied, so new
39
+ * scan fields cannot leak by accident.
40
+ */
41
+ export declare function buildCatalog(input: {
42
+ servers: ScannedServer[];
43
+ scannedAt: string | null;
44
+ aliases: Record<string, ModelAlias>;
45
+ labels: Record<string, string>;
46
+ capabilities?: Map<string, string[]>;
47
+ }): Catalog;
48
+ export declare function isLoopbackHost(host: string): boolean;
49
+ export declare function parseListen(raw: string): {
50
+ host: string;
51
+ port: number;
52
+ };
53
+ export interface ServeOptions {
54
+ host: string;
55
+ port: number;
56
+ /** Re-scan interval; 0 = never (serve the last scan only). */
57
+ refreshMs: number;
58
+ /** Requests per minute per token. */
59
+ rate: number;
60
+ log?: (line: string) => void;
61
+ /** Minimum gap between serve.json writes of a token's last use (default 60s). */
62
+ lastUsedFlushMs?: number;
63
+ }
64
+ export interface ServeHandle {
65
+ url: string;
66
+ port: number;
67
+ catalog(): Catalog;
68
+ refresh(rescan: boolean): Promise<void>;
69
+ close(): Promise<void>;
70
+ }
71
+ export declare function startServe(opts: ServeOptions): Promise<ServeHandle>;
72
+ export interface ServeState {
73
+ pid: number;
74
+ url: string;
75
+ funnel_url: string | null;
76
+ started_at: string;
77
+ }
78
+ export declare function serveStatePath(): string;
79
+ /** "never", "just now", "5m ago", "3h ago", "12d ago". */
80
+ export declare function relativeTime(iso: string | null | undefined, now?: number): string;
81
+ /** Aligned token table: NAME, CREATED, EXPIRES, LAST USED (optionally numbered). */
82
+ export declare function tokenTable(tokens: ServeToken[], opts?: {
83
+ numbered?: boolean;
84
+ now?: number;
85
+ }): string;
86
+ /**
87
+ * Parse a pick like "1,3", "2-4", "1 3", or "all" against a list of `count`.
88
+ * Returns 0-based indices ([] for an empty answer = cancel), or an error string.
89
+ */
90
+ export declare function parseSelection(input: string, count: number): number[] | string;
91
+ type Ask = (question: string) => Promise<string>;
92
+ /**
93
+ * Numbered pick + y/N confirm. Returns the names to remove ([] = nothing).
94
+ * Throws on an invalid pick. `ask` and `write` are injectable for tests.
95
+ */
96
+ export declare function chooseTokensToRemove(tokens: ServeToken[], ask: Ask, write: (text: string) => void): Promise<string[]>;
97
+ /** `token prune` with no flags removes expired tokens and these. */
98
+ export declare const DEFAULT_PRUNE_UNUSED_DAYS = 30;
99
+ /** Tokens `prune` would remove, with a reason each. */
100
+ export declare function pruneCandidates(tokens: ServeToken[], opts: {
101
+ expired: boolean;
102
+ unusedDays?: number;
103
+ }, now?: number): Array<{
104
+ token: ServeToken;
105
+ reason: string;
106
+ }>;
107
+ export declare function main(): Promise<void>;
108
+ export {};