@pairlobby/server-core 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/admission.d.ts +34 -11
- package/dist/admission.d.ts.map +1 -1
- package/dist/admission.js +49 -24
- package/dist/admission.js.map +1 -1
- package/dist/auto-close-store.d.ts +1 -0
- package/dist/auto-close-store.d.ts.map +1 -1
- package/dist/auto-close-store.js +3 -0
- package/dist/auto-close-store.js.map +1 -1
- package/dist/changes.d.ts +1 -0
- package/dist/changes.d.ts.map +1 -1
- package/dist/changes.js +3 -0
- package/dist/changes.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/ports.d.ts +34 -0
- package/dist/ports.d.ts.map +1 -0
- package/dist/ports.js +48 -0
- package/dist/ports.js.map +1 -0
- package/dist/relay.d.ts +146 -0
- package/dist/relay.d.ts.map +1 -0
- package/dist/relay.js +488 -0
- package/dist/relay.js.map +1 -0
- package/dist/router.d.ts +4 -97
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +5 -376
- package/dist/router.js.map +1 -1
- package/dist/service.d.ts +35 -3
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +136 -9
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +9 -1
- package/dist/store.d.ts.map +1 -1
- package/package.json +3 -3
package/dist/relay.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { LiveTransport, LocalRoom, ReceiptMode, RelayLimits, ServerDescription } from '@pairlobby/protocol';
|
|
2
|
+
import type { Admission, RelayMember } from './admission.js';
|
|
3
|
+
import type { ChangeNotifier } from './changes.js';
|
|
4
|
+
import type { RequestContext, RoomExecutor, Scheduler } from './ports.js';
|
|
5
|
+
import type { RoomService } from './service.js';
|
|
6
|
+
export interface RelayOptions {
|
|
7
|
+
service: RoomService;
|
|
8
|
+
/** Orders each room's work; defaults to running it as it comes, for a platform that orders it already. */
|
|
9
|
+
executor?: RoomExecutor;
|
|
10
|
+
/** Wakes `tick` for the next auto-close. Absent means the platform runs `tick` itself, such as from a Durable Object alarm. */
|
|
11
|
+
scheduler?: Scheduler;
|
|
12
|
+
/**
|
|
13
|
+
* The room an invite code's digest belongs to, which `edge` needs before any room sees the code.
|
|
14
|
+
* Defaults to the service's own store; a relay with one store per room passes its directory.
|
|
15
|
+
*/
|
|
16
|
+
inviteRoom?: (digest: string) => Promise<string | null>;
|
|
17
|
+
/**
|
|
18
|
+
* True when the relay sits behind a proxy or a public address. Every caller then arrives from
|
|
19
|
+
* the proxy, so no connection proves a caller is local, and joining by name is refused.
|
|
20
|
+
*/
|
|
21
|
+
behindProxy?: boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Exact origins a browser may call from. A CLI sends no `Origin`, so an
|
|
24
|
+
* unexpected one means a web page is talking to us — including a malicious
|
|
25
|
+
* page reaching for loopback.
|
|
26
|
+
*/
|
|
27
|
+
allowedOrigins?: string[];
|
|
28
|
+
/** `Host` values to accept, defeating DNS rebinding against a local server. */
|
|
29
|
+
allowedHosts?: string[] | ((host: string) => boolean);
|
|
30
|
+
/** What only the adapter knows for `GET /v1/server`: its share addresses and its build. Every relay answers the path. */
|
|
31
|
+
serverInfo?: () => ServerInfo;
|
|
32
|
+
/**
|
|
33
|
+
* Wakes held reads. Present means the relay offers long-poll: `GET .../events?wait=` waits for a change
|
|
34
|
+
* instead of answering empty. Only a relay whose writes all pass through one process can offer it.
|
|
35
|
+
*/
|
|
36
|
+
changes?: ChangeNotifier;
|
|
37
|
+
/**
|
|
38
|
+
* How clients hear about new events, when the relay knows. Defaults to `long-poll` with `changes` and is
|
|
39
|
+
* otherwise left out, so a client keeps its own rule; a relay with a socket hub says `websocket`.
|
|
40
|
+
*/
|
|
41
|
+
transport?: LiveTransport;
|
|
42
|
+
/** Features beyond those the router can see for itself. */
|
|
43
|
+
features?: string[];
|
|
44
|
+
/**
|
|
45
|
+
* Answers `GET /v1/invites/probe?prefix=`: whether any invite this server issued
|
|
46
|
+
* has a digest starting with that prefix. Lets a client on the same network find
|
|
47
|
+
* the relay behind a bare code; absent means the path is unknown.
|
|
48
|
+
*/
|
|
49
|
+
inviteProbe?: (prefix: string) => Promise<boolean>;
|
|
50
|
+
/**
|
|
51
|
+
* Whether this request came from the relay's own device, its local network or its
|
|
52
|
+
* tailnet. Only a transport that sees the connection's real address can say; absent
|
|
53
|
+
* means never, so joining by name is refused.
|
|
54
|
+
*/
|
|
55
|
+
peerIsLocal?: (request: Request, context: RequestContext) => boolean;
|
|
56
|
+
/** Open rooms that allow local joins, matching a name or all of them; answers `GET /v1/rooms/local[?name=]`. */
|
|
57
|
+
localRooms?: (name?: string) => Promise<LocalRoom[]>;
|
|
58
|
+
/** The create token and rate limits; absent means anyone who reaches the relay may create rooms and try codes freely. */
|
|
59
|
+
admission?: Admission;
|
|
60
|
+
/** Answers `GET /.well-known/pairlobby.json`, which tells `pairlobby setup` who this relay is and how to log in. */
|
|
61
|
+
discovery?: () => DiscoveryDocument;
|
|
62
|
+
/**
|
|
63
|
+
* Token-mode login: answers `GET /api/online/account` for a caller holding the team
|
|
64
|
+
* token (the create token) or a personal token, with the member it names or null for
|
|
65
|
+
* the team token. Absent means the path is unknown. Needs `admission`.
|
|
66
|
+
*/
|
|
67
|
+
accountCheck?: (request: Request, member: RelayMember | null) => RelayAccount;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Internal claim from `edge` to `inRoom`: the member whose personal token came with a room
|
|
71
|
+
* creation or an invite redemption. `edge` removes any copy a caller sent.
|
|
72
|
+
*/
|
|
73
|
+
export declare const MEMBER_HEADER = "x-pairlobby-member";
|
|
74
|
+
/** Where `edge` sends a request that needs a room: the room's id, and the request to run there. */
|
|
75
|
+
export type RoomRoute = {
|
|
76
|
+
roomId: string;
|
|
77
|
+
request: Request;
|
|
78
|
+
};
|
|
79
|
+
export interface Relay {
|
|
80
|
+
/** Both halves in one process. */
|
|
81
|
+
handle(request: Request, context?: RequestContext): Promise<Response>;
|
|
82
|
+
/** Admission and every route that needs no room. A request reaching `inRoom` must have come through here. */
|
|
83
|
+
edge(request: Request, context?: RequestContext): Promise<Response | RoomRoute>;
|
|
84
|
+
/** A request `edge` sent to `roomId`, run in that room's order. */
|
|
85
|
+
inRoom(request: Request, roomId: string, context?: RequestContext): Promise<Response>;
|
|
86
|
+
/** Closes rooms whose auto-close time has passed, then plans the next wake-up. */
|
|
87
|
+
tick(kind: 'auto-close'): Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
/** What a deployment says about itself to `pairlobby setup`. */
|
|
90
|
+
export interface DiscoveryDocument {
|
|
91
|
+
version: 1;
|
|
92
|
+
name?: string;
|
|
93
|
+
/** `members` says people log in with their own tokens, so a login names a person. */
|
|
94
|
+
login: {
|
|
95
|
+
mode: 'token' | 'none';
|
|
96
|
+
tokenHelp?: string;
|
|
97
|
+
verify?: string;
|
|
98
|
+
members?: boolean;
|
|
99
|
+
};
|
|
100
|
+
relays: string[];
|
|
101
|
+
features: string[];
|
|
102
|
+
support?: string;
|
|
103
|
+
}
|
|
104
|
+
/** The account a team token stands for: no person, just where its rooms go. */
|
|
105
|
+
export interface TeamAccount {
|
|
106
|
+
email: null;
|
|
107
|
+
userId: 'team';
|
|
108
|
+
name: string | null;
|
|
109
|
+
server: string;
|
|
110
|
+
handle: null;
|
|
111
|
+
}
|
|
112
|
+
/** The account a personal token stands for: one member, by handle. */
|
|
113
|
+
export interface MemberAccount {
|
|
114
|
+
email: null;
|
|
115
|
+
userId: `member:${string}`;
|
|
116
|
+
name: string | null;
|
|
117
|
+
server: string;
|
|
118
|
+
handle: string;
|
|
119
|
+
}
|
|
120
|
+
export type RelayAccount = TeamAccount | MemberAccount;
|
|
121
|
+
export interface ServerInfo {
|
|
122
|
+
/** Addresses other devices can use to reach this server; empty when it only listens on this device. */
|
|
123
|
+
shareUrls: string[];
|
|
124
|
+
/** True when a device on the same network or tailnet can find this server from an invite code alone. */
|
|
125
|
+
discoverable?: boolean;
|
|
126
|
+
/** The relay's own version, so a client can tell when it runs an older build than the CLI. */
|
|
127
|
+
version?: string;
|
|
128
|
+
}
|
|
129
|
+
export declare function createRelay(options: RelayOptions): Relay;
|
|
130
|
+
/** The relay's own account of itself, from what this router was given: nothing is claimed that it was not. */
|
|
131
|
+
export declare function describe(options: RelayOptions): ServerDescription;
|
|
132
|
+
export type RelayDescriptionInput = {
|
|
133
|
+
limits: RelayLimits;
|
|
134
|
+
features: string[];
|
|
135
|
+
transport?: LiveTransport;
|
|
136
|
+
receiptMode?: ReceiptMode;
|
|
137
|
+
info?: ServerInfo;
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* `GET /v1/server` for any relay, including one that answers it outside this router, such as
|
|
141
|
+
* a Worker in front of Durable Objects. Every relay speaking this protocol has message stages.
|
|
142
|
+
*/
|
|
143
|
+
export declare function describeRelay({ limits, features, transport, receiptMode, info }: RelayDescriptionInput): ServerDescription;
|
|
144
|
+
/** Refuses a request declaring another protocol version, before anything else looks at it. Null when it may go on. */
|
|
145
|
+
export declare function protocolVersionRefusal(request: Request): Response | null;
|
|
146
|
+
//# sourceMappingURL=relay.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay.d.ts","sourceRoot":"","sources":["../src/relay.ts"],"names":[],"mappings":"AA8CA,OAAO,KAAK,EAAY,aAAa,EAAE,SAAS,EAAgB,WAAW,EAAE,WAAW,EAAE,iBAAiB,EAAC,MAAM,qBAAqB,CAAC;AAExI,OAAO,KAAK,EAAC,SAAS,EAAE,WAAW,EAAC,MAAM,gBAAgB,CAAC;AAC3D,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,cAAc,CAAC;AAEjD,OAAO,KAAK,EAAC,cAAc,EAAE,YAAY,EAAE,SAAS,EAAC,MAAM,YAAY,CAAC;AACxE,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,cAAc,CAAC;AAE9C,MAAM,WAAW,YAAY;IACzB,OAAO,EAAE,WAAW,CAAC;IACrB,0GAA0G;IAC1G,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB,+HAA+H;IAC/H,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB;;;OAGG;IACH,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACxD;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,+EAA+E;IAC/E,YAAY,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC;IACtD,yHAAyH;IACzH,UAAU,CAAC,EAAE,MAAM,UAAU,CAAC;IAC9B;;;OAGG;IACH,OAAO,CAAC,EAAE,cAAc,CAAC;IACzB;;;OAGG;IACH,SAAS,CAAC,EAAE,aAAa,CAAC;IAC1B,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACnD;;;;OAIG;IACH,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,cAAc,KAAK,OAAO,CAAC;IACrE,gHAAgH;IAChH,UAAU,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC;IACrD,yHAAyH;IACzH,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,oHAAoH;IACpH,SAAS,CAAC,EAAE,MAAM,iBAAiB,CAAC;IACpC;;;;OAIG;IACH,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,WAAW,GAAG,IAAI,KAAK,YAAY,CAAC;CACjF;AAED;;;GAGG;AACH,eAAO,MAAM,aAAa,uBAAuB,CAAC;AAElD,mGAAmG;AACnG,MAAM,MAAM,SAAS,GAAG;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAC,CAAC;AAE3D,MAAM,WAAW,KAAK;IAClB,kCAAkC;IAClC,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACtE,6GAA6G;IAC7G,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,GAAG,SAAS,CAAC,CAAC;IAChF,mEAAmE;IACnE,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACtF,kFAAkF;IAClF,IAAI,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3C;AAED,gEAAgE;AAChE,MAAM,WAAW,iBAAiB;IAC9B,OAAO,EAAE,CAAC,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qFAAqF;IACrF,KAAK,EAAE;QAAC,IAAI,EAAE,OAAO,GAAG,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,OAAO,CAAA;KAAC,CAAC;IACxF,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,+EAA+E;AAC/E,MAAM,WAAW,WAAW;IACxB,KAAK,EAAE,IAAI,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,IAAI,CAAC;CAChB;AAED,sEAAsE;AACtE,MAAM,WAAW,aAAa;IAC1B,KAAK,EAAE,IAAI,CAAC;IACZ,MAAM,EAAE,UAAU,MAAM,EAAE,CAAC;IAC3B,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,YAAY,GAAG,WAAW,GAAG,aAAa,CAAC;AAEvD,MAAM,WAAW,UAAU;IACvB,uGAAuG;IACvG,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,wGAAwG;IACxG,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,8FAA8F;IAC9F,OAAO,CAAC,EAAE,MAAM,CAAC;CACpB;AAID,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,KAAK,CAsDxD;AA8HD,8GAA8G;AAC9G,wBAAgB,QAAQ,CAAC,OAAO,EAAE,YAAY,GAAG,iBAAiB,CAcjE;AAED,MAAM,MAAM,qBAAqB,GAAG;IAAC,MAAM,EAAE,WAAW,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,SAAS,CAAC,EAAE,aAAa,CAAC;IAAC,WAAW,CAAC,EAAE,WAAW,CAAC;IAAC,IAAI,CAAC,EAAE,UAAU,CAAA;CAAC,CAAC;AAEvJ;;;GAGG;AACH,wBAAgB,aAAa,CAAC,EAAC,MAAM,EAAE,QAAQ,EAAE,SAAS,EAAE,WAAW,EAAE,IAAI,EAAC,EAAE,qBAAqB,GAAG,iBAAiB,CASxH;AAgPD,sHAAsH;AACtH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,OAAO,GAAG,QAAQ,GAAG,IAAI,CAGxE"}
|
package/dist/relay.js
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
//! The relay kernel: every protocol rule a relay applies, over Web `Request`/`Response`,
|
|
2
|
+
//! so each platform only adapts its transport, storage, ordering and timers (`ports.ts`).
|
|
3
|
+
//!
|
|
4
|
+
//! A request goes through two halves. `edge` admits it and answers what needs no room;
|
|
5
|
+
//! otherwise it names the room. `inRoom` then runs it inside that room. A platform with
|
|
6
|
+
//! one process calls `handle`, which does both; one with an object per room, such as a
|
|
7
|
+
//! Worker in front of Durable Objects, calls `edge` in front and `inRoom` in the object.
|
|
8
|
+
import { INVITE_PROBE_LENGTH, CreateInviteRequest, CreateRoomRequest, PROTOCOL_VERSION, PROTOCOL_VERSION_HEADER, FileId, MAX_EVENT_WAIT_MS, ProtocolError, ReadEventsQuery, ReceiptRequest, RedeemInviteRequest, JoinAsGuestRequest, LocalJoinRequest, RoomId, hashCredential, newId, normalizeInviteCode, SetLocalJoinRequest, RenameRoomRequest, RenameSelfRequest, SendEventRequest, SetAccessRequest, SetInviteRoleRequest, SetAutoCloseRequest, SetLockedRequest, SetMutedRequest, SetPermissionsRequest, SetRoleRequest, TurnClaimRequest, TurnTokenRequest, TurnModeRequest, TurnActionRequest, ResolveRequestRequest, ProviderWaitRequest, SetExpiryRequest, ControlRequest } from '@pairlobby/protocol';
|
|
9
|
+
import { directExecutor } from './ports.js';
|
|
10
|
+
/**
|
|
11
|
+
* Internal claim from `edge` to `inRoom`: the member whose personal token came with a room
|
|
12
|
+
* creation or an invite redemption. `edge` removes any copy a caller sent.
|
|
13
|
+
*/
|
|
14
|
+
export const MEMBER_HEADER = 'x-pairlobby-member';
|
|
15
|
+
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', [PROTOCOL_VERSION_HEADER]: String(PROTOCOL_VERSION) };
|
|
16
|
+
export function createRelay(options) {
|
|
17
|
+
const { service } = options;
|
|
18
|
+
const executor = options.executor ?? directExecutor();
|
|
19
|
+
const inviteRoom = options.inviteRoom ?? ((digest) => service.inviteRoom(digest));
|
|
20
|
+
const peerIsLocal = (request, context) => !options.behindProxy && Boolean(options.peerIsLocal?.(request, context));
|
|
21
|
+
async function edge(request, context = {}) {
|
|
22
|
+
try {
|
|
23
|
+
return await edgeRoute(claiming(request, null), context, options, inviteRoom, peerIsLocal);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return failure(error);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function inRoom(request, roomId, context = {}) {
|
|
30
|
+
const write = !['GET', 'HEAD'].includes(request.method.toUpperCase());
|
|
31
|
+
return executor.run(roomId, write, async () => {
|
|
32
|
+
try {
|
|
33
|
+
return await roomRoute(request, roomId, context, options);
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return failure(error);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
async function tick(_kind) {
|
|
41
|
+
const scheduler = options.scheduler;
|
|
42
|
+
await executor.run(null, true, async () => {
|
|
43
|
+
try {
|
|
44
|
+
const result = await service.closeDueRooms();
|
|
45
|
+
scheduler?.plan(result.more ? Date.now() : result.nextAt);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
scheduler?.plan(Date.now() + 60_000);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
edge,
|
|
54
|
+
inRoom,
|
|
55
|
+
tick,
|
|
56
|
+
async handle(request, context = {}) {
|
|
57
|
+
const routed = await edge(request, context);
|
|
58
|
+
if (routed instanceof Response) {
|
|
59
|
+
return routed;
|
|
60
|
+
}
|
|
61
|
+
const response = inRoom(routed.request, routed.roomId, context);
|
|
62
|
+
// Every write may change an auto-close deadline, so the wake-up is planned again after it.
|
|
63
|
+
if (options.scheduler && !['GET', 'HEAD'].includes(request.method.toUpperCase())) {
|
|
64
|
+
void tick('auto-close');
|
|
65
|
+
}
|
|
66
|
+
return response;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function failure(error) {
|
|
71
|
+
if (error instanceof ProtocolError) {
|
|
72
|
+
return errorResponse(error.code, error.message, error.httpStatus, error.details);
|
|
73
|
+
}
|
|
74
|
+
if (isSchemaError(error)) {
|
|
75
|
+
return errorResponse('invalid_request', 'the request body did not match the protocol schema', 400);
|
|
76
|
+
}
|
|
77
|
+
return errorResponse('server_unavailable', 'the server could not complete this request', 503);
|
|
78
|
+
}
|
|
79
|
+
async function edgeRoute(request, context, options, inviteRoom, peerIsLocal) {
|
|
80
|
+
const guard = checkHeaders(request, options);
|
|
81
|
+
if (guard) {
|
|
82
|
+
return guard;
|
|
83
|
+
}
|
|
84
|
+
const url = new URL(request.url);
|
|
85
|
+
const path = url.pathname;
|
|
86
|
+
const segments = path.split('/').filter(Boolean);
|
|
87
|
+
const method = request.method.toUpperCase();
|
|
88
|
+
if (method === 'GET' && path === '/v1/server') {
|
|
89
|
+
return json(describe(options));
|
|
90
|
+
}
|
|
91
|
+
if (options.inviteProbe && method === 'GET' && path === '/v1/invites/probe') {
|
|
92
|
+
const prefix = url.searchParams.get('prefix') ?? '';
|
|
93
|
+
if (!new RegExp(`^[0-9a-f]{${INVITE_PROBE_LENGTH}}$`).test(prefix)) {
|
|
94
|
+
return errorResponse('invalid_request', `prefix must be ${INVITE_PROBE_LENGTH} lowercase hex digits`, 400);
|
|
95
|
+
}
|
|
96
|
+
return json({ known: await options.inviteProbe(prefix) });
|
|
97
|
+
}
|
|
98
|
+
if (options.discovery && method === 'GET' && path === '/.well-known/pairlobby.json') {
|
|
99
|
+
return new Response(JSON.stringify(options.discovery()), { status: 200, headers: { ...JSON_HEADERS, 'cache-control': 'public, max-age=300' } });
|
|
100
|
+
}
|
|
101
|
+
if (options.accountCheck && options.admission && method === 'GET' && path === '/api/online/account') {
|
|
102
|
+
const member = await options.admission.beforeAccountCheck(request, context);
|
|
103
|
+
return json(options.accountCheck(request, member));
|
|
104
|
+
}
|
|
105
|
+
const local = await routeLocal(request, context, options, peerIsLocal);
|
|
106
|
+
if (local) {
|
|
107
|
+
return local;
|
|
108
|
+
}
|
|
109
|
+
if (segments[0] !== 'v1') {
|
|
110
|
+
return errorResponse('invalid_request', 'unknown path', 404);
|
|
111
|
+
}
|
|
112
|
+
// A new room gets its id here, because a relay with an object per room routes by it.
|
|
113
|
+
if (segments[1] === 'rooms' && segments.length === 2 && method === 'POST') {
|
|
114
|
+
const member = (await options.admission?.beforeCreate(request, context)) ?? null;
|
|
115
|
+
return { roomId: newId('room'), request: claiming(request, member) };
|
|
116
|
+
}
|
|
117
|
+
// A code names its room only through its digest; the room then checks the code itself.
|
|
118
|
+
if (segments[1] === 'invites' && segments[2] === 'redeem' && method === 'POST') {
|
|
119
|
+
await options.admission?.beforeRedeem(request, context);
|
|
120
|
+
const body = await request.text();
|
|
121
|
+
const input = RedeemInviteRequest.parse(JSON.parse(body));
|
|
122
|
+
const normalized = normalizeInviteCode(input.code);
|
|
123
|
+
const roomId = normalized ? await inviteRoom(await hashCredential(normalized)) : null;
|
|
124
|
+
if (!roomId) {
|
|
125
|
+
await options.admission?.afterUnknownInvite(request, context);
|
|
126
|
+
throw new ProtocolError('invite_unknown', normalized ? 'that invite code is not valid' : 'that invite code is not well formed');
|
|
127
|
+
}
|
|
128
|
+
const member = (await options.admission?.member(request)) ?? null;
|
|
129
|
+
return { roomId, request: claiming(new Request(request.url, { method: request.method, headers: request.headers, body }), member) };
|
|
130
|
+
}
|
|
131
|
+
if (segments[1] !== 'rooms' || segments.length < 3) {
|
|
132
|
+
return errorResponse('invalid_request', 'unknown path', 404);
|
|
133
|
+
}
|
|
134
|
+
return { roomId: segments[2], request };
|
|
135
|
+
}
|
|
136
|
+
/** The request with `MEMBER_HEADER` naming `member`, or without it for null; unchanged when it already says so. */
|
|
137
|
+
function claiming(request, member) {
|
|
138
|
+
if (request.headers.get(MEMBER_HEADER) === (member?.handle ?? null)) {
|
|
139
|
+
return request;
|
|
140
|
+
}
|
|
141
|
+
const headers = new Headers(request.headers);
|
|
142
|
+
headers.delete(MEMBER_HEADER);
|
|
143
|
+
if (member) {
|
|
144
|
+
headers.set(MEMBER_HEADER, member.handle);
|
|
145
|
+
}
|
|
146
|
+
return new Request(request, { headers });
|
|
147
|
+
}
|
|
148
|
+
/** The verified member `edge` put on a request; only `edge` may set it. */
|
|
149
|
+
function claimedMember(request) {
|
|
150
|
+
return request.headers.get(MEMBER_HEADER) ?? undefined;
|
|
151
|
+
}
|
|
152
|
+
const NOT_LOCAL = 'joining by name is only open to devices on this relay\'s local network or tailnet; ask for an invite code';
|
|
153
|
+
/**
|
|
154
|
+
* Finding and joining rooms by name: both need a transport that vouches the caller is local.
|
|
155
|
+
* A lookup is answered here; a join that passes the check goes on to its room.
|
|
156
|
+
*/
|
|
157
|
+
async function routeLocal(request, context, options, peerIsLocal) {
|
|
158
|
+
const url = new URL(request.url);
|
|
159
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
160
|
+
const method = request.method.toUpperCase();
|
|
161
|
+
const lookup = method === 'GET' && segments.length === 3 && segments[0] === 'v1' && segments[1] === 'rooms' && segments[2] === 'local';
|
|
162
|
+
const join = method === 'POST' && segments.length === 4 && segments[0] === 'v1' && segments[1] === 'rooms' && segments[3] === 'local-join';
|
|
163
|
+
if (!lookup && !join) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
if (lookup && !options.localRooms) {
|
|
167
|
+
return errorResponse('invalid_request', 'unknown path', 404);
|
|
168
|
+
}
|
|
169
|
+
if (!peerIsLocal(request, context)) {
|
|
170
|
+
return errorResponse('unauthorized', NOT_LOCAL, 401);
|
|
171
|
+
}
|
|
172
|
+
if (lookup) {
|
|
173
|
+
// No name at all lists every room open to the local network, for the room list.
|
|
174
|
+
if (!url.searchParams.has('name')) {
|
|
175
|
+
return json({ rooms: await options.localRooms() });
|
|
176
|
+
}
|
|
177
|
+
const name = (url.searchParams.get('name') ?? '').trim();
|
|
178
|
+
if (name.length === 0 || name.length > 64) {
|
|
179
|
+
return errorResponse('invalid_request', 'name must be 1–64 characters', 400);
|
|
180
|
+
}
|
|
181
|
+
return json({ rooms: await options.localRooms(name) });
|
|
182
|
+
}
|
|
183
|
+
return { roomId: RoomId.parse(segments[2]), request };
|
|
184
|
+
}
|
|
185
|
+
/** The relay's own account of itself, from what this router was given: nothing is claimed that it was not. */
|
|
186
|
+
export function describe(options) {
|
|
187
|
+
const profile = options.service.relayProfile();
|
|
188
|
+
const features = [
|
|
189
|
+
...(profile.files ? ['files'] : []),
|
|
190
|
+
...(options.peerIsLocal && options.localRooms && !options.behindProxy ? ['local-join'] : []),
|
|
191
|
+
...(options.inviteProbe ? ['invite-probe'] : []),
|
|
192
|
+
...(options.admission?.createTokenRequired ? ['create-token'] : []),
|
|
193
|
+
...(options.accountCheck && options.admission ? ['token-login'] : []),
|
|
194
|
+
...(options.admission?.memberTokens ? ['member-tokens'] : []),
|
|
195
|
+
...(profile.receiptMode !== 'events' ? ['receipt-watermark'] : []),
|
|
196
|
+
...(options.features ?? [])
|
|
197
|
+
];
|
|
198
|
+
const transport = options.transport ?? (options.changes ? 'long-poll' : undefined);
|
|
199
|
+
return describeRelay({ limits: profile.limits, features, receiptMode: profile.receiptMode, ...(transport ? { transport } : {}), ...(options.serverInfo ? { info: options.serverInfo() } : {}) });
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* `GET /v1/server` for any relay, including one that answers it outside this router, such as
|
|
203
|
+
* a Worker in front of Durable Objects. Every relay speaking this protocol has message stages.
|
|
204
|
+
*/
|
|
205
|
+
export function describeRelay({ limits, features, transport, receiptMode, info }) {
|
|
206
|
+
return {
|
|
207
|
+
protocol: PROTOCOL_VERSION,
|
|
208
|
+
...info,
|
|
209
|
+
features: [...new Set(['message-stages', ...features])],
|
|
210
|
+
...(transport ? { transport: { live: transport, ...(transport === 'long-poll' ? { maxWaitMs: MAX_EVENT_WAIT_MS } : {}) } } : {}),
|
|
211
|
+
limits,
|
|
212
|
+
...(receiptMode ? { receiptMode } : {})
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* A read that waits: registered before reading so a write in between still wakes it, and
|
|
217
|
+
* answered as soon as anything is newer than `after`, at the deadline, or on any other change
|
|
218
|
+
* to the room, so the client rereads whatever else it watches.
|
|
219
|
+
*/
|
|
220
|
+
async function readWaiting(service, changes, roomId, credential, query) {
|
|
221
|
+
const waiting = changes.wait(roomId, Math.min(query.wait, MAX_EVENT_WAIT_MS));
|
|
222
|
+
try {
|
|
223
|
+
const page = await service.read(roomId, credential, query.after, query.limit, query.receiptsAfter);
|
|
224
|
+
if (page.events.length > 0 || (page.receipts?.length ?? 0) > 0 || !waiting || !(await waiting.changed)) {
|
|
225
|
+
return json(page);
|
|
226
|
+
}
|
|
227
|
+
return json(await service.read(roomId, credential, query.after, query.limit, query.receiptsAfter));
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
waiting?.cancel();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** Everything inside one room. Admission already happened in `edge`. */
|
|
234
|
+
async function roomRoute(request, roomId, context, options) {
|
|
235
|
+
const { service, admission, changes } = options;
|
|
236
|
+
const url = new URL(request.url);
|
|
237
|
+
const segments = url.pathname.split('/').filter(Boolean);
|
|
238
|
+
const method = request.method.toUpperCase();
|
|
239
|
+
if (segments[1] === 'rooms' && segments.length === 2 && method === 'POST') {
|
|
240
|
+
const input = CreateRoomRequest.parse(await request.json());
|
|
241
|
+
const member = claimedMember(request);
|
|
242
|
+
const created = await service.createRoom({ ...input, roomId, ...(member ? { owner: member, member } : {}) });
|
|
243
|
+
return json({ room: created.snapshot, participantId: created.participantId, invite: created.invite }, 201);
|
|
244
|
+
}
|
|
245
|
+
if (segments[1] === 'invites' && segments[2] === 'redeem' && method === 'POST') {
|
|
246
|
+
const input = RedeemInviteRequest.parse(await request.json());
|
|
247
|
+
const member = claimedMember(request);
|
|
248
|
+
try {
|
|
249
|
+
const result = await service.redeemInvite({ ...input, ...(member ? { member } : {}) });
|
|
250
|
+
return json({ roomId: result.roomId, participantId: result.participantId, role: result.role, room: result.snapshot });
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (error instanceof ProtocolError && error.code === 'invite_unknown') {
|
|
254
|
+
await admission?.afterUnknownInvite(request, context);
|
|
255
|
+
}
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (method === 'POST' && segments.length === 4 && segments[3] === 'local-join') {
|
|
260
|
+
const input = LocalJoinRequest.parse(await request.json());
|
|
261
|
+
const result = await service.joinOnLocalNetwork(roomId, input);
|
|
262
|
+
return json({ roomId: result.roomId, participantId: result.participantId, role: result.role, room: result.snapshot });
|
|
263
|
+
}
|
|
264
|
+
// Guest entry is the one room route with no credential: knowing the id is the claim.
|
|
265
|
+
if (method === 'POST' && segments[3] === 'guests') {
|
|
266
|
+
const input = JoinAsGuestRequest.parse(await request.json());
|
|
267
|
+
const result = await service.joinAsGuest(roomId, input);
|
|
268
|
+
return json({ roomId: result.roomId, participantId: result.participantId, role: result.role, room: result.snapshot });
|
|
269
|
+
}
|
|
270
|
+
const credential = bearer(request);
|
|
271
|
+
if (segments.length === 3 && method === 'GET') {
|
|
272
|
+
return json(await service.snapshot(roomId, credential));
|
|
273
|
+
}
|
|
274
|
+
if (segments.length === 3 && method === 'DELETE') {
|
|
275
|
+
await service.delete(roomId, credential);
|
|
276
|
+
return new Response(null, { status: 204, headers: JSON_HEADERS });
|
|
277
|
+
}
|
|
278
|
+
switch (`${method} ${segments[3]}`) {
|
|
279
|
+
case 'GET turns':
|
|
280
|
+
return json(await service.turns.status(roomId, credential));
|
|
281
|
+
case 'POST turns': {
|
|
282
|
+
const input = await readOptionalJson(request);
|
|
283
|
+
if (segments[4] === 'mode') {
|
|
284
|
+
return json(await service.turns.mode(roomId, credential, TurnModeRequest.parse(input).mode));
|
|
285
|
+
}
|
|
286
|
+
return json(await service.turns.control(roomId, credential, TurnActionRequest.parse(input)));
|
|
287
|
+
}
|
|
288
|
+
case 'POST self': {
|
|
289
|
+
if (segments.length !== 5 || segments[4] !== 'name') {
|
|
290
|
+
return errorResponse('invalid_request', 'unknown self operation', 404);
|
|
291
|
+
}
|
|
292
|
+
return json(await service.renameSelf(roomId, credential, RenameSelfRequest.parse(await request.json())));
|
|
293
|
+
}
|
|
294
|
+
case 'POST rejoin':
|
|
295
|
+
return json(await service.rejoin(roomId, credential));
|
|
296
|
+
case 'POST invites': {
|
|
297
|
+
const { role, reusable, expiresAt, defaultName } = CreateInviteRequest.parse(await readOptionalJson(request));
|
|
298
|
+
return json(await service.mintInvite(roomId, credential, role, reusable, expiresAt, defaultName), 201);
|
|
299
|
+
}
|
|
300
|
+
case 'POST requests': {
|
|
301
|
+
if (segments[4] && segments[5] === 'claim') {
|
|
302
|
+
return json(await service.turns.claim(roomId, credential, segments[4], TurnClaimRequest.parse(await request.json()).claimId));
|
|
303
|
+
}
|
|
304
|
+
if (segments[4] && segments[5] === 'working') {
|
|
305
|
+
await service.turns.working(roomId, credential, segments[4], TurnTokenRequest.parse(await request.json()).token);
|
|
306
|
+
return json({ ok: true });
|
|
307
|
+
}
|
|
308
|
+
if (segments[4] && segments[5] === 'renew') {
|
|
309
|
+
return json(await service.turns.renew(roomId, credential, segments[4], TurnTokenRequest.parse(await request.json()).token));
|
|
310
|
+
}
|
|
311
|
+
if (segments[4] && segments[5] === 'pass') {
|
|
312
|
+
await service.turns.pass(roomId, credential, segments[4], TurnTokenRequest.parse(await request.json()).token);
|
|
313
|
+
return json({ ok: true });
|
|
314
|
+
}
|
|
315
|
+
if (segments[4] && segments[5] === 'defer') {
|
|
316
|
+
const input = ProviderWaitRequest.parse(await request.json());
|
|
317
|
+
const { token, model, source, ...wait } = input;
|
|
318
|
+
const providerWait = { ...wait, ...(model ? { model } : {}), ...(source ? { source } : {}) };
|
|
319
|
+
await service.turns.defer(roomId, credential, segments[4], token, providerWait);
|
|
320
|
+
return json({ ok: true });
|
|
321
|
+
}
|
|
322
|
+
if (segments[4] && segments[5] === 'resume') {
|
|
323
|
+
return json(await service.turns.resume(roomId, credential, segments[4]));
|
|
324
|
+
}
|
|
325
|
+
if (segments[4] && segments[5] === 'resolve') {
|
|
326
|
+
return json(await service.resolveRequest(roomId, credential, segments[4], ResolveRequestRequest.parse(await request.json())));
|
|
327
|
+
}
|
|
328
|
+
if (segments[4] && segments[5] === 'ack') {
|
|
329
|
+
return json(await service.acknowledgeMessage(roomId, credential, segments[4]));
|
|
330
|
+
}
|
|
331
|
+
return errorResponse('invalid_request', 'unknown request operation', 404);
|
|
332
|
+
}
|
|
333
|
+
case 'GET requests': {
|
|
334
|
+
if (segments[4]) {
|
|
335
|
+
return json(await service.request(roomId, credential, segments[4]));
|
|
336
|
+
}
|
|
337
|
+
const query = ReadEventsQuery.parse(Object.fromEntries(url.searchParams));
|
|
338
|
+
return json(await service.requests(roomId, credential, query.after, query.limit, url.searchParams.get('to') ?? undefined, url.searchParams.get('turns') === '1'));
|
|
339
|
+
}
|
|
340
|
+
case 'POST files': {
|
|
341
|
+
const name = url.searchParams.get('name') ?? '';
|
|
342
|
+
if (!name.trim() || name.length > 1024) {
|
|
343
|
+
return errorResponse('invalid_request', 'name the file with ?name=', 400);
|
|
344
|
+
}
|
|
345
|
+
const bytes = new Uint8Array(await request.arrayBuffer());
|
|
346
|
+
return json({ file: await service.uploadFile(roomId, credential, { name, mediaType: request.headers.get('content-type') ?? '', bytes }) }, 201);
|
|
347
|
+
}
|
|
348
|
+
case 'GET files': {
|
|
349
|
+
if (!segments[4]) {
|
|
350
|
+
return json(await service.listFiles(roomId, credential));
|
|
351
|
+
}
|
|
352
|
+
const { file, bytes } = await service.downloadFile(roomId, credential, FileId.parse(segments[4]));
|
|
353
|
+
// Always a download, never something a browser would render or run: the name and type came from another participant.
|
|
354
|
+
return new Response(bytes, { status: 200, headers: { 'content-type': 'application/octet-stream', 'content-disposition': `attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`, 'content-length': String(bytes.byteLength), 'x-content-type-options': 'nosniff', 'cache-control': 'private, no-store', 'pairlobby-file-sha256': file.sha256, [PROTOCOL_VERSION_HEADER]: String(PROTOCOL_VERSION) } });
|
|
355
|
+
}
|
|
356
|
+
case 'GET events': {
|
|
357
|
+
const query = ReadEventsQuery.parse(Object.fromEntries(new URL(request.url).searchParams));
|
|
358
|
+
if (changes && query.wait > 0) {
|
|
359
|
+
return readWaiting(service, changes, roomId, credential, query);
|
|
360
|
+
}
|
|
361
|
+
return json(await service.read(roomId, credential, query.after, query.limit, query.receiptsAfter));
|
|
362
|
+
}
|
|
363
|
+
case 'POST receipts':
|
|
364
|
+
return json(await service.receipt(roomId, credential, ReceiptRequest.parse(await request.json()).throughSeq));
|
|
365
|
+
case 'POST events': {
|
|
366
|
+
const input = SendEventRequest.parse(await request.json());
|
|
367
|
+
const result = await service.send(roomId, credential, input);
|
|
368
|
+
return json(result, result.deduplicated ? 200 : 201);
|
|
369
|
+
}
|
|
370
|
+
case 'POST control': {
|
|
371
|
+
const input = ControlRequest.parse(await request.json());
|
|
372
|
+
if (input.interrupt) {
|
|
373
|
+
if (!input.paused) {
|
|
374
|
+
return errorResponse('invalid_request', 'an interrupt also pauses; send paused: true', 400);
|
|
375
|
+
}
|
|
376
|
+
const result = await service.interrupt(roomId, credential, input.targetParticipantId);
|
|
377
|
+
return json({ revision: result.event.type === 'control.pause' ? result.event.payload.revision : 0, event: result.event, fenced: result.fenced });
|
|
378
|
+
}
|
|
379
|
+
const event = await service.control(roomId, credential, input.targetParticipantId, input.paused);
|
|
380
|
+
return json({ revision: event.type === 'control.pause' || event.type === 'control.resume' ? event.payload.revision : 0, event });
|
|
381
|
+
}
|
|
382
|
+
case 'POST name': {
|
|
383
|
+
const { name } = RenameRoomRequest.parse(await request.json());
|
|
384
|
+
return json({ event: await service.rename(roomId, credential, name) });
|
|
385
|
+
}
|
|
386
|
+
case 'POST lock': {
|
|
387
|
+
const { locked } = SetLockedRequest.parse(await request.json());
|
|
388
|
+
return json({ event: await service.setLocked(roomId, credential, locked) });
|
|
389
|
+
}
|
|
390
|
+
case 'POST participants': {
|
|
391
|
+
if (segments.length === 6 && segments[5] === 'role') {
|
|
392
|
+
const { role } = SetRoleRequest.parse(await request.json());
|
|
393
|
+
return json(await service.setRole(roomId, credential, segments[4], role));
|
|
394
|
+
}
|
|
395
|
+
if (segments.length === 6 && segments[5] === 'permissions') {
|
|
396
|
+
const { permissions } = SetPermissionsRequest.parse(await request.json());
|
|
397
|
+
return json(await service.setParticipantPermissions(roomId, credential, segments[4], permissions));
|
|
398
|
+
}
|
|
399
|
+
if (segments.length !== 6 || segments[5] !== 'mute') {
|
|
400
|
+
return errorResponse('invalid_request', 'unknown participant operation', 404);
|
|
401
|
+
}
|
|
402
|
+
const { muted } = SetMutedRequest.parse(await request.json());
|
|
403
|
+
return json({ event: await service.setMuted(roomId, credential, segments[4], muted) });
|
|
404
|
+
}
|
|
405
|
+
case 'POST permissions': {
|
|
406
|
+
const { permissions } = SetPermissionsRequest.parse(await request.json());
|
|
407
|
+
return json(await service.setRoomPermissions(roomId, credential, permissions));
|
|
408
|
+
}
|
|
409
|
+
case 'POST access': {
|
|
410
|
+
const { joinPolicy } = SetAccessRequest.parse(await request.json());
|
|
411
|
+
return json({ event: await service.setJoinPolicy(roomId, credential, joinPolicy) });
|
|
412
|
+
}
|
|
413
|
+
case 'POST local-access': {
|
|
414
|
+
const { localJoin } = SetLocalJoinRequest.parse(await request.json());
|
|
415
|
+
return json({ event: await service.setLocalJoin(roomId, credential, localJoin) });
|
|
416
|
+
}
|
|
417
|
+
case 'POST auto-close': {
|
|
418
|
+
const { autoClose } = SetAutoCloseRequest.parse(await request.json());
|
|
419
|
+
return json({ event: await service.setAutoClose(roomId, credential, autoClose) });
|
|
420
|
+
}
|
|
421
|
+
case 'POST invite-role': {
|
|
422
|
+
const { inviteRole } = SetInviteRoleRequest.parse(await request.json());
|
|
423
|
+
return json({ event: await service.setInviteRole(roomId, credential, inviteRole) });
|
|
424
|
+
}
|
|
425
|
+
case 'POST expiry': {
|
|
426
|
+
const { expiresAt } = SetExpiryRequest.parse(await request.json());
|
|
427
|
+
return json({ event: await service.setExpiry(roomId, credential, expiresAt) });
|
|
428
|
+
}
|
|
429
|
+
case 'POST close':
|
|
430
|
+
return json({ event: await service.close(roomId, credential) });
|
|
431
|
+
case 'POST leave':
|
|
432
|
+
return json({ event: await service.leave(roomId, credential) });
|
|
433
|
+
case 'GET export':
|
|
434
|
+
return json(await service.export(roomId, credential));
|
|
435
|
+
case 'DELETE participants': {
|
|
436
|
+
const participantId = segments[4];
|
|
437
|
+
if (!participantId) {
|
|
438
|
+
return errorResponse('invalid_request', 'no participant named', 400);
|
|
439
|
+
}
|
|
440
|
+
return json({ event: await service.revoke(roomId, credential, participantId) });
|
|
441
|
+
}
|
|
442
|
+
default:
|
|
443
|
+
return errorResponse('invalid_request', 'unknown path', 404);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/** Refuses a request declaring another protocol version, before anything else looks at it. Null when it may go on. */
|
|
447
|
+
export function protocolVersionRefusal(request) {
|
|
448
|
+
const declared = request.headers.get(PROTOCOL_VERSION_HEADER);
|
|
449
|
+
return declared !== null && declared !== String(PROTOCOL_VERSION) ? errorResponse('protocol_version_unsupported', `this server speaks protocol version ${PROTOCOL_VERSION}`, 400) : null;
|
|
450
|
+
}
|
|
451
|
+
function checkHeaders(request, options) {
|
|
452
|
+
const refused = protocolVersionRefusal(request);
|
|
453
|
+
if (refused) {
|
|
454
|
+
return refused;
|
|
455
|
+
}
|
|
456
|
+
const origin = request.headers.get('origin');
|
|
457
|
+
if (origin !== null && !(options.allowedOrigins ?? []).includes(origin)) {
|
|
458
|
+
return errorResponse('unauthorized', 'this origin may not call this server', 401);
|
|
459
|
+
}
|
|
460
|
+
const host = request.headers.get('host');
|
|
461
|
+
const allowedHosts = options.allowedHosts;
|
|
462
|
+
if (allowedHosts && host !== null && !(typeof allowedHosts === 'function' ? allowedHosts(host) : allowedHosts.includes(host))) {
|
|
463
|
+
return errorResponse('unauthorized', 'unexpected host header', 401);
|
|
464
|
+
}
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
function bearer(request) {
|
|
468
|
+
const header = request.headers.get('authorization') ?? '';
|
|
469
|
+
const match = /^Bearer (.+)$/.exec(header);
|
|
470
|
+
if (!match) {
|
|
471
|
+
throw new ProtocolError('unauthorized', 'a bearer credential is required');
|
|
472
|
+
}
|
|
473
|
+
return match[1];
|
|
474
|
+
}
|
|
475
|
+
async function readOptionalJson(request) {
|
|
476
|
+
const text = await request.text();
|
|
477
|
+
return text.length === 0 ? {} : JSON.parse(text);
|
|
478
|
+
}
|
|
479
|
+
function json(body, status = 200) {
|
|
480
|
+
return new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
|
|
481
|
+
}
|
|
482
|
+
function errorResponse(code, message, status, details = {}) {
|
|
483
|
+
return new Response(JSON.stringify({ error: { code, message, ...details } }), { status, headers: JSON_HEADERS });
|
|
484
|
+
}
|
|
485
|
+
function isSchemaError(error) {
|
|
486
|
+
return typeof error === 'object' && error !== null && error.name === 'ZodError';
|
|
487
|
+
}
|
|
488
|
+
//# sourceMappingURL=relay.js.map
|