@fungi.computer/seam 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamstack Consulting LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ # Fungi host seam
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Owns the Ed25519 request authentication protocol between the Hub and its on-box
4
+ hosts. Consumers import `@fungi.computer/seam`. The moved implementation and
5
+ replay/signature tests retain their original behavior. Keys remain with the
6
+ operator; no keys are stored in this package. The package ships the protocol and
7
+ replay guard; hosts own key provisioning.
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
+
16
+ Run `pnpm --filter @fungi.computer/seam test` for signature, audience, body and
17
+ replay laws. Computer and game-server host tests exercise its real consumers.
18
+
19
+ ## Packed consumer readiness
20
+
21
+ Run `pnpm run build`, `pnpm run pack` and `pnpm run stranger` in this package.
22
+ The maintained stranger proof installs only package tarballs into a fresh npm
23
+ consumer, checks the exact shipped files, runs the public runtime fixture and
24
+ compiles every JavaScript entrypoint with strict NodeNext and no library-check
25
+ skip. This qualification does not publish npm releases or push a repository
26
+ split.
27
+
28
+ See [LICENSE](LICENSE) for the license and the package manifest for its public
29
+ repository destination.
30
+
31
+ ## Quick start
32
+
33
+ ```ts
34
+ import { SeamReplayGuard } from "@fungi.computer/seam";
35
+
36
+ const replay = new SeamReplayGuard();
37
+ const now = Date.now();
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
45
+ ```
46
+
47
+ ## Core concepts
48
+
49
+ The sender signs method, audience, path, timestamp, nonce and payload digest.
50
+ The receiver checks the signature, freshness and replay guard against its pinned
51
+ keys.
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
+
67
+ ## Examples and checks
68
+
69
+ The packed runtime fixture at `test/stranger/runtime.mjs` exercises the public
70
+ API in a fresh consumer. Run `pnpm run stranger` after building the package.
71
+
72
+ ## Install
73
+
74
+ Install the package tarball with npm. The manifest pins its runtime
75
+ dependencies.
76
+
77
+ ## Contribute and license
78
+
79
+ See [LICENSE](LICENSE) for the MIT terms.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Mutual authentication for on-box host seams: every request in either
3
+ * direction is signed with Ed25519 by its sender and verified against a
4
+ * public key the receiver pins.
5
+ *
6
+ * - Down: the Hub signs with the platform seam key; the host pins its public key.
7
+ * - Up: the host signs reports with its host key; the Hub pins it in the host registry.
8
+ *
9
+ * The signature covers an audience (so a request meant for one side cannot
10
+ * be replayed at the other), the method, path and query, a timestamp, a
11
+ * nonce and the body digest. Streaming bodies (forwarded guest traffic,
12
+ * port previews, WebSocket upgrades) sign `UNSIGNED-PAYLOAD` and rely on the
13
+ * TLS transport (Cloudflare Tunnel) for body integrity. Receivers reject a
14
+ * timestamp outside the skew window and a nonce seen within it. Private keys
15
+ * stay with their owner; projections and registries carry public data only.
16
+ *
17
+ * This module is plain Web Crypto so the Worker and the Node.js host share it.
18
+ */
19
+ export declare const seamHeader = "x-fungi-seam";
20
+ /** Digest sentinel for a body whose integrity is supplied by its transport. */
21
+ export declare const unsignedPayload = "UNSIGNED-PAYLOAD";
22
+ /** Sender identity and private Ed25519 key used to sign seam requests. */
23
+ export type SeamSigner = Readonly<{
24
+ /** Key identifier included in the signed bytes and the request header. */
25
+ keyId: string;
26
+ /** Private Ed25519 key with signing usage; custody stays with the sender. */
27
+ key: CryptoKey;
28
+ }>;
29
+ /** Receiver-pinned public Ed25519 verification keys, indexed by sender key ID. */
30
+ export type SeamVerifiers = ReadonlyMap<string, CryptoKey>;
31
+ /** `keyId:base64(pkcs8)`, the form a Worker secret or key file holds. */
32
+ export declare function importSeamSigner(secret: string): Promise<SeamSigner>;
33
+ /** `{ keyId: base64(raw 32-byte public key) }`, the pinned peers. */
34
+ export declare function importSeamVerifiers(publicKeys: Readonly<Record<string, string>>): Promise<SeamVerifiers>;
35
+ /** The body digest to sign: SHA-256 hex of buffered bytes, or unsigned. */
36
+ export declare function seamBodyDigest(body: ArrayBuffer | Uint8Array<ArrayBuffer> | null): Promise<string>;
37
+ /** The `x-fungi-seam` header value for one request. */
38
+ export declare function signSeamRequest(signer: SeamSigner, audience: string, method: string, url: string, digest: string, now?: number): Promise<string>;
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 {
67
+ #private;
68
+ /** Record one nonce, refusing capacity rather than evicting live history. */
69
+ admit({ nonce, now, expiresAt }: SeamReplayRecord): SeamReplayAdmission;
70
+ }
71
+ /**
72
+ * Verify one request's seam header. `digest` is the receiver's own digest of
73
+ * the buffered body, or `null` when the route accepts an unsigned stream.
74
+ * Answers the sender's key id.
75
+ */
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 ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Mutual authentication for on-box host seams: every request in either
3
+ * direction is signed with Ed25519 by its sender and verified against a
4
+ * public key the receiver pins.
5
+ *
6
+ * - Down: the Hub signs with the platform seam key; the host pins its public key.
7
+ * - Up: the host signs reports with its host key; the Hub pins it in the host registry.
8
+ *
9
+ * The signature covers an audience (so a request meant for one side cannot
10
+ * be replayed at the other), the method, path and query, a timestamp, a
11
+ * nonce and the body digest. Streaming bodies (forwarded guest traffic,
12
+ * port previews, WebSocket upgrades) sign `UNSIGNED-PAYLOAD` and rely on the
13
+ * TLS transport (Cloudflare Tunnel) for body integrity. Receivers reject a
14
+ * timestamp outside the skew window and a nonce seen within it. Private keys
15
+ * stay with their owner; projections and registries carry public data only.
16
+ *
17
+ * This module is plain Web Crypto so the Worker and the Node.js host share it.
18
+ */
19
+ export const seamHeader = "x-fungi-seam";
20
+ /** Digest sentinel for a body whose integrity is supplied by its transport. */
21
+ export const unsignedPayload = "UNSIGNED-PAYLOAD";
22
+ const version = "fungi-seam-v1";
23
+ const maximumSkewMs = 60_000;
24
+ const maximumNonces = 10_000;
25
+ class SeamAuthError extends Error {
26
+ constructor(reason) {
27
+ super(`Seam request rejected: ${reason}`);
28
+ this.name = "SeamAuthError";
29
+ }
30
+ }
31
+ const keyIdPattern = /^[a-z0-9][a-z0-9._-]{0,63}$/;
32
+ const noncePattern = /^[0-9a-f]{32}$/;
33
+ const digestPattern = /^([0-9a-f]{64}|UNSIGNED-PAYLOAD)$/;
34
+ const signaturePattern = /^[A-Za-z0-9_-]{86}$/;
35
+ function bytesFromBase64(value) {
36
+ const binary = atob(value);
37
+ const bytes = new Uint8Array(binary.length);
38
+ for (let index = 0; index < binary.length; index += 1)
39
+ bytes[index] = binary.charCodeAt(index);
40
+ return bytes;
41
+ }
42
+ function base64url(bytes) {
43
+ let binary = "";
44
+ for (const byte of new Uint8Array(bytes))
45
+ binary += String.fromCharCode(byte);
46
+ return btoa(binary)
47
+ .replaceAll("+", "-")
48
+ .replaceAll("/", "_")
49
+ .replace(/=+$/, "");
50
+ }
51
+ function bytesFromBase64url(value) {
52
+ const padded = value.replaceAll("-", "+").replaceAll("_", "/");
53
+ return bytesFromBase64(padded + "=".repeat((4 - (padded.length % 4)) % 4));
54
+ }
55
+ function hex(bytes) {
56
+ return Array.from(new Uint8Array(bytes), (byte) => byte.toString(16).padStart(2, "0")).join("");
57
+ }
58
+ /** `keyId:base64(pkcs8)`, the form a Worker secret or key file holds. */
59
+ export async function importSeamSigner(secret) {
60
+ const at = secret.indexOf(":");
61
+ const keyId = secret.slice(0, at);
62
+ if (at < 1 || !keyIdPattern.test(keyId))
63
+ throw new SeamAuthError("the signing key has no valid key id");
64
+ const key = await crypto.subtle.importKey("pkcs8", bytesFromBase64(secret.slice(at + 1)), { name: "Ed25519" }, false, ["sign"]);
65
+ return { keyId, key };
66
+ }
67
+ /** `{ keyId: base64(raw 32-byte public key) }`, the pinned peers. */
68
+ export async function importSeamVerifiers(publicKeys) {
69
+ const verifiers = new Map();
70
+ for (const [keyId, raw] of Object.entries(publicKeys)) {
71
+ if (!keyIdPattern.test(keyId))
72
+ throw new SeamAuthError(`invalid pinned key id ${keyId}`);
73
+ verifiers.set(keyId, await crypto.subtle.importKey("raw", bytesFromBase64(raw), { name: "Ed25519" }, false, ["verify"]));
74
+ }
75
+ return verifiers;
76
+ }
77
+ /** The body digest to sign: SHA-256 hex of buffered bytes, or unsigned. */
78
+ export async function seamBodyDigest(body) {
79
+ if (body === null)
80
+ return unsignedPayload;
81
+ return hex(await crypto.subtle.digest("SHA-256", body));
82
+ }
83
+ function canonical(audience, keyId, method, pathAndQuery, timestamp, nonce, digest) {
84
+ // A copy, so the bytes sit in a plain ArrayBuffer Web Crypto accepts.
85
+ return new Uint8Array(new TextEncoder().encode([
86
+ version,
87
+ audience,
88
+ keyId,
89
+ method.toUpperCase(),
90
+ pathAndQuery,
91
+ String(timestamp),
92
+ nonce,
93
+ digest,
94
+ ].join("\n")));
95
+ }
96
+ function pathAndQueryOf(url) {
97
+ const parsed = new URL(url);
98
+ return `${parsed.pathname}${parsed.search}`;
99
+ }
100
+ /** The `x-fungi-seam` header value for one request. */
101
+ export async function signSeamRequest(signer, audience, method, url, digest, now = Date.now()) {
102
+ const nonceBytes = new Uint8Array(16);
103
+ crypto.getRandomValues(nonceBytes);
104
+ const nonce = hex(nonceBytes.buffer);
105
+ const signature = await crypto.subtle.sign({ name: "Ed25519" }, signer.key, canonical(audience, signer.keyId, method, pathAndQueryOf(url), now, nonce, digest));
106
+ return `v1 key=${signer.keyId} ts=${String(now)} nonce=${nonce} body=${digest} sig=${base64url(signature)}`;
107
+ }
108
+ /** Bounded process-local history; deliberately not durable across reconstruction. */
109
+ export class SeamReplayGuard {
110
+ #seen = new Map();
111
+ /** Record one nonce, refusing capacity rather than evicting live history. */
112
+ admit({ nonce, now, expiresAt }) {
113
+ this.#prune(now);
114
+ if (this.#seen.has(nonce))
115
+ return "replayed";
116
+ if (this.#seen.size >= maximumNonces)
117
+ return "at-capacity";
118
+ this.#seen.set(nonce, expiresAt);
119
+ return "admitted";
120
+ }
121
+ #prune(now) {
122
+ for (const [seen, expires] of this.#seen) {
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);
127
+ }
128
+ }
129
+ }
130
+ function seamFields(value) {
131
+ const fields = new Map();
132
+ const parts = (value ?? "").split(" ");
133
+ if (parts[0] !== "v1")
134
+ throw new SeamAuthError("missing or unknown version");
135
+ for (const part of parts.slice(1)) {
136
+ const [name, field] = seamField(part);
137
+ fields.set(name, field);
138
+ }
139
+ return fields;
140
+ }
141
+ function seamField(part) {
142
+ const at = part.indexOf("=");
143
+ if (at < 1)
144
+ throw new SeamAuthError("malformed header");
145
+ return [part.slice(0, at), part.slice(at + 1)];
146
+ }
147
+ function requiredField(fields, name) {
148
+ return fields.get(name) ?? "";
149
+ }
150
+ function parseSeamHeader(value) {
151
+ const fields = seamFields(value);
152
+ const parsed = {
153
+ keyId: requiredField(fields, "key"),
154
+ timestamp: Number(fields.get("ts")),
155
+ nonce: requiredField(fields, "nonce"),
156
+ digest: requiredField(fields, "body"),
157
+ signature: requiredField(fields, "sig"),
158
+ };
159
+ if (fields.size !== 5 || !validSeamHeader(parsed))
160
+ throw new SeamAuthError("malformed header");
161
+ return parsed;
162
+ }
163
+ function validSeamHeader(header) {
164
+ return [
165
+ keyIdPattern.test(header.keyId),
166
+ Number.isSafeInteger(header.timestamp),
167
+ noncePattern.test(header.nonce),
168
+ digestPattern.test(header.digest),
169
+ signaturePattern.test(header.signature),
170
+ ].every(Boolean);
171
+ }
172
+ /**
173
+ * Verify one request's seam header. `digest` is the receiver's own digest of
174
+ * the buffered body, or `null` when the route accepts an unsigned stream.
175
+ * Answers the sender's key id.
176
+ */
177
+ export async function verifySeamRequest(verifiers, audience, method, url, header, digest, replay, now = Date.now()) {
178
+ const parsed = parseSeamHeader(header);
179
+ const key = verifiers.get(parsed.keyId);
180
+ if (key === undefined)
181
+ throw new SeamAuthError("unknown key");
182
+ assertSeamFreshness(parsed, digest, now);
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));
184
+ if (!valid)
185
+ throw new SeamAuthError("bad signature");
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
+ }
204
+ }
205
+ function assertSeamFreshness(parsed, digest, now) {
206
+ if (Math.abs(now - parsed.timestamp) > maximumSkewMs)
207
+ throw new SeamAuthError("timestamp outside the skew window");
208
+ assertSeamDigest(parsed.digest, digest);
209
+ }
210
+ function assertSeamDigest(signed, digest) {
211
+ const expected = digest ?? unsignedPayload;
212
+ if (signed === expected)
213
+ return;
214
+ throw new SeamAuthError(digest === null
215
+ ? "route takes an unsigned stream only"
216
+ : "body digest mismatch");
217
+ }
@@ -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,86 @@
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
+ /** The exact GET path signed by Local and verified by its receiving link. */
6
+ export declare function machineLinkDialUrl(issuer: string, teamId: string, deviceId: string): URL;
7
+ /** Post destination only; delegation keys and admission belong to their engine. */
8
+ export type AgentMailAddress = {
9
+ /** Owning Team or standalone scope, a nonempty identity of at most 256 characters. */
10
+ readonly scopeId: string;
11
+ /** Destination Agent, a nonempty identity of at most 256 characters. */
12
+ readonly agentId: string;
13
+ };
14
+ /** Maintained decoder for the shared Post address, not delegation authority. */
15
+ export declare const agentMailAddressSchema: Schema.Codec<AgentMailAddress>;
16
+ /** Idempotent Session creation requested through an authorized machine link. */
17
+ export type MachineAgentCreateSession = {
18
+ /** Finite operation discriminator. */
19
+ readonly type: "createSession";
20
+ /** Caller-owned creation intent, a nonempty identity of at most 256 characters. */
21
+ readonly requestId: string;
22
+ };
23
+ /** Read the destination owner's outcome for one previously accepted command. */
24
+ export type MachineAgentSettlement = {
25
+ /** Finite operation discriminator. */
26
+ readonly type: "settlement";
27
+ /** Requesting Agent; the receiver still checks its relationship to the command. */
28
+ readonly requesterAgentId: string;
29
+ /** Recipient Session identity, nonempty and at most 256 characters. */
30
+ readonly sessionId: string;
31
+ /** Original Accepted command identity, nonempty and at most 256 characters. */
32
+ readonly commandId: string;
33
+ };
34
+ /** Finite Agent operations, not an arbitrary HTTP/RPC tunnel. */
35
+ export type MachineAgentOperation = MachineAgentCreateSession | MachineAgentSettlement;
36
+ /** Decode operation grammar without granting access to an Agent or Session. */
37
+ export declare const machineAgentOperationSchema: Schema.Codec<MachineAgentOperation>;
38
+ /** An operation plus its host-derived caller, still subject to receiver admission. */
39
+ export type MachineAgentRequest = {
40
+ /** Calling Agent identity; syntax alone never authenticates this field. */
41
+ readonly actorAgentId: string;
42
+ /** The finite operation to admit at the destination. */
43
+ readonly operation: MachineAgentOperation;
44
+ };
45
+ /** Decode the request representation, not caller authority. */
46
+ export declare const machineAgentRequestSchema: Schema.Codec<MachineAgentRequest>;
47
+ /** One public Agent directory entry, without a private Local selector or path. */
48
+ export type MachineDirectoryAgent = {
49
+ /** Team Agent identity, nonempty and at most 256 characters. */
50
+ readonly agentId: string;
51
+ /** Display label of at most 256 characters. */
52
+ readonly name: string;
53
+ };
54
+ /** Bounded directory representation sent by an enrolled machine. */
55
+ export type MachineDirectory = {
56
+ /** At most 256 public Agent entries; the host checks their enrollment. */
57
+ readonly agents: readonly MachineDirectoryAgent[];
58
+ };
59
+ /** Decode the bounded directory representation without admitting its entries. */
60
+ export declare const machineDirectorySchema: Schema.Codec<MachineDirectory>;
61
+ /** Ask the owner to remove one enrolled Team Agent projection. */
62
+ export type MachineLinkLeaveFrame = {
63
+ /** Finite control discriminator. */
64
+ readonly type: "leave";
65
+ /** Team Agent identity, nonempty and at most 256 characters. */
66
+ readonly agentId: string;
67
+ };
68
+ /** Read one bounded transcript page through the Session owner's ordinary cursor. */
69
+ export type MachineLinkReadFrame = {
70
+ /** Finite control discriminator. */
71
+ readonly type: "read";
72
+ /** Correlation identity, nonempty and at most 256 characters. */
73
+ readonly requestId: string;
74
+ /** Team Agent identity, nonempty and at most 256 characters. */
75
+ readonly agentId: string;
76
+ /** Session identity, nonempty and at most 256 characters. */
77
+ readonly sessionId: string;
78
+ /** Opaque older-page cursor from Session.read(), not a transport stream cursor. */
79
+ readonly before?: string;
80
+ /** Integer page bound from one through 64. */
81
+ readonly limit: number;
82
+ };
83
+ /** Non-mail requests, not authenticated membership or admitted Session access. */
84
+ export type MachineLinkControlFrame = MachineLinkLeaveFrame | MachineLinkReadFrame;
85
+ /** Decode JSON only; the host still owns device, member and Agent admission. */
86
+ export declare function decodeMachineLinkControlFrame(text: string): MachineLinkControlFrame;
@@ -0,0 +1,63 @@
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
+ /** The exact GET path signed by Local and verified by its receiving link. */
8
+ export function machineLinkDialUrl(issuer, teamId, deviceId) {
9
+ return new URL(`/api/teams/${encodeURIComponent(teamId)}/local/mesh/${encodeURIComponent(deviceId)}/connect`, issuer);
10
+ }
11
+ const identitySchema = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(256));
12
+ /** Maintained decoder for the shared Post address, not delegation authority. */
13
+ export const agentMailAddressSchema = Schema.Struct({
14
+ scopeId: identitySchema,
15
+ agentId: identitySchema,
16
+ });
17
+ /** Decode operation grammar without granting access to an Agent or Session. */
18
+ export const machineAgentOperationSchema = Schema.Union([
19
+ Schema.Struct({
20
+ type: Schema.Literal("createSession"),
21
+ requestId: identitySchema,
22
+ }),
23
+ Schema.Struct({
24
+ type: Schema.Literal("settlement"),
25
+ requesterAgentId: identitySchema,
26
+ sessionId: identitySchema,
27
+ commandId: identitySchema,
28
+ }),
29
+ ]);
30
+ /** Decode the request representation, not caller authority. */
31
+ export const machineAgentRequestSchema = Schema.Struct({
32
+ actorAgentId: identitySchema,
33
+ operation: machineAgentOperationSchema,
34
+ });
35
+ /** Decode the bounded directory representation without admitting its entries. */
36
+ export const machineDirectorySchema = Schema.Struct({
37
+ agents: Schema.Array(Schema.Struct({
38
+ agentId: identitySchema,
39
+ name: Schema.String.check(Schema.isMaxLength(256)),
40
+ })).check(Schema.isMaxLength(256)),
41
+ });
42
+ const leaveFrameSchema = Schema.Struct({
43
+ type: Schema.Literal("leave"),
44
+ agentId: identitySchema,
45
+ });
46
+ const readFrameSchema = Schema.Struct({
47
+ type: Schema.Literal("read"),
48
+ requestId: identitySchema,
49
+ agentId: identitySchema,
50
+ sessionId: identitySchema,
51
+ // Session.read() pages before its opaque oldestCursor, not after a stream.
52
+ before: Schema.optionalKey(Schema.String),
53
+ limit: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(64)),
54
+ });
55
+ const controlFrameSchema = Schema.Union([leaveFrameSchema, readFrameSchema]);
56
+ /** Decode JSON only; the host still owns device, member and Agent admission. */
57
+ export function decodeMachineLinkControlFrame(text) {
58
+ if (new TextEncoder().encode(text).byteLength > 512 * 1024)
59
+ throw new RangeError("Machine link frame exceeds 512 KiB");
60
+ return Schema.decodeUnknownSync(Schema.fromJsonString(controlFrameSchema), {
61
+ onExcessProperty: "error",
62
+ })(text);
63
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@fungi.computer/seam",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "author": "Levi <levi@technomancy.dev>",
5
+ "private": false,
6
+ "description": "Ed25519 authentication between Fungi Hub and on-box hosts",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
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"
16
+ }
17
+ },
18
+ "dependencies": {
19
+ "@fungi.computer/carrier": "0.1.0",
20
+ "effect": "4.0.2"
21
+ },
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/fungi-computer/seam.git"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "LICENSE"
31
+ ]
6
32
  }