@synapse-sdk/client 0.3.6 → 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/index.d.ts +1 -1
- package/dist/index.js +1 -1
- 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/session.d.ts +12 -2
- package/dist/session.js +38 -13
- package/dist/synapse.d.ts +27 -2
- package/dist/synapse.js +42 -6
- package/dist/target.d.ts +113 -0
- package/dist/target.js +377 -0
- package/dist/types.d.ts +49 -1
- package/package.json +4 -3
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { defaultReconnectPolicy, exponentialPolicy, fixedPolicy, withMaxAttempts
|
|
|
4
4
|
export { isTasksRefusal } from "./tasks.ts";
|
|
5
5
|
export { setMoqDebug } from "./moq_log.ts";
|
|
6
6
|
export type * from "./types.ts";
|
|
7
|
-
export { brokerCertHelperUrl, discoverBrokerCertSha256, ensureWebTransport } from "./session.ts";
|
|
7
|
+
export { brokerCertHelperUrl, discoverBrokerCertSha256, ensureWebTransport, namesRoot } from "./session.ts";
|
|
8
8
|
export { replaceEqualDeep, shallowEqual } from "./util.ts";
|
|
9
9
|
export { orderAfter, orderBefore, orderBetween, ulid } from "./scene.ts";
|
|
10
10
|
export { derivedFrameKey, derivedFrameName, materializeFrames, materializeHardpoints, validateDerivedFrame, type MaterializeReport } from "./scene_frames.ts";
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { Synapse, FIRST_CONNECT_GIVE_UP_MS } from "./synapse.js";
|
|
|
4
4
|
export { defaultReconnectPolicy, exponentialPolicy, fixedPolicy, withMaxAttempts } from "./node.js";
|
|
5
5
|
export { isTasksRefusal } from "./tasks.js";
|
|
6
6
|
export { setMoqDebug } from "./moq_log.js";
|
|
7
|
-
export { brokerCertHelperUrl, discoverBrokerCertSha256, ensureWebTransport } from "./session.js";
|
|
7
|
+
export { brokerCertHelperUrl, discoverBrokerCertSha256, ensureWebTransport, namesRoot } from "./session.js";
|
|
8
8
|
export { replaceEqualDeep, shallowEqual } from "./util.js";
|
|
9
9
|
export { orderAfter, orderBefore, orderBetween, ulid } from "./scene.js";
|
|
10
10
|
export { derivedFrameKey, derivedFrameName, materializeFrames, materializeHardpoints, validateDerivedFrame } from "./scene_frames.js";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { SynapseError } from "@synapse-sdk/core";
|
|
2
|
+
import type { Discovery, DiscoveryState, FetchToken } from "./types.ts";
|
|
3
|
+
import type { Cell } from "./util.ts";
|
|
4
|
+
/** The key server a node asks when `setAuth` names none. */
|
|
5
|
+
export declare const DEFAULT_AUTH_URL = "https://e9e413af.synapse.build";
|
|
6
|
+
/** The discovery value with no credential set, or just after `setAuth` (a new object each time: a change). */
|
|
7
|
+
export declare const idleDiscovery: () => DiscoveryState;
|
|
8
|
+
/** The refusal when no credential is set or the sign-in gives none. */
|
|
9
|
+
export declare const notSignedIn: () => SynapseError;
|
|
10
|
+
/** `GET /discover`'s body as the `Discovery` value; `undefined` when it does not have that shape. */
|
|
11
|
+
export declare function discoveryOf(body: unknown): Discovery | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* One `setAuth(fetchToken, { authUrl })` of one node: the key server's client and the caches it fills. A new one at every
|
|
14
|
+
* `setAuth` (the old one's caches go with it). It writes the node's `discovery` value only while it is the node's
|
|
15
|
+
* current one (`current`), so an answer arriving after a `setAuth` changes nothing.
|
|
16
|
+
*/
|
|
17
|
+
export declare class Auth {
|
|
18
|
+
#private;
|
|
19
|
+
private readonly fetchToken;
|
|
20
|
+
private readonly discovery;
|
|
21
|
+
private readonly current;
|
|
22
|
+
constructor(fetchToken: FetchToken, authUrl: string, discovery: Cell<DiscoveryState>, current: (a: Auth) => boolean);
|
|
23
|
+
/** `GET /discover`, setting the value (`pending`, then `success` or `error`, the last data kept); one at a time: a call while one is out joins it. */
|
|
24
|
+
discover(): Promise<Discovery>;
|
|
25
|
+
/** The discover answer: the value's when it is `success` (`cached`), else fetched now. */
|
|
26
|
+
known(): Promise<{
|
|
27
|
+
discovery: Discovery;
|
|
28
|
+
cached: boolean;
|
|
29
|
+
}>;
|
|
30
|
+
/** The token for cluster `id`: the kept one while it does not expire within `FRESH_FOR_S` (`cached`), else `POST /clusters/{id}/token`, kept. */
|
|
31
|
+
clusterToken(id: string): Promise<{
|
|
32
|
+
token: string;
|
|
33
|
+
cached: boolean;
|
|
34
|
+
}>;
|
|
35
|
+
/** Forgets the token of cluster `id` (the relay refused it). */
|
|
36
|
+
dropToken(id: string): void;
|
|
37
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The key server for a signed-in person (PLAN v0.38 §3.3): what a node set with `setAuth` asks it, `GET /discover`
|
|
2
|
+
// (the relay and the person's clusters with their runtimes) and `POST /clusters/{id}/token` (a cluster token), with
|
|
3
|
+
// the person's credential fetched at each request and sent only in the `Authorization` header. Both answers are kept
|
|
4
|
+
// in memory, per node, until the next `setAuth`: the discover answer as the node's `discovery` state value, the cluster
|
|
5
|
+
// tokens by cluster id while fresh (`FRESH_FOR_S`). Nothing is written anywhere else; no token is ever put in a URL, an
|
|
6
|
+
// error or a log line.
|
|
7
|
+
import { SynapseError } from "@synapse-sdk/core";
|
|
8
|
+
import { describeError } from "./session.js";
|
|
9
|
+
import { FRESH_FOR_S, knownOfListed, label } from "./target.js";
|
|
10
|
+
/** The key server a node asks when `setAuth` names none. */
|
|
11
|
+
export const DEFAULT_AUTH_URL = "https://e9e413af.synapse.build";
|
|
12
|
+
/** The discovery value with no credential set, or just after `setAuth` (a new object each time: a change). */
|
|
13
|
+
export const idleDiscovery = () => ({ status: "idle" });
|
|
14
|
+
/** The refusal when no credential is set or the sign-in gives none. */
|
|
15
|
+
export const notSignedIn = () => new SynapseError("unauthorized", { message: "not signed in: sign in first", source: "sdk" });
|
|
16
|
+
const isTable = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
17
|
+
/** An answer that is not what the key server says it sends: `connection_lost` (retried; never a caller's mistake). */
|
|
18
|
+
const unreadable = (what) => new SynapseError("connection_lost", { message: `the key server's answer to ${what} could not be read`, source: "sdk" });
|
|
19
|
+
/** `GET /discover`'s body as the `Discovery` value; `undefined` when it does not have that shape. */
|
|
20
|
+
export function discoveryOf(body) {
|
|
21
|
+
if (!isTable(body) || typeof body.relay !== "string" || typeof body.user_id !== "string" || !Array.isArray(body.clusters))
|
|
22
|
+
return undefined;
|
|
23
|
+
const clusters = [];
|
|
24
|
+
for (const c of body.clusters) {
|
|
25
|
+
if (!isTable(c) || typeof c.id !== "string" || typeof c.name !== "string" || !isTable(c.owner) || !isTable(c.runtimes))
|
|
26
|
+
return undefined;
|
|
27
|
+
const o = c.owner;
|
|
28
|
+
if (typeof o.kind !== "string" || typeof o.id !== "string" || (o.slug !== undefined && o.slug !== null && typeof o.slug !== "string"))
|
|
29
|
+
return undefined;
|
|
30
|
+
const owner = typeof o.slug === "string" ? { kind: o.kind, id: o.id, slug: o.slug } : { kind: o.kind, id: o.id };
|
|
31
|
+
const runtimes = {};
|
|
32
|
+
for (const [name, id] of Object.entries(c.runtimes)) {
|
|
33
|
+
if (typeof id !== "string")
|
|
34
|
+
return undefined;
|
|
35
|
+
runtimes[name] = id;
|
|
36
|
+
}
|
|
37
|
+
const listed = { id: c.id, name: c.name, owner };
|
|
38
|
+
clusters.push({ ...listed, label: label(knownOfListed(listed)), runtimes });
|
|
39
|
+
}
|
|
40
|
+
return { relay: body.relay, userId: body.user_id, clusters };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* One `setAuth(fetchToken, { authUrl })` of one node: the key server's client and the caches it fills. A new one at every
|
|
44
|
+
* `setAuth` (the old one's caches go with it). It writes the node's `discovery` value only while it is the node's
|
|
45
|
+
* current one (`current`), so an answer arriving after a `setAuth` changes nothing.
|
|
46
|
+
*/
|
|
47
|
+
export class Auth {
|
|
48
|
+
fetchToken;
|
|
49
|
+
discovery;
|
|
50
|
+
current;
|
|
51
|
+
#tokens = new Map();
|
|
52
|
+
#pending;
|
|
53
|
+
#authUrl;
|
|
54
|
+
constructor(fetchToken, authUrl, discovery, current) {
|
|
55
|
+
this.fetchToken = fetchToken;
|
|
56
|
+
this.discovery = discovery;
|
|
57
|
+
this.current = current;
|
|
58
|
+
this.#authUrl = authUrl.replace(/\/+$/, "");
|
|
59
|
+
}
|
|
60
|
+
#set(f) { if (this.current(this))
|
|
61
|
+
this.discovery.set(f(this.discovery.get())); }
|
|
62
|
+
/** `GET /discover`, setting the value (`pending`, then `success` or `error`, the last data kept); one at a time: a call while one is out joins it. */
|
|
63
|
+
discover() {
|
|
64
|
+
return this.#pending ??= (async () => {
|
|
65
|
+
this.#set(p => (p.data ? { status: "pending", data: p.data } : { status: "pending" }));
|
|
66
|
+
try {
|
|
67
|
+
const d = discoveryOf(await this.#request("GET", "/discover"));
|
|
68
|
+
if (!d)
|
|
69
|
+
throw unreadable("discover");
|
|
70
|
+
this.#set(() => ({ status: "success", data: d }));
|
|
71
|
+
return d;
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
const err = e instanceof SynapseError ? e : new SynapseError("internal", { message: describeError(e), source: "sdk" });
|
|
75
|
+
this.#set(p => (p.data ? { status: "error", data: p.data, error: err } : { status: "error", error: err }));
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
this.#pending = undefined;
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
}
|
|
83
|
+
/** The discover answer: the value's when it is `success` (`cached`), else fetched now. */
|
|
84
|
+
async known() {
|
|
85
|
+
const v = this.discovery.get();
|
|
86
|
+
if (this.current(this) && v.status === "success" && v.data)
|
|
87
|
+
return { discovery: v.data, cached: true };
|
|
88
|
+
return { discovery: await this.discover(), cached: false };
|
|
89
|
+
}
|
|
90
|
+
/** The token for cluster `id`: the kept one while it does not expire within `FRESH_FOR_S` (`cached`), else `POST /clusters/{id}/token`, kept. */
|
|
91
|
+
async clusterToken(id) {
|
|
92
|
+
const kept = this.#tokens.get(id);
|
|
93
|
+
if (kept && kept.exp > Math.floor(Date.now() / 1000) + FRESH_FOR_S)
|
|
94
|
+
return { token: kept.token, cached: true };
|
|
95
|
+
const body = await this.#request("POST", `/clusters/${encodeURIComponent(id)}/token`);
|
|
96
|
+
const exp = isTable(body) && typeof body.expires_at === "string" ? Date.parse(body.expires_at) : NaN;
|
|
97
|
+
if (!isTable(body) || typeof body.token !== "string" || body.token === "" || Number.isNaN(exp))
|
|
98
|
+
throw unreadable("a cluster token request");
|
|
99
|
+
this.#tokens.set(id, { token: body.token, exp: Math.floor(exp / 1000) });
|
|
100
|
+
return { token: body.token, cached: false };
|
|
101
|
+
}
|
|
102
|
+
/** Forgets the token of cluster `id` (the relay refused it). */
|
|
103
|
+
dropToken(id) { this.#tokens.delete(id); }
|
|
104
|
+
/**
|
|
105
|
+
* One request with the credential fetched now. The key server's answers (PLAN v0.38 §3.3.5): `401` `unauthorized`
|
|
106
|
+
* (and every cache dropped), `400`/`403`/`404` `invalid_input`, anything else not 2xx, or no answer, `connection_lost`;
|
|
107
|
+
* each with the key server's own sentence when it sent one.
|
|
108
|
+
*/
|
|
109
|
+
async #request(method, path) {
|
|
110
|
+
let credential;
|
|
111
|
+
try {
|
|
112
|
+
credential = await this.fetchToken();
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
throw new SynapseError("connection_lost", { message: `signing in gave no credential: ${describeError(e)}`, source: "sdk" });
|
|
116
|
+
}
|
|
117
|
+
if (!credential)
|
|
118
|
+
throw notSignedIn();
|
|
119
|
+
let res;
|
|
120
|
+
try {
|
|
121
|
+
res = await globalThis.fetch(`${this.#authUrl}${path}`, { method, headers: { Authorization: `Bearer ${credential}` } });
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
throw new SynapseError("connection_lost", { message: `the key server could not be reached: ${describeError(e)}`, source: "transport" });
|
|
125
|
+
}
|
|
126
|
+
let body;
|
|
127
|
+
try {
|
|
128
|
+
body = JSON.parse(await res.text());
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
body = undefined;
|
|
132
|
+
}
|
|
133
|
+
if (res.ok)
|
|
134
|
+
return body;
|
|
135
|
+
const sentence = isTable(body) && typeof body.error === "string" && body.error !== "" ? body.error : `the key server answered ${res.status}`;
|
|
136
|
+
if (res.status === 401) {
|
|
137
|
+
const err = new SynapseError("unauthorized", { message: sentence, source: "sdk" });
|
|
138
|
+
this.#tokens.clear();
|
|
139
|
+
this.#set(p => (p.status === "success" ? { status: "error", data: p.data, error: err } : p));
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
if (res.status === 400 || res.status === 403 || res.status === 404)
|
|
143
|
+
throw new SynapseError("invalid_input", { message: sentence, source: "sdk" });
|
|
144
|
+
throw new SynapseError("connection_lost", { message: sentence, source: "sdk" });
|
|
145
|
+
}
|
|
146
|
+
}
|
package/dist/node.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SynapseError, type ClientType, type ErrorBody, type Identity, type TimeDomain } from "@synapse-sdk/core";
|
|
2
|
+
import { Auth } from "./keyserver.ts";
|
|
2
3
|
import { Session, type Dial, type SessionKnobs } from "./session.ts";
|
|
3
|
-
import type { ConnectOptions, ConnectTarget, ConnectionState, Notice, ReconnectPolicy } from "./types.ts";
|
|
4
|
+
import type { ConnectOptions, ConnectTarget, ConnectionState, DiscoveryState, FetchToken, Notice, ReconnectPolicy } from "./types.ts";
|
|
4
5
|
import { Cell, type Deferred } from "./util.ts";
|
|
5
6
|
/** `initial × 2^(attempt−1)` capped at `max`, times an equal jitter U(0.5, 1); never gives up. */
|
|
6
7
|
export declare function exponentialPolicy(initialMs: number, maxMs: number): ReconnectPolicy;
|
|
@@ -108,6 +109,13 @@ export declare class Node {
|
|
|
108
109
|
readonly projectNotices: Set<(n: Notice) => void>;
|
|
109
110
|
/** Resolves once the node has ended for good: `close()`, and nothing else. */
|
|
110
111
|
readonly ended: Deferred<void>;
|
|
112
|
+
/**
|
|
113
|
+
* The signed-in person's key server (`setAuth`), asked when a target named by name is dialed (PLAN v0.38 §3.2);
|
|
114
|
+
* `undefined`: nobody is signed in. Read at each attempt, never by a live session: signing out ends no session.
|
|
115
|
+
*/
|
|
116
|
+
auth: Auth | undefined;
|
|
117
|
+
/** The key server's answer about the signed-in person, kept by `auth` (`node.discovery`). */
|
|
118
|
+
readonly discovery: Cell<DiscoveryState>;
|
|
111
119
|
epochs: number;
|
|
112
120
|
reconnects: number;
|
|
113
121
|
callsSent: number;
|
|
@@ -118,6 +126,8 @@ export declare class Node {
|
|
|
118
126
|
finalDom?: Promise<TimeDomain>;
|
|
119
127
|
});
|
|
120
128
|
get isEnded(): boolean;
|
|
129
|
+
/** `setAuth`: a new key-server client for `fetchToken` (none: signed out); what the previous one kept is forgotten and `discovery` is `idle` again. */
|
|
130
|
+
setAuth(fetchToken: FetchToken | undefined, authUrl: string): void;
|
|
121
131
|
get isConnected(): boolean;
|
|
122
132
|
/** Whether the node rests `failed` on target `generation` (a `failed` of an older target, not yet replaced by the loop, is not this target's). */
|
|
123
133
|
failedOn(generation: number): boolean;
|
|
@@ -161,6 +171,13 @@ export declare class Node {
|
|
|
161
171
|
/** Starts the loop (once the time domain is final). `first` is a `connect()` call's reply. `signals`: close on SIGINT/SIGTERM (Bun/Node). */
|
|
162
172
|
run(stand: Stand, first: Reply | undefined, signals: boolean): void;
|
|
163
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Dials `dial`, a target named as the CLI names it, through the key server of `auth` (`resolveViaKeyserver`), with `open`.
|
|
176
|
+
* When the relay refuses (`unauthorized`) a cluster token that came from the cache, that token is dropped, one is asked
|
|
177
|
+
* again and dialed once more within this attempt (a token the relay stopped taking before its expiry costs one request,
|
|
178
|
+
* not a `failed`); a second refusal is the attempt's. A token given as such is dialed as it is.
|
|
179
|
+
*/
|
|
180
|
+
export declare function dialByName<T>(dial: Dial, auth: Auth, open: (at: Dial) => Promise<T>): Promise<T>;
|
|
164
181
|
/** Resolves the identity a node declares (`resolve_identity`): a missing name is minted, a `web`/`hmi` node without a `user_id` mints one. */
|
|
165
182
|
export declare function resolveIdentity(o: {
|
|
166
183
|
type: ClientType;
|
|
@@ -168,7 +185,12 @@ export declare function resolveIdentity(o: {
|
|
|
168
185
|
userId?: string;
|
|
169
186
|
credential?: string;
|
|
170
187
|
}): Identity;
|
|
171
|
-
/**
|
|
188
|
+
/**
|
|
189
|
+
* Resolves a `ConnectTarget` against the node's current identity (fields left out are kept). A url that is not a URL is a
|
|
190
|
+
* target as the CLI names it (`infra-1@devel`): its grammar is checked now, and it is resolved at every attempt (through
|
|
191
|
+
* the key server when a person is signed in, else from the CLI's files); with neither (a browser, nobody signed in) it is
|
|
192
|
+
* refused at once.
|
|
193
|
+
*/
|
|
172
194
|
export declare function resolveTarget(node: Node, t: ConnectTarget): Dial;
|
|
173
195
|
/** A given broker url that is not a URL is the caller's mistake: `invalid_input`. */
|
|
174
196
|
export declare function checkUrl(url: string): void;
|
package/dist/node.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// the handles replay when told.
|
|
9
9
|
import { ClockSync, SynapseError, calibrateMonotonicClock, defaultDomId, isValidClientName, mintName, mintUserId, nowUs } from "@synapse-sdk/core";
|
|
10
10
|
import { closeOnSignals, forgetOnSignals } from "./signals.js";
|
|
11
|
+
import { BROWSER_REFUSAL, canReadFiles, isUrl, parseTargetStrict, resolveStr, resolveViaKeyserver } from "./target.js";
|
|
12
|
+
import { Auth, idleDiscovery } from "./keyserver.js";
|
|
11
13
|
import { Session, closeError, defaultMaxDatagram, discoverBrokerCertSha256, makeDial, openSession, urlOrEndpoint } from "./session.js";
|
|
12
14
|
import { startOn } from "./calls.js";
|
|
13
15
|
import { AsyncQueue, Cell, deferred } from "./util.js";
|
|
@@ -84,6 +86,13 @@ export class Node {
|
|
|
84
86
|
/** The loop's mailbox: unbounded, so no `close()` or target change is ever dropped. */
|
|
85
87
|
#commands = new AsyncQueue(Infinity);
|
|
86
88
|
#monitors = new Set();
|
|
89
|
+
/**
|
|
90
|
+
* The signed-in person's key server (`setAuth`), asked when a target named by name is dialed (PLAN v0.38 §3.2);
|
|
91
|
+
* `undefined`: nobody is signed in. Read at each attempt, never by a live session: signing out ends no session.
|
|
92
|
+
*/
|
|
93
|
+
auth;
|
|
94
|
+
/** The key server's answer about the signed-in person, kept by `auth` (`node.discovery`). */
|
|
95
|
+
discovery = new Cell(idleDiscovery());
|
|
87
96
|
epochs = 0;
|
|
88
97
|
reconnects = 0;
|
|
89
98
|
callsSent = 0;
|
|
@@ -98,6 +107,11 @@ export class Node {
|
|
|
98
107
|
this.prepared = opts.finalDom ? opts.finalDom.then(d => { this.dom = d; }) : Promise.resolve();
|
|
99
108
|
}
|
|
100
109
|
get isEnded() { return this.#endedFlag; }
|
|
110
|
+
/** `setAuth`: a new key-server client for `fetchToken` (none: signed out); what the previous one kept is forgotten and `discovery` is `idle` again. */
|
|
111
|
+
setAuth(fetchToken, authUrl) {
|
|
112
|
+
this.auth = fetchToken ? new Auth(fetchToken, authUrl, this.discovery, a => this.auth === a) : undefined;
|
|
113
|
+
this.discovery.set(idleDiscovery());
|
|
114
|
+
}
|
|
101
115
|
get isConnected() { return this.link.get().status === "connected"; }
|
|
102
116
|
/** Whether the node rests `failed` on target `generation` (a `failed` of an older target, not yet replaced by the loop, is not this target's). */
|
|
103
117
|
failedOn(generation) { const l = this.link.get(); return l.status === "failed" && l.generation === generation; }
|
|
@@ -328,9 +342,19 @@ async function runLoop(node, commands, stand, first) {
|
|
|
328
342
|
if (ac.signal.aborted)
|
|
329
343
|
return { kind: "opened", result: new SynapseError("canceled", "superseded") };
|
|
330
344
|
try {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
345
|
+
const open = async (at) => {
|
|
346
|
+
// The pin: given, else discovered per attempt (a restarted dev broker has a fresh certificate), else unpinned.
|
|
347
|
+
const cert = at.cert ?? await discoverBrokerCertSha256(at.url, { signal: ac.signal });
|
|
348
|
+
return openSession(node.knobs, { ...at, cert }, node.dom.id, ac.signal);
|
|
349
|
+
};
|
|
350
|
+
// A URL is dialed as it is. A target named as the CLI names it is resolved now, by one source chosen per attempt
|
|
351
|
+
// (PLAN v0.38 §3.2): the key server when a person is signed in (`setAuth`), else the CLI's files (a token the
|
|
352
|
+
// CLI refreshed since the last attempt is this attempt's). A token given as such wins.
|
|
353
|
+
const auth = node.auth;
|
|
354
|
+
const session = isUrl(dial.url) ? await open(dial)
|
|
355
|
+
: auth ? await dialByName(dial, auth, open)
|
|
356
|
+
: await resolveStr(dial.url).then(r => open({ ...dial, url: r.url, token: dial.token ?? r.token }));
|
|
357
|
+
return { kind: "opened", result: session };
|
|
334
358
|
}
|
|
335
359
|
catch (e) {
|
|
336
360
|
return { kind: "opened", result: e instanceof SynapseError ? e : new SynapseError("connection_lost", { message: String(e), cause: e }) };
|
|
@@ -476,6 +500,26 @@ async function runLoop(node, commands, stand, first) {
|
|
|
476
500
|
}
|
|
477
501
|
}
|
|
478
502
|
}
|
|
503
|
+
/**
|
|
504
|
+
* Dials `dial`, a target named as the CLI names it, through the key server of `auth` (`resolveViaKeyserver`), with `open`.
|
|
505
|
+
* When the relay refuses (`unauthorized`) a cluster token that came from the cache, that token is dropped, one is asked
|
|
506
|
+
* again and dialed once more within this attempt (a token the relay stopped taking before its expiry costs one request,
|
|
507
|
+
* not a `failed`); a second refusal is the attempt's. A token given as such is dialed as it is.
|
|
508
|
+
*/
|
|
509
|
+
export async function dialByName(dial, auth, open) {
|
|
510
|
+
const r = await resolveViaKeyserver(dial.url, auth);
|
|
511
|
+
const at = { ...dial, url: r.url, token: dial.token ?? r.token };
|
|
512
|
+
try {
|
|
513
|
+
return await open(at);
|
|
514
|
+
}
|
|
515
|
+
catch (e) {
|
|
516
|
+
if (!(e instanceof SynapseError) || e.code !== "unauthorized" || dial.token !== undefined || !r.cached || r.cluster === undefined)
|
|
517
|
+
throw e;
|
|
518
|
+
auth.dropToken(r.cluster);
|
|
519
|
+
const { token } = await auth.clusterToken(r.cluster);
|
|
520
|
+
return open({ ...at, token });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
479
523
|
/** Ends session `s`: the node stops naming it (handles are told, the status is true from that instant), then the session is torn down (calls failed, tasks awaited, close sent). */
|
|
480
524
|
async function teardown(node, s, next) {
|
|
481
525
|
node.setLink(next);
|
|
@@ -495,9 +539,25 @@ export function resolveIdentity(o) {
|
|
|
495
539
|
id.credential = o.credential;
|
|
496
540
|
return id;
|
|
497
541
|
}
|
|
498
|
-
/**
|
|
542
|
+
/**
|
|
543
|
+
* Resolves a `ConnectTarget` against the node's current identity (fields left out are kept). A url that is not a URL is a
|
|
544
|
+
* target as the CLI names it (`infra-1@devel`): its grammar is checked now, and it is resolved at every attempt (through
|
|
545
|
+
* the key server when a person is signed in, else from the CLI's files); with neither (a browser, nobody signed in) it is
|
|
546
|
+
* refused at once.
|
|
547
|
+
*/
|
|
499
548
|
export function resolveTarget(node, t) {
|
|
500
|
-
|
|
549
|
+
if (isUrl(t.url))
|
|
550
|
+
checkUrl(t.url);
|
|
551
|
+
else {
|
|
552
|
+
if (!node.auth && !canReadFiles())
|
|
553
|
+
throw new SynapseError("invalid_input", { message: BROWSER_REFUSAL, source: "sdk" });
|
|
554
|
+
try {
|
|
555
|
+
parseTargetStrict(t.url);
|
|
556
|
+
}
|
|
557
|
+
catch (e) {
|
|
558
|
+
throw new SynapseError("invalid_input", { message: `${e instanceof Error ? e.message : String(e)}, or a URL`, source: "sdk" });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
501
561
|
const cur = node.target.get().identity;
|
|
502
562
|
if (t.name !== undefined && !isValidClientName(t.name))
|
|
503
563
|
throw new SynapseError("invalid_input", `name ${JSON.stringify(t.name)} is not [a-z0-9_-]{1,64}`);
|
package/dist/session.d.ts
CHANGED
|
@@ -89,14 +89,24 @@ export declare function describeError(e: unknown): string;
|
|
|
89
89
|
* GOAWAY looks like one).
|
|
90
90
|
*/
|
|
91
91
|
export declare function closeError(err: Error | null): SynapseError;
|
|
92
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* `https://host[:port]` → `http://host:port/certificate.sha256`, the runtime's plain-HTTP helper on the
|
|
94
|
+
* runtime's own port (443 when the URL names none, as the Rust SDK). Before 2026-10-17 the default
|
|
95
|
+
* port was lost and a relay URL was asked on port 80, which no relay answers: a 2 s stall per attempt.
|
|
96
|
+
*/
|
|
93
97
|
export declare function brokerCertHelperUrl(brokerUrl: string): string;
|
|
98
|
+
/** Whether `url` names a root (`/runtimes/<id>`, `/cluster/<c>/runtimes/<id>`): a relay, a deployment with a real certificate, never asked for a pin (`withRoot` makes the same split). */
|
|
99
|
+
export declare function namesRoot(url: string): boolean;
|
|
94
100
|
/**
|
|
95
101
|
* The runtime's URL when the caller gave none: `SYNAPSE_ENDPOINT` (Bun/Node; the runtime sets it for every task it starts).
|
|
96
102
|
* A browser has no environment: there, as with neither, `invalid_input`.
|
|
97
103
|
*/
|
|
98
104
|
export declare function urlOrEndpoint(url: string | undefined): string;
|
|
99
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* The certificate pin when the caller gave none (SDK §13): `SYNAPSE_BROKER_CERT_SHA256` (Bun/Node); else, for a
|
|
107
|
+
* runtime URL (no path: a self-signed certificate, pinned), its helper; a URL that names a root (a relay) is
|
|
108
|
+
* never asked and connects unpinned, against the system's roots. `undefined` = unpinned. Per attempt.
|
|
109
|
+
*/
|
|
100
110
|
export declare function discoverBrokerCertSha256(brokerUrl: string, opts?: {
|
|
101
111
|
timeoutMs?: number;
|
|
102
112
|
signal?: AbortSignal;
|
package/dist/session.js
CHANGED
|
@@ -332,8 +332,20 @@ export function closeError(err) {
|
|
|
332
332
|
return new SynapseError("unauthorized", { message: text.trim(), source: "broker", cause: err });
|
|
333
333
|
return new SynapseError("connection_lost", { message: text.trim() || err.name, source: "transport", cause: err });
|
|
334
334
|
}
|
|
335
|
-
/**
|
|
336
|
-
|
|
335
|
+
/**
|
|
336
|
+
* `https://host[:port]` → `http://host:port/certificate.sha256`, the runtime's plain-HTTP helper on the
|
|
337
|
+
* runtime's own port (443 when the URL names none, as the Rust SDK). Before 2026-10-17 the default
|
|
338
|
+
* port was lost and a relay URL was asked on port 80, which no relay answers: a 2 s stall per attempt.
|
|
339
|
+
*/
|
|
340
|
+
export function brokerCertHelperUrl(brokerUrl) { const u = new URL(brokerUrl); return `http://${u.hostname}:${u.port || (u.protocol === "http:" ? "80" : "443")}/certificate.sha256`; }
|
|
341
|
+
/** Whether `url` names a root (`/runtimes/<id>`, `/cluster/<c>/runtimes/<id>`): a relay, a deployment with a real certificate, never asked for a pin (`withRoot` makes the same split). */
|
|
342
|
+
export function namesRoot(url) { try {
|
|
343
|
+
const p = new URL(url).pathname;
|
|
344
|
+
return p !== "" && p !== "/";
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return false;
|
|
348
|
+
} }
|
|
337
349
|
/**
|
|
338
350
|
* The runtime's URL when the caller gave none: `SYNAPSE_ENDPOINT` (Bun/Node; the runtime sets it for every task it starts).
|
|
339
351
|
* A browser has no environment: there, as with neither, `invalid_input`.
|
|
@@ -347,22 +359,35 @@ export function urlOrEndpoint(url) {
|
|
|
347
359
|
throw new SynapseError("invalid_input", { message: "no url given and SYNAPSE_ENDPOINT is not set", source: "sdk" });
|
|
348
360
|
}
|
|
349
361
|
const SHA256_HEX = /^[0-9a-f]{64}$/i;
|
|
350
|
-
/**
|
|
362
|
+
/**
|
|
363
|
+
* The certificate pin when the caller gave none (SDK §13): `SYNAPSE_BROKER_CERT_SHA256` (Bun/Node); else, for a
|
|
364
|
+
* runtime URL (no path: a self-signed certificate, pinned), its helper; a URL that names a root (a relay) is
|
|
365
|
+
* never asked and connects unpinned, against the system's roots. `undefined` = unpinned. Per attempt.
|
|
366
|
+
*/
|
|
351
367
|
export async function discoverBrokerCertSha256(brokerUrl, opts = {}) {
|
|
352
368
|
const env = globalThis.process?.env?.SYNAPSE_BROKER_CERT_SHA256?.trim();
|
|
353
369
|
if (env)
|
|
354
370
|
return env;
|
|
355
|
-
|
|
356
|
-
try {
|
|
357
|
-
const res = await fetch(brokerCertHelperUrl(brokerUrl), { signal });
|
|
358
|
-
if (!res.ok)
|
|
359
|
-
return undefined;
|
|
360
|
-
const text = (await res.text()).trim();
|
|
361
|
-
return SHA256_HEX.test(text) ? text : undefined;
|
|
362
|
-
}
|
|
363
|
-
catch {
|
|
371
|
+
if (namesRoot(brokerUrl))
|
|
364
372
|
return undefined;
|
|
365
|
-
|
|
373
|
+
const timeoutMs = opts.timeoutMs ?? 2000;
|
|
374
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
|
|
375
|
+
const fetched = (async () => {
|
|
376
|
+
try {
|
|
377
|
+
const res = await fetch(brokerCertHelperUrl(brokerUrl), { signal });
|
|
378
|
+
if (!res.ok)
|
|
379
|
+
return undefined;
|
|
380
|
+
const text = (await res.text()).trim();
|
|
381
|
+
return SHA256_HEX.test(text) ? text : undefined;
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
})();
|
|
387
|
+
// Bun does not abort a connect that gets no answer (measured 2026-10-17: a filtered port hung the dial for
|
|
388
|
+
// good); the race bounds the wait whatever the runtime does with the signal.
|
|
389
|
+
const r = await withTimeout(fetched, timeoutMs);
|
|
390
|
+
return r === TIMEOUT ? undefined : r;
|
|
366
391
|
}
|
|
367
392
|
/**
|
|
368
393
|
* `token_from_url`: `url` without its `jwt=` query parameters, and the last non-empty one (as the relays
|
package/dist/synapse.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type Age, type ErrorBody, type ProjectSnapshot, type ServiceContract, t
|
|
|
2
2
|
import { Node } from "./node.ts";
|
|
3
3
|
import { StoreHandleImpl } from "./store.ts";
|
|
4
4
|
import { type Warn } from "./topics.ts";
|
|
5
|
-
import type { CallOptions, Catalog, ConnectOptions, ConnectTarget, ConnectionState, Envelope, Info, Metrics, Publisher, Registration, Run, ServeHandler, ServeOptions, SceneHandle, StoreHandle, StoreOptions, SubscribeOptions, Subscription, Tasks, View, ViewOptions } from "./types.ts";
|
|
5
|
+
import type { CallOptions, Catalog, ConnectOptions, ConnectTarget, ConnectionState, Discovery, DiscoveryState, Envelope, FetchToken, Info, Metrics, Publisher, Registration, Run, ServeHandler, ServeOptions, SceneHandle, StoreHandle, StoreOptions, SubscribeOptions, Subscription, Tasks, View, ViewOptions } from "./types.ts";
|
|
6
6
|
import { type StateValue } from "./util.ts";
|
|
7
7
|
export { FIRST_CONNECT_GIVE_UP_MS } from "./node.ts";
|
|
8
8
|
export declare class Synapse implements AsyncDisposable {
|
|
@@ -15,7 +15,8 @@ export declare class Synapse implements AsyncDisposable {
|
|
|
15
15
|
*/
|
|
16
16
|
constructor(opts: ConnectOptions);
|
|
17
17
|
/**
|
|
18
|
-
* Connects the node to `target` (a string is the url
|
|
18
|
+
* Connects the node to `target` (a string is the url: a URL, or in Bun/Node a target such as `"infra-1@devel"`, see
|
|
19
|
+
* `ConnectTarget.url`), the one connect verb: the first connect, and how an HMI moves
|
|
19
20
|
* from one robot's runtime to another. The current session, if any, is torn down before `target` is dialed; every
|
|
20
21
|
* handle re-binds to the new session as across a reconnect. Identity fields `target` leaves out keep the node's.
|
|
21
22
|
*
|
|
@@ -41,6 +42,30 @@ export declare class Synapse implements AsyncDisposable {
|
|
|
41
42
|
* resolves quietly). Resolves once the session is gone; already `idle`: at once. `not_connected` once the node has ended.
|
|
42
43
|
*/
|
|
43
44
|
disconnect(): Promise<void>;
|
|
45
|
+
/**
|
|
46
|
+
* Signs a person in for connecting by name (`connect("infra-1@devel")`) through the key server: `fetchToken` gives the
|
|
47
|
+
* person's credential and is called each time the key server is asked (never kept); `authUrl` is the key server
|
|
48
|
+
* (default `https://e9e413af.synapse.build`; an `http://` one is taken as it is, for a key server on your own machine while
|
|
49
|
+
* developing, never in production: the credential travels in the header). `setAuth(undefined)` signs out. Either way,
|
|
50
|
+
* what the node kept from the key server (your clusters, the cluster tokens) is forgotten and `discovery` is `idle` again.
|
|
51
|
+
*
|
|
52
|
+
* A live session is never touched: the sign-in is read only when the node dials, so signing out ends nothing now, and
|
|
53
|
+
* the next dial of a target named by name fails `unauthorized` (`failed`). With a person signed in, a target named by
|
|
54
|
+
* name is resolved through the key server also in Bun/Node, instead of the CLI's files. Call it before `connect()`.
|
|
55
|
+
*/
|
|
56
|
+
setAuth(fetchToken: FetchToken | undefined, opts?: {
|
|
57
|
+
authUrl?: string;
|
|
58
|
+
}): void;
|
|
59
|
+
/**
|
|
60
|
+
* Asks the key server for the signed-in person's clusters and their runtimes, sets `discovery` from the answer and
|
|
61
|
+
* resolves with it; one request at a time (a call while one is out joins it). Rejects `unauthorized` when nobody is
|
|
62
|
+
* signed in (`setAuth`; `discovery` stays `idle`) or the key server does not take the sign-in, `invalid_input` when it
|
|
63
|
+
* refuses the request, `connection_lost` when it cannot be reached or is busy (its sentence when it gave one). Without
|
|
64
|
+
* a sign-in it does not read the CLI's files.
|
|
65
|
+
*/
|
|
66
|
+
discover(): Promise<Discovery>;
|
|
67
|
+
/** What the key server said about the signed-in person (`discover()`), a state value: `get()` now, `onChange` for each change, `changed()` for the next one, `watch()` for each one in a loop. Connecting by name reads the same answer. */
|
|
68
|
+
readonly discovery: StateValue<DiscoveryState>;
|
|
44
69
|
/** The node's engine (the SDK's own modules and tests use it). */
|
|
45
70
|
get node(): Node;
|
|
46
71
|
/** Where the node stands (`ConnectionState`), a state value: `get()` now, `onChange` for each change, `changed()` for the next one, `watch()` for each one in a loop. */
|
package/dist/synapse.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { SynapseError, ageOf, fecSymbolSizeFor, isServiceContract, nowUs, resolveTopic, serviceName, } from "@synapse-sdk/core";
|
|
6
6
|
import { failedRun, startOn } from "./calls.js";
|
|
7
7
|
import { Node, createNode, endpointDial, resolveTarget } from "./node.js";
|
|
8
|
+
import { DEFAULT_AUTH_URL, notSignedIn } from "./keyserver.js";
|
|
8
9
|
import { ServiceRegistration } from "./serve.js";
|
|
9
10
|
import { openScene } from "./scene.js";
|
|
10
11
|
import { Namespace, StoreHandleImpl, attachStore, openStore } from "./store.js";
|
|
@@ -25,18 +26,21 @@ function descKey(d) {
|
|
|
25
26
|
return JSON.stringify({ delivery: d.delivery, content_type: d.content_type, schema_hash: d.schema_hash ?? null, fec: d.fec ?? null });
|
|
26
27
|
}
|
|
27
28
|
/**
|
|
28
|
-
* A fact of the node as a state value (`node.status`, `node.catalog`, `node.project`): `read` is the fact
|
|
29
|
-
* object while it is unchanged. It changes with the node's link
|
|
30
|
-
* that `watched` names; a callback is rebound to the new
|
|
29
|
+
* A fact of the node as a state value (`node.status`, `node.catalog`, `node.project`, `node.discovery`): `read` is the fact
|
|
30
|
+
* now, the same object while it is unchanged. It changes with the node's link, with the node's own values `kept` names,
|
|
31
|
+
* and, while connected, with the current session's values that `watched` names; a callback is rebound to the new
|
|
32
|
+
* session's values on every link change.
|
|
31
33
|
*/
|
|
32
34
|
class NodeCell {
|
|
33
35
|
node;
|
|
34
36
|
read;
|
|
35
37
|
watched;
|
|
36
|
-
|
|
38
|
+
kept;
|
|
39
|
+
constructor(node, read, watched, kept = []) {
|
|
37
40
|
this.node = node;
|
|
38
41
|
this.read = read;
|
|
39
42
|
this.watched = watched;
|
|
43
|
+
this.kept = kept;
|
|
40
44
|
}
|
|
41
45
|
get() { return this.read(); }
|
|
42
46
|
/** `cb` with the value on each change of what it is read from; returns the remover. */
|
|
@@ -51,7 +55,9 @@ class NodeCell {
|
|
|
51
55
|
};
|
|
52
56
|
bind();
|
|
53
57
|
const unLink = this.node.link.subscribe(() => { bind(); listener(); });
|
|
54
|
-
|
|
58
|
+
const unKept = this.kept.map(c => c.subscribe(listener));
|
|
59
|
+
return () => { unLink(); for (const u of unKept)
|
|
60
|
+
u(); for (const u of un)
|
|
55
61
|
u(); };
|
|
56
62
|
}
|
|
57
63
|
/** The value after its next change; once the node ends, the value then, whether or not it changed (nothing will change any more). */
|
|
@@ -104,9 +110,11 @@ export class Synapse {
|
|
|
104
110
|
return lastCatalog;
|
|
105
111
|
}, s => [s.topics, s.services]);
|
|
106
112
|
this.project = new NodeCell(n, () => n.currentSession()?.project.get(), s => [s.project]);
|
|
113
|
+
this.discovery = new NodeCell(n, () => n.discovery.get(), () => [], [n.discovery]);
|
|
107
114
|
}
|
|
108
115
|
/**
|
|
109
|
-
* Connects the node to `target` (a string is the url
|
|
116
|
+
* Connects the node to `target` (a string is the url: a URL, or in Bun/Node a target such as `"infra-1@devel"`, see
|
|
117
|
+
* `ConnectTarget.url`), the one connect verb: the first connect, and how an HMI moves
|
|
110
118
|
* from one robot's runtime to another. The current session, if any, is torn down before `target` is dialed; every
|
|
111
119
|
* handle re-binds to the new session as across a reconnect. Identity fields `target` leaves out keep the node's.
|
|
112
120
|
*
|
|
@@ -149,6 +157,34 @@ export class Synapse {
|
|
|
149
157
|
* resolves quietly). Resolves once the session is gone; already `idle`: at once. `not_connected` once the node has ended.
|
|
150
158
|
*/
|
|
151
159
|
disconnect() { return this.#node.declare(undefined); }
|
|
160
|
+
// -------------------------------------------------------------------------------- sign-in
|
|
161
|
+
/**
|
|
162
|
+
* Signs a person in for connecting by name (`connect("infra-1@devel")`) through the key server: `fetchToken` gives the
|
|
163
|
+
* person's credential and is called each time the key server is asked (never kept); `authUrl` is the key server
|
|
164
|
+
* (default `https://e9e413af.synapse.build`; an `http://` one is taken as it is, for a key server on your own machine while
|
|
165
|
+
* developing, never in production: the credential travels in the header). `setAuth(undefined)` signs out. Either way,
|
|
166
|
+
* what the node kept from the key server (your clusters, the cluster tokens) is forgotten and `discovery` is `idle` again.
|
|
167
|
+
*
|
|
168
|
+
* A live session is never touched: the sign-in is read only when the node dials, so signing out ends nothing now, and
|
|
169
|
+
* the next dial of a target named by name fails `unauthorized` (`failed`). With a person signed in, a target named by
|
|
170
|
+
* name is resolved through the key server also in Bun/Node, instead of the CLI's files. Call it before `connect()`.
|
|
171
|
+
*/
|
|
172
|
+
setAuth(fetchToken, opts = {}) { this.#node.setAuth(fetchToken, opts.authUrl ?? DEFAULT_AUTH_URL); }
|
|
173
|
+
/**
|
|
174
|
+
* Asks the key server for the signed-in person's clusters and their runtimes, sets `discovery` from the answer and
|
|
175
|
+
* resolves with it; one request at a time (a call while one is out joins it). Rejects `unauthorized` when nobody is
|
|
176
|
+
* signed in (`setAuth`; `discovery` stays `idle`) or the key server does not take the sign-in, `invalid_input` when it
|
|
177
|
+
* refuses the request, `connection_lost` when it cannot be reached or is busy (its sentence when it gave one). Without
|
|
178
|
+
* a sign-in it does not read the CLI's files.
|
|
179
|
+
*/
|
|
180
|
+
discover() {
|
|
181
|
+
const auth = this.#node.auth;
|
|
182
|
+
const p = auth ? auth.discover() : Promise.reject(notSignedIn());
|
|
183
|
+
p.catch(() => { });
|
|
184
|
+
return p;
|
|
185
|
+
}
|
|
186
|
+
/** What the key server said about the signed-in person (`discover()`), a state value: `get()` now, `onChange` for each change, `changed()` for the next one, `watch()` for each one in a loop. Connecting by name reads the same answer. */
|
|
187
|
+
discovery;
|
|
152
188
|
/** The node's engine (the SDK's own modules and tests use it). */
|
|
153
189
|
get node() { return this.#node; }
|
|
154
190
|
#session() { return this.#node.currentSession(); }
|
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
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -41,6 +41,18 @@ export interface ConnectTarget {
|
|
|
41
41
|
/**
|
|
42
42
|
* The runtime, `https://host:4470` (the path to dial is learned from the runtime at every attempt). Through a relay: the relay with
|
|
43
43
|
* the runtime's path, `https://<relay>/cluster/<cluster>/runtimes/<runtime_id>`, and `token`. A `jwt=` in the URL is taken as the token.
|
|
44
|
+
*
|
|
45
|
+
* Or, in Bun/Node, a target as the CLI's `-t` names it (anything without `<scheme>://`): `"infra-1@devel"` (runtime `infra-1`
|
|
46
|
+
* of cluster `devel`), `"infra-1"` (in the default cluster), `"arm@medatech/devel"` (an org's cluster, by its slug). The relay
|
|
47
|
+
* URL and the token come from the CLI's files on this machine (`~/.synapse/cluster.toml` and the tokens beside it), read again
|
|
48
|
+
* at every attempt, so a token the CLI refreshed is used on the next reconnect (`token` given here wins). The SDK only reads
|
|
49
|
+
* those files: `synapse login` and `synapse cluster login <cluster>` first. A name this machine does not know is
|
|
50
|
+
* `invalid_input`, a missing or expired token `unauthorized`, each naming the CLI command that fixes it.
|
|
51
|
+
*
|
|
52
|
+
* With a person signed in (`node.setAuth`), in a browser or in Bun/Node, the same names are resolved through the key
|
|
53
|
+
* server instead, from your clusters: `"infra-1@devel"`, `"arm@medatech/devel"` (the cluster is always named). A name
|
|
54
|
+
* not in your clusters is `invalid_input`; nobody signed in, or a sign-in the key server does not take, `unauthorized`.
|
|
55
|
+
* In a browser with nobody signed in, a name is refused at once (`invalid_input`): sign in, or pass the relay URL and a token.
|
|
44
56
|
*/
|
|
45
57
|
url: string;
|
|
46
58
|
/** The type to declare there; left out keeps the node's current one. */
|
|
@@ -51,7 +63,7 @@ export interface ConnectTarget {
|
|
|
51
63
|
userId?: string;
|
|
52
64
|
/** The credential to present there; left out keeps the node's current one. Never printed. */
|
|
53
65
|
credential?: string;
|
|
54
|
-
/** Pin of *this*
|
|
66
|
+
/** Pin of *this* runtime's self-signed certificate (SHA-256 hex). Absent: `SYNAPSE_BROKER_CERT_SHA256`, then, for a runtime URL (no path), its helper; a URL naming a root (a relay) connects unpinned against the system's roots (per attempt). */
|
|
55
67
|
brokerCertSha256?: string;
|
|
56
68
|
/** The relay token for *this* target; left out = no token (a token for another runtime would be refused). Never printed. */
|
|
57
69
|
token?: string;
|
|
@@ -85,6 +97,42 @@ export type ConnectionState = {
|
|
|
85
97
|
status: "failed";
|
|
86
98
|
error: SynapseError;
|
|
87
99
|
};
|
|
100
|
+
/**
|
|
101
|
+
* Gives the signed-in person's credential for the key server, fetched each time the node asks it (`setAuth`);
|
|
102
|
+
* `null` or `undefined` means nobody is signed in.
|
|
103
|
+
*/
|
|
104
|
+
export type FetchToken = () => Promise<string | null | undefined>;
|
|
105
|
+
/** The key server's answer about the signed-in person (`node.discover()`): the relay to dial and your clusters with their runtimes. */
|
|
106
|
+
export interface Discovery {
|
|
107
|
+
/** The relay a runtime of these clusters is dialed through. */
|
|
108
|
+
relay: string;
|
|
109
|
+
/** The signed-in person. */
|
|
110
|
+
userId: string;
|
|
111
|
+
/** Your clusters: your own, and those of the organizations you belong to. */
|
|
112
|
+
clusters: {
|
|
113
|
+
id: string;
|
|
114
|
+
name: string;
|
|
115
|
+
/** Who owns it: `kind` `user` (yours) or `org`, its `id`, and the organization's `slug` when it has one. */
|
|
116
|
+
owner: {
|
|
117
|
+
kind: string;
|
|
118
|
+
id: string;
|
|
119
|
+
slug?: string;
|
|
120
|
+
};
|
|
121
|
+
/** How `connect()` names it: `name` for yours, `<slug>/<name>` (or `<org id>/<name>`) for an organization's: `"infra-1@" + label`. */
|
|
122
|
+
label: string;
|
|
123
|
+
/** Its runtimes, name → runtime id. */
|
|
124
|
+
runtimes: Record<string, string>;
|
|
125
|
+
}[];
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* `node.discovery`: `idle` with nobody signed in (or just after `setAuth`), `pending` while the key server is asked,
|
|
129
|
+
* `success` with the answer, `error` with why it failed (`data` still holds the last answer, if there was one).
|
|
130
|
+
*/
|
|
131
|
+
export interface DiscoveryState {
|
|
132
|
+
status: "idle" | "pending" | "success" | "error";
|
|
133
|
+
data?: Discovery;
|
|
134
|
+
error?: SynapseError;
|
|
135
|
+
}
|
|
88
136
|
/** This node's identity and the current session's hello facts; empty strings between sessions. */
|
|
89
137
|
export interface Info {
|
|
90
138
|
name: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synapse-sdk/client",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"description": "Synapse SDK client: connect a node to the runtime; topics, services, calls, store, video.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
"dist"
|
|
15
15
|
],
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@synapse-sdk/core": "0.3.
|
|
18
|
-
"@moq/net": "0.4.2"
|
|
17
|
+
"@synapse-sdk/core": "0.3.7",
|
|
18
|
+
"@moq/net": "0.4.2",
|
|
19
|
+
"smol-toml": "1.9.0"
|
|
19
20
|
},
|
|
20
21
|
"peerDependencies": {
|
|
21
22
|
"@moq/web-transport": "0.1.5"
|