@fungi.computer/seam 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -2
- package/dist/index.d.ts +36 -5
- package/dist/index.js +31 -20
- package/dist/machine-link-post.d.ts +38 -0
- package/dist/machine-link-post.js +129 -0
- package/dist/machine-link-protocol.d.ts +88 -0
- package/dist/machine-link-protocol.js +67 -0
- package/package.json +9 -2
package/README.md
CHANGED
|
@@ -6,6 +6,13 @@ replay/signature tests retain their original behavior. Keys remain with the
|
|
|
6
6
|
operator; no keys are stored in this package. The package ships the protocol and
|
|
7
7
|
replay guard; hosts own key provisioning.
|
|
8
8
|
|
|
9
|
+
`@fungi.computer/seam/machine-link-protocol` owns the shared machine-link
|
|
10
|
+
audience/dial URL, bounded read/leave and Agent-operation grammar, Agent mail
|
|
11
|
+
address schema, and `createMachineLinkPostChannel`. The channel carries ordinary
|
|
12
|
+
Carrier Post envelopes and receipts; Carrier retains retries. Hosts own live
|
|
13
|
+
authorization, socket lifetime and durable replay storage. Delegation request
|
|
14
|
+
keys and execution policy remain with the delegation engine, not Seam.
|
|
15
|
+
|
|
9
16
|
Run `pnpm --filter @fungi.computer/seam test` for signature, audience, body and
|
|
10
17
|
replay laws. Computer and game-server host tests exercise its real consumers.
|
|
11
18
|
|
|
@@ -28,8 +35,13 @@ import { SeamReplayGuard } from "@fungi.computer/seam";
|
|
|
28
35
|
|
|
29
36
|
const replay = new SeamReplayGuard();
|
|
30
37
|
const now = Date.now();
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
const record = {
|
|
39
|
+
nonce: "00112233445566778899aabbccddeeff",
|
|
40
|
+
now,
|
|
41
|
+
expiresAt: now + 120_000,
|
|
42
|
+
};
|
|
43
|
+
console.log(replay.admit(record)); // admitted
|
|
44
|
+
console.log(replay.admit(record)); // replayed
|
|
33
45
|
```
|
|
34
46
|
|
|
35
47
|
## Core concepts
|
|
@@ -38,6 +50,20 @@ The sender signs method, audience, path, timestamp, nonce and payload digest.
|
|
|
38
50
|
The receiver checks the signature, freshness and replay guard against its pinned
|
|
39
51
|
keys.
|
|
40
52
|
|
|
53
|
+
`verifySeamRequest` accepts one `SeamReplayStore`. It verifies the signature
|
|
54
|
+
before awaiting atomic admission, and supplies a deadline covering the complete
|
|
55
|
+
freshness window, including its last instant. The store must preserve that
|
|
56
|
+
deadline, record before reporting success, and refuse at its finite capacity
|
|
57
|
+
rather than evict still-live nonces. Its closed decisions are `admitted`,
|
|
58
|
+
`replayed`, `at-capacity`, and `unavailable`; all refusals fail verification.
|
|
59
|
+
|
|
60
|
+
`SeamReplayGuard` is the process-local implementation, with a private
|
|
61
|
+
10,000-record ceiling. It is not persistent. A durable receiver must bind one
|
|
62
|
+
store to its reconstruction-stable receiver namespace; storage and numeric
|
|
63
|
+
capacity are host implementation decisions. There is no memory fallback on
|
|
64
|
+
persistence failure. A record remains live when `expiresAt === now`; prune only
|
|
65
|
+
strictly older deadlines, without assuming insertion order is expiry order.
|
|
66
|
+
|
|
41
67
|
## Examples and checks
|
|
42
68
|
|
|
43
69
|
The packed runtime fixture at `test/stranger/runtime.mjs` exercises the public
|
package/dist/index.d.ts
CHANGED
|
@@ -17,11 +17,16 @@
|
|
|
17
17
|
* This module is plain Web Crypto so the Worker and the Node.js host share it.
|
|
18
18
|
*/
|
|
19
19
|
export declare const seamHeader = "x-fungi-seam";
|
|
20
|
+
/** Digest sentinel for a body whose integrity is supplied by its transport. */
|
|
20
21
|
export declare const unsignedPayload = "UNSIGNED-PAYLOAD";
|
|
22
|
+
/** Sender identity and private Ed25519 key used to sign seam requests. */
|
|
21
23
|
export type SeamSigner = Readonly<{
|
|
24
|
+
/** Key identifier included in the signed bytes and the request header. */
|
|
22
25
|
keyId: string;
|
|
26
|
+
/** Private Ed25519 key with signing usage; custody stays with the sender. */
|
|
23
27
|
key: CryptoKey;
|
|
24
28
|
}>;
|
|
29
|
+
/** Receiver-pinned public Ed25519 verification keys, indexed by sender key ID. */
|
|
25
30
|
export type SeamVerifiers = ReadonlyMap<string, CryptoKey>;
|
|
26
31
|
/** `keyId:base64(pkcs8)`, the form a Worker secret or key file holds. */
|
|
27
32
|
export declare function importSeamSigner(secret: string): Promise<SeamSigner>;
|
|
@@ -31,15 +36,41 @@ export declare function importSeamVerifiers(publicKeys: Readonly<Record<string,
|
|
|
31
36
|
export declare function seamBodyDigest(body: ArrayBuffer | Uint8Array<ArrayBuffer> | null): Promise<string>;
|
|
32
37
|
/** The `x-fungi-seam` header value for one request. */
|
|
33
38
|
export declare function signSeamRequest(signer: SeamSigner, audience: string, method: string, url: string, digest: string, now?: number): Promise<string>;
|
|
34
|
-
/**
|
|
35
|
-
export
|
|
39
|
+
/** An atomic admission decision; refusal never discards still-live history. */
|
|
40
|
+
export type SeamReplayAdmission = "admitted" | "replayed" | "at-capacity" | "unavailable";
|
|
41
|
+
/** Seam supplies the retention deadline, inclusive of its last fresh instant. */
|
|
42
|
+
export type SeamReplayRecord = Readonly<{
|
|
43
|
+
/** Authenticated request nonce, unique within this logical receiver's history. */
|
|
44
|
+
nonce: string;
|
|
45
|
+
/** Receiver's current time in Unix epoch milliseconds. */
|
|
46
|
+
now: number;
|
|
47
|
+
/**
|
|
48
|
+
* Inclusive retention deadline in Unix epoch milliseconds.
|
|
49
|
+
* Prune only when this deadline is strictly earlier than `now`.
|
|
50
|
+
*/
|
|
51
|
+
expiresAt: number;
|
|
52
|
+
}>;
|
|
53
|
+
/**
|
|
54
|
+
* One logical receiver's bounded replay history. A durable host must reopen
|
|
55
|
+
* the same receiver namespace after reconstruction, never fall back to memory.
|
|
56
|
+
* Admission atomically prunes expired records, checks nonce uniqueness and the
|
|
57
|
+
* implementation's finite capacity, then records the nonce before answering
|
|
58
|
+
* `admitted`. Persist before success; never shorten Seam's inclusive deadline.
|
|
59
|
+
* Storage mechanics and the numeric capacity belong to the implementation.
|
|
60
|
+
*/
|
|
61
|
+
export type SeamReplayStore = Readonly<{
|
|
62
|
+
/** Atomically admit one nonce or refuse it; commit before reporting admission. */
|
|
63
|
+
admit: (record: SeamReplayRecord) => SeamReplayAdmission | Promise<SeamReplayAdmission>;
|
|
64
|
+
}>;
|
|
65
|
+
/** Bounded process-local history; deliberately not durable across reconstruction. */
|
|
66
|
+
export declare class SeamReplayGuard implements SeamReplayStore {
|
|
36
67
|
#private;
|
|
37
|
-
/** Record
|
|
38
|
-
admit(nonce
|
|
68
|
+
/** Record one nonce, refusing capacity rather than evicting live history. */
|
|
69
|
+
admit({ nonce, now, expiresAt }: SeamReplayRecord): SeamReplayAdmission;
|
|
39
70
|
}
|
|
40
71
|
/**
|
|
41
72
|
* Verify one request's seam header. `digest` is the receiver's own digest of
|
|
42
73
|
* the buffered body, or `null` when the route accepts an unsigned stream.
|
|
43
74
|
* Answers the sender's key id.
|
|
44
75
|
*/
|
|
45
|
-
export declare function verifySeamRequest(verifiers: SeamVerifiers, audience: string, method: string, url: string, header: string | null, digest: string | null, replay:
|
|
76
|
+
export declare function verifySeamRequest(verifiers: SeamVerifiers, audience: string, method: string, url: string, header: string | null, digest: string | null, replay: SeamReplayStore, now?: number): Promise<string>;
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* This module is plain Web Crypto so the Worker and the Node.js host share it.
|
|
18
18
|
*/
|
|
19
19
|
export const seamHeader = "x-fungi-seam";
|
|
20
|
+
/** Digest sentinel for a body whose integrity is supplied by its transport. */
|
|
20
21
|
export const unsignedPayload = "UNSIGNED-PAYLOAD";
|
|
21
22
|
const version = "fungi-seam-v1";
|
|
22
23
|
const maximumSkewMs = 60_000;
|
|
@@ -104,32 +105,27 @@ export async function signSeamRequest(signer, audience, method, url, digest, now
|
|
|
104
105
|
const signature = await crypto.subtle.sign({ name: "Ed25519" }, signer.key, canonical(audience, signer.keyId, method, pathAndQueryOf(url), now, nonce, digest));
|
|
105
106
|
return `v1 key=${signer.keyId} ts=${String(now)} nonce=${nonce} body=${digest} sig=${base64url(signature)}`;
|
|
106
107
|
}
|
|
107
|
-
/**
|
|
108
|
+
/** Bounded process-local history; deliberately not durable across reconstruction. */
|
|
108
109
|
export class SeamReplayGuard {
|
|
109
110
|
#seen = new Map();
|
|
110
|
-
/** Record
|
|
111
|
-
admit(nonce, now) {
|
|
111
|
+
/** Record one nonce, refusing capacity rather than evicting live history. */
|
|
112
|
+
admit({ nonce, now, expiresAt }) {
|
|
112
113
|
this.#prune(now);
|
|
113
114
|
if (this.#seen.has(nonce))
|
|
114
|
-
return
|
|
115
|
-
this.#
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
return "replayed";
|
|
116
|
+
if (this.#seen.size >= maximumNonces)
|
|
117
|
+
return "at-capacity";
|
|
118
|
+
this.#seen.set(nonce, expiresAt);
|
|
119
|
+
return "admitted";
|
|
118
120
|
}
|
|
119
121
|
#prune(now) {
|
|
120
122
|
for (const [seen, expires] of this.#seen) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
123
|
+
// Freshness accepts the boundary instant. Clock steps also mean Map
|
|
124
|
+
// insertion order need not be expiry order: inspect every bounded entry.
|
|
125
|
+
if (expires < now)
|
|
126
|
+
this.#seen.delete(seen);
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
|
-
#makeRoom() {
|
|
127
|
-
if (this.#seen.size < maximumNonces)
|
|
128
|
-
return;
|
|
129
|
-
const oldest = this.#seen.keys().next();
|
|
130
|
-
if (!oldest.done)
|
|
131
|
-
this.#seen.delete(oldest.value);
|
|
132
|
-
}
|
|
133
129
|
}
|
|
134
130
|
function seamFields(value) {
|
|
135
131
|
const fields = new Map();
|
|
@@ -187,9 +183,24 @@ export async function verifySeamRequest(verifiers, audience, method, url, header
|
|
|
187
183
|
const valid = await crypto.subtle.verify({ name: "Ed25519" }, key, bytesFromBase64url(parsed.signature), canonical(audience, parsed.keyId, method, pathAndQueryOf(url), parsed.timestamp, parsed.nonce, parsed.digest));
|
|
188
184
|
if (!valid)
|
|
189
185
|
throw new SeamAuthError("bad signature");
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
186
|
+
const admission = await replay.admit({
|
|
187
|
+
nonce: parsed.nonce,
|
|
188
|
+
now,
|
|
189
|
+
// A timestamp first admitted at ts-skew remains fresh through ts+skew.
|
|
190
|
+
expiresAt: now + 2 * maximumSkewMs,
|
|
191
|
+
});
|
|
192
|
+
switch (admission) {
|
|
193
|
+
case "admitted":
|
|
194
|
+
return parsed.keyId;
|
|
195
|
+
case "replayed":
|
|
196
|
+
throw new SeamAuthError("replayed nonce");
|
|
197
|
+
case "at-capacity":
|
|
198
|
+
throw new SeamAuthError("replay capacity reached");
|
|
199
|
+
case "unavailable":
|
|
200
|
+
throw new SeamAuthError("replay store unavailable");
|
|
201
|
+
default:
|
|
202
|
+
throw new SeamAuthError("replay store unavailable");
|
|
203
|
+
}
|
|
193
204
|
}
|
|
194
205
|
function assertSeamFreshness(parsed, digest, now) {
|
|
195
206
|
if (Math.abs(now - parsed.timestamp) > maximumSkewMs)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type PostDeliveryResult, type PostEnvelope } from "@fungi.computer/carrier/post";
|
|
2
|
+
declare const MachineLinkPostUnavailable_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
3
|
+
readonly _tag: "MachineLinkPostUnavailable";
|
|
4
|
+
} & Readonly<A>;
|
|
5
|
+
/** No receiver receipt was obtained. Carrier, not Seam, retains retries. */
|
|
6
|
+
export declare class MachineLinkPostUnavailable extends MachineLinkPostUnavailable_base<{
|
|
7
|
+
/** Transient transport failure; none of these asserts command refusal. */
|
|
8
|
+
readonly reason: "closed" | "busy" | "timed_out" | "send_failed";
|
|
9
|
+
/** The native send failure, when the platform supplied one. */
|
|
10
|
+
readonly cause?: unknown;
|
|
11
|
+
}> {
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* One connection's bounded Post I/O, without an outbox, dedupe store or RPC
|
|
15
|
+
* capability table. Native socket ownership and authentication stay with the
|
|
16
|
+
* host. Reconstruct after hibernation when idle; close on disconnect/restart
|
|
17
|
+
* and let the existing Carrier outbox retry the same frozen envelope.
|
|
18
|
+
*
|
|
19
|
+
* Receipt payloads remain opaque. The Agent mail route still requires a real
|
|
20
|
+
* Shiitake Accepted receipt; a generic Post acknowledgment is not one.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createMachineLinkPostChannel(options: {
|
|
23
|
+
send: (frame: string) => void | Promise<void>;
|
|
24
|
+
receive: (envelope: PostEnvelope) => Promise<PostDeliveryResult>;
|
|
25
|
+
limits: {
|
|
26
|
+
maxInFlight: number;
|
|
27
|
+
maxFrameBytes: number;
|
|
28
|
+
acknowledgmentMs: number;
|
|
29
|
+
};
|
|
30
|
+
}): {
|
|
31
|
+
/** Send now; never acknowledge custody before the receiver replies. */
|
|
32
|
+
deliver: (envelope: PostEnvelope) => Promise<PostDeliveryResult>;
|
|
33
|
+
/** Called only for a host-authenticated, current native socket. */
|
|
34
|
+
receive: (text: string) => Promise<void>;
|
|
35
|
+
/** Forget only pending I/O. The durable sender still owns undelivered work. */
|
|
36
|
+
close: () => void;
|
|
37
|
+
};
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import * as Data from "effect/Data";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import { PostDeliveryResultSchema, PostEnvelopeSchema, } from "@fungi.computer/carrier/post";
|
|
4
|
+
const identitySchema = Schema.Struct({
|
|
5
|
+
key: PostEnvelopeSchema.fields.key,
|
|
6
|
+
kind: PostEnvelopeSchema.fields.kind,
|
|
7
|
+
address: PostEnvelopeSchema.fields.address,
|
|
8
|
+
});
|
|
9
|
+
const frameSchema = Schema.Union([
|
|
10
|
+
Schema.Struct({
|
|
11
|
+
type: Schema.Literal("post"),
|
|
12
|
+
envelope: PostEnvelopeSchema,
|
|
13
|
+
}),
|
|
14
|
+
Schema.Struct({
|
|
15
|
+
type: Schema.Literal("receipt"),
|
|
16
|
+
identity: identitySchema,
|
|
17
|
+
result: PostDeliveryResultSchema,
|
|
18
|
+
}),
|
|
19
|
+
]);
|
|
20
|
+
const positiveInteger = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER));
|
|
21
|
+
const limitsSchema = Schema.Struct({
|
|
22
|
+
maxInFlight: positiveInteger,
|
|
23
|
+
maxFrameBytes: positiveInteger,
|
|
24
|
+
acknowledgmentMs: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1),
|
|
25
|
+
// The maintained platform timer cannot represent a larger delay.
|
|
26
|
+
Schema.isLessThanOrEqualTo(2_147_483_647)),
|
|
27
|
+
});
|
|
28
|
+
/** No receiver receipt was obtained. Carrier, not Seam, retains retries. */
|
|
29
|
+
export class MachineLinkPostUnavailable extends Data.TaggedError("MachineLinkPostUnavailable") {
|
|
30
|
+
}
|
|
31
|
+
function identity(envelope) {
|
|
32
|
+
return {
|
|
33
|
+
key: envelope.key,
|
|
34
|
+
kind: envelope.kind,
|
|
35
|
+
address: envelope.address,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* One connection's bounded Post I/O, without an outbox, dedupe store or RPC
|
|
40
|
+
* capability table. Native socket ownership and authentication stay with the
|
|
41
|
+
* host. Reconstruct after hibernation when idle; close on disconnect/restart
|
|
42
|
+
* and let the existing Carrier outbox retry the same frozen envelope.
|
|
43
|
+
*
|
|
44
|
+
* Receipt payloads remain opaque. The Agent mail route still requires a real
|
|
45
|
+
* Shiitake Accepted receipt; a generic Post acknowledgment is not one.
|
|
46
|
+
*/
|
|
47
|
+
export function createMachineLinkPostChannel(options) {
|
|
48
|
+
const limits = Schema.decodeUnknownSync(limitsSchema)(options.limits);
|
|
49
|
+
const decode = Schema.decodeUnknownSync(Schema.fromJsonString(frameSchema), {
|
|
50
|
+
onExcessProperty: "error",
|
|
51
|
+
});
|
|
52
|
+
const pending = new Map();
|
|
53
|
+
let closed = false;
|
|
54
|
+
let receiving = 0;
|
|
55
|
+
function checkSize(text) {
|
|
56
|
+
if (new TextEncoder().encode(text).byteLength > limits.maxFrameBytes)
|
|
57
|
+
throw new RangeError("Machine link Post frame exceeds its byte ceiling");
|
|
58
|
+
}
|
|
59
|
+
async function send(text) {
|
|
60
|
+
if (closed)
|
|
61
|
+
throw new MachineLinkPostUnavailable({ reason: "closed" });
|
|
62
|
+
checkSize(text);
|
|
63
|
+
await options.send(text);
|
|
64
|
+
}
|
|
65
|
+
function take(key) {
|
|
66
|
+
const entry = pending.get(key);
|
|
67
|
+
if (entry !== undefined) {
|
|
68
|
+
pending.delete(key);
|
|
69
|
+
clearTimeout(entry.timer);
|
|
70
|
+
}
|
|
71
|
+
return entry;
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
/** Send now; never acknowledge custody before the receiver replies. */
|
|
75
|
+
deliver: async (envelope) => {
|
|
76
|
+
if (closed)
|
|
77
|
+
throw new MachineLinkPostUnavailable({ reason: "closed" });
|
|
78
|
+
const key = JSON.stringify(identity(envelope));
|
|
79
|
+
if (pending.has(key) || pending.size >= limits.maxInFlight)
|
|
80
|
+
throw new MachineLinkPostUnavailable({ reason: "busy" });
|
|
81
|
+
const text = JSON.stringify({ type: "post", envelope });
|
|
82
|
+
checkSize(text);
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
take(key)?.reject(new MachineLinkPostUnavailable({ reason: "timed_out" }));
|
|
86
|
+
}, limits.acknowledgmentMs);
|
|
87
|
+
pending.set(key, { resolve, reject, timer });
|
|
88
|
+
void send(text).catch((cause) => {
|
|
89
|
+
take(key)?.reject(new MachineLinkPostUnavailable({
|
|
90
|
+
reason: "send_failed",
|
|
91
|
+
cause,
|
|
92
|
+
}));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
/** Called only for a host-authenticated, current native socket. */
|
|
97
|
+
receive: async (text) => {
|
|
98
|
+
if (closed)
|
|
99
|
+
throw new MachineLinkPostUnavailable({ reason: "closed" });
|
|
100
|
+
checkSize(text);
|
|
101
|
+
const frame = decode(text);
|
|
102
|
+
if (frame.type === "receipt") {
|
|
103
|
+
// Late receipts cannot create custody or a future successful send.
|
|
104
|
+
take(JSON.stringify(identity(frame.identity)))?.resolve(frame.result);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (receiving >= limits.maxInFlight)
|
|
108
|
+
throw new MachineLinkPostUnavailable({ reason: "busy" });
|
|
109
|
+
receiving += 1;
|
|
110
|
+
try {
|
|
111
|
+
const result = Schema.decodeUnknownSync(PostDeliveryResultSchema)(await options.receive(frame.envelope));
|
|
112
|
+
await send(JSON.stringify({
|
|
113
|
+
type: "receipt",
|
|
114
|
+
identity: identity(frame.envelope),
|
|
115
|
+
result,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
receiving -= 1;
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
/** Forget only pending I/O. The durable sender still owns undelivered work. */
|
|
123
|
+
close: () => {
|
|
124
|
+
closed = true;
|
|
125
|
+
for (const key of pending.keys())
|
|
126
|
+
take(key)?.reject(new MachineLinkPostUnavailable({ reason: "closed" }));
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
export { createMachineLinkPostChannel, MachineLinkPostUnavailable, } from "./machine-link-post.js";
|
|
3
|
+
/** Shared signature audience; neither names nor private Local selectors enter it. */
|
|
4
|
+
export declare function machineLinkAudience(teamId: string, deviceId: string): string;
|
|
5
|
+
/** One wire contract for the signed machine dial; no older-version fallback. */
|
|
6
|
+
export declare const machineLinkProtocolVersion = "1";
|
|
7
|
+
/** The exact GET path signed by Local and verified by its receiving link. */
|
|
8
|
+
export declare function machineLinkDialUrl(issuer: string, teamId: string, deviceId: string): URL;
|
|
9
|
+
/** Post destination only; delegation keys and admission belong to their engine. */
|
|
10
|
+
export type AgentMailAddress = {
|
|
11
|
+
/** Owning Team or standalone scope, a nonempty identity of at most 256 characters. */
|
|
12
|
+
readonly scopeId: string;
|
|
13
|
+
/** Destination Agent, a nonempty identity of at most 256 characters. */
|
|
14
|
+
readonly agentId: string;
|
|
15
|
+
};
|
|
16
|
+
/** Maintained decoder for the shared Post address, not delegation authority. */
|
|
17
|
+
export declare const agentMailAddressSchema: Schema.Codec<AgentMailAddress>;
|
|
18
|
+
/** Idempotent Session creation requested through an authorized machine link. */
|
|
19
|
+
export type MachineAgentCreateSession = {
|
|
20
|
+
/** Finite operation discriminator. */
|
|
21
|
+
readonly type: "createSession";
|
|
22
|
+
/** Caller-owned creation intent, a nonempty identity of at most 256 characters. */
|
|
23
|
+
readonly requestId: string;
|
|
24
|
+
};
|
|
25
|
+
/** Read the destination owner's outcome for one previously accepted command. */
|
|
26
|
+
export type MachineAgentSettlement = {
|
|
27
|
+
/** Finite operation discriminator. */
|
|
28
|
+
readonly type: "settlement";
|
|
29
|
+
/** Requesting Agent; the receiver still checks its relationship to the command. */
|
|
30
|
+
readonly requesterAgentId: string;
|
|
31
|
+
/** Recipient Session identity, nonempty and at most 256 characters. */
|
|
32
|
+
readonly sessionId: string;
|
|
33
|
+
/** Original Accepted command identity, nonempty and at most 256 characters. */
|
|
34
|
+
readonly commandId: string;
|
|
35
|
+
};
|
|
36
|
+
/** Finite Agent operations, not an arbitrary HTTP/RPC tunnel. */
|
|
37
|
+
export type MachineAgentOperation = MachineAgentCreateSession | MachineAgentSettlement;
|
|
38
|
+
/** Decode operation grammar without granting access to an Agent or Session. */
|
|
39
|
+
export declare const machineAgentOperationSchema: Schema.Codec<MachineAgentOperation>;
|
|
40
|
+
/** An operation plus its host-derived caller, still subject to receiver admission. */
|
|
41
|
+
export type MachineAgentRequest = {
|
|
42
|
+
/** Calling Agent identity; syntax alone never authenticates this field. */
|
|
43
|
+
readonly actorAgentId: string;
|
|
44
|
+
/** The finite operation to admit at the destination. */
|
|
45
|
+
readonly operation: MachineAgentOperation;
|
|
46
|
+
};
|
|
47
|
+
/** Decode the request representation, not caller authority. */
|
|
48
|
+
export declare const machineAgentRequestSchema: Schema.Codec<MachineAgentRequest>;
|
|
49
|
+
/** One public Agent directory entry, without a private Local selector or path. */
|
|
50
|
+
export type MachineDirectoryAgent = {
|
|
51
|
+
/** Team Agent identity, nonempty and at most 256 characters. */
|
|
52
|
+
readonly agentId: string;
|
|
53
|
+
/** Display label of at most 256 characters. */
|
|
54
|
+
readonly name: string;
|
|
55
|
+
};
|
|
56
|
+
/** Bounded directory representation sent by an enrolled machine. */
|
|
57
|
+
export type MachineDirectory = {
|
|
58
|
+
/** At most 256 public Agent entries; the host checks their enrollment. */
|
|
59
|
+
readonly agents: readonly MachineDirectoryAgent[];
|
|
60
|
+
};
|
|
61
|
+
/** Decode the bounded directory representation without admitting its entries. */
|
|
62
|
+
export declare const machineDirectorySchema: Schema.Codec<MachineDirectory>;
|
|
63
|
+
/** Ask the owner to remove one enrolled Team Agent projection. */
|
|
64
|
+
export type MachineLinkLeaveFrame = {
|
|
65
|
+
/** Finite control discriminator. */
|
|
66
|
+
readonly type: "leave";
|
|
67
|
+
/** Team Agent identity, nonempty and at most 256 characters. */
|
|
68
|
+
readonly agentId: string;
|
|
69
|
+
};
|
|
70
|
+
/** Read one bounded transcript page through the Session owner's ordinary cursor. */
|
|
71
|
+
export type MachineLinkReadFrame = {
|
|
72
|
+
/** Finite control discriminator. */
|
|
73
|
+
readonly type: "read";
|
|
74
|
+
/** Correlation identity, nonempty and at most 256 characters. */
|
|
75
|
+
readonly requestId: string;
|
|
76
|
+
/** Team Agent identity, nonempty and at most 256 characters. */
|
|
77
|
+
readonly agentId: string;
|
|
78
|
+
/** Session identity, nonempty and at most 256 characters. */
|
|
79
|
+
readonly sessionId: string;
|
|
80
|
+
/** Opaque older-page cursor from Session.read(), not a transport stream cursor. */
|
|
81
|
+
readonly before?: string;
|
|
82
|
+
/** Integer page bound from one through 64. */
|
|
83
|
+
readonly limit: number;
|
|
84
|
+
};
|
|
85
|
+
/** Non-mail requests, not authenticated membership or admitted Session access. */
|
|
86
|
+
export type MachineLinkControlFrame = MachineLinkLeaveFrame | MachineLinkReadFrame;
|
|
87
|
+
/** Decode JSON only; the host still owns device, member and Agent admission. */
|
|
88
|
+
export declare function decodeMachineLinkControlFrame(text: string): MachineLinkControlFrame;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
export { createMachineLinkPostChannel, MachineLinkPostUnavailable, } from "./machine-link-post.js";
|
|
3
|
+
/** Shared signature audience; neither names nor private Local selectors enter it. */
|
|
4
|
+
export function machineLinkAudience(teamId, deviceId) {
|
|
5
|
+
return `fungi-machine-link:${teamId}:${deviceId}`;
|
|
6
|
+
}
|
|
7
|
+
/** One wire contract for the signed machine dial; no older-version fallback. */
|
|
8
|
+
export const machineLinkProtocolVersion = "1";
|
|
9
|
+
/** The exact GET path signed by Local and verified by its receiving link. */
|
|
10
|
+
export function machineLinkDialUrl(issuer, teamId, deviceId) {
|
|
11
|
+
const url = new URL(`/api/teams/${encodeURIComponent(teamId)}/local/mesh/${encodeURIComponent(deviceId)}/connect`, issuer);
|
|
12
|
+
url.searchParams.set("version", machineLinkProtocolVersion);
|
|
13
|
+
return url;
|
|
14
|
+
}
|
|
15
|
+
const identitySchema = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
|
|
16
|
+
/** Maintained decoder for the shared Post address, not delegation authority. */
|
|
17
|
+
export const agentMailAddressSchema = Schema.Struct({
|
|
18
|
+
scopeId: identitySchema,
|
|
19
|
+
agentId: identitySchema,
|
|
20
|
+
});
|
|
21
|
+
/** Decode operation grammar without granting access to an Agent or Session. */
|
|
22
|
+
export const machineAgentOperationSchema = Schema.Union([
|
|
23
|
+
Schema.Struct({
|
|
24
|
+
type: Schema.Literal("createSession"),
|
|
25
|
+
requestId: identitySchema,
|
|
26
|
+
}),
|
|
27
|
+
Schema.Struct({
|
|
28
|
+
type: Schema.Literal("settlement"),
|
|
29
|
+
requesterAgentId: identitySchema,
|
|
30
|
+
sessionId: identitySchema,
|
|
31
|
+
commandId: identitySchema,
|
|
32
|
+
}),
|
|
33
|
+
]);
|
|
34
|
+
/** Decode the request representation, not caller authority. */
|
|
35
|
+
export const machineAgentRequestSchema = Schema.Struct({
|
|
36
|
+
actorAgentId: identitySchema,
|
|
37
|
+
operation: machineAgentOperationSchema,
|
|
38
|
+
});
|
|
39
|
+
/** Decode the bounded directory representation without admitting its entries. */
|
|
40
|
+
export const machineDirectorySchema = Schema.Struct({
|
|
41
|
+
agents: Schema.Array(Schema.Struct({
|
|
42
|
+
agentId: identitySchema,
|
|
43
|
+
name: Schema.String.check(Schema.isMaxLength(256)),
|
|
44
|
+
})).check(Schema.isMaxLength(256)),
|
|
45
|
+
});
|
|
46
|
+
const leaveFrameSchema = Schema.Struct({
|
|
47
|
+
type: Schema.Literal("leave"),
|
|
48
|
+
agentId: identitySchema,
|
|
49
|
+
});
|
|
50
|
+
const readFrameSchema = Schema.Struct({
|
|
51
|
+
type: Schema.Literal("read"),
|
|
52
|
+
requestId: identitySchema,
|
|
53
|
+
agentId: identitySchema,
|
|
54
|
+
sessionId: identitySchema,
|
|
55
|
+
// Session.read() pages before its opaque oldestCursor, not after a stream.
|
|
56
|
+
before: Schema.optionalKey(Schema.String),
|
|
57
|
+
limit: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(64)),
|
|
58
|
+
});
|
|
59
|
+
const controlFrameSchema = Schema.Union([leaveFrameSchema, readFrameSchema]);
|
|
60
|
+
/** Decode JSON only; the host still owns device, member and Agent admission. */
|
|
61
|
+
export function decodeMachineLinkControlFrame(text) {
|
|
62
|
+
if (new TextEncoder().encode(text).byteLength > 512 * 1024)
|
|
63
|
+
throw new RangeError("Machine link frame exceeds 512 KiB");
|
|
64
|
+
return Schema.decodeUnknownSync(Schema.fromJsonString(controlFrameSchema), {
|
|
65
|
+
onExcessProperty: "error",
|
|
66
|
+
})(text);
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fungi.computer/seam",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"author": "Levi <levi@technomancy.dev>",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Ed25519 authentication between Fungi Hub and on-box hosts",
|
|
@@ -9,9 +9,16 @@
|
|
|
9
9
|
".": {
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"import": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./machine-link-protocol": {
|
|
14
|
+
"types": "./dist/machine-link-protocol.d.ts",
|
|
15
|
+
"import": "./dist/machine-link-protocol.js"
|
|
12
16
|
}
|
|
13
17
|
},
|
|
14
|
-
"dependencies": {
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@fungi.computer/carrier": "0.2.0",
|
|
20
|
+
"effect": "4.0.2"
|
|
21
|
+
},
|
|
15
22
|
"license": "MIT",
|
|
16
23
|
"repository": {
|
|
17
24
|
"type": "git",
|