wire-mesh-core 1.20.0 → 1.22.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/dist/domain/capability-grant.cjs +3 -2
- package/dist/domain/capability-grant.mjs +2 -1
- package/dist/domain/capability-request.cjs +1 -1
- package/dist/domain/capability-request.mjs +1 -1
- package/dist/domain/grant-candidates.cjs +47 -0
- package/dist/domain/grant-candidates.d.cts +26 -0
- package/dist/domain/grant-candidates.d.mts +26 -0
- package/dist/domain/grant-candidates.mjs +45 -0
- package/dist/domain/mesh-session.cjs +4 -2
- package/dist/domain/mesh-session.d.cts +4 -0
- package/dist/domain/mesh-session.d.mts +4 -0
- package/dist/domain/mesh-session.mjs +4 -2
- package/dist/domain/relay-hub.cjs +5 -0
- package/dist/domain/relay-hub.d.cts +8 -1
- package/dist/domain/relay-hub.d.mts +8 -1
- package/dist/domain/relay-hub.mjs +5 -0
- package/dist/domain/revocation-view.cjs +1 -1
- package/dist/domain/revocation-view.mjs +1 -1
- package/dist/domain/room-token-verification.cjs +1 -1
- package/dist/domain/room-token-verification.mjs +1 -1
- package/dist/domain/tokens.cjs +1 -1
- package/dist/domain/tokens.mjs +1 -1
- package/dist/token-scope-CxHXTT3u.cjs +43 -0
- package/dist/token-scope-Z4bmci4M.mjs +32 -0
- package/dist/{tokens-Dp9BnUTZ.cjs → tokens-CiNXdKJ8.cjs} +10 -44
- package/dist/{tokens-B5BUgkba.mjs → tokens-VicRWTj1.mjs} +6 -34
- package/package.json +5 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_generated_protocol = require("../generated/protocol.cjs");
|
|
3
|
-
const
|
|
3
|
+
const require_token_scope = require("../token-scope-CxHXTT3u.cjs");
|
|
4
|
+
const require_tokens = require("../tokens-CiNXdKJ8.cjs");
|
|
4
5
|
//#region src/domain/capability-grant.ts
|
|
5
6
|
/**
|
|
6
7
|
* The generic, capability-agnostic capability-grant primitive (wire-mesh#117, spec/management.cddl) -- the unsolicited push counterpart to capability-request.ts's own ask/response primitive. Where capability-request lifts core/room's room.join shape (a pull: the requester asks, the owner mints and returns a grant on the same response) out of that one domain, capability-grant lifts room.invite's own shape (a push: the owner mints unprompted and delivers the grant in the request itself, since there is no approval round-trip to carry it back) the same way. core/room's own room-client.ts is the reference consumer: it builds sendRoomInvite as a thin wrapper over sendCapabilityGrant below, and wires createRoomRouter's dispatch onto createCapabilityGrantHandler for the receiving side.
|
|
@@ -68,7 +69,7 @@ function createCapabilityGrantHandler(options) {
|
|
|
68
69
|
});
|
|
69
70
|
return;
|
|
70
71
|
}
|
|
71
|
-
if (!
|
|
72
|
+
if (!require_token_scope.scopeNarrows(verdict.claims.scope, incoming.scope)) {
|
|
72
73
|
await incoming.respond({
|
|
73
74
|
result: "error",
|
|
74
75
|
code: "scope_mismatch"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { capabilityGrantSchema } from "../generated/protocol.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { n as scopeNarrows } from "../token-scope-Z4bmci4M.mjs";
|
|
3
|
+
import { i as verifyCapabilityToken } from "../tokens-VicRWTj1.mjs";
|
|
3
4
|
//#region src/domain/capability-grant.ts
|
|
4
5
|
/**
|
|
5
6
|
* The generic, capability-agnostic capability-grant primitive (wire-mesh#117, spec/management.cddl) -- the unsolicited push counterpart to capability-request.ts's own ask/response primitive. Where capability-request lifts core/room's room.join shape (a pull: the requester asks, the owner mints and returns a grant on the same response) out of that one domain, capability-grant lifts room.invite's own shape (a push: the owner mints unprompted and delivers the grant in the request itself, since there is no approval round-trip to carry it back) the same way. core/room's own room-client.ts is the reference consumer: it builds sendRoomInvite as a thin wrapper over sendCapabilityGrant below, and wires createRoomRouter's dispatch onto createCapabilityGrantHandler for the receiving side.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_generated_protocol = require("../generated/protocol.cjs");
|
|
3
|
-
const require_tokens = require("../tokens-
|
|
3
|
+
const require_tokens = require("../tokens-CiNXdKJ8.cjs");
|
|
4
4
|
//#region src/domain/capability-request.ts
|
|
5
5
|
/**
|
|
6
6
|
* The generic, capability-agnostic capability-request / capability-grant-ok primitive (wire-mesh#78, spec/management.cddl). Lifts core/room's own room.join shape -- an ungated ask, held open, a human (or any other domain-supplied decision) accepts or refuses, acceptance mints and returns a token on the same response -- out of that one domain so any capability can reuse it, not just room:member. core/room's own room-client.ts is the reference consumer: it rewires requestToJoin/createRoomRouter's room.join handling onto requestCapability/createCapabilityRequestHandler below, supplying room-specific extension fields (its member list) through this module's own extensions mechanism rather than this module knowing anything about rooms.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { capabilityGrantOkSchema, capabilityRequestSchema } from "../generated/protocol.mjs";
|
|
2
|
-
import { n as mintCapabilityToken } from "../tokens-
|
|
2
|
+
import { n as mintCapabilityToken } from "../tokens-VicRWTj1.mjs";
|
|
3
3
|
//#region src/domain/capability-request.ts
|
|
4
4
|
/**
|
|
5
5
|
* The generic, capability-agnostic capability-request / capability-grant-ok primitive (wire-mesh#78, spec/management.cddl). Lifts core/room's own room.join shape -- an ungated ask, held open, a human (or any other domain-supplied decision) accepts or refuses, acceptance mints and returns a token on the same response -- out of that one domain so any capability can reuse it, not just room:member. core/room's own room-client.ts is the reference consumer: it rewires requestToJoin/createRoomRouter's room.join handling onto requestCapability/createCapabilityRequestHandler below, supplying room-specific extension fields (its member list) through this module's own extensions mechanism rather than this module knowing anything about rooms.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_generated_protocol = require("../generated/protocol.cjs");
|
|
3
|
+
const require_token_scope = require("../token-scope-CxHXTT3u.cjs");
|
|
4
|
+
let zod = require("zod");
|
|
5
|
+
//#region src/domain/grant-candidates.ts
|
|
6
|
+
/**
|
|
7
|
+
* Gossip-advertised delegable-grant candidates (wire-mesh#87): lets a bearer of a still-delegable held token advertise its own willingness to field a capability-request for a narrower grant, and lets a requester discover the best candidate it can currently see before falling back to a capability's own structural root (that fallback -- e.g. a room's owner device-id -- is the caller's own concern, entirely outside this module; findGrantCandidate only ever returns a nearer-than-root candidate or undefined). Rides peer-advert's existing open extension tail (spec/transport.cddl, wire-mesh#91) under the domain-qualified key "capability-request/candidates" -- no new CDDL, no new wire mechanism, just a structured value on an already-shipped extension point.
|
|
8
|
+
*
|
|
9
|
+
* Honest limit, not closed here: this is only ever as complete as gossip propagation allows. A request can go unanswered by a nearer delegate not because none exists, but because it hasn't been gossiped yet -- the same "detection with propagation delay" honesty this spec already accepts for kick propagation and revocation timing.
|
|
10
|
+
*/
|
|
11
|
+
const GOSSIP_CANDIDATES_KEY = "capability-request/candidates";
|
|
12
|
+
const grantCandidateSchema = zod.z.object({
|
|
13
|
+
capability: zod.z.string(),
|
|
14
|
+
scope: require_generated_protocol.capabilityScopeSchema,
|
|
15
|
+
"delegations-remaining": zod.z.number().int().nonnegative().optional()
|
|
16
|
+
});
|
|
17
|
+
const grantCandidateListSchema = zod.z.array(grantCandidateSchema);
|
|
18
|
+
/**
|
|
19
|
+
* Advertises this session's own willingness to field a capability-request for each of candidates, via sendGossipUpdate's own re-advertisement mechanism. Callers own their own re-advertisement cadence (there is no timer here, matching sendGossipUpdate's own design) -- calling this again with a fresh list replaces what was previously advertised under this key, exactly as sendGossipUpdate's own peer-advert re-send already does for any other extension field.
|
|
20
|
+
*/
|
|
21
|
+
async function advertiseGrantCandidates(session, candidates) {
|
|
22
|
+
await session.sendGossipUpdate({ [GOSSIP_CANDIDATES_KEY]: candidates });
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Scans directory for the best currently-gossiped delegate that could field a capability-request for capability over scope, returning its device-id -- or undefined when nothing closer than the capability's own structural root has been gossiped. A candidate qualifies when its own advertised capability matches exactly and its own advertised scope narrows into the requested scope (scopeNarrows' existing narrowing relation: the candidate's scope is the same as or a broader ancestor of scope), and its own delegations-remaining is either absent (unbounded) or still greater than zero.
|
|
26
|
+
*
|
|
27
|
+
* Selection policy, deliberately simple and explicitly stated rather than left implicit: the first qualifying candidate in directory's own order wins. Gossip carries no distance, latency, or load signal a requester could meaningfully rank candidates by, so no ordering directory provides is more defensible than any other -- "first seen" is exactly as reasonable a tie-break as "highest delegations-remaining" and simplest to implement and reason about.
|
|
28
|
+
*
|
|
29
|
+
* A malformed or non-conforming candidates value (from a misbehaving or out-of-date peer) is silently skipped, not treated as an error: this is a discovery hint over self-asserted, unsigned gossip, not a security check -- the token minted from any candidate this function returns still goes through mintCapabilityToken's own full narrowing checks regardless.
|
|
30
|
+
*/
|
|
31
|
+
function findGrantCandidate(directory, capability, scope) {
|
|
32
|
+
for (const entry of directory) {
|
|
33
|
+
const raw = entry.advert[GOSSIP_CANDIDATES_KEY];
|
|
34
|
+
if (raw === void 0) continue;
|
|
35
|
+
const parsed = grantCandidateListSchema.safeParse(raw);
|
|
36
|
+
if (!parsed.success) continue;
|
|
37
|
+
for (const candidate of parsed.data) {
|
|
38
|
+
if (candidate.capability !== capability) continue;
|
|
39
|
+
const remaining = candidate["delegations-remaining"];
|
|
40
|
+
if (remaining !== void 0 && remaining <= 0) continue;
|
|
41
|
+
if (require_token_scope.scopeNarrows(candidate.scope, scope)) return entry.device;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
exports.advertiseGrantCandidates = advertiseGrantCandidates;
|
|
47
|
+
exports.findGrantCandidate = findGrantCandidate;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { o as CapabilityScope, x as DeviceId } from "../protocol-l9RMR3s5.cjs";
|
|
2
|
+
import { DirectoryEntry, MeshSession } from "./mesh-session.cjs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/domain/grant-candidates.d.ts
|
|
5
|
+
declare const grantCandidateSchema: z.ZodObject<{
|
|
6
|
+
capability: z.ZodString;
|
|
7
|
+
scope: z.ZodLazy<z.ZodObject<{
|
|
8
|
+
kind: z.ZodString;
|
|
9
|
+
path: z.ZodOptional<z.ZodString>;
|
|
10
|
+
}, z.core.$strip>>;
|
|
11
|
+
"delegations-remaining": z.ZodOptional<z.ZodNumber>;
|
|
12
|
+
}, z.core.$strip>;
|
|
13
|
+
export type GrantCandidate = z.infer<typeof grantCandidateSchema>;
|
|
14
|
+
/**
|
|
15
|
+
* Advertises this session's own willingness to field a capability-request for each of candidates, via sendGossipUpdate's own re-advertisement mechanism. Callers own their own re-advertisement cadence (there is no timer here, matching sendGossipUpdate's own design) -- calling this again with a fresh list replaces what was previously advertised under this key, exactly as sendGossipUpdate's own peer-advert re-send already does for any other extension field.
|
|
16
|
+
*/
|
|
17
|
+
export declare function advertiseGrantCandidates(session: Readonly<MeshSession>, candidates: readonly GrantCandidate[]): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Scans directory for the best currently-gossiped delegate that could field a capability-request for capability over scope, returning its device-id -- or undefined when nothing closer than the capability's own structural root has been gossiped. A candidate qualifies when its own advertised capability matches exactly and its own advertised scope narrows into the requested scope (scopeNarrows' existing narrowing relation: the candidate's scope is the same as or a broader ancestor of scope), and its own delegations-remaining is either absent (unbounded) or still greater than zero.
|
|
20
|
+
*
|
|
21
|
+
* Selection policy, deliberately simple and explicitly stated rather than left implicit: the first qualifying candidate in directory's own order wins. Gossip carries no distance, latency, or load signal a requester could meaningfully rank candidates by, so no ordering directory provides is more defensible than any other -- "first seen" is exactly as reasonable a tie-break as "highest delegations-remaining" and simplest to implement and reason about.
|
|
22
|
+
*
|
|
23
|
+
* A malformed or non-conforming candidates value (from a misbehaving or out-of-date peer) is silently skipped, not treated as an error: this is a discovery hint over self-asserted, unsigned gossip, not a security check -- the token minted from any candidate this function returns still goes through mintCapabilityToken's own full narrowing checks regardless.
|
|
24
|
+
*/
|
|
25
|
+
export declare function findGrantCandidate(directory: readonly DirectoryEntry[], capability: string, scope: Readonly<CapabilityScope>): DeviceId | undefined;
|
|
26
|
+
//#endregion
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { o as CapabilityScope, x as DeviceId } from "../protocol-l9RMR3s5.mjs";
|
|
2
|
+
import { DirectoryEntry, MeshSession } from "./mesh-session.mjs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/domain/grant-candidates.d.ts
|
|
5
|
+
declare const grantCandidateSchema: z.ZodObject<{
|
|
6
|
+
capability: z.ZodString;
|
|
7
|
+
scope: z.ZodLazy<z.ZodObject<{
|
|
8
|
+
kind: z.ZodString;
|
|
9
|
+
path: z.ZodOptional<z.ZodString>;
|
|
10
|
+
}, z.core.$strip>>;
|
|
11
|
+
"delegations-remaining": z.ZodOptional<z.ZodNumber>;
|
|
12
|
+
}, z.core.$strip>;
|
|
13
|
+
export type GrantCandidate = z.infer<typeof grantCandidateSchema>;
|
|
14
|
+
/**
|
|
15
|
+
* Advertises this session's own willingness to field a capability-request for each of candidates, via sendGossipUpdate's own re-advertisement mechanism. Callers own their own re-advertisement cadence (there is no timer here, matching sendGossipUpdate's own design) -- calling this again with a fresh list replaces what was previously advertised under this key, exactly as sendGossipUpdate's own peer-advert re-send already does for any other extension field.
|
|
16
|
+
*/
|
|
17
|
+
export declare function advertiseGrantCandidates(session: Readonly<MeshSession>, candidates: readonly GrantCandidate[]): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Scans directory for the best currently-gossiped delegate that could field a capability-request for capability over scope, returning its device-id -- or undefined when nothing closer than the capability's own structural root has been gossiped. A candidate qualifies when its own advertised capability matches exactly and its own advertised scope narrows into the requested scope (scopeNarrows' existing narrowing relation: the candidate's scope is the same as or a broader ancestor of scope), and its own delegations-remaining is either absent (unbounded) or still greater than zero.
|
|
20
|
+
*
|
|
21
|
+
* Selection policy, deliberately simple and explicitly stated rather than left implicit: the first qualifying candidate in directory's own order wins. Gossip carries no distance, latency, or load signal a requester could meaningfully rank candidates by, so no ordering directory provides is more defensible than any other -- "first seen" is exactly as reasonable a tie-break as "highest delegations-remaining" and simplest to implement and reason about.
|
|
22
|
+
*
|
|
23
|
+
* A malformed or non-conforming candidates value (from a misbehaving or out-of-date peer) is silently skipped, not treated as an error: this is a discovery hint over self-asserted, unsigned gossip, not a security check -- the token minted from any candidate this function returns still goes through mintCapabilityToken's own full narrowing checks regardless.
|
|
24
|
+
*/
|
|
25
|
+
export declare function findGrantCandidate(directory: readonly DirectoryEntry[], capability: string, scope: Readonly<CapabilityScope>): DeviceId | undefined;
|
|
26
|
+
//#endregion
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { capabilityScopeSchema } from "../generated/protocol.mjs";
|
|
2
|
+
import { n as scopeNarrows } from "../token-scope-Z4bmci4M.mjs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/domain/grant-candidates.ts
|
|
5
|
+
/**
|
|
6
|
+
* Gossip-advertised delegable-grant candidates (wire-mesh#87): lets a bearer of a still-delegable held token advertise its own willingness to field a capability-request for a narrower grant, and lets a requester discover the best candidate it can currently see before falling back to a capability's own structural root (that fallback -- e.g. a room's owner device-id -- is the caller's own concern, entirely outside this module; findGrantCandidate only ever returns a nearer-than-root candidate or undefined). Rides peer-advert's existing open extension tail (spec/transport.cddl, wire-mesh#91) under the domain-qualified key "capability-request/candidates" -- no new CDDL, no new wire mechanism, just a structured value on an already-shipped extension point.
|
|
7
|
+
*
|
|
8
|
+
* Honest limit, not closed here: this is only ever as complete as gossip propagation allows. A request can go unanswered by a nearer delegate not because none exists, but because it hasn't been gossiped yet -- the same "detection with propagation delay" honesty this spec already accepts for kick propagation and revocation timing.
|
|
9
|
+
*/
|
|
10
|
+
const GOSSIP_CANDIDATES_KEY = "capability-request/candidates";
|
|
11
|
+
const grantCandidateSchema = z.object({
|
|
12
|
+
capability: z.string(),
|
|
13
|
+
scope: capabilityScopeSchema,
|
|
14
|
+
"delegations-remaining": z.number().int().nonnegative().optional()
|
|
15
|
+
});
|
|
16
|
+
const grantCandidateListSchema = z.array(grantCandidateSchema);
|
|
17
|
+
/**
|
|
18
|
+
* Advertises this session's own willingness to field a capability-request for each of candidates, via sendGossipUpdate's own re-advertisement mechanism. Callers own their own re-advertisement cadence (there is no timer here, matching sendGossipUpdate's own design) -- calling this again with a fresh list replaces what was previously advertised under this key, exactly as sendGossipUpdate's own peer-advert re-send already does for any other extension field.
|
|
19
|
+
*/
|
|
20
|
+
async function advertiseGrantCandidates(session, candidates) {
|
|
21
|
+
await session.sendGossipUpdate({ [GOSSIP_CANDIDATES_KEY]: candidates });
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Scans directory for the best currently-gossiped delegate that could field a capability-request for capability over scope, returning its device-id -- or undefined when nothing closer than the capability's own structural root has been gossiped. A candidate qualifies when its own advertised capability matches exactly and its own advertised scope narrows into the requested scope (scopeNarrows' existing narrowing relation: the candidate's scope is the same as or a broader ancestor of scope), and its own delegations-remaining is either absent (unbounded) or still greater than zero.
|
|
25
|
+
*
|
|
26
|
+
* Selection policy, deliberately simple and explicitly stated rather than left implicit: the first qualifying candidate in directory's own order wins. Gossip carries no distance, latency, or load signal a requester could meaningfully rank candidates by, so no ordering directory provides is more defensible than any other -- "first seen" is exactly as reasonable a tie-break as "highest delegations-remaining" and simplest to implement and reason about.
|
|
27
|
+
*
|
|
28
|
+
* A malformed or non-conforming candidates value (from a misbehaving or out-of-date peer) is silently skipped, not treated as an error: this is a discovery hint over self-asserted, unsigned gossip, not a security check -- the token minted from any candidate this function returns still goes through mintCapabilityToken's own full narrowing checks regardless.
|
|
29
|
+
*/
|
|
30
|
+
function findGrantCandidate(directory, capability, scope) {
|
|
31
|
+
for (const entry of directory) {
|
|
32
|
+
const raw = entry.advert[GOSSIP_CANDIDATES_KEY];
|
|
33
|
+
if (raw === void 0) continue;
|
|
34
|
+
const parsed = grantCandidateListSchema.safeParse(raw);
|
|
35
|
+
if (!parsed.success) continue;
|
|
36
|
+
for (const candidate of parsed.data) {
|
|
37
|
+
if (candidate.capability !== capability) continue;
|
|
38
|
+
const remaining = candidate["delegations-remaining"];
|
|
39
|
+
if (remaining !== void 0 && remaining <= 0) continue;
|
|
40
|
+
if (scopeNarrows(candidate.scope, scope)) return entry.device;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
export { advertiseGrantCandidates, findGrantCandidate };
|
|
@@ -31,7 +31,7 @@ function localHandshake(domains) {
|
|
|
31
31
|
/**
|
|
32
32
|
* Builds the connection-agnostic session state machine and its public MeshSession surface. dial is null for a session that can never (re)connect on its own -- acceptMeshSession's case, where the one connection it will ever have already exists by construction and reconnect therefore cannot apply (only the remote redialing, and being accepted again, produces a fresh connection). createMeshSession supplies dial as transport.connect so its own connect()/reconnect behaviour is unchanged from before this was factored out.
|
|
33
33
|
*/
|
|
34
|
-
function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addresses = []) {
|
|
34
|
+
function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addresses = [], onFrame, onSessionEnd) {
|
|
35
35
|
let connection = null;
|
|
36
36
|
let state = { status: "idle" };
|
|
37
37
|
let handshake = { status: "pending" };
|
|
@@ -239,8 +239,10 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
|
|
|
239
239
|
for await (const frame of link.receive()) {
|
|
240
240
|
if (feedCancelled) return;
|
|
241
241
|
applyFrame(frame);
|
|
242
|
+
await onFrame?.(link, frame);
|
|
242
243
|
emit();
|
|
243
244
|
}
|
|
245
|
+
onSessionEnd?.(link);
|
|
244
246
|
if (state.status === "connected") handleDisconnect("node closed the connection", address, localDomains);
|
|
245
247
|
}
|
|
246
248
|
/** Builds this side's own self-advert: this node's own directly-reachable addresses (wire-mesh#38), or none for a caller with nothing to offer (a browser client, which cannot accept inbound connections) -- either is an honest advert, not a stopgap. extensions merge onto peer-advert's own open `* tstr => any` tail -- the mechanism sendGossipUpdate uses to keep a gossiped fact (presence status, an accept/refuse policy, or any future domain's own) live over the connection's lifetime. Extensions are spread before the three mandatory fields (never after) so a caller-supplied key of the same name can never shadow them on the wire -- validateGossipExtensions already rejects that case loudly, but the field order is kept safe in its own right rather than relying solely on the guard staying in sync. */
|
|
@@ -466,7 +468,7 @@ async function acceptMeshSession(connection, identity, localDomains, options = {
|
|
|
466
468
|
if (peerDeviceIdResolved) return;
|
|
467
469
|
peerDeviceIdResolved = true;
|
|
468
470
|
resolvePeerDeviceId?.(advert.device);
|
|
469
|
-
}, options.addresses);
|
|
471
|
+
}, options.addresses, options.onFrame, options.onSessionEnd);
|
|
470
472
|
await wireUpConnection(connection, options.label ?? "accepted", localDomains);
|
|
471
473
|
return {
|
|
472
474
|
...session,
|
|
@@ -101,6 +101,10 @@ export interface AcceptedMeshSessionOptions {
|
|
|
101
101
|
clock?: Readonly<Clock>;
|
|
102
102
|
/** This node's own directly-reachable "host:port" candidates (wire-mesh#38), advertised in this session's self-advert. Omit (or pass none) for a caller with nothing to offer. */
|
|
103
103
|
addresses?: readonly string[];
|
|
104
|
+
/** Observes every frame this session receives, alongside (never instead of) its own manage-request/gossip/handshake handling (wire-mesh#102) -- the integration point an "ordinary opted-in node relays for peers it's already talking to" uses: pass a shared RelayHub instance's own handleFrame, bound to this connection, and it sees the identical frame stream this session's own applyFrame already consumes, with no second for-await loop racing over the same connection.receive(). A dedicated wire-mesh-node/cloudflare-hub deployment, which never runs a MeshSession at all, is unaffected -- this is additive, opt-in, and irrelevant to that case. */
|
|
105
|
+
onFrame?: (connection: Readonly<Connection>, frame: Frame) => void | Promise<void>;
|
|
106
|
+
/** Fired once this session's own connection.receive() stream ends -- the counterpart a RelayHub's own forgetConnection needs, since its registry is keyed by this exact Connection and must be cleaned up when it specifically ends. */
|
|
107
|
+
onSessionEnd?: (connection: Readonly<Connection>) => void;
|
|
104
108
|
}
|
|
105
109
|
/** Wires an already-accepted Connection up as a full MeshSession, mirroring exactly what createMeshSession's own dial path does once a connection exists (send handshake, send self-advert, negotiate, consume frames) -- the wire-mesh#45 prerequisite agent-comms needs, since its peers both listen and dial rather than only ever dialing the way web-console's own console UI does. Reconnect does not apply here: if this connection drops, only the remote redialing and being accepted again produces a new connection, and therefore a new session -- there is nothing on this side to retry. */
|
|
106
110
|
export declare function acceptMeshSession(connection: Readonly<Connection>, identity: Readonly<IdentityPort>, localDomains: readonly string[], options?: Readonly<AcceptedMeshSessionOptions>): Promise<AcceptedMeshSession>;
|
|
@@ -101,6 +101,10 @@ export interface AcceptedMeshSessionOptions {
|
|
|
101
101
|
clock?: Readonly<Clock>;
|
|
102
102
|
/** This node's own directly-reachable "host:port" candidates (wire-mesh#38), advertised in this session's self-advert. Omit (or pass none) for a caller with nothing to offer. */
|
|
103
103
|
addresses?: readonly string[];
|
|
104
|
+
/** Observes every frame this session receives, alongside (never instead of) its own manage-request/gossip/handshake handling (wire-mesh#102) -- the integration point an "ordinary opted-in node relays for peers it's already talking to" uses: pass a shared RelayHub instance's own handleFrame, bound to this connection, and it sees the identical frame stream this session's own applyFrame already consumes, with no second for-await loop racing over the same connection.receive(). A dedicated wire-mesh-node/cloudflare-hub deployment, which never runs a MeshSession at all, is unaffected -- this is additive, opt-in, and irrelevant to that case. */
|
|
105
|
+
onFrame?: (connection: Readonly<Connection>, frame: Frame) => void | Promise<void>;
|
|
106
|
+
/** Fired once this session's own connection.receive() stream ends -- the counterpart a RelayHub's own forgetConnection needs, since its registry is keyed by this exact Connection and must be cleaned up when it specifically ends. */
|
|
107
|
+
onSessionEnd?: (connection: Readonly<Connection>) => void;
|
|
104
108
|
}
|
|
105
109
|
/** Wires an already-accepted Connection up as a full MeshSession, mirroring exactly what createMeshSession's own dial path does once a connection exists (send handshake, send self-advert, negotiate, consume frames) -- the wire-mesh#45 prerequisite agent-comms needs, since its peers both listen and dial rather than only ever dialing the way web-console's own console UI does. Reconnect does not apply here: if this connection drops, only the remote redialing and being accepted again produces a new connection, and therefore a new session -- there is nothing on this side to retry. */
|
|
106
110
|
export declare function acceptMeshSession(connection: Readonly<Connection>, identity: Readonly<IdentityPort>, localDomains: readonly string[], options?: Readonly<AcceptedMeshSessionOptions>): Promise<AcceptedMeshSession>;
|
|
@@ -30,7 +30,7 @@ function localHandshake(domains) {
|
|
|
30
30
|
/**
|
|
31
31
|
* Builds the connection-agnostic session state machine and its public MeshSession surface. dial is null for a session that can never (re)connect on its own -- acceptMeshSession's case, where the one connection it will ever have already exists by construction and reconnect therefore cannot apply (only the remote redialing, and being accepted again, produces a fresh connection). createMeshSession supplies dial as transport.connect so its own connect()/reconnect behaviour is unchanged from before this was factored out.
|
|
32
32
|
*/
|
|
33
|
-
function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addresses = []) {
|
|
33
|
+
function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addresses = [], onFrame, onSessionEnd) {
|
|
34
34
|
let connection = null;
|
|
35
35
|
let state = { status: "idle" };
|
|
36
36
|
let handshake = { status: "pending" };
|
|
@@ -238,8 +238,10 @@ function createSessionCore(identity, clock, reconnect, dial, onPeerAdvert, addre
|
|
|
238
238
|
for await (const frame of link.receive()) {
|
|
239
239
|
if (feedCancelled) return;
|
|
240
240
|
applyFrame(frame);
|
|
241
|
+
await onFrame?.(link, frame);
|
|
241
242
|
emit();
|
|
242
243
|
}
|
|
244
|
+
onSessionEnd?.(link);
|
|
243
245
|
if (state.status === "connected") handleDisconnect("node closed the connection", address, localDomains);
|
|
244
246
|
}
|
|
245
247
|
/** Builds this side's own self-advert: this node's own directly-reachable addresses (wire-mesh#38), or none for a caller with nothing to offer (a browser client, which cannot accept inbound connections) -- either is an honest advert, not a stopgap. extensions merge onto peer-advert's own open `* tstr => any` tail -- the mechanism sendGossipUpdate uses to keep a gossiped fact (presence status, an accept/refuse policy, or any future domain's own) live over the connection's lifetime. Extensions are spread before the three mandatory fields (never after) so a caller-supplied key of the same name can never shadow them on the wire -- validateGossipExtensions already rejects that case loudly, but the field order is kept safe in its own right rather than relying solely on the guard staying in sync. */
|
|
@@ -465,7 +467,7 @@ async function acceptMeshSession(connection, identity, localDomains, options = {
|
|
|
465
467
|
if (peerDeviceIdResolved) return;
|
|
466
468
|
peerDeviceIdResolved = true;
|
|
467
469
|
resolvePeerDeviceId?.(advert.device);
|
|
468
|
-
}, options.addresses);
|
|
470
|
+
}, options.addresses, options.onFrame, options.onSessionEnd);
|
|
469
471
|
await wireUpConnection(connection, options.label ?? "accepted", localDomains);
|
|
470
472
|
return {
|
|
471
473
|
...session,
|
|
@@ -104,6 +104,11 @@ function createRelayHub() {
|
|
|
104
104
|
forgetConnection(connection);
|
|
105
105
|
}
|
|
106
106
|
},
|
|
107
|
+
registerConnection(connection) {
|
|
108
|
+
connections.add(connection);
|
|
109
|
+
},
|
|
110
|
+
onFrame: handleFrame,
|
|
111
|
+
onDisconnect: forgetConnection,
|
|
107
112
|
stop() {
|
|
108
113
|
connections.clear();
|
|
109
114
|
devices.clear();
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import { D as Frame } from "../protocol-l9RMR3s5.cjs";
|
|
1
2
|
import { t as Connection } from "../transport-Baipikk7.cjs";
|
|
2
3
|
//#region src/domain/relay-hub.d.ts
|
|
3
4
|
export interface RelayHub {
|
|
4
|
-
/** Drives one accepted connection until it closes: registers gossip-advertised devices, re-broadcasts each gossip frame to every other connected client and replies to the gossiping connection with a catch-up frame of every other already-known device, answers relay-connect by pairing and notifying the target, and forwards relay-data within established pairings. Resolves when the connection's frame stream ends. */
|
|
5
|
+
/** Drives one accepted connection until it closes: registers gossip-advertised devices, re-broadcasts each gossip frame to every other connected client and replies to the gossiping connection with a catch-up frame of every other already-known device, answers relay-connect by pairing and notifying the target, and forwards relay-data within established pairings. Resolves when the connection's frame stream ends. Internally just registerConnection followed by a loop of onFrame calls and a final onDisconnect -- kept as its own method since a dedicated hub deployment (wire-mesh-node/cloudflare-hub) has nothing else driving the connection and wants the whole lifecycle in one call. */
|
|
5
6
|
handleConnection: (connection: Readonly<Connection>) => Promise<void>;
|
|
7
|
+
/** Registers a connection with this hub without taking over its own frame-consumption loop (wire-mesh#102) -- the entry point an "ordinary opted-in node relays for peers it's already talking to" uses, alongside onFrame/onDisconnect below, to let a MeshSession's own single connection.receive() loop drive this hub rather than running a second, competing one. Must be called once, before the first onFrame call for this connection, so gossip fan-out (which forwards to every OTHER registered connection) already sees it. */
|
|
8
|
+
registerConnection: (connection: Readonly<Connection>) => void;
|
|
9
|
+
/** Observes one frame on an already-registered connection -- the same per-frame logic handleConnection's own loop calls internally, exposed directly so a caller with its own frame-consumption loop can drive it without a second for-await over the same connection.receive(). */
|
|
10
|
+
onFrame: (connection: Readonly<Connection>, frame: Frame) => Promise<void>;
|
|
11
|
+
/** Cleans up all registry and pairing state for one connection once its own frame stream has ended -- the onSessionEnd counterpart registerConnection/onFrame needs for the shared-consumption case, since this hub's registry is keyed by Connection and has no other way to learn a connection is gone. */
|
|
12
|
+
onDisconnect: (connection: Readonly<Connection>) => void;
|
|
6
13
|
/** Drops all registry and pairing state -- used by tests and by transport teardown. */
|
|
7
14
|
stop: () => void;
|
|
8
15
|
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
+
import { D as Frame } from "../protocol-l9RMR3s5.mjs";
|
|
1
2
|
import { t as Connection } from "../transport-DWbvUc3L.mjs";
|
|
2
3
|
//#region src/domain/relay-hub.d.ts
|
|
3
4
|
export interface RelayHub {
|
|
4
|
-
/** Drives one accepted connection until it closes: registers gossip-advertised devices, re-broadcasts each gossip frame to every other connected client and replies to the gossiping connection with a catch-up frame of every other already-known device, answers relay-connect by pairing and notifying the target, and forwards relay-data within established pairings. Resolves when the connection's frame stream ends. */
|
|
5
|
+
/** Drives one accepted connection until it closes: registers gossip-advertised devices, re-broadcasts each gossip frame to every other connected client and replies to the gossiping connection with a catch-up frame of every other already-known device, answers relay-connect by pairing and notifying the target, and forwards relay-data within established pairings. Resolves when the connection's frame stream ends. Internally just registerConnection followed by a loop of onFrame calls and a final onDisconnect -- kept as its own method since a dedicated hub deployment (wire-mesh-node/cloudflare-hub) has nothing else driving the connection and wants the whole lifecycle in one call. */
|
|
5
6
|
handleConnection: (connection: Readonly<Connection>) => Promise<void>;
|
|
7
|
+
/** Registers a connection with this hub without taking over its own frame-consumption loop (wire-mesh#102) -- the entry point an "ordinary opted-in node relays for peers it's already talking to" uses, alongside onFrame/onDisconnect below, to let a MeshSession's own single connection.receive() loop drive this hub rather than running a second, competing one. Must be called once, before the first onFrame call for this connection, so gossip fan-out (which forwards to every OTHER registered connection) already sees it. */
|
|
8
|
+
registerConnection: (connection: Readonly<Connection>) => void;
|
|
9
|
+
/** Observes one frame on an already-registered connection -- the same per-frame logic handleConnection's own loop calls internally, exposed directly so a caller with its own frame-consumption loop can drive it without a second for-await over the same connection.receive(). */
|
|
10
|
+
onFrame: (connection: Readonly<Connection>, frame: Frame) => Promise<void>;
|
|
11
|
+
/** Cleans up all registry and pairing state for one connection once its own frame stream has ended -- the onSessionEnd counterpart registerConnection/onFrame needs for the shared-consumption case, since this hub's registry is keyed by Connection and has no other way to learn a connection is gone. */
|
|
12
|
+
onDisconnect: (connection: Readonly<Connection>) => void;
|
|
6
13
|
/** Drops all registry and pairing state -- used by tests and by transport teardown. */
|
|
7
14
|
stop: () => void;
|
|
8
15
|
}
|
|
@@ -103,6 +103,11 @@ function createRelayHub() {
|
|
|
103
103
|
forgetConnection(connection);
|
|
104
104
|
}
|
|
105
105
|
},
|
|
106
|
+
registerConnection(connection) {
|
|
107
|
+
connections.add(connection);
|
|
108
|
+
},
|
|
109
|
+
onFrame: handleFrame,
|
|
110
|
+
onDisconnect: forgetConnection,
|
|
106
111
|
stop() {
|
|
107
112
|
connections.clear();
|
|
108
113
|
devices.clear();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_tokens = require("../tokens-
|
|
2
|
+
const require_tokens = require("../tokens-CiNXdKJ8.cjs");
|
|
3
3
|
const require_domain_device_id = require("./device-id.cjs");
|
|
4
4
|
//#region src/domain/revocation-view.ts
|
|
5
5
|
function revocationKey(tokenId) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_tokens = require("../tokens-
|
|
2
|
+
const require_tokens = require("../tokens-CiNXdKJ8.cjs");
|
|
3
3
|
const require_domain_device_id = require("./device-id.cjs");
|
|
4
4
|
const require_domain_room_path = require("./room-path.cjs");
|
|
5
5
|
//#region src/domain/room-token-verification.ts
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { i as verifyCapabilityToken } from "../tokens-
|
|
1
|
+
import { i as verifyCapabilityToken } from "../tokens-VicRWTj1.mjs";
|
|
2
2
|
import { deviceIdToHex } from "./device-id.mjs";
|
|
3
3
|
import { parseRoomPath } from "./room-path.mjs";
|
|
4
4
|
//#region src/domain/room-token-verification.ts
|
package/dist/domain/tokens.cjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_tokens = require("../tokens-
|
|
2
|
+
const require_tokens = require("../tokens-CiNXdKJ8.cjs");
|
|
3
3
|
exports.canGrant = require_tokens.canGrant;
|
|
4
4
|
exports.mintCapabilityToken = require_tokens.mintCapabilityToken;
|
|
5
5
|
exports.mintRevocationEntry = require_tokens.mintRevocationEntry;
|
package/dist/domain/tokens.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as verifyRevocationEntry, i as verifyCapabilityToken, n as mintCapabilityToken, r as mintRevocationEntry, t as canGrant } from "../tokens-
|
|
1
|
+
import { a as verifyRevocationEntry, i as verifyCapabilityToken, n as mintCapabilityToken, r as mintRevocationEntry, t as canGrant } from "../tokens-VicRWTj1.mjs";
|
|
2
2
|
export { canGrant, mintCapabilityToken, mintRevocationEntry, verifyCapabilityToken, verifyRevocationEntry };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/domain/token-scope.ts
|
|
2
|
+
/**
|
|
3
|
+
* Extracted out of tokens.ts specifically so token-predicates.ts (the generic evaluator's own delegate handlers) and tokens.ts (the chain walk, and its re-export for capability-grant.ts) can both depend on this pure, no-domain-imports module without creating an import cycle between the two -- tokens.ts imports evaluateNarrowing from token-predicates.ts, and token-predicates.ts's own scope-narrows/bearer-is handlers need these exact comparisons, so neither of those two files may import the other.
|
|
4
|
+
*/
|
|
5
|
+
function bytesEqual(a, b) {
|
|
6
|
+
if (a.length !== b.length) return false;
|
|
7
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
/** True when the path contains a "." or ".." segment. Purely lexical prefix comparison would let "/work/../org" pass under "/work" -- a path that normalises outside the parent -- so any relative segment fails the narrowing comparison wholesale: fail-closed rather than reimplementing path normalisation, consistent with how empty, case-different, and non-boundary-prefixed paths already behave. */
|
|
11
|
+
function hasRelativeSegment(path) {
|
|
12
|
+
return path.split("/").some((segment) => segment === "." || segment === "..");
|
|
13
|
+
}
|
|
14
|
+
/** True when childPath is parentPath or a descendant of it, compared on "/"-segment boundaries: "/work/sub" narrows "/work", but "/workbook" does NOT narrow "/work" despite the string prefix, because "book" continues the same segment. Paths containing "." or ".." segments never narrow anything (see hasRelativeSegment). */
|
|
15
|
+
function pathNarrows(childPath, parentPath) {
|
|
16
|
+
if (hasRelativeSegment(childPath) || hasRelativeSegment(parentPath)) return false;
|
|
17
|
+
if (childPath === parentPath) return true;
|
|
18
|
+
if (!childPath.startsWith(parentPath)) return false;
|
|
19
|
+
if (parentPath.endsWith("/")) return true;
|
|
20
|
+
return childPath.charAt(parentPath.length) === "/";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* True when childScope narrows parentScope per tokens.cddl ("each hop can only narrow authority, never widen it"): the kind must be identical (a different kind is a different kind of authority, not a narrower one), and a parent with a path requires the child to carry an equal-or-descendant path -- an absent child path means the kind's whole-scope root, which is wider than any path-narrowed parent. A parent with no path (whole-scope root) lets any child path under the same kind through. Exported for capability-grant.ts's own obligation 4 (an unsolicited push's embedded token must equal-or-root the enclosing request's own scope), which is exactly this same narrowing relation applied outside a delegation chain, and for token-predicates.ts's own `scope-narrows` delegate handler.
|
|
24
|
+
*/
|
|
25
|
+
function scopeNarrows(parent, child) {
|
|
26
|
+
if (parent.kind !== child.kind) return false;
|
|
27
|
+
if (parent.path === void 0) return true;
|
|
28
|
+
if (child.path === void 0) return false;
|
|
29
|
+
return pathNarrows(child.path, parent.path);
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
Object.defineProperty(exports, "bytesEqual", {
|
|
33
|
+
enumerable: true,
|
|
34
|
+
get: function() {
|
|
35
|
+
return bytesEqual;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
Object.defineProperty(exports, "scopeNarrows", {
|
|
39
|
+
enumerable: true,
|
|
40
|
+
get: function() {
|
|
41
|
+
return scopeNarrows;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
//#region src/domain/token-scope.ts
|
|
2
|
+
/**
|
|
3
|
+
* Extracted out of tokens.ts specifically so token-predicates.ts (the generic evaluator's own delegate handlers) and tokens.ts (the chain walk, and its re-export for capability-grant.ts) can both depend on this pure, no-domain-imports module without creating an import cycle between the two -- tokens.ts imports evaluateNarrowing from token-predicates.ts, and token-predicates.ts's own scope-narrows/bearer-is handlers need these exact comparisons, so neither of those two files may import the other.
|
|
4
|
+
*/
|
|
5
|
+
function bytesEqual(a, b) {
|
|
6
|
+
if (a.length !== b.length) return false;
|
|
7
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
/** True when the path contains a "." or ".." segment. Purely lexical prefix comparison would let "/work/../org" pass under "/work" -- a path that normalises outside the parent -- so any relative segment fails the narrowing comparison wholesale: fail-closed rather than reimplementing path normalisation, consistent with how empty, case-different, and non-boundary-prefixed paths already behave. */
|
|
11
|
+
function hasRelativeSegment(path) {
|
|
12
|
+
return path.split("/").some((segment) => segment === "." || segment === "..");
|
|
13
|
+
}
|
|
14
|
+
/** True when childPath is parentPath or a descendant of it, compared on "/"-segment boundaries: "/work/sub" narrows "/work", but "/workbook" does NOT narrow "/work" despite the string prefix, because "book" continues the same segment. Paths containing "." or ".." segments never narrow anything (see hasRelativeSegment). */
|
|
15
|
+
function pathNarrows(childPath, parentPath) {
|
|
16
|
+
if (hasRelativeSegment(childPath) || hasRelativeSegment(parentPath)) return false;
|
|
17
|
+
if (childPath === parentPath) return true;
|
|
18
|
+
if (!childPath.startsWith(parentPath)) return false;
|
|
19
|
+
if (parentPath.endsWith("/")) return true;
|
|
20
|
+
return childPath.charAt(parentPath.length) === "/";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* True when childScope narrows parentScope per tokens.cddl ("each hop can only narrow authority, never widen it"): the kind must be identical (a different kind is a different kind of authority, not a narrower one), and a parent with a path requires the child to carry an equal-or-descendant path -- an absent child path means the kind's whole-scope root, which is wider than any path-narrowed parent. A parent with no path (whole-scope root) lets any child path under the same kind through. Exported for capability-grant.ts's own obligation 4 (an unsolicited push's embedded token must equal-or-root the enclosing request's own scope), which is exactly this same narrowing relation applied outside a delegation chain, and for token-predicates.ts's own `scope-narrows` delegate handler.
|
|
24
|
+
*/
|
|
25
|
+
function scopeNarrows(parent, child) {
|
|
26
|
+
if (parent.kind !== child.kind) return false;
|
|
27
|
+
if (parent.path === void 0) return true;
|
|
28
|
+
if (child.path === void 0) return false;
|
|
29
|
+
return pathNarrows(child.path, parent.path);
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
export { scopeNarrows as n, bytesEqual as t };
|
|
@@ -1,38 +1,8 @@
|
|
|
1
1
|
const require_generated_protocol = require("./generated/protocol.cjs");
|
|
2
|
+
const require_token_scope = require("./token-scope-CxHXTT3u.cjs");
|
|
2
3
|
let zod = require("zod");
|
|
3
4
|
let cbor2 = require("cbor2");
|
|
4
5
|
let trilean = require("trilean");
|
|
5
|
-
//#region src/domain/token-scope.ts
|
|
6
|
-
/**
|
|
7
|
-
* Extracted out of tokens.ts specifically so token-predicates.ts (the generic evaluator's own delegate handlers) and tokens.ts (the chain walk, and its re-export for capability-grant.ts) can both depend on this pure, no-domain-imports module without creating an import cycle between the two -- tokens.ts imports evaluateNarrowing from token-predicates.ts, and token-predicates.ts's own scope-narrows/bearer-is handlers need these exact comparisons, so neither of those two files may import the other.
|
|
8
|
-
*/
|
|
9
|
-
function bytesEqual(a, b) {
|
|
10
|
-
if (a.length !== b.length) return false;
|
|
11
|
-
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
12
|
-
return true;
|
|
13
|
-
}
|
|
14
|
-
/** True when the path contains a "." or ".." segment. Purely lexical prefix comparison would let "/work/../org" pass under "/work" -- a path that normalises outside the parent -- so any relative segment fails the narrowing comparison wholesale: fail-closed rather than reimplementing path normalisation, consistent with how empty, case-different, and non-boundary-prefixed paths already behave. */
|
|
15
|
-
function hasRelativeSegment(path) {
|
|
16
|
-
return path.split("/").some((segment) => segment === "." || segment === "..");
|
|
17
|
-
}
|
|
18
|
-
/** True when childPath is parentPath or a descendant of it, compared on "/"-segment boundaries: "/work/sub" narrows "/work", but "/workbook" does NOT narrow "/work" despite the string prefix, because "book" continues the same segment. Paths containing "." or ".." segments never narrow anything (see hasRelativeSegment). */
|
|
19
|
-
function pathNarrows(childPath, parentPath) {
|
|
20
|
-
if (hasRelativeSegment(childPath) || hasRelativeSegment(parentPath)) return false;
|
|
21
|
-
if (childPath === parentPath) return true;
|
|
22
|
-
if (!childPath.startsWith(parentPath)) return false;
|
|
23
|
-
if (parentPath.endsWith("/")) return true;
|
|
24
|
-
return childPath.charAt(parentPath.length) === "/";
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* True when childScope narrows parentScope per tokens.cddl ("each hop can only narrow authority, never widen it"): the kind must be identical (a different kind is a different kind of authority, not a narrower one), and a parent with a path requires the child to carry an equal-or-descendant path -- an absent child path means the kind's whole-scope root, which is wider than any path-narrowed parent. A parent with no path (whole-scope root) lets any child path under the same kind through. Exported for capability-grant.ts's own obligation 4 (an unsolicited push's embedded token must equal-or-root the enclosing request's own scope), which is exactly this same narrowing relation applied outside a delegation chain, and for token-predicates.ts's own `scope-narrows` delegate handler.
|
|
28
|
-
*/
|
|
29
|
-
function scopeNarrows(parent, child) {
|
|
30
|
-
if (parent.kind !== child.kind) return false;
|
|
31
|
-
if (parent.path === void 0) return true;
|
|
32
|
-
if (child.path === void 0) return false;
|
|
33
|
-
return pathNarrows(child.path, parent.path);
|
|
34
|
-
}
|
|
35
|
-
//#endregion
|
|
36
6
|
//#region src/domain/token-predicates.ts
|
|
37
7
|
/** The wire shape of `token-claims.conditions` once CBOR-decoded: trilean's own PredicateNodeSchema is the single source of truth for what a condition entry may contain, re-validated here rather than trusted from a CDDL-generated shadow schema (see tokens.cddl's own comment on why `conditions` is an opaque bstr, not a native CDDL type) -- a token from an untrusted peer must pass trilean's real schema before any of its conditions are evaluated. Explicitly annotated: trilean's PredicateNodeSchema is a deeply recursive z.lazy() type whose inferred shape is too large for tsdown's declaration-file generator to serialise (TS7056) without this. */
|
|
38
8
|
const conditionsListSchema = zod.z.array(trilean.PredicateNodeSchema);
|
|
@@ -67,7 +37,7 @@ function booleanFound(value) {
|
|
|
67
37
|
const narrowingHandlers = {
|
|
68
38
|
[BEARER_IS]: (_payload, context) => {
|
|
69
39
|
if (context.parentClaims === void 0) return booleanFound(false);
|
|
70
|
-
return booleanFound(bytesEqual(context.parentClaims.bearer, context.childIssuer));
|
|
40
|
+
return booleanFound(require_token_scope.bytesEqual(context.parentClaims.bearer, context.childIssuer));
|
|
71
41
|
},
|
|
72
42
|
[EXPIRES_AT]: (_payload, context) => {
|
|
73
43
|
if (context.parentClaims === void 0) return booleanFound(false);
|
|
@@ -75,7 +45,7 @@ const narrowingHandlers = {
|
|
|
75
45
|
},
|
|
76
46
|
[SCOPE_NARROWS]: (_payload, context) => {
|
|
77
47
|
if (context.parentClaims === void 0) return booleanFound(false);
|
|
78
|
-
return booleanFound(scopeNarrows(context.parentClaims.scope, context.candidate.scope));
|
|
48
|
+
return booleanFound(require_token_scope.scopeNarrows(context.parentClaims.scope, context.candidate.scope));
|
|
79
49
|
},
|
|
80
50
|
[CAPABILITY_IS]: (_payload, context) => {
|
|
81
51
|
if (context.parentClaims === void 0) return booleanFound(false);
|
|
@@ -211,7 +181,7 @@ async function verifyCapabilityToken(token, options) {
|
|
|
211
181
|
...options.extraPredicateResolvers !== void 0 ? { extraPredicateResolvers: options.extraPredicateResolvers } : {}
|
|
212
182
|
});
|
|
213
183
|
if (!verdict.ok) return verdict;
|
|
214
|
-
if (options.expectedBearer !== void 0 && !bytesEqual(verdict.claims.bearer, options.expectedBearer)) return {
|
|
184
|
+
if (options.expectedBearer !== void 0 && !require_token_scope.bytesEqual(verdict.claims.bearer, options.expectedBearer)) return {
|
|
215
185
|
ok: false,
|
|
216
186
|
reason: "bearer_mismatch"
|
|
217
187
|
};
|
|
@@ -221,7 +191,7 @@ async function verifyCapabilityToken(token, options) {
|
|
|
221
191
|
* Does one recorded revocation-claims entry actually revoke targetClaims, per management.cddl's own additive obligation? Valid when EITHER the entry's own issuer equals the target token's own issuer (the original, unconditional rule -- only a token's own issuer may revoke it), OR the entry carries an `authorization` that independently verifies as an ordinary capability-token -- with `expectedBearer` set to THIS entry's own `issuer`, proving the authorization was actually granted to the party submitting this revocation, not merely referenced from someone else's -- whose own `capability` is `manage:revoke` (spec/registry/core-capabilities.md) and whose own `scope` narrows targetClaims' scope. An authorization that fails any part of this (wrong capability, scope doesn't narrow, fails ordinary verification -- expired, revoked, bad signature, bearer mismatch) makes the entry no more valid than if `authorization` were absent; it never falls back to weakening the issuer-match rule.
|
|
222
192
|
*/
|
|
223
193
|
async function revocationEntryGrantsRevoke(entry, targetClaims, options) {
|
|
224
|
-
if (bytesEqual(entry.issuer, targetClaims.issuer)) return true;
|
|
194
|
+
if (require_token_scope.bytesEqual(entry.issuer, targetClaims.issuer)) return true;
|
|
225
195
|
if (entry.authorization === void 0) return false;
|
|
226
196
|
let decodedAuthorization;
|
|
227
197
|
try {
|
|
@@ -235,7 +205,7 @@ async function revocationEntryGrantsRevoke(entry, targetClaims, options) {
|
|
|
235
205
|
...options,
|
|
236
206
|
expectedBearer: entry.issuer
|
|
237
207
|
});
|
|
238
|
-
return authorizationVerdict.ok && authorizationVerdict.claims.capability === "manage:revoke" && scopeNarrows(authorizationVerdict.claims.scope, targetClaims.scope);
|
|
208
|
+
return authorizationVerdict.ok && authorizationVerdict.claims.capability === "manage:revoke" && require_token_scope.scopeNarrows(authorizationVerdict.claims.scope, targetClaims.scope);
|
|
239
209
|
}
|
|
240
210
|
async function verifyTokenChain(token, options) {
|
|
241
211
|
const [protectedHeader, , payload, signature] = token;
|
|
@@ -262,7 +232,8 @@ async function verifyTokenChain(token, options) {
|
|
|
262
232
|
ok: false,
|
|
263
233
|
reason: "bad_signature"
|
|
264
234
|
};
|
|
265
|
-
|
|
235
|
+
const derivedIssuerId = await options.identity.deriveDeviceId(claims["issuer-key"]["public-key"]);
|
|
236
|
+
if (!require_token_scope.bytesEqual(derivedIssuerId, claims.issuer)) return {
|
|
266
237
|
ok: false,
|
|
267
238
|
reason: "wrong_issuer"
|
|
268
239
|
};
|
|
@@ -377,7 +348,8 @@ async function verifyRevocationEntry(entry, options) {
|
|
|
377
348
|
ok: false,
|
|
378
349
|
reason: "bad_signature"
|
|
379
350
|
};
|
|
380
|
-
|
|
351
|
+
const derivedIssuerId = await options.identity.deriveDeviceId(claims["issuer-key"]["public-key"]);
|
|
352
|
+
if (!require_token_scope.bytesEqual(derivedIssuerId, claims.issuer)) return {
|
|
381
353
|
ok: false,
|
|
382
354
|
reason: "wrong_issuer"
|
|
383
355
|
};
|
|
@@ -500,12 +472,6 @@ Object.defineProperty(exports, "mintRevocationEntry", {
|
|
|
500
472
|
return mintRevocationEntry;
|
|
501
473
|
}
|
|
502
474
|
});
|
|
503
|
-
Object.defineProperty(exports, "scopeNarrows", {
|
|
504
|
-
enumerable: true,
|
|
505
|
-
get: function() {
|
|
506
|
-
return scopeNarrows;
|
|
507
|
-
}
|
|
508
|
-
});
|
|
509
475
|
Object.defineProperty(exports, "verifyCapabilityToken", {
|
|
510
476
|
enumerable: true,
|
|
511
477
|
get: function() {
|
|
@@ -1,38 +1,8 @@
|
|
|
1
1
|
import { capabilityTokenSchema, revocationClaimsSchema, tokenClaimsSchema } from "./generated/protocol.mjs";
|
|
2
|
+
import { n as scopeNarrows, t as bytesEqual } from "./token-scope-Z4bmci4M.mjs";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2";
|
|
4
5
|
import { PredicateNodeSchema, evaluatePredicate } from "trilean";
|
|
5
|
-
//#region src/domain/token-scope.ts
|
|
6
|
-
/**
|
|
7
|
-
* Extracted out of tokens.ts specifically so token-predicates.ts (the generic evaluator's own delegate handlers) and tokens.ts (the chain walk, and its re-export for capability-grant.ts) can both depend on this pure, no-domain-imports module without creating an import cycle between the two -- tokens.ts imports evaluateNarrowing from token-predicates.ts, and token-predicates.ts's own scope-narrows/bearer-is handlers need these exact comparisons, so neither of those two files may import the other.
|
|
8
|
-
*/
|
|
9
|
-
function bytesEqual(a, b) {
|
|
10
|
-
if (a.length !== b.length) return false;
|
|
11
|
-
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
12
|
-
return true;
|
|
13
|
-
}
|
|
14
|
-
/** True when the path contains a "." or ".." segment. Purely lexical prefix comparison would let "/work/../org" pass under "/work" -- a path that normalises outside the parent -- so any relative segment fails the narrowing comparison wholesale: fail-closed rather than reimplementing path normalisation, consistent with how empty, case-different, and non-boundary-prefixed paths already behave. */
|
|
15
|
-
function hasRelativeSegment(path) {
|
|
16
|
-
return path.split("/").some((segment) => segment === "." || segment === "..");
|
|
17
|
-
}
|
|
18
|
-
/** True when childPath is parentPath or a descendant of it, compared on "/"-segment boundaries: "/work/sub" narrows "/work", but "/workbook" does NOT narrow "/work" despite the string prefix, because "book" continues the same segment. Paths containing "." or ".." segments never narrow anything (see hasRelativeSegment). */
|
|
19
|
-
function pathNarrows(childPath, parentPath) {
|
|
20
|
-
if (hasRelativeSegment(childPath) || hasRelativeSegment(parentPath)) return false;
|
|
21
|
-
if (childPath === parentPath) return true;
|
|
22
|
-
if (!childPath.startsWith(parentPath)) return false;
|
|
23
|
-
if (parentPath.endsWith("/")) return true;
|
|
24
|
-
return childPath.charAt(parentPath.length) === "/";
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* True when childScope narrows parentScope per tokens.cddl ("each hop can only narrow authority, never widen it"): the kind must be identical (a different kind is a different kind of authority, not a narrower one), and a parent with a path requires the child to carry an equal-or-descendant path -- an absent child path means the kind's whole-scope root, which is wider than any path-narrowed parent. A parent with no path (whole-scope root) lets any child path under the same kind through. Exported for capability-grant.ts's own obligation 4 (an unsolicited push's embedded token must equal-or-root the enclosing request's own scope), which is exactly this same narrowing relation applied outside a delegation chain, and for token-predicates.ts's own `scope-narrows` delegate handler.
|
|
28
|
-
*/
|
|
29
|
-
function scopeNarrows(parent, child) {
|
|
30
|
-
if (parent.kind !== child.kind) return false;
|
|
31
|
-
if (parent.path === void 0) return true;
|
|
32
|
-
if (child.path === void 0) return false;
|
|
33
|
-
return pathNarrows(child.path, parent.path);
|
|
34
|
-
}
|
|
35
|
-
//#endregion
|
|
36
6
|
//#region src/domain/token-predicates.ts
|
|
37
7
|
/** The wire shape of `token-claims.conditions` once CBOR-decoded: trilean's own PredicateNodeSchema is the single source of truth for what a condition entry may contain, re-validated here rather than trusted from a CDDL-generated shadow schema (see tokens.cddl's own comment on why `conditions` is an opaque bstr, not a native CDDL type) -- a token from an untrusted peer must pass trilean's real schema before any of its conditions are evaluated. Explicitly annotated: trilean's PredicateNodeSchema is a deeply recursive z.lazy() type whose inferred shape is too large for tsdown's declaration-file generator to serialise (TS7056) without this. */
|
|
38
8
|
const conditionsListSchema = z.array(PredicateNodeSchema);
|
|
@@ -262,7 +232,8 @@ async function verifyTokenChain(token, options) {
|
|
|
262
232
|
ok: false,
|
|
263
233
|
reason: "bad_signature"
|
|
264
234
|
};
|
|
265
|
-
|
|
235
|
+
const derivedIssuerId = await options.identity.deriveDeviceId(claims["issuer-key"]["public-key"]);
|
|
236
|
+
if (!bytesEqual(derivedIssuerId, claims.issuer)) return {
|
|
266
237
|
ok: false,
|
|
267
238
|
reason: "wrong_issuer"
|
|
268
239
|
};
|
|
@@ -377,7 +348,8 @@ async function verifyRevocationEntry(entry, options) {
|
|
|
377
348
|
ok: false,
|
|
378
349
|
reason: "bad_signature"
|
|
379
350
|
};
|
|
380
|
-
|
|
351
|
+
const derivedIssuerId = await options.identity.deriveDeviceId(claims["issuer-key"]["public-key"]);
|
|
352
|
+
if (!bytesEqual(derivedIssuerId, claims.issuer)) return {
|
|
381
353
|
ok: false,
|
|
382
354
|
reason: "wrong_issuer"
|
|
383
355
|
};
|
|
@@ -482,4 +454,4 @@ async function mintRevocationEntry(options) {
|
|
|
482
454
|
];
|
|
483
455
|
}
|
|
484
456
|
//#endregion
|
|
485
|
-
export { verifyRevocationEntry as a, verifyCapabilityToken as i, mintCapabilityToken as n,
|
|
457
|
+
export { verifyRevocationEntry as a, verifyCapabilityToken as i, mintCapabilityToken as n, mintRevocationEntry as r, canGrant as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wire-mesh-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "pnpm@12.4.1+sha512.2e81e399d73fe8390dab25e06aa788ab7a5908248d2f5a370f82b481147a6a7a367bf8048f9a6fdb6460f21a66f0542dedb8b94ca2c8723596741920b1656d4c",
|
|
6
6
|
"repository": {
|
|
@@ -98,6 +98,10 @@
|
|
|
98
98
|
"import": "./dist/domain/device-id.mjs",
|
|
99
99
|
"require": "./dist/domain/device-id.cjs"
|
|
100
100
|
},
|
|
101
|
+
"./domain/grant-candidates": {
|
|
102
|
+
"import": "./dist/domain/grant-candidates.mjs",
|
|
103
|
+
"require": "./dist/domain/grant-candidates.cjs"
|
|
104
|
+
},
|
|
101
105
|
"./domain/handshake": {
|
|
102
106
|
"import": "./dist/domain/handshake.mjs",
|
|
103
107
|
"require": "./dist/domain/handshake.cjs"
|