@synapse-sdk/client 0.3.4 → 0.3.7
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/dist/calls.d.ts +5 -5
- package/dist/calls.js +19 -21
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/keyserver.d.ts +37 -0
- package/dist/keyserver.js +146 -0
- package/dist/node.d.ts +24 -2
- package/dist/node.js +65 -5
- package/dist/scene.d.ts +22 -11
- package/dist/scene.js +297 -218
- package/dist/session.d.ts +12 -2
- package/dist/session.js +39 -14
- package/dist/store.d.ts +12 -4
- package/dist/store.js +99 -69
- package/dist/synapse.d.ts +29 -7
- package/dist/synapse.js +44 -11
- package/dist/target.d.ts +113 -0
- package/dist/target.js +377 -0
- package/dist/types.d.ts +84 -25
- package/package.json +4 -3
package/dist/target.d.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { Auth } from "./keyserver.ts";
|
|
2
|
+
/** What `-t local` means: the runtime on this machine, dialed directly. */
|
|
3
|
+
export declare const LOCAL = "local";
|
|
4
|
+
/** The runtime on this machine. */
|
|
5
|
+
export declare const LOCAL_URL = "https://localhost:4470";
|
|
6
|
+
/** A token is treated as expired when it expires within this many seconds (`target.rs` `FRESH_FOR_S`). */
|
|
7
|
+
export declare const FRESH_FOR_S: number;
|
|
8
|
+
/** Whether `s` is a URL (a scheme and `://`); anything else given as a url is a target. */
|
|
9
|
+
export declare function isUrl(s: string): boolean;
|
|
10
|
+
/** A runtime id or a cluster id: a UUID in its canonical lowercase text. */
|
|
11
|
+
export declare function isId(s: string): boolean;
|
|
12
|
+
/** A runtime name: `[a-z0-9_-]{1,64}`, and never a runtime id. */
|
|
13
|
+
export declare function validName(s: string): boolean;
|
|
14
|
+
/** A cluster name: lowercase letters, digits and `-`, at most 63, not starting with `-`, not `none`. */
|
|
15
|
+
export declare function validClusterName(s: string): boolean;
|
|
16
|
+
/** One cluster as cluster.toml keeps it (`[cluster.<id>]`). */
|
|
17
|
+
export interface Cluster {
|
|
18
|
+
name: string;
|
|
19
|
+
/** The org's slug (or id, without a slug), or the user id of a person's own cluster. */
|
|
20
|
+
owner: string;
|
|
21
|
+
/** `user` (a person's own cluster) or `org`. */
|
|
22
|
+
kind: string;
|
|
23
|
+
/** The relay, `https://host[:port]`. */
|
|
24
|
+
relay: string;
|
|
25
|
+
/** The file holding the token for this cluster (absent after `cluster logout`). */
|
|
26
|
+
token?: string;
|
|
27
|
+
/** The cluster's runtimes as the key server last gave them (name → runtime id). */
|
|
28
|
+
runtimes: Record<string, string>;
|
|
29
|
+
}
|
|
30
|
+
/** cluster.toml. */
|
|
31
|
+
export interface ClusterFile {
|
|
32
|
+
keyserver?: string;
|
|
33
|
+
/** The id of the cluster a runtime name without `@<cluster>` is looked up in. */
|
|
34
|
+
default?: string;
|
|
35
|
+
default_runtime?: string;
|
|
36
|
+
/** Cluster id → what this machine keeps of it. */
|
|
37
|
+
cluster: Record<string, Cluster>;
|
|
38
|
+
}
|
|
39
|
+
/** A cluster as the resolution rule sees it. */
|
|
40
|
+
export interface Known {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
owner: string;
|
|
44
|
+
personal: boolean;
|
|
45
|
+
}
|
|
46
|
+
/** How people name it: `name` (yours) or `owner/name` (an org's). */
|
|
47
|
+
export declare function label(k: Known): string;
|
|
48
|
+
/** A cluster named in a target: `<name>` or `<owner>/<name>`. */
|
|
49
|
+
export interface ClusterRef {
|
|
50
|
+
owner?: string;
|
|
51
|
+
name: string;
|
|
52
|
+
}
|
|
53
|
+
export declare function parseClusterRef(s: string): ClusterRef;
|
|
54
|
+
/**
|
|
55
|
+
* The resolution rule: the cluster `r` names among `known`. `<owner>/<name>`: that org's cluster of that name.
|
|
56
|
+
* `<name>`: your own cluster of that name, else the one org cluster of that name; two or more such org clusters
|
|
57
|
+
* are an error naming each one's qualified form. `undefined`: no cluster by that reference.
|
|
58
|
+
*/
|
|
59
|
+
export declare function pick(known: readonly Known[], r: ClusterRef): Known | undefined;
|
|
60
|
+
/** A runtime named in a target: `local`, or `<runtime>[@<cluster>]`. */
|
|
61
|
+
export type Target = {
|
|
62
|
+
kind: "local";
|
|
63
|
+
} | {
|
|
64
|
+
kind: "runtime";
|
|
65
|
+
runtime: string;
|
|
66
|
+
cluster?: string;
|
|
67
|
+
};
|
|
68
|
+
export declare function parseTarget(s: string): Target;
|
|
69
|
+
/** `parseTarget`, and the runtime a name or a runtime id and the cluster a cluster reference. */
|
|
70
|
+
export declare function parseTargetStrict(s: string): Target;
|
|
71
|
+
/** Where to dial and with which token. */
|
|
72
|
+
export interface Resolved {
|
|
73
|
+
url: string;
|
|
74
|
+
token: string | undefined;
|
|
75
|
+
label: string;
|
|
76
|
+
}
|
|
77
|
+
/** The refusal of a target string where nobody is signed in (`setAuth`) and the CLI's files cannot be read (a browser). */
|
|
78
|
+
export declare const BROWSER_REFUSAL = "connect by name needs a signed-in person (setAuth) or the CLI's files on this machine; or pass the relay URL and a token";
|
|
79
|
+
/** Whether this is Bun/Node, where the CLI's files can be read. */
|
|
80
|
+
export declare function canReadFiles(): boolean;
|
|
81
|
+
/** cluster.toml's text, the file at `path`; a file that keeps clusters by name (not by id) is refused. */
|
|
82
|
+
export declare function parseClusterFile(text: string, path: string): Promise<ClusterFile>;
|
|
83
|
+
/** A cluster as the key server lists it (`GET /discover`): yours when its owner is a user; an organization's by its slug, or its id without one. */
|
|
84
|
+
export declare function knownOfListed(c: {
|
|
85
|
+
id: string;
|
|
86
|
+
name: string;
|
|
87
|
+
owner: {
|
|
88
|
+
kind: string;
|
|
89
|
+
id: string;
|
|
90
|
+
slug?: string;
|
|
91
|
+
};
|
|
92
|
+
}): Known;
|
|
93
|
+
/** The `exp` claim (seconds since the epoch) of a token, read from its payload without checking it. */
|
|
94
|
+
export declare function tokenExp(jwt: string): number | undefined;
|
|
95
|
+
/**
|
|
96
|
+
* The target string `s` as the CLI's `-t` reads it, from `$HOME/.synapse/cluster.toml` and the tokens beside it,
|
|
97
|
+
* reading only: the cluster by `pick` (`@<cluster>`) or the default one; the runtime id from the file (a runtime id
|
|
98
|
+
* is taken as it is); the token the file names for the cluster while it is fresh, else this machine's own token for
|
|
99
|
+
* that runtime. A miss is `invalid_input`, a missing or expired token `unauthorized`, each naming the CLI command
|
|
100
|
+
* that fixes it. Read anew at every call (a token the CLI refreshed is the next dial's).
|
|
101
|
+
*/
|
|
102
|
+
export declare function resolveStr(s: string): Promise<Resolved>;
|
|
103
|
+
/**
|
|
104
|
+
* The target string `s` resolved through the key server for the signed-in person of `auth` (PLAN v0.38 §3.3): the grammar
|
|
105
|
+
* and `pick` as `resolveStr`; the cluster among your clusters (the discover answer the node keeps, else asked now), the
|
|
106
|
+
* runtime id from its runtimes (a runtime id is taken as it is), the relay from the answer, the cluster's token (kept while
|
|
107
|
+
* fresh, else asked now). A miss in a kept answer asks the key server once more (a runtime enrolled since), then is
|
|
108
|
+
* `invalid_input`. `cluster` and `cached` tell the dial which token to drop if the relay refuses a kept one; `local` has neither.
|
|
109
|
+
*/
|
|
110
|
+
export declare function resolveViaKeyserver(s: string, auth: Auth): Promise<Resolved & {
|
|
111
|
+
cluster: string | undefined;
|
|
112
|
+
cached: boolean;
|
|
113
|
+
}>;
|
package/dist/target.js
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
// Targets (D 2026-10-19): a runtime named the way the CLI's `-t` names it (`infra-1@devel`, `infra-1` in the
|
|
2
|
+
// default cluster, `arm@medatech/devel`, `local`), turned into the relay URL and token to dial with, from the
|
|
3
|
+
// files the CLI keeps on this machine: `~/.synapse/cluster.toml` and, beside it, `tokens/<cluster id>/*.jwt`.
|
|
4
|
+
// This is `crates/synapse-client/src/target.rs` line for line (the grammar, `pick`, the freshness margin, the
|
|
5
|
+
// URL, the error sentences); the CLI owns the files and the key server, this module only reads. Bun/Node
|
|
6
|
+
// only: the file system is imported lazily, so a browser bundle carries no Node import.
|
|
7
|
+
// Or (PLAN v0.38 §3.2), for a node with a signed-in person (`setAuth`), from the key server: the same grammar, the same
|
|
8
|
+
// `pick`, the relay and the runtime from its discover answer, the token for the cluster from it (`resolveViaKeyserver`).
|
|
9
|
+
// With neither, a target string is refused at `connect()` (`BROWSER_REFUSAL`).
|
|
10
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
11
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
12
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
13
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return path;
|
|
17
|
+
};
|
|
18
|
+
import { SynapseError } from "@synapse-sdk/core";
|
|
19
|
+
/** What `-t local` means: the runtime on this machine, dialed directly. */
|
|
20
|
+
export const LOCAL = "local";
|
|
21
|
+
/** The runtime on this machine. */
|
|
22
|
+
export const LOCAL_URL = "https://localhost:4470";
|
|
23
|
+
/** A token is treated as expired when it expires within this many seconds (`target.rs` `FRESH_FOR_S`). */
|
|
24
|
+
export const FRESH_FOR_S = 5 * 60;
|
|
25
|
+
/** What a file an older synapse wrote is told. */
|
|
26
|
+
const UPDATE = "run synapse login to update the file";
|
|
27
|
+
/** The `kind` kept for an org's cluster (anything else is a person's own). */
|
|
28
|
+
const ORG = "org";
|
|
29
|
+
/** Whether `s` is a URL (a scheme and `://`); anything else given as a url is a target. */
|
|
30
|
+
export function isUrl(s) { return /^[a-z][a-z0-9+.-]*:\/\//i.test(s); }
|
|
31
|
+
/** Rust's `{:?}` of a string, as the error sentences quote it. */
|
|
32
|
+
const q = (s) => JSON.stringify(s);
|
|
33
|
+
/** A runtime id or a cluster id: a UUID in its canonical lowercase text. */
|
|
34
|
+
export function isId(s) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(s); }
|
|
35
|
+
/** A runtime name: `[a-z0-9_-]{1,64}`, and never a runtime id. */
|
|
36
|
+
export function validName(s) { return /^[a-z0-9_-]{1,64}$/.test(s) && !isId(s); }
|
|
37
|
+
/** A cluster name: lowercase letters, digits and `-`, at most 63, not starting with `-`, not `none`. */
|
|
38
|
+
export function validClusterName(s) { return s !== "none" && /^[a-z0-9-]{1,63}$/.test(s) && !s.startsWith("-"); }
|
|
39
|
+
/** How people name it: `name` (yours) or `owner/name` (an org's). */
|
|
40
|
+
export function label(k) { return k.personal ? k.name : `${k.owner}/${k.name}`; }
|
|
41
|
+
export function parseClusterRef(s) {
|
|
42
|
+
const i = s.indexOf("/");
|
|
43
|
+
const owner = i >= 0 ? s.slice(0, i) : undefined;
|
|
44
|
+
const name = i >= 0 ? s.slice(i + 1) : s;
|
|
45
|
+
if (!validClusterName(name))
|
|
46
|
+
throw new Error(`a cluster is <name> or <org>/<name>, the name lowercase letters, digits and '-' (and not \`none\`); got ${q(s)}`);
|
|
47
|
+
if (owner !== undefined && !/^[A-Za-z0-9_-]+$/.test(owner))
|
|
48
|
+
throw new Error(`a cluster is <name> or <org>/<name>, the org its slug or id; got ${q(s)}`);
|
|
49
|
+
return owner === undefined ? { name } : { owner, name };
|
|
50
|
+
}
|
|
51
|
+
const refText = (r) => r.owner === undefined ? r.name : `${r.owner}/${r.name}`;
|
|
52
|
+
/**
|
|
53
|
+
* The resolution rule: the cluster `r` names among `known`. `<owner>/<name>`: that org's cluster of that name.
|
|
54
|
+
* `<name>`: your own cluster of that name, else the one org cluster of that name; two or more such org clusters
|
|
55
|
+
* are an error naming each one's qualified form. `undefined`: no cluster by that reference.
|
|
56
|
+
*/
|
|
57
|
+
export function pick(known, r) {
|
|
58
|
+
const named = known.filter(k => k.name === r.name);
|
|
59
|
+
if (r.owner !== undefined)
|
|
60
|
+
return named.find(k => !k.personal && k.owner === r.owner);
|
|
61
|
+
const mine = named.find(k => k.personal);
|
|
62
|
+
if (mine)
|
|
63
|
+
return mine;
|
|
64
|
+
if (named.length <= 1)
|
|
65
|
+
return named[0];
|
|
66
|
+
const forms = named.map(label);
|
|
67
|
+
const last = forms.pop();
|
|
68
|
+
throw new Error(`more than one cluster is named ${q(r.name)}: say ${forms.join(", ")} or ${last}`);
|
|
69
|
+
}
|
|
70
|
+
export function parseTarget(s) {
|
|
71
|
+
if (s === LOCAL)
|
|
72
|
+
return { kind: "local" };
|
|
73
|
+
const i = s.indexOf("@");
|
|
74
|
+
const runtime = i >= 0 ? s.slice(0, i) : s;
|
|
75
|
+
const cluster = i >= 0 ? s.slice(i + 1) : undefined;
|
|
76
|
+
if (runtime === "" || cluster === "")
|
|
77
|
+
throw new Error(`a runtime is <name>[@<cluster>] or local, got ${q(s)}`);
|
|
78
|
+
return cluster === undefined ? { kind: "runtime", runtime } : { kind: "runtime", runtime, cluster };
|
|
79
|
+
}
|
|
80
|
+
/** `parseTarget`, and the runtime a name or a runtime id and the cluster a cluster reference. */
|
|
81
|
+
export function parseTargetStrict(s) {
|
|
82
|
+
const t = parseTarget(s);
|
|
83
|
+
if (t.kind === "runtime") {
|
|
84
|
+
if (!validName(t.runtime) && !isId(t.runtime))
|
|
85
|
+
throw new Error(`a runtime is <name>[@<cluster>] or local, the name [a-z0-9_-]{1,64} or a runtime id; got ${q(s)}`);
|
|
86
|
+
if (t.cluster !== undefined)
|
|
87
|
+
parseClusterRef(t.cluster);
|
|
88
|
+
}
|
|
89
|
+
return t;
|
|
90
|
+
}
|
|
91
|
+
/** The refusal of a target string where nobody is signed in (`setAuth`) and the CLI's files cannot be read (a browser). */
|
|
92
|
+
export const BROWSER_REFUSAL = "connect by name needs a signed-in person (setAuth) or the CLI's files on this machine; or pass the relay URL and a token";
|
|
93
|
+
/** Whether this is Bun/Node, where the CLI's files can be read. */
|
|
94
|
+
export function canReadFiles() {
|
|
95
|
+
const p = globalThis.process;
|
|
96
|
+
return typeof p?.versions?.node === "string";
|
|
97
|
+
}
|
|
98
|
+
const invalid = (e) => e instanceof SynapseError ? e : new SynapseError("invalid_input", { message: e instanceof Error ? e.message : String(e), source: "sdk" });
|
|
99
|
+
let io;
|
|
100
|
+
/** The file system, imported on first use (never at the top: a browser bundle must not see Node modules). */
|
|
101
|
+
function loadIo() {
|
|
102
|
+
return io ??= (async () => {
|
|
103
|
+
// Specifiers in variables: bundlers leave them alone (the same as `ensureWebTransport`).
|
|
104
|
+
const [fsSpec, osSpec, pathSpec] = ["node:fs/promises", "node:os", "node:path"];
|
|
105
|
+
const fs = (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ fsSpec)));
|
|
106
|
+
const os = (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ osSpec)));
|
|
107
|
+
const path = (await import(__rewriteRelativeImportExtension(/* @vite-ignore */ pathSpec)));
|
|
108
|
+
return {
|
|
109
|
+
readFile: p => fs.readFile(p, "utf8"),
|
|
110
|
+
isFile: p => fs.stat(p).then(s => s.isFile(), () => false),
|
|
111
|
+
homedir: () => os.homedir(),
|
|
112
|
+
join: (...parts) => path.join(...parts),
|
|
113
|
+
dirname: p => path.dirname(p),
|
|
114
|
+
};
|
|
115
|
+
})().catch((e) => { io = undefined; throw new SynapseError("invalid_input", { message: BROWSER_REFUSAL, source: "sdk", cause: e }); });
|
|
116
|
+
}
|
|
117
|
+
/** Where cluster.toml is: `$HOME/.synapse/cluster.toml` (`$HOME` read now, as Rust does; the OS's home without it). */
|
|
118
|
+
function defaultPath(io) {
|
|
119
|
+
const env = globalThis.process?.env?.HOME;
|
|
120
|
+
const home = env || io.homedir();
|
|
121
|
+
if (!home)
|
|
122
|
+
throw new Error("HOME is not set");
|
|
123
|
+
return io.join(home, ".synapse/cluster.toml");
|
|
124
|
+
}
|
|
125
|
+
/** Where token `name` of cluster `id` is kept: `tokens/<id>/<name>.jwt` next to cluster.toml at `config`. */
|
|
126
|
+
function tokenFile(io, config, id, name) { return io.join(io.dirname(config), "tokens", id, `${name}.jwt`); }
|
|
127
|
+
const isTable = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date);
|
|
128
|
+
/** Whether `raw` keeps clusters by name, as the synapse before cluster ids did. */
|
|
129
|
+
function oldStyle(raw) {
|
|
130
|
+
const named = isTable(raw.cluster) && Object.keys(raw.cluster).some(k => !isId(k));
|
|
131
|
+
return named || (typeof raw.default === "string" && !isId(raw.default));
|
|
132
|
+
}
|
|
133
|
+
/** The shape check Rust's `deny_unknown_fields` structs make. */
|
|
134
|
+
function schema(raw) {
|
|
135
|
+
const known = (t, keys) => { for (const k of Object.keys(t))
|
|
136
|
+
if (!keys.includes(k))
|
|
137
|
+
throw new Error(`unknown field \`${k}\`, expected one of ${keys.map(k => `\`${k}\``).join(", ")}`); };
|
|
138
|
+
const str = (t, k, need) => {
|
|
139
|
+
const v = t[k];
|
|
140
|
+
if (v === undefined) {
|
|
141
|
+
if (need)
|
|
142
|
+
throw new Error(`missing field \`${k}\``);
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
if (typeof v !== "string")
|
|
146
|
+
throw new Error(`invalid type for \`${k}\`, expected a string`);
|
|
147
|
+
return v;
|
|
148
|
+
};
|
|
149
|
+
known(raw, ["keyserver", "default", "default_runtime", "cluster"]);
|
|
150
|
+
const file = { cluster: {} };
|
|
151
|
+
for (const k of ["keyserver", "default", "default_runtime"]) {
|
|
152
|
+
const v = str(raw, k, false);
|
|
153
|
+
if (v !== undefined)
|
|
154
|
+
file[k] = v;
|
|
155
|
+
}
|
|
156
|
+
if (raw.cluster !== undefined && !isTable(raw.cluster))
|
|
157
|
+
throw new Error("invalid type for `cluster`, expected a table");
|
|
158
|
+
for (const [id, c] of Object.entries(raw.cluster ?? {})) {
|
|
159
|
+
if (!isTable(c))
|
|
160
|
+
throw new Error(`invalid type for \`cluster.${id}\`, expected a table`);
|
|
161
|
+
known(c, ["name", "owner", "kind", "relay", "token", "runtimes"]);
|
|
162
|
+
const entry = { name: str(c, "name", true), owner: str(c, "owner", true), kind: str(c, "kind", true), relay: str(c, "relay", true), runtimes: {} };
|
|
163
|
+
const token = str(c, "token", false);
|
|
164
|
+
if (token !== undefined)
|
|
165
|
+
entry.token = token;
|
|
166
|
+
if (c.runtimes !== undefined) {
|
|
167
|
+
if (!isTable(c.runtimes))
|
|
168
|
+
throw new Error("invalid type for `runtimes`, expected a table");
|
|
169
|
+
for (const r of Object.keys(c.runtimes))
|
|
170
|
+
entry.runtimes[r] = str(c.runtimes, r, true);
|
|
171
|
+
}
|
|
172
|
+
file.cluster[id] = entry;
|
|
173
|
+
}
|
|
174
|
+
return file;
|
|
175
|
+
}
|
|
176
|
+
/** cluster.toml's text, the file at `path`; a file that keeps clusters by name (not by id) is refused. */
|
|
177
|
+
export async function parseClusterFile(text, path) {
|
|
178
|
+
const { parse } = await import("smol-toml");
|
|
179
|
+
let raw;
|
|
180
|
+
try {
|
|
181
|
+
raw = parse(text);
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
throw new Error(`${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
185
|
+
}
|
|
186
|
+
if (oldStyle(raw))
|
|
187
|
+
throw new Error(`${path} names clusters as an older synapse did; ${UPDATE}`);
|
|
188
|
+
try {
|
|
189
|
+
return schema(raw);
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
throw new Error(`${path} (a key from an older synapse? ${UPDATE}): ${e instanceof Error ? e.message : String(e)}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
async function loadClusterFile(io, path) {
|
|
196
|
+
let text;
|
|
197
|
+
try {
|
|
198
|
+
text = await io.readFile(path);
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
if (e.code === "ENOENT")
|
|
202
|
+
return { cluster: {} };
|
|
203
|
+
throw new Error(`reading ${path}: ${e instanceof Error ? e.message : String(e)}`);
|
|
204
|
+
}
|
|
205
|
+
return parseClusterFile(text, path);
|
|
206
|
+
}
|
|
207
|
+
/** `clusters` in id order (Rust's `BTreeMap`: the order the ambiguity error lists them in), from cluster.toml or the key server. */
|
|
208
|
+
function knownOf(clusters) {
|
|
209
|
+
return [...clusters].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
210
|
+
}
|
|
211
|
+
/** The clusters `file` keeps. */
|
|
212
|
+
function knownOfFile(file) { return knownOf(Object.keys(file.cluster).map(id => knownId(file, id))); }
|
|
213
|
+
/** A cluster as the key server lists it (`GET /discover`): yours when its owner is a user; an organization's by its slug, or its id without one. */
|
|
214
|
+
export function knownOfListed(c) {
|
|
215
|
+
return { id: c.id, name: c.name, owner: c.owner.slug ?? c.owner.id, personal: c.owner.kind === "user" };
|
|
216
|
+
}
|
|
217
|
+
function knownId(file, id) {
|
|
218
|
+
const c = file.cluster[id];
|
|
219
|
+
return c && { id, name: c.name, owner: c.owner, personal: c.kind !== ORG };
|
|
220
|
+
}
|
|
221
|
+
// ------------------------------------------------------------------------------------ tokens
|
|
222
|
+
/** The `exp` claim (seconds since the epoch) of a token, read from its payload without checking it. */
|
|
223
|
+
export function tokenExp(jwt) {
|
|
224
|
+
const payload = jwt.trim().split(".")[1];
|
|
225
|
+
if (payload === undefined)
|
|
226
|
+
return undefined;
|
|
227
|
+
const b64 = payload.replace(/=+$/, "");
|
|
228
|
+
if (!/^[A-Za-z0-9_-]*$/.test(b64) || b64.length % 4 === 1)
|
|
229
|
+
return undefined;
|
|
230
|
+
try {
|
|
231
|
+
const bin = atob(b64.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - b64.length % 4) % 4));
|
|
232
|
+
const claims = JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, ch => ch.charCodeAt(0))));
|
|
233
|
+
const exp = isTable(claims) ? claims.exp : undefined;
|
|
234
|
+
return typeof exp === "number" && Number.isInteger(exp) && exp >= 0 ? exp : undefined;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** The token at `path` when it is there and does not expire within `FRESH_FOR_S` (a token without `exp` never expires). */
|
|
241
|
+
async function freshToken(io, path) {
|
|
242
|
+
let token;
|
|
243
|
+
try {
|
|
244
|
+
token = (await io.readFile(path)).trim();
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
if (token === "")
|
|
250
|
+
return undefined;
|
|
251
|
+
const exp = tokenExp(token);
|
|
252
|
+
return exp !== undefined && exp <= Math.floor(Date.now() / 1000) + FRESH_FOR_S ? undefined : token;
|
|
253
|
+
}
|
|
254
|
+
// ------------------------------------------------------------------------------------ resolve
|
|
255
|
+
/**
|
|
256
|
+
* The target string `s` as the CLI's `-t` reads it, from `$HOME/.synapse/cluster.toml` and the tokens beside it,
|
|
257
|
+
* reading only: the cluster by `pick` (`@<cluster>`) or the default one; the runtime id from the file (a runtime id
|
|
258
|
+
* is taken as it is); the token the file names for the cluster while it is fresh, else this machine's own token for
|
|
259
|
+
* that runtime. A miss is `invalid_input`, a missing or expired token `unauthorized`, each naming the CLI command
|
|
260
|
+
* that fixes it. Read anew at every call (a token the CLI refreshed is the next dial's).
|
|
261
|
+
*/
|
|
262
|
+
export async function resolveStr(s) {
|
|
263
|
+
let target;
|
|
264
|
+
try {
|
|
265
|
+
target = parseTargetStrict(s);
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
throw invalid(e);
|
|
269
|
+
}
|
|
270
|
+
if (target.kind === "local")
|
|
271
|
+
return local();
|
|
272
|
+
if (!canReadFiles())
|
|
273
|
+
throw new SynapseError("invalid_input", { message: BROWSER_REFUSAL, source: "sdk" });
|
|
274
|
+
const fsio = await loadIo();
|
|
275
|
+
let config, file;
|
|
276
|
+
try {
|
|
277
|
+
config = defaultPath(fsio);
|
|
278
|
+
file = await loadClusterFile(fsio, config);
|
|
279
|
+
}
|
|
280
|
+
catch (e) {
|
|
281
|
+
throw invalid(e);
|
|
282
|
+
}
|
|
283
|
+
const { runtime } = target;
|
|
284
|
+
let cluster;
|
|
285
|
+
try {
|
|
286
|
+
if (target.cluster !== undefined) {
|
|
287
|
+
const r = parseClusterRef(target.cluster);
|
|
288
|
+
const k = pick(knownOfFile(file), r);
|
|
289
|
+
if (!k)
|
|
290
|
+
throw new Error(`cluster ${refText(r)} is not known on this machine: run synapse cluster login ${refText(r)} (synapse login first)`);
|
|
291
|
+
cluster = k;
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
const k = file.default === undefined ? undefined : knownId(file, file.default);
|
|
295
|
+
if (!k)
|
|
296
|
+
throw new Error("no cluster chosen: synapse cluster login <cluster> first");
|
|
297
|
+
cluster = k;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
throw invalid(e);
|
|
302
|
+
}
|
|
303
|
+
const lbl = label(cluster);
|
|
304
|
+
const entry = file.cluster[cluster.id];
|
|
305
|
+
const id = isId(runtime) ? runtime : entry.runtimes[runtime];
|
|
306
|
+
if (id === undefined)
|
|
307
|
+
throw invalid(`no runtime named ${q(runtime)} is known on this machine in cluster ${lbl}: run synapse cluster runtime list ${lbl}`);
|
|
308
|
+
const devicePath = tokenFile(fsio, config, cluster.id, runtime);
|
|
309
|
+
const device = validName(runtime) && await fsio.isFile(devicePath) ? devicePath : undefined;
|
|
310
|
+
const token = (entry.token !== undefined ? await freshToken(fsio, entry.token) : undefined) ?? (device !== undefined ? await freshToken(fsio, device) : undefined);
|
|
311
|
+
if (token === undefined) {
|
|
312
|
+
const fix = device !== undefined ? `run synapse cluster login ${lbl}, or synapse runtime init again on this machine` : `run synapse cluster login ${lbl}`;
|
|
313
|
+
const why = entry.token !== undefined ? `the token for cluster ${lbl} on this machine has expired or is gone` : `logged out of cluster ${lbl}`;
|
|
314
|
+
throw new SynapseError("unauthorized", { message: `${why}: ${fix}`, source: "sdk" });
|
|
315
|
+
}
|
|
316
|
+
let url;
|
|
317
|
+
try {
|
|
318
|
+
url = new URL(entry.relay);
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
throw invalid(`the relay of cluster ${lbl} is not a URL: ${e instanceof Error ? e.message : String(e)}`);
|
|
322
|
+
}
|
|
323
|
+
url.pathname = `/cluster/${cluster.id}/runtimes/${id}`;
|
|
324
|
+
return { url: url.toString(), token, label: `${runtime}@${lbl}` };
|
|
325
|
+
}
|
|
326
|
+
function local() { return { url: LOCAL_URL, token: undefined, label: LOCAL }; }
|
|
327
|
+
/**
|
|
328
|
+
* The target string `s` resolved through the key server for the signed-in person of `auth` (PLAN v0.38 §3.3): the grammar
|
|
329
|
+
* and `pick` as `resolveStr`; the cluster among your clusters (the discover answer the node keeps, else asked now), the
|
|
330
|
+
* runtime id from its runtimes (a runtime id is taken as it is), the relay from the answer, the cluster's token (kept while
|
|
331
|
+
* fresh, else asked now). A miss in a kept answer asks the key server once more (a runtime enrolled since), then is
|
|
332
|
+
* `invalid_input`. `cluster` and `cached` tell the dial which token to drop if the relay refuses a kept one; `local` has neither.
|
|
333
|
+
*/
|
|
334
|
+
export async function resolveViaKeyserver(s, auth) {
|
|
335
|
+
let target;
|
|
336
|
+
try {
|
|
337
|
+
target = parseTargetStrict(s);
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
throw invalid(e);
|
|
341
|
+
}
|
|
342
|
+
if (target.kind === "local")
|
|
343
|
+
return { ...local(), cluster: undefined, cached: false };
|
|
344
|
+
const { runtime } = target;
|
|
345
|
+
if (target.cluster === undefined)
|
|
346
|
+
throw invalid(`say which of your clusters: ${runtime}@<cluster>`);
|
|
347
|
+
const ref = parseClusterRef(target.cluster);
|
|
348
|
+
let { discovery, cached } = await auth.known();
|
|
349
|
+
for (;;) {
|
|
350
|
+
let k;
|
|
351
|
+
try {
|
|
352
|
+
k = pick(knownOf(discovery.clusters.map(knownOfListed)), ref);
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
throw invalid(e);
|
|
356
|
+
}
|
|
357
|
+
const c = k && discovery.clusters.find(c => c.id === k.id);
|
|
358
|
+
const id = c && (isId(runtime) ? runtime : c.runtimes[runtime]);
|
|
359
|
+
if (k && c && id !== undefined) {
|
|
360
|
+
const { token, cached: tokenCached } = await auth.clusterToken(c.id);
|
|
361
|
+
const lbl = label(k);
|
|
362
|
+
let url;
|
|
363
|
+
try {
|
|
364
|
+
url = new URL(discovery.relay);
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
throw invalid(`the relay the key server named is not a URL: ${e instanceof Error ? e.message : String(e)}`);
|
|
368
|
+
}
|
|
369
|
+
url.pathname = `/cluster/${c.id}/runtimes/${id}`;
|
|
370
|
+
return { url: url.toString(), token, label: `${runtime}@${lbl}`, cluster: c.id, cached: tokenCached };
|
|
371
|
+
}
|
|
372
|
+
if (!cached)
|
|
373
|
+
throw invalid(k ? `nothing named ${q(runtime)} in your cluster ${label(k)}` : `nothing named ${refText(ref)} in your clusters`);
|
|
374
|
+
discovery = await auth.discover();
|
|
375
|
+
cached = false;
|
|
376
|
+
}
|
|
377
|
+
}
|