mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-26 07:57:10 +00:00
feat: guest turn for peer (#3640)
This commit is contained in:
@@ -5,6 +5,7 @@ import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { PEER_COSTS } from './costs.js';
|
||||
import { signGuestGrant, verifyGuestGrant } from './guestGrant.js';
|
||||
import type { PeerController } from './PeerController.js';
|
||||
|
||||
let server: PuterServer;
|
||||
@@ -261,15 +262,32 @@ describe('PeerController', () => {
|
||||
});
|
||||
|
||||
describe('route registration', () => {
|
||||
it('registers all three expected routes', () => {
|
||||
it('registers every expected route', () => {
|
||||
const router = new PuterRouter();
|
||||
controller.registerRoutes(router);
|
||||
|
||||
const paths = router.routes.map((r) => r.path);
|
||||
expect(paths).toContain('/peer/signaller-info');
|
||||
expect(paths).toContain('/peer/generate-turn');
|
||||
expect(paths).toContain('/peer/turn-grant');
|
||||
expect(paths).toContain('/peer/guest-turn');
|
||||
expect(paths).toContain('/turn/ingest-usage');
|
||||
});
|
||||
|
||||
it('keeps minting credentials behind auth and redeeming open', () => {
|
||||
const router = new PuterRouter();
|
||||
controller.registerRoutes(router);
|
||||
const optionsFor = (path: string) =>
|
||||
router.routes.find((r) => r.path === path)!.options;
|
||||
|
||||
// The authenticated path must stay authenticated: everything that
|
||||
// rides on `requireAuth` (suspended accounts, pending
|
||||
// verification, access-token rejection) is attached to it.
|
||||
expect(optionsFor('/peer/generate-turn').requireAuth).toBe(true);
|
||||
expect(optionsFor('/peer/turn-grant').requireAuth).toBe(true);
|
||||
// The guest path is open by design — the grant is the credential.
|
||||
expect(optionsFor('/peer/guest-turn').requireAuth).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -540,3 +558,416 @@ describe('PeerController TURN', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- Guest TURN access -------------------------------------------------
|
||||
//
|
||||
// A host mints a grant; someone it invited redeems that grant for relay
|
||||
// credentials without an account. The tests that matter here are about
|
||||
// attribution — a guest's credentials must be stamped with the *host's*
|
||||
// identifier, so the usage ingest above bills the host — and about refusing
|
||||
// anything the host didn't sign.
|
||||
|
||||
describe('PeerController guest TURN', () => {
|
||||
let guestServer: PuterServer;
|
||||
let generateTurn: Function;
|
||||
let createTurnGrant: Function;
|
||||
let guestTurn: Function;
|
||||
let guestTurnKey: (req: Request) => string;
|
||||
|
||||
const GRANT_SECRET = 'guest-grant-secret';
|
||||
const hostActor = {
|
||||
user: { uuid: '11111111-2222-3333-4444-555555555555' },
|
||||
};
|
||||
const hostIdentifier = Buffer.from(
|
||||
hostActor.user.uuid.replaceAll('-', ''),
|
||||
'hex',
|
||||
).toString('base64url');
|
||||
|
||||
beforeAll(async () => {
|
||||
guestServer = await setupTestServer({
|
||||
peers: {
|
||||
signaller_url: 'wss://signal.test',
|
||||
turn: {
|
||||
cloudflare_turn_service_id: 'svc-1',
|
||||
cloudflare_turn_api_token: 'token-1',
|
||||
ttl: 3600,
|
||||
},
|
||||
guest_turn: {
|
||||
grant_secret: GRANT_SECRET,
|
||||
grant_ttl: 900,
|
||||
credential_ttl: 600,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
const router = new PuterRouter();
|
||||
(
|
||||
guestServer.controllers.peer as unknown as PeerController
|
||||
).registerRoutes(router);
|
||||
const route = (path: string) =>
|
||||
router.routes.find((r) => r.path === path)!;
|
||||
generateTurn = route('/peer/generate-turn').handler;
|
||||
createTurnGrant = route('/peer/turn-grant').handler;
|
||||
guestTurn = route('/peer/guest-turn').handler;
|
||||
guestTurnKey = (route('/peer/guest-turn').options.rateLimit as
|
||||
{ key: (req: Request) => string }).key;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await guestServer?.shutdown();
|
||||
});
|
||||
|
||||
const mintGrant = (actor: unknown = hostActor): string => {
|
||||
const { res, captured } = makeRes();
|
||||
createTurnGrant(makeReq({ actor }), res);
|
||||
return (captured.body as { grant: string }).grant;
|
||||
};
|
||||
|
||||
const stubCloudflare = () =>
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ iceServers: [{ urls: 'turn:cf.test' }] }),
|
||||
} as never);
|
||||
|
||||
/** The `{ ttl, customIdentifier }` body sent upstream on the last call. */
|
||||
const upstreamBody = (spy: ReturnType<typeof stubCloudflare>) =>
|
||||
JSON.parse(
|
||||
(spy.mock.calls.at(-1)![1] as RequestInit).body as string,
|
||||
) as { ttl: number; customIdentifier: string };
|
||||
|
||||
describe('turn-grant', () => {
|
||||
it('issues a grant carrying the caller as the paying account', () => {
|
||||
const { res, captured } = makeRes();
|
||||
createTurnGrant(makeReq({ actor: hostActor }), res);
|
||||
|
||||
const body = captured.body as {
|
||||
grant: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
expect(typeof body.grant).toBe('string');
|
||||
expect(verifyGuestGrant({
|
||||
grant: body.grant,
|
||||
secret: GRANT_SECRET,
|
||||
})).toEqual({
|
||||
status: 'ok',
|
||||
customIdentifier: hostIdentifier,
|
||||
expiresAt: body.expiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('honors the configured grant ttl', () => {
|
||||
const { res, captured } = makeRes();
|
||||
createTurnGrant(makeReq({ actor: hostActor }), res);
|
||||
|
||||
const { expiresAt } = captured.body as { expiresAt: number };
|
||||
const ttl = expiresAt - Math.floor(Date.now() / 1000);
|
||||
expect(ttl).toBeGreaterThan(890);
|
||||
expect(ttl).toBeLessThanOrEqual(900);
|
||||
});
|
||||
|
||||
it('carries the app segment for an app-under-user host', () => {
|
||||
const grant = mintGrant({
|
||||
...hostActor,
|
||||
app: { uid: 'app-66666666-7777-8888-9999-aaaaaaaaaaaa' },
|
||||
});
|
||||
|
||||
const verified = verifyGuestGrant({
|
||||
grant,
|
||||
secret: GRANT_SECRET,
|
||||
});
|
||||
expect(verified).toMatchObject({ status: 'ok' });
|
||||
expect(
|
||||
(verified as { customIdentifier: string }).customIdentifier
|
||||
.split(':'),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('never signs two identical grants for the same host', () => {
|
||||
expect(mintGrant()).not.toBe(mintGrant());
|
||||
});
|
||||
|
||||
it('does not reach the upstream credential API', () => {
|
||||
const fetchSpy = stubCloudflare();
|
||||
try {
|
||||
mintGrant();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('guest-turn', () => {
|
||||
it('mints credentials attributed to the host, not the guest', async () => {
|
||||
const grant = mintGrant();
|
||||
const fetchSpy = stubCloudflare();
|
||||
try {
|
||||
const { res, captured } = makeRes();
|
||||
await guestTurn(makeReq({ body: { grant } }), res);
|
||||
|
||||
expect(captured.body).toEqual({
|
||||
ttl: 600,
|
||||
iceServers: [{ urls: 'turn:cf.test' }],
|
||||
});
|
||||
expect(upstreamBody(fetchSpy).customIdentifier).toBe(
|
||||
hostIdentifier,
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('stamps the same identifier the host would get for itself', async () => {
|
||||
const grant = mintGrant();
|
||||
const fetchSpy = stubCloudflare();
|
||||
try {
|
||||
await guestTurn(makeReq({ body: { grant } }), makeRes().res);
|
||||
const guestIdentifier =
|
||||
upstreamBody(fetchSpy).customIdentifier;
|
||||
|
||||
await generateTurn(
|
||||
makeReq({ actor: hostActor }),
|
||||
makeRes().res,
|
||||
);
|
||||
expect(upstreamBody(fetchSpy).customIdentifier).toBe(
|
||||
guestIdentifier,
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('ignores any session the guest happens to carry', async () => {
|
||||
const grant = mintGrant();
|
||||
const fetchSpy = stubCloudflare();
|
||||
try {
|
||||
// A signed-in caller redeeming someone else's grant is still
|
||||
// billed to the grant's host — attribution comes from the
|
||||
// ticket, never from the request.
|
||||
await guestTurn(
|
||||
makeReq({
|
||||
body: { grant },
|
||||
actor: {
|
||||
user: {
|
||||
uuid: '99999999-8888-7777-6666-555555555555',
|
||||
},
|
||||
},
|
||||
}),
|
||||
makeRes().res,
|
||||
);
|
||||
expect(upstreamBody(fetchSpy).customIdentifier).toBe(
|
||||
hostIdentifier,
|
||||
);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('clamps the guest credential ttl to the host ttl', async () => {
|
||||
const shortServer = await setupTestServer({
|
||||
peers: {
|
||||
turn: {
|
||||
cloudflare_turn_service_id: 'svc-1',
|
||||
cloudflare_turn_api_token: 'token-1',
|
||||
ttl: 120,
|
||||
},
|
||||
guest_turn: {
|
||||
grant_secret: GRANT_SECRET,
|
||||
credential_ttl: 99_999,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
const fetchSpy = stubCloudflare();
|
||||
try {
|
||||
const router = new PuterRouter();
|
||||
(
|
||||
shortServer.controllers.peer as unknown as PeerController
|
||||
).registerRoutes(router);
|
||||
const handler = router.routes.find(
|
||||
(r) => r.path === '/peer/guest-turn',
|
||||
)!.handler;
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await handler(makeReq({ body: { grant: mintGrant() } }), res);
|
||||
|
||||
expect(captured.body).toMatchObject({ ttl: 120 });
|
||||
expect(upstreamBody(fetchSpy).ttl).toBe(120);
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
await shortServer.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a missing grant with 400 without calling upstream', async () => {
|
||||
const fetchSpy = stubCloudflare();
|
||||
try {
|
||||
await expect(
|
||||
guestTurn(makeReq({ body: {} }), makeRes().res),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
code: 'peer_grant_malformed',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a forged grant with 403 without calling upstream', async () => {
|
||||
const forged = signGuestGrant({
|
||||
customIdentifier: hostIdentifier,
|
||||
ttlSeconds: 900,
|
||||
secret: 'not-our-secret',
|
||||
}).grant;
|
||||
const fetchSpy = stubCloudflare();
|
||||
try {
|
||||
await expect(
|
||||
guestTurn(makeReq({ body: { grant: forged } }), makeRes().res),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
code: 'peer_grant_invalid',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports an expired grant distinctly so the app can ask for a new one', async () => {
|
||||
const expired = signGuestGrant({
|
||||
customIdentifier: hostIdentifier,
|
||||
ttlSeconds: 60,
|
||||
secret: GRANT_SECRET,
|
||||
now: Date.now() - 3_600_000,
|
||||
}).grant;
|
||||
const fetchSpy = stubCloudflare();
|
||||
try {
|
||||
await expect(
|
||||
guestTurn(
|
||||
makeReq({ body: { grant: expired } }),
|
||||
makeRes().res,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
code: 'peer_grant_expired',
|
||||
});
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps an upstream failure to 500 without echoing its body', async () => {
|
||||
const grant = mintGrant();
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: async () => 'cloudflare said no',
|
||||
} as never);
|
||||
const warnSpy = vi
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
try {
|
||||
await expect(
|
||||
guestTurn(makeReq({ body: { grant } }), makeRes().res),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 500,
|
||||
message: 'TURN credential generation failed',
|
||||
});
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('rate-limit bucketing', () => {
|
||||
it('buckets a host and its guests together', () => {
|
||||
const req = makeReq({ body: { grant: mintGrant() } });
|
||||
expect(guestTurnKey(req)).toBe(`host:${hostIdentifier}`);
|
||||
// A second guest of the same host lands in the same bucket even
|
||||
// though the grant string differs.
|
||||
expect(
|
||||
guestTurnKey(makeReq({ body: { grant: mintGrant() } })),
|
||||
).toBe(guestTurnKey(req));
|
||||
});
|
||||
|
||||
it('separates two hosts', () => {
|
||||
const otherGrant = mintGrant({
|
||||
user: { uuid: '99999999-8888-7777-6666-555555555555' },
|
||||
});
|
||||
expect(
|
||||
guestTurnKey(makeReq({ body: { grant: otherGrant } })),
|
||||
).not.toBe(
|
||||
guestTurnKey(makeReq({ body: { grant: mintGrant() } })),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the caller network when no grant parses', () => {
|
||||
expect(guestTurnKey(makeReq({ body: {} }))).toMatch(/^net:/);
|
||||
expect(
|
||||
guestTurnKey(makeReq({ body: { grant: 'garbage' } })),
|
||||
).toMatch(/^net:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when guest access is not configured', () => {
|
||||
it('refuses to issue or redeem a grant', async () => {
|
||||
const noGuestServer = await setupTestServer({
|
||||
peers: {
|
||||
turn: {
|
||||
cloudflare_turn_service_id: 'svc-1',
|
||||
cloudflare_turn_api_token: 'token-1',
|
||||
ttl: 3600,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
try {
|
||||
const router = new PuterRouter();
|
||||
(
|
||||
noGuestServer.controllers.peer as unknown as PeerController
|
||||
).registerRoutes(router);
|
||||
const handlerFor = (path: string) =>
|
||||
router.routes.find((r) => r.path === path)!.handler;
|
||||
|
||||
expect(() =>
|
||||
handlerFor('/peer/turn-grant')(
|
||||
makeReq({ actor: hostActor }),
|
||||
makeRes().res,
|
||||
),
|
||||
).toThrow(expect.objectContaining({ statusCode: 503 }));
|
||||
|
||||
await expect(
|
||||
handlerFor('/peer/guest-turn')(
|
||||
makeReq({ body: { grant: mintGrant() } }),
|
||||
makeRes().res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 503 });
|
||||
} finally {
|
||||
await noGuestServer.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to issue a grant it could not redeem', async () => {
|
||||
const noTurnServer = await setupTestServer({
|
||||
peers: { guest_turn: { grant_secret: GRANT_SECRET } },
|
||||
} as never);
|
||||
try {
|
||||
const router = new PuterRouter();
|
||||
(
|
||||
noTurnServer.controllers.peer as unknown as PeerController
|
||||
).registerRoutes(router);
|
||||
const handler = router.routes.find(
|
||||
(r) => r.path === '/peer/turn-grant',
|
||||
)!.handler;
|
||||
|
||||
expect(() =>
|
||||
handler(makeReq({ actor: hostActor }), makeRes().res),
|
||||
).toThrow(expect.objectContaining({ statusCode: 503 }));
|
||||
} finally {
|
||||
await noTurnServer.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,14 +21,31 @@ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { computeNetworkFingerprint } from '../../core/http/middleware/rateLimit.js';
|
||||
import type { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterController } from '../types.js';
|
||||
import { PEER_COSTS } from './costs.js';
|
||||
import {
|
||||
readClaimedGrantIdentifier,
|
||||
signGuestGrant,
|
||||
verifyGuestGrant,
|
||||
} from './guestGrant.js';
|
||||
import {
|
||||
DEFAULT_FREE_SUBSCRIPTION,
|
||||
DEFAULT_TEMP_SUBSCRIPTION,
|
||||
} from '../../services/metering/consts.js';
|
||||
|
||||
/** Grant lifetime when config doesn't say. Long enough for a sitting. */
|
||||
const DEFAULT_GRANT_TTL = 3600;
|
||||
|
||||
/**
|
||||
* Guest credential lifetime when config doesn't say, and never longer than the
|
||||
* host's own `turn.ttl`. Shorter than a host credential on purpose: a guest
|
||||
* credential is handed to someone with no account behind it, so the window in
|
||||
* which a leaked one can relay traffic on the host's tab stays small.
|
||||
*/
|
||||
const DEFAULT_GUEST_CREDENTIAL_TTL = 3600;
|
||||
|
||||
/**
|
||||
* Constant-time secret comparison for the internal-auth header. HMAC both sides
|
||||
* under a random per-process key to a fixed 32-byte digest first: this avoids
|
||||
@@ -88,11 +105,21 @@ const actorToTurnIdentifier = (actor: Actor): string => {
|
||||
/**
|
||||
* Peer controller — WebRTC signalling info + TURN credential generation.
|
||||
*
|
||||
* Config shape: config.peers.signaller_url — WebRTC signaller URL
|
||||
* config.peers.fallback_ice — fallback ICE server list
|
||||
* config.peers.turn.cloudflare_turn_service_id
|
||||
* config.peers.turn.cloudflare_turn_api_token config.peers.turn.ttl —
|
||||
* credential TTL (default 86400)
|
||||
* Two ways to get relay credentials: an authenticated caller mints its own
|
||||
* (`/peer/generate-turn`), or a host mints a grant (`/peer/turn-grant`) that
|
||||
* people it invited redeem without an account (`/peer/guest-turn`). Both paths
|
||||
* end at the same upstream call and stamp the same `customIdentifier`, so relay
|
||||
* usage is attributed to a real account either way — for a guest, the host's.
|
||||
*
|
||||
* Config shape, all under `config.peers`:
|
||||
*
|
||||
* - `signaller_url` — WebRTC signaller URL
|
||||
* - `fallback_ice` — fallback ICE server list
|
||||
* - `turn.cloudflare_turn_service_id`, `turn.cloudflare_turn_api_token`
|
||||
* - `turn.ttl` — credential TTL (default 86400)
|
||||
* - `guest_turn.grant_secret` — HMAC key for guest grants; absent disables the
|
||||
* guest routes
|
||||
* - `guest_turn.grant_ttl`, `guest_turn.credential_ttl`
|
||||
*/
|
||||
export class PeerController extends PuterController {
|
||||
override getReportedCosts(): Record<string, unknown>[] {
|
||||
@@ -146,6 +173,58 @@ export class PeerController extends PuterController {
|
||||
},
|
||||
this.#generateTurn,
|
||||
);
|
||||
router.post(
|
||||
'/peer/turn-grant',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireAuth: true,
|
||||
// Issuing a grant costs nothing upstream — it's one HMAC — but
|
||||
// each one lets a crowd of guests mint credentials against
|
||||
// this account, so it carries the same per-account ceiling as
|
||||
// minting credentials directly. One grant serves a whole
|
||||
// session; a host at this limit is re-issuing in a loop.
|
||||
rateLimit: {
|
||||
scope: 'peer-turn-grant',
|
||||
limit: 30,
|
||||
window: 60_000,
|
||||
key: 'user',
|
||||
bySubscription: {
|
||||
[DEFAULT_FREE_SUBSCRIPTION]: 10,
|
||||
[DEFAULT_TEMP_SUBSCRIPTION]: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
this.#createTurnGrant,
|
||||
);
|
||||
router.post(
|
||||
'/peer/guest-turn',
|
||||
{
|
||||
subdomain: 'api',
|
||||
// Deliberately unauthenticated: the grant in the body is the
|
||||
// credential, and it names the account that pays. Keyed on the
|
||||
// host the grant claims rather than the caller, so one host's
|
||||
// guests share one bucket and no host can be relayed for by
|
||||
// more guests per minute than this — the only ceiling on guest
|
||||
// spend we can apply before the bytes are already spent.
|
||||
// A grant that doesn't parse can't name a bucket, so those
|
||||
// requests fall back to the caller's own network.
|
||||
rateLimit: {
|
||||
scope: 'peer-guest-turn',
|
||||
limit: 60,
|
||||
window: 60_000,
|
||||
key: (req: Request) => {
|
||||
const claimed = readClaimedGrantIdentifier(
|
||||
(req.body as { grant?: unknown } | undefined)
|
||||
?.grant,
|
||||
);
|
||||
return claimed
|
||||
? `host:${claimed}`
|
||||
: `net:${computeNetworkFingerprint(req)}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
this.#guestTurn,
|
||||
);
|
||||
router.post(
|
||||
'/turn/ingest-usage',
|
||||
{
|
||||
@@ -171,8 +250,12 @@ export class PeerController extends PuterController {
|
||||
});
|
||||
};
|
||||
|
||||
/** POST /peer/generate-turn — generate TURN credentials via Cloudflare. */
|
||||
#generateTurn = async (req: Request, res: Response): Promise<void> => {
|
||||
/** Upstream TURN settings, or 503 when this deployment has none configured. */
|
||||
#requireTurnConfig = (): {
|
||||
serviceId: string;
|
||||
apiToken: string;
|
||||
ttl: number;
|
||||
} => {
|
||||
const cfg = this.config.peers;
|
||||
if (
|
||||
!cfg ||
|
||||
@@ -185,11 +268,38 @@ export class PeerController extends PuterController {
|
||||
legacyCode: 'response_timeout',
|
||||
});
|
||||
}
|
||||
const serviceId = cfg.turn.cloudflare_turn_service_id;
|
||||
const apiToken = cfg.turn.cloudflare_turn_api_token;
|
||||
const ttl = cfg.turn.ttl;
|
||||
return {
|
||||
serviceId: cfg.turn.cloudflare_turn_service_id,
|
||||
apiToken: cfg.turn.cloudflare_turn_api_token,
|
||||
ttl: cfg.turn.ttl,
|
||||
};
|
||||
};
|
||||
|
||||
const customIdentifier = actorToTurnIdentifier(req.actor);
|
||||
/**
|
||||
* The signing key for guest grants, or 503 when this deployment hasn't set
|
||||
* one. No key means no guest access — never a fallback to another secret,
|
||||
* which would let a credential minted for one purpose be spent on another.
|
||||
*/
|
||||
#requireGuestGrantSecret = (): string => {
|
||||
const secret = this.config.peers?.guest_turn?.grant_secret;
|
||||
if (!secret) {
|
||||
throw new HttpError(503, 'Guest TURN access is not configured', {
|
||||
legacyCode: 'response_timeout',
|
||||
});
|
||||
}
|
||||
return secret;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mint relay credentials upstream, attributed to `customIdentifier`. The
|
||||
* one place that talks to the credential API, so every caller — host or
|
||||
* guest — produces identically shaped, identically attributed usage.
|
||||
*/
|
||||
#mintIceServers = async (
|
||||
customIdentifier: string,
|
||||
ttl: number,
|
||||
): Promise<unknown> => {
|
||||
const { serviceId, apiToken } = this.#requireTurnConfig();
|
||||
|
||||
const cfRes = await fetch(
|
||||
`https://rtc.live.cloudflare.com/v1/turn/keys/${serviceId}/credentials/generate-ice-servers`,
|
||||
@@ -216,7 +326,85 @@ export class PeerController extends PuterController {
|
||||
}
|
||||
|
||||
const data = (await cfRes.json()) as { iceServers?: unknown };
|
||||
res.json({ ttl, iceServers: data.iceServers });
|
||||
return data.iceServers;
|
||||
};
|
||||
|
||||
/** POST /peer/generate-turn — generate TURN credentials via Cloudflare. */
|
||||
#generateTurn = async (req: Request, res: Response): Promise<void> => {
|
||||
const { ttl } = this.#requireTurnConfig();
|
||||
const iceServers = await this.#mintIceServers(
|
||||
actorToTurnIdentifier(req.actor),
|
||||
ttl,
|
||||
);
|
||||
res.json({ ttl, iceServers });
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /peer/turn-grant — issue a grant the caller can hand to guests.
|
||||
*
|
||||
* The grant names the caller as the account guest relay usage is billed to,
|
||||
* so it is only as shareable as the caller wants their allowance to be:
|
||||
* anyone holding it can mint guest credentials until it expires.
|
||||
*/
|
||||
#createTurnGrant = (req: Request, res: Response): void => {
|
||||
const secret = this.#requireGuestGrantSecret();
|
||||
// Refuse to hand out a ticket this deployment couldn't redeem.
|
||||
this.#requireTurnConfig();
|
||||
|
||||
const { grant, expiresAt } = signGuestGrant({
|
||||
customIdentifier: actorToTurnIdentifier(req.actor),
|
||||
ttlSeconds:
|
||||
this.config.peers?.guest_turn?.grant_ttl ?? DEFAULT_GRANT_TTL,
|
||||
secret,
|
||||
});
|
||||
|
||||
res.json({ grant, expiresAt });
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /peer/guest-turn — redeem a host's grant for relay credentials.
|
||||
*
|
||||
* Attribution comes from the grant alone; any session the caller happens to
|
||||
* carry is ignored, so the account named in the grant is the account
|
||||
* charged whether the guest is signed in or not.
|
||||
*/
|
||||
#guestTurn = async (req: Request, res: Response): Promise<void> => {
|
||||
const secret = this.#requireGuestGrantSecret();
|
||||
const { ttl: hostTtl } = this.#requireTurnConfig();
|
||||
|
||||
const verified = verifyGuestGrant({
|
||||
grant: (req.body as { grant?: unknown } | undefined)?.grant,
|
||||
secret,
|
||||
});
|
||||
if (verified.status !== 'ok') {
|
||||
if (verified.status === 'malformed') {
|
||||
throw new HttpError(400, 'Missing or malformed grant', {
|
||||
code: 'peer_grant_malformed',
|
||||
});
|
||||
}
|
||||
// Expiry is readable from the grant the caller already holds, so
|
||||
// saying so tells them nothing they didn't know and lets the app
|
||||
// ask the host for a fresh one instead of retrying a dead ticket.
|
||||
if (verified.status === 'expired') {
|
||||
throw new HttpError(403, 'Guest grant has expired', {
|
||||
code: 'peer_grant_expired',
|
||||
});
|
||||
}
|
||||
throw new HttpError(403, 'Guest grant is not valid', {
|
||||
code: 'peer_grant_invalid',
|
||||
});
|
||||
}
|
||||
|
||||
const ttl = Math.min(
|
||||
hostTtl,
|
||||
this.config.peers?.guest_turn?.credential_ttl ??
|
||||
DEFAULT_GUEST_CREDENTIAL_TTL,
|
||||
);
|
||||
const iceServers = await this.#mintIceServers(
|
||||
verified.customIdentifier,
|
||||
ttl,
|
||||
);
|
||||
res.json({ ttl, iceServers });
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
readClaimedGrantIdentifier,
|
||||
signGuestGrant,
|
||||
verifyGuestGrant,
|
||||
} from './guestGrant.js';
|
||||
|
||||
const SECRET = 'grant-secret';
|
||||
const USER_ID = 'AAAAAAAAAAAAAAAAAAAAAA';
|
||||
const APP_ID = 'BBBBBBBBBBBBBBBBBBBBBB';
|
||||
|
||||
/**
|
||||
* Hand-build a grant so tests can put payloads through the real signature
|
||||
* (things the issuer would never emit) and confirm verification still refuses
|
||||
* them. Mirrors the wire format deliberately: if the format changes, these fail
|
||||
* and get looked at.
|
||||
*/
|
||||
const forgeGrant = (
|
||||
payload: unknown,
|
||||
{ secret = SECRET, version = 'pg1' } = {},
|
||||
): string => {
|
||||
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const signature = createHmac('sha256', secret)
|
||||
.update(`${version}.${encoded}`)
|
||||
.digest('base64url');
|
||||
return `${version}.${encoded}.${signature}`;
|
||||
};
|
||||
|
||||
describe('signGuestGrant / verifyGuestGrant', () => {
|
||||
it('round-trips a user identifier and its expiry', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const { grant, expiresAt } = signGuestGrant({
|
||||
customIdentifier: USER_ID,
|
||||
ttlSeconds: 3600,
|
||||
secret: SECRET,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(expiresAt).toBe(Math.floor(now / 1000) + 3600);
|
||||
|
||||
const verified = verifyGuestGrant({ grant, secret: SECRET, now });
|
||||
expect(verified).toEqual({
|
||||
status: 'ok',
|
||||
customIdentifier: USER_ID,
|
||||
expiresAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('round-trips an app-under-user identifier', () => {
|
||||
const identifier = `${USER_ID}:${APP_ID}`;
|
||||
const { grant } = signGuestGrant({
|
||||
customIdentifier: identifier,
|
||||
ttlSeconds: 60,
|
||||
secret: SECRET,
|
||||
});
|
||||
|
||||
const verified = verifyGuestGrant({ grant, secret: SECRET });
|
||||
expect(verified).toMatchObject({
|
||||
status: 'ok',
|
||||
customIdentifier: identifier,
|
||||
});
|
||||
});
|
||||
|
||||
it('issues distinct grants for the same identifier and second', () => {
|
||||
const args = {
|
||||
customIdentifier: USER_ID,
|
||||
ttlSeconds: 60,
|
||||
secret: SECRET,
|
||||
now: 1_700_000_000_000,
|
||||
};
|
||||
expect(signGuestGrant(args).grant).not.toBe(signGuestGrant(args).grant);
|
||||
});
|
||||
|
||||
it('rejects a grant signed with a different secret', () => {
|
||||
const { grant } = signGuestGrant({
|
||||
customIdentifier: USER_ID,
|
||||
ttlSeconds: 60,
|
||||
secret: 'other-secret',
|
||||
});
|
||||
|
||||
expect(verifyGuestGrant({ grant, secret: SECRET })).toEqual({
|
||||
status: 'invalid',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a tampered payload', () => {
|
||||
const { grant } = signGuestGrant({
|
||||
customIdentifier: USER_ID,
|
||||
ttlSeconds: 60,
|
||||
secret: SECRET,
|
||||
});
|
||||
const [version, , signature] = grant.split('.');
|
||||
const swapped = Buffer.from(
|
||||
JSON.stringify({ id: APP_ID, exp: 9_999_999_999, n: 'x' }),
|
||||
).toString('base64url');
|
||||
|
||||
expect(
|
||||
verifyGuestGrant({
|
||||
grant: `${version}.${swapped}.${signature}`,
|
||||
secret: SECRET,
|
||||
}),
|
||||
).toEqual({ status: 'invalid' });
|
||||
});
|
||||
|
||||
it('rejects a tampered signature', () => {
|
||||
const { grant } = signGuestGrant({
|
||||
customIdentifier: USER_ID,
|
||||
ttlSeconds: 60,
|
||||
secret: SECRET,
|
||||
});
|
||||
const [version, payload, signature] = grant.split('.');
|
||||
const flipped =
|
||||
signature![0] === 'A'
|
||||
? `B${signature!.slice(1)}`
|
||||
: `A${signature!.slice(1)}`;
|
||||
|
||||
expect(
|
||||
verifyGuestGrant({
|
||||
grant: `${version}.${payload}.${flipped}`,
|
||||
secret: SECRET,
|
||||
}),
|
||||
).toEqual({ status: 'invalid' });
|
||||
});
|
||||
|
||||
it('rejects a signature of the wrong length', () => {
|
||||
const { grant } = signGuestGrant({
|
||||
customIdentifier: USER_ID,
|
||||
ttlSeconds: 60,
|
||||
secret: SECRET,
|
||||
});
|
||||
const [version, payload] = grant.split('.');
|
||||
|
||||
expect(
|
||||
verifyGuestGrant({
|
||||
grant: `${version}.${payload}.AAAA`,
|
||||
secret: SECRET,
|
||||
}),
|
||||
).toEqual({ status: 'invalid' });
|
||||
});
|
||||
|
||||
it('reports an expired grant distinctly from an invalid one', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const { grant } = signGuestGrant({
|
||||
customIdentifier: USER_ID,
|
||||
ttlSeconds: 60,
|
||||
secret: SECRET,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(
|
||||
verifyGuestGrant({ grant, secret: SECRET, now: now + 61_000 }),
|
||||
).toEqual({ status: 'expired' });
|
||||
});
|
||||
|
||||
it('treats the expiry second itself as expired', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const { grant, expiresAt } = signGuestGrant({
|
||||
customIdentifier: USER_ID,
|
||||
ttlSeconds: 60,
|
||||
secret: SECRET,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(
|
||||
verifyGuestGrant({
|
||||
grant,
|
||||
secret: SECRET,
|
||||
now: expiresAt * 1000,
|
||||
}),
|
||||
).toEqual({ status: 'expired' });
|
||||
expect(
|
||||
verifyGuestGrant({
|
||||
grant,
|
||||
secret: SECRET,
|
||||
now: expiresAt * 1000 - 1,
|
||||
}),
|
||||
).toMatchObject({ status: 'ok' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a non-string', 42],
|
||||
['undefined', undefined],
|
||||
['an empty string', ''],
|
||||
['too few segments', 'pg1.payload'],
|
||||
['too many segments', 'pg1.payload.sig.extra'],
|
||||
['an over-long string', `pg1.${'a'.repeat(600)}.sig`],
|
||||
])('rejects %s as malformed', (_label, grant) => {
|
||||
expect(verifyGuestGrant({ grant, secret: SECRET })).toEqual({
|
||||
status: 'malformed',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an unknown version even when correctly signed', () => {
|
||||
const grant = forgeGrant(
|
||||
{ id: USER_ID, exp: 9_999_999_999, n: 'x' },
|
||||
{ version: 'pg2' },
|
||||
);
|
||||
|
||||
expect(verifyGuestGrant({ grant, secret: SECRET })).toEqual({
|
||||
status: 'malformed',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a correctly signed payload that is not JSON', () => {
|
||||
const encoded = Buffer.from('not json').toString('base64url');
|
||||
const signature = createHmac('sha256', SECRET)
|
||||
.update(`pg1.${encoded}`)
|
||||
.digest('base64url');
|
||||
|
||||
expect(
|
||||
verifyGuestGrant({
|
||||
grant: `pg1.${encoded}.${signature}`,
|
||||
secret: SECRET,
|
||||
}),
|
||||
).toEqual({ status: 'malformed' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'an identifier of the wrong shape',
|
||||
{ id: 'nope', exp: 9_999_999_999 },
|
||||
],
|
||||
['a non-string identifier', { id: 42, exp: 9_999_999_999 }],
|
||||
['a missing identifier', { exp: 9_999_999_999 }],
|
||||
['a non-numeric expiry', { id: USER_ID, exp: 'soon' }],
|
||||
['a missing expiry', { id: USER_ID }],
|
||||
['an infinite expiry', { id: USER_ID, exp: Infinity }],
|
||||
])('rejects %s even when correctly signed', (_label, payload) => {
|
||||
expect(
|
||||
verifyGuestGrant({ grant: forgeGrant(payload), secret: SECRET }),
|
||||
).toEqual({ status: 'malformed' });
|
||||
});
|
||||
|
||||
it('rejects an identifier carrying a third segment', () => {
|
||||
const grant = forgeGrant({
|
||||
id: `${USER_ID}:${APP_ID}:${APP_ID}`,
|
||||
exp: 9_999_999_999,
|
||||
});
|
||||
|
||||
expect(verifyGuestGrant({ grant, secret: SECRET })).toEqual({
|
||||
status: 'malformed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('readClaimedGrantIdentifier', () => {
|
||||
it('reads the identifier from a valid grant', () => {
|
||||
const { grant } = signGuestGrant({
|
||||
customIdentifier: USER_ID,
|
||||
ttlSeconds: 60,
|
||||
secret: SECRET,
|
||||
});
|
||||
|
||||
expect(readClaimedGrantIdentifier(grant)).toBe(USER_ID);
|
||||
});
|
||||
|
||||
it('reads the claimed identifier without checking the signature', () => {
|
||||
// Bucketing runs before verification, so this is expected: a forged
|
||||
// grant still names a bucket, and the handler still rejects it.
|
||||
const grant = forgeGrant(
|
||||
{ id: USER_ID, exp: 9_999_999_999, n: 'x' },
|
||||
{ secret: 'wrong-secret' },
|
||||
);
|
||||
|
||||
expect(readClaimedGrantIdentifier(grant)).toBe(USER_ID);
|
||||
});
|
||||
|
||||
it('reads an expired grant, which the handler then rejects', () => {
|
||||
const { grant } = signGuestGrant({
|
||||
customIdentifier: USER_ID,
|
||||
ttlSeconds: 60,
|
||||
secret: SECRET,
|
||||
now: 1_000_000_000_000,
|
||||
});
|
||||
|
||||
expect(readClaimedGrantIdentifier(grant)).toBe(USER_ID);
|
||||
expect(verifyGuestGrant({ grant, secret: SECRET })).toEqual({
|
||||
status: 'expired',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a non-string', 42],
|
||||
['undefined', undefined],
|
||||
['a wrong-shaped string', 'not-a-grant'],
|
||||
['an unknown version', 'pg2.abc.def'],
|
||||
['an over-long string', `pg1.${'a'.repeat(600)}.sig`],
|
||||
[
|
||||
'a non-JSON payload',
|
||||
`pg1.${Buffer.from('x').toString('base64url')}.sig`,
|
||||
],
|
||||
])('returns null for %s', (_label, grant) => {
|
||||
expect(readClaimedGrantIdentifier(grant)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an identifier of the wrong shape', () => {
|
||||
const grant = forgeGrant({ id: 'nope', exp: 9_999_999_999 });
|
||||
expect(readClaimedGrantIdentifier(grant)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Guest TURN grants.
|
||||
*
|
||||
* A grant is a stateless, signed ticket an authenticated host hands to people
|
||||
* it invites, letting them mint TURN credentials without an account of their
|
||||
* own. It carries the identifier the resulting relay usage is attributed to —
|
||||
* the host's — so a guest's egress is metered and billed exactly as if the host
|
||||
* had relayed it, and an anonymous caller can never mint credentials that
|
||||
* nobody pays for.
|
||||
*
|
||||
* Grants are verified by signature alone; nothing is stored. That keeps the
|
||||
* check a single HMAC, at the cost of the ticket staying valid for its full
|
||||
* lifetime once issued — so lifetimes are short and the issuing route is
|
||||
* per-account rate limited.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Version prefix, included in the signed material so a future format can't be
|
||||
* swapped in under a signature made for this one.
|
||||
*/
|
||||
const GRANT_VERSION = 'pg1';
|
||||
|
||||
/**
|
||||
* Longest grant string we will do any work on. A real grant is ~150 bytes; the
|
||||
* cap keeps a malicious body from turning verification into a hashing job.
|
||||
*/
|
||||
const MAX_GRANT_LENGTH = 512;
|
||||
|
||||
/**
|
||||
* Base64url of 16 raw uuid bytes is 22 characters. A grant identifier is one
|
||||
* such segment for a user actor, or two joined by `:` for app-under-user —
|
||||
* matching `customIdentifier` in the peer controller.
|
||||
*/
|
||||
const IDENTIFIER_RE = /^[A-Za-z0-9_-]{22}(:[A-Za-z0-9_-]{22})?$/;
|
||||
|
||||
/** Decoded grant payload. Field names are short because they ride in a URL. */
|
||||
interface GrantPayload {
|
||||
/** The `customIdentifier` relay usage is attributed to. */
|
||||
id: string;
|
||||
/** Expiry, seconds since the epoch. */
|
||||
exp: number;
|
||||
/** Random nonce, so two grants issued in the same second still differ. */
|
||||
n: string;
|
||||
}
|
||||
|
||||
/** Why a grant was rejected. Distinguished so clients can react usefully. */
|
||||
export type GrantRejection = 'malformed' | 'invalid' | 'expired';
|
||||
|
||||
/**
|
||||
* Discriminated on a string rather than a boolean so it narrows under the
|
||||
* project's non-strict build config too.
|
||||
*/
|
||||
export type GrantVerification =
|
||||
| { status: 'ok'; customIdentifier: string; expiresAt: number }
|
||||
| { status: GrantRejection };
|
||||
|
||||
const sign = (signedMaterial: string, secret: string): Buffer =>
|
||||
createHmac('sha256', secret).update(signedMaterial).digest();
|
||||
|
||||
/**
|
||||
* Issue a grant for `customIdentifier`, valid for `ttlSeconds`.
|
||||
*
|
||||
* @returns The grant string and its expiry (seconds since the epoch).
|
||||
*/
|
||||
export const signGuestGrant = ({
|
||||
customIdentifier,
|
||||
ttlSeconds,
|
||||
secret,
|
||||
now = Date.now(),
|
||||
}: {
|
||||
customIdentifier: string;
|
||||
ttlSeconds: number;
|
||||
secret: string;
|
||||
now?: number;
|
||||
}): { grant: string; expiresAt: number } => {
|
||||
const expiresAt = Math.floor(now / 1000) + ttlSeconds;
|
||||
const payload: GrantPayload = {
|
||||
id: customIdentifier,
|
||||
exp: expiresAt,
|
||||
n: randomBytes(12).toString('base64url'),
|
||||
};
|
||||
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||
const signedMaterial = `${GRANT_VERSION}.${encoded}`;
|
||||
const signature = sign(signedMaterial, secret).toString('base64url');
|
||||
return { grant: `${signedMaterial}.${signature}`, expiresAt };
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a grant and return the identifier its usage should be attributed to.
|
||||
*
|
||||
* The signature is checked before the payload is decoded, so a forged grant
|
||||
* never reaches the parser, and the identifier is re-validated against the
|
||||
* shape the upstream credential API accepts even though it arrives signed.
|
||||
*/
|
||||
export const verifyGuestGrant = ({
|
||||
grant,
|
||||
secret,
|
||||
now = Date.now(),
|
||||
}: {
|
||||
grant: unknown;
|
||||
secret: string;
|
||||
now?: number;
|
||||
}): GrantVerification => {
|
||||
if (
|
||||
typeof grant !== 'string' ||
|
||||
grant.length === 0 ||
|
||||
grant.length > MAX_GRANT_LENGTH
|
||||
) {
|
||||
return { status: 'malformed' };
|
||||
}
|
||||
|
||||
const parts = grant.split('.');
|
||||
if (parts.length !== 3) return { status: 'malformed' };
|
||||
const [version, encoded, signature] = parts as [string, string, string];
|
||||
if (version !== GRANT_VERSION) return { status: 'malformed' };
|
||||
|
||||
const expected = sign(`${version}.${encoded}`, secret);
|
||||
const provided = Buffer.from(signature, 'base64url');
|
||||
if (
|
||||
provided.length !== expected.length ||
|
||||
!timingSafeEqual(provided, expected)
|
||||
) {
|
||||
return { status: 'invalid' };
|
||||
}
|
||||
|
||||
let payload: GrantPayload;
|
||||
try {
|
||||
payload = JSON.parse(
|
||||
Buffer.from(encoded, 'base64url').toString('utf8'),
|
||||
) as GrantPayload;
|
||||
} catch {
|
||||
return { status: 'malformed' };
|
||||
}
|
||||
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload.id !== 'string' ||
|
||||
!IDENTIFIER_RE.test(payload.id) ||
|
||||
typeof payload.exp !== 'number' ||
|
||||
!Number.isFinite(payload.exp)
|
||||
) {
|
||||
return { status: 'malformed' };
|
||||
}
|
||||
|
||||
if (payload.exp * 1000 <= now) return { status: 'expired' };
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
customIdentifier: payload.id,
|
||||
expiresAt: payload.exp,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the claimed identifier out of a grant _without_ verifying it, for
|
||||
* rate-limit bucketing only.
|
||||
*
|
||||
* Bucketing has to happen before the handler runs, and a forged grant is
|
||||
* rejected there without reaching the upstream API — so an unverified read is
|
||||
* enough to put a host's guests in one bucket, and the worst a forger achieves
|
||||
* is choosing which bucket their own rejections land in. Never use this to
|
||||
* decide attribution.
|
||||
*
|
||||
* @returns The claimed identifier, or null if the grant doesn't parse.
|
||||
*/
|
||||
export const readClaimedGrantIdentifier = (grant: unknown): string | null => {
|
||||
if (typeof grant !== 'string' || grant.length > MAX_GRANT_LENGTH) {
|
||||
return null;
|
||||
}
|
||||
const parts = grant.split('.');
|
||||
if (parts.length !== 3 || parts[0] !== GRANT_VERSION) return null;
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(parts[1]!, 'base64url').toString('utf8'),
|
||||
) as { id?: unknown };
|
||||
if (
|
||||
typeof payload?.id !== 'string' ||
|
||||
!IDENTIFIER_RE.test(payload.id)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return payload.id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -343,6 +343,22 @@ export interface IPeersConfig {
|
||||
/** Credential TTL in seconds. Default 86400. */
|
||||
ttl?: number;
|
||||
};
|
||||
/**
|
||||
* Relay access for guests of an authenticated host, who mint credentials
|
||||
* against a signed grant instead of an account of their own.
|
||||
*/
|
||||
guest_turn?: {
|
||||
/**
|
||||
* HMAC key for guest grants. Absent disables the guest routes with a
|
||||
* 503 — a deployment opts into guest relay access by setting this. Must
|
||||
* not be shared with any other secret.
|
||||
*/
|
||||
grant_secret?: string;
|
||||
/** Grant lifetime in seconds. Default 3600. */
|
||||
grant_ttl?: number;
|
||||
/** Guest credential TTL in seconds, clamped to `turn.ttl`. Default 3600. */
|
||||
credential_ttl?: number;
|
||||
};
|
||||
/** Shared secret for the internal `/turn/ingest-usage` endpoint. */
|
||||
internal_auth_secret?: string;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ Use the Peer API to build peer-to-peer applications without the need for a serve
|
||||
|
||||
<div class="info">
|
||||
|
||||
Peer connections require authentication. On websites, Puter.js will prompt the user to authenticate if needed.
|
||||
Hosting a session requires authentication — on websites, Puter.js will prompt the user if needed. Guests can join without an account: pass `anonToken`, plus a `turnGrant` from the host so the connection can still use Puter's relays. See [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/).
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Connects to a peer server and returns a [`PuterPeerConnection`](/Objects/puterpe
|
||||
|
||||
<div class="info">
|
||||
|
||||
On websites, Puter.js may prompt the user to authenticate before connecting.
|
||||
On websites, Puter.js may prompt the user to authenticate before connecting. To let someone join without an account, pass `anonToken` — and a `turnGrant` from the host, so the connection can still use Puter's relays. See [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/).
|
||||
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,8 @@ A string invite code created by `puter.peer.serve()`.
|
||||
|
||||
- `iceServers` (`RTCIceServer[]`) Custom ICE servers (STUN/TURN) to use instead of the Puter-managed relays.
|
||||
- `forceRelay` (`boolean`) Whether to force connections to route through a relay instead of attempting peer-to-peer (default). Metering charges may apply.
|
||||
- `anonToken` (`String`) Join without a Puter session. Any uuid — it identifies this guest for the duration of the session, and no sign-in prompt is shown. The host sees the guest as `anonymous`, so anything you want to call them is yours to send over the connection.
|
||||
- `turnGrant` (`String`) A grant from [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/). Lets a guest use the Puter-managed relays on the host's account. Without one, a guest connects only where a direct connection is possible; with `forceRelay`, a guest needs one.
|
||||
|
||||
## Return value
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
title: puter.peer.createGuestGrant()
|
||||
description: Let guests without a Puter account use Puter's TURN relays on your account.
|
||||
platforms: [websites, apps]
|
||||
---
|
||||
|
||||
|
||||
Creates a **guest grant**: a short-lived token that lets people without a Puter session use the Puter-managed TURN relays. Hand it to the people you invite alongside the invite code, and they pass it to [`puter.peer.connect()`](/Peer/connect/) as `turnGrant`.
|
||||
|
||||
Without a grant, a guest can still join a session — but only over direct connections. Relay credentials are what make a connection work when one side is behind a NAT or firewall that blocks direct traffic, and minting them requires an account. The grant is how your account vouches for the guest.
|
||||
|
||||
<div class="info">
|
||||
|
||||
Relay traffic a guest sends is metered against **your** account, at the same rate as your own. Anyone holding the grant can mint credentials until it expires, so share it with the session you meant to host, and let it expire rather than reusing one indefinitely.
|
||||
|
||||
</div>
|
||||
|
||||
## Syntax
|
||||
|
||||
```js
|
||||
const { grant, expiresAt } = await puter.peer.createGuestGrant();
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
None.
|
||||
|
||||
## Return value
|
||||
|
||||
A `Promise` that resolves to an object with:
|
||||
|
||||
- `grant` (`String`) The grant to give your guests.
|
||||
- `expiresAt` (`Number`) When the grant stops being accepted, in seconds since the epoch. Past this point, redeeming it fails with `peer_grant_expired` and you issue a new one.
|
||||
|
||||
Rejects if the caller isn't authenticated, or if the deployment doesn't offer guest relay access.
|
||||
|
||||
## Example
|
||||
|
||||
```html
|
||||
<html>
|
||||
<body>
|
||||
<script src="https://js.puter.com/v2/"></script>
|
||||
<h3>Host a session guests can join</h3>
|
||||
<button id="host">Start hosting</button>
|
||||
<pre id="out" style="background:#f4f4f4; padding:10px;"></pre>
|
||||
|
||||
<script>
|
||||
const out = document.getElementById('out');
|
||||
|
||||
document.getElementById('host').addEventListener('click', async () => {
|
||||
// Hosting requires a Puter account; joining will not.
|
||||
const server = await puter.peer.serve();
|
||||
const { grant, expiresAt } = await puter.peer.createGuestGrant();
|
||||
|
||||
// Everything a guest needs, in one link.
|
||||
const link = new URL(location.href);
|
||||
link.hash = new URLSearchParams({
|
||||
code: server.inviteCode,
|
||||
grant,
|
||||
}).toString();
|
||||
|
||||
out.textContent =
|
||||
`Invite link:\n${link}\n\n` +
|
||||
`Good until ${new Date(expiresAt * 1000).toLocaleTimeString()}`;
|
||||
|
||||
server.addEventListener('connection', (event) => {
|
||||
out.textContent += `\n${event.user?.username ?? 'someone'} joined`;
|
||||
event.conn.addEventListener('message', (e) => {
|
||||
out.textContent += `\nmessage: ${e.data}`;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The guest side of the same page: join with the code and grant from
|
||||
// the link, no sign-in prompt.
|
||||
(async () => {
|
||||
const params = new URLSearchParams(location.hash.slice(1));
|
||||
const code = params.get('code');
|
||||
const grant = params.get('grant');
|
||||
if ( !code ) return;
|
||||
|
||||
const conn = await puter.peer.connect(code, {
|
||||
anonToken: crypto.randomUUID(),
|
||||
turnGrant: grant,
|
||||
});
|
||||
conn.addEventListener('open', () => {
|
||||
out.textContent += '\nJoined as a guest';
|
||||
conn.send('hello from a guest');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
@@ -11,8 +11,17 @@ Fetches TURN relay credentials ahead of time so that peer connections can start
|
||||
|
||||
```js
|
||||
await puter.peer.ensureTurnRelays();
|
||||
await puter.peer.ensureTurnRelays(options);
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
#### `options` (optional)
|
||||
|
||||
`options` is an object with the following properties:
|
||||
|
||||
- `turnGrant` (`String`) A grant from [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/), to preload relays as a guest with no Puter session. Credentials are minted against the account that issued the grant.
|
||||
|
||||
## Return value
|
||||
|
||||
A `Promise` that resolves when relay details are cached. If relays cannot be loaded, Puter.js will fall back to default ICE servers when connecting.
|
||||
|
||||
@@ -28,6 +28,9 @@ const server = await puter.peer.serve(options);
|
||||
|
||||
- `iceServers` (`RTCIceServer[]`) Custom ICE servers (STUN/TURN) to use instead of the Puter-managed relays.
|
||||
- `forceRelay` (`boolean`) Whether to force connections to route through a relay instead of attempting peer-to-peer (default). Metering charges will increase.
|
||||
- `anonToken` (`String`) Host without a Puter session. Any uuid; no sign-in prompt is shown. An anonymous host has no account to attribute relay usage to, so it cannot issue guest grants and gets no relays of its own.
|
||||
|
||||
To let people join your session without accounts of their own, keep hosting authenticated and give them a grant — see [`puter.peer.createGuestGrant()`](/Peer/createGuestGrant/).
|
||||
|
||||
## Return value
|
||||
|
||||
|
||||
@@ -136,6 +136,17 @@ Recipients are emailed by default and opt out with the unsubscribe link the mail
|
||||
|
||||
Over these, **the share still succeeds** — only the announcement is dropped. The recipient's notification is kept up to date either way, and folds several senders into one ("alice and bob shared 5 items with you"), so nothing is lost; it just doesn't interrupt them again. Emails are additionally batched: everything triggered for one recipient within a 90-second window goes as a single digest message. Recipients can also refuse shares outright — from one sender, or from everyone — which fails that sender's `share` call with `recipient_not_accepting_shares`. Both are managed from **Settings → Security → Blocked people**.
|
||||
|
||||
### Peer connections
|
||||
|
||||
| Limit | Paid | Free | Anonymous |
|
||||
| --- | --- | --- | --- |
|
||||
| Relay credentials per minute | 30 | 10 | 5 |
|
||||
| Guest grants issued per minute | 30 | 10 | 5 |
|
||||
|
||||
Signalling details are public deployment config and bounded per network instead of per account, at 3,000 reads/min.
|
||||
|
||||
Guests are bounded per *host*: everyone holding grants from the same account shares **60 relay-credential requests/min**. Relay traffic a guest sends is metered against the account that issued the grant, so treat a grant as something that spends your allowance — issue it for the session you meant to host, and let it expire rather than reusing one indefinitely.
|
||||
|
||||
### Everything at once
|
||||
|
||||
Every driver call also passes one shared per-account budget of **8,000 calls/min** before the per-API limits above. It exists to catch a runaway loop, not to shape normal traffic — a client that sees a 429 from it is looping.
|
||||
|
||||
@@ -687,6 +687,14 @@ let sidebar = [
|
||||
source: '/Peer/connect.md',
|
||||
path: '/Peer/connect',
|
||||
},
|
||||
{
|
||||
title: '<code>createGuestGrant()</code>',
|
||||
page_title: '<code>puter.peer.createGuestGrant()</code>',
|
||||
title_tag: 'puter.peer.createGuestGrant()',
|
||||
icon: '/assets/img/function.svg',
|
||||
source: '/Peer/createGuestGrant.md',
|
||||
path: '/Peer/createGuestGrant',
|
||||
},
|
||||
{
|
||||
title: '<code>ensureTurnRelays()</code>',
|
||||
page_title: '<code>puter.peer.ensureTurnRelays()</code>',
|
||||
|
||||
@@ -8,7 +8,10 @@ import { PuterModule } from '../lib/PuterModule.js';
|
||||
* @property {RTCIceServer[]} [iceServers] Custom ICE servers (STUN/TURN) to use instead of the
|
||||
* Puter-managed relays.
|
||||
* @property {boolean} [forceRelay] Route every candidate through a TURN relay.
|
||||
* @property {string} [anonToken] Connect without a Puter session, using a token the server issued.
|
||||
* @property {string} [anonToken] Take part without a Puter session. Any uuid; it identifies this
|
||||
* guest for the duration of the session and skips the sign-in prompt.
|
||||
* @property {string} [turnGrant] A grant from `puter.peer.createGuestGrant()`, letting a guest with
|
||||
* no session use the Puter-managed relays on the granting account's allowance.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -498,7 +501,12 @@ export class PuterPeerConnection extends EventTarget {
|
||||
/**
|
||||
* The `puter.peer` API. Provides WebRTC data channels with built-in signaling
|
||||
* and TURN relays for connecting clients directly without your own signaling
|
||||
* server. Peer connections require authentication.
|
||||
* server.
|
||||
*
|
||||
* Hosting a session requires authentication. Guests can join one without an
|
||||
* account by passing `anonToken`, and reach the Puter-managed relays with a
|
||||
* `turnGrant` the host issued via `createGuestGrant()` — relay usage is
|
||||
* charged to the host that issued it.
|
||||
*/
|
||||
export class PeerModule extends PuterModule {
|
||||
#signallerUrl;
|
||||
@@ -507,6 +515,36 @@ export class PeerModule extends PuterModule {
|
||||
#turnTTL;
|
||||
#turnStartedAt;
|
||||
#turnFailed;
|
||||
#turnSource;
|
||||
|
||||
/**
|
||||
* Creates a grant that lets guests without a Puter session use the
|
||||
* Puter-managed relays. Requires authentication.
|
||||
*
|
||||
* Hand the grant to the people you invite — alongside the invite code —
|
||||
* and they pass it to `connect()` as `turnGrant`. Their relay usage counts
|
||||
* against this account, so treat the grant as something that spends your
|
||||
* allowance: share it with the session you meant to host, and let it
|
||||
* expire rather than reusing one indefinitely.
|
||||
*
|
||||
* @returns {Promise<{ grant: string, expiresAt: number }>} The grant, and
|
||||
* when it stops being accepted (seconds since the epoch).
|
||||
*/
|
||||
async createGuestGrant () {
|
||||
const response = await fetchUrl(`${this.APIOrigin}/peer/turn-grant`, {
|
||||
method: 'POST',
|
||||
includePuterAuth: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if ( ! response.ok ) {
|
||||
throw new Error('Failed to create a guest grant.');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches TURN relay credentials ahead of time so connections start
|
||||
@@ -514,19 +552,44 @@ export class PeerModule extends PuterModule {
|
||||
* it resolves either way: if relays can't be loaded, connecting falls back
|
||||
* to the default ICE servers.
|
||||
*
|
||||
* With `turnGrant`, credentials are minted against the granting account
|
||||
* instead of the caller's own session, which is how a guest gets relays
|
||||
* without signing in.
|
||||
*
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.turnGrant] A grant from `createGuestGrant()`.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async ensureTurnRelays () {
|
||||
async ensureTurnRelays (options = {}) {
|
||||
// Credentials are tied to whoever is paying for them, so a change of
|
||||
// source invalidates both the cached servers and a previous failure —
|
||||
// otherwise a guest who tried before holding a grant would be stuck
|
||||
// with the fallback for the rest of the page's life.
|
||||
const source = options.turnGrant ? `grant:${options.turnGrant}` : 'session';
|
||||
if ( source !== this.#turnSource ) {
|
||||
this.#turnSource = source;
|
||||
this.#turnServers = undefined;
|
||||
this.#turnFailed = false;
|
||||
}
|
||||
|
||||
if ( this.#turnFailed ) return;
|
||||
if ( this.#turnServers && Date.now() - this.#turnStartedAt < this.#turnTTL * 1000 ) return;
|
||||
|
||||
const response = await fetchUrl(`${this.APIOrigin}/peer/generate-turn`, {
|
||||
method: 'POST',
|
||||
includePuterAuth: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
const response = options.turnGrant
|
||||
? await fetchUrl(`${this.APIOrigin}/peer/guest-turn`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ grant: options.turnGrant }),
|
||||
})
|
||||
: await fetchUrl(`${this.APIOrigin}/peer/generate-turn`, {
|
||||
method: 'POST',
|
||||
includePuterAuth: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if ( ! response.ok ) {
|
||||
this.#turnFailed = true;
|
||||
@@ -565,7 +628,7 @@ export class PeerModule extends PuterModule {
|
||||
if ( options?.iceServers ) {
|
||||
iceServers = options.iceServers;
|
||||
} else {
|
||||
await this.ensureTurnRelays();
|
||||
await this.ensureTurnRelays(options);
|
||||
if ( this.#turnServers ) {
|
||||
iceServers = this.#turnServers;
|
||||
} else {
|
||||
@@ -583,7 +646,7 @@ export class PeerModule extends PuterModule {
|
||||
}
|
||||
/**
|
||||
* Creates a peer server and starts it, resolving to the server once it has
|
||||
* an invite code. Requires authentication.
|
||||
* an invite code. Requires authentication, unless `anonToken` is supplied.
|
||||
*
|
||||
* @param {PuterPeerOptions} [options]
|
||||
* @returns {Promise<PuterPeerServer>}
|
||||
@@ -598,7 +661,9 @@ export class PeerModule extends PuterModule {
|
||||
|
||||
/**
|
||||
* Connects to a peer server using an invite code from `serve()`, resolving
|
||||
* once the offer has been exchanged. Requires authentication.
|
||||
* once the offer has been exchanged. Requires authentication, unless
|
||||
* `anonToken` is supplied to join without a session — pair it with a
|
||||
* `turnGrant` from the host so the connection can still use relays.
|
||||
*
|
||||
* @param {string} invitecode
|
||||
* @param {PuterPeerOptions} [options]
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Relay-credential plumbing for `puter.peer`: an authenticated caller mints
|
||||
* its own, a guest redeems a host's grant. `fetchUrl` is the HTTP boundary, so
|
||||
* that is what's stubbed; everything above it is the real module.
|
||||
*/
|
||||
|
||||
const { fetchUrlMock } = vi.hoisted(() => ({ fetchUrlMock: vi.fn() }));
|
||||
vi.mock('../lib/networkUtils.js', () => ({ fetchUrl: fetchUrlMock }));
|
||||
|
||||
const { PeerModule } = await import('./Peer.js');
|
||||
|
||||
const API_ORIGIN = 'https://api.test';
|
||||
|
||||
/** A `fetchUrl` response stub. */
|
||||
const respond = (body, ok = true) => ({ ok, json: async () => body });
|
||||
|
||||
/** Routes stubbed responses by URL, so tests declare intent, not call order. */
|
||||
const routeFetch = (routes) => {
|
||||
fetchUrlMock.mockImplementation(async (url, opts) => {
|
||||
for (const [fragment, responder] of Object.entries(routes)) {
|
||||
if (url.includes(fragment)) {
|
||||
return typeof responder === 'function'
|
||||
? responder(opts)
|
||||
: responder;
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected request to ${url}`);
|
||||
});
|
||||
};
|
||||
|
||||
/** The options `fetchUrl` was called with for the first URL that matches. */
|
||||
const callTo = (fragment) =>
|
||||
fetchUrlMock.mock.calls.find(([url]) => url.includes(fragment));
|
||||
|
||||
const makePeer = ({ authToken = null, env = 'web' } = {}) => {
|
||||
const puter = {
|
||||
authToken,
|
||||
APIOrigin: API_ORIGIN,
|
||||
env,
|
||||
ui: { authenticateWithPuter: vi.fn(async () => {}) },
|
||||
};
|
||||
return { peer: new PeerModule(puter), puter };
|
||||
};
|
||||
|
||||
const HOST_SERVERS = [{ urls: 'turn:host.test' }];
|
||||
const GUEST_SERVERS = [{ urls: 'turn:guest.test' }];
|
||||
|
||||
beforeEach(() => {
|
||||
fetchUrlMock.mockReset();
|
||||
});
|
||||
|
||||
describe('createGuestGrant', () => {
|
||||
it('mints a grant against the caller session', async () => {
|
||||
routeFetch({
|
||||
'/peer/turn-grant': respond({
|
||||
grant: 'pg1.payload.sig',
|
||||
expiresAt: 1_700_000_900,
|
||||
}),
|
||||
});
|
||||
const { peer } = makePeer({ authToken: 'host-token' });
|
||||
|
||||
await expect(peer.createGuestGrant()).resolves.toEqual({
|
||||
grant: 'pg1.payload.sig',
|
||||
expiresAt: 1_700_000_900,
|
||||
});
|
||||
|
||||
const [url, opts] = callTo('/peer/turn-grant');
|
||||
expect(url).toBe(`${API_ORIGIN}/peer/turn-grant`);
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(opts.includePuterAuth).toBe(true);
|
||||
});
|
||||
|
||||
it('throws when the grant is refused', async () => {
|
||||
routeFetch({ '/peer/turn-grant': respond({}, false) });
|
||||
const { peer } = makePeer({ authToken: 'host-token' });
|
||||
|
||||
await expect(peer.createGuestGrant()).rejects.toThrow(
|
||||
'Failed to create a guest grant.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureTurnRelays', () => {
|
||||
it('uses the authenticated endpoint when no grant is given', async () => {
|
||||
routeFetch({
|
||||
'/peer/generate-turn': respond({
|
||||
iceServers: HOST_SERVERS,
|
||||
ttl: 3600,
|
||||
}),
|
||||
});
|
||||
const { peer } = makePeer({ authToken: 'host-token' });
|
||||
|
||||
await peer.ensureTurnRelays();
|
||||
|
||||
const [, opts] = callTo('/peer/generate-turn');
|
||||
expect(opts.includePuterAuth).toBe(true);
|
||||
expect(opts.body).toBeUndefined();
|
||||
expect(callTo('/peer/guest-turn')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('redeems a grant at the guest endpoint, without sending a session', async () => {
|
||||
routeFetch({
|
||||
'/peer/guest-turn': respond({
|
||||
iceServers: GUEST_SERVERS,
|
||||
ttl: 600,
|
||||
}),
|
||||
});
|
||||
const { peer } = makePeer();
|
||||
|
||||
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
|
||||
|
||||
const [url, opts] = callTo('/peer/guest-turn');
|
||||
expect(url).toBe(`${API_ORIGIN}/peer/guest-turn`);
|
||||
expect(opts.method).toBe('POST');
|
||||
expect(opts.includePuterAuth).toBeUndefined();
|
||||
expect(JSON.parse(opts.body)).toEqual({ grant: 'grant-1' });
|
||||
expect(callTo('/peer/generate-turn')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reuses credentials within their ttl', async () => {
|
||||
routeFetch({
|
||||
'/peer/guest-turn': respond({
|
||||
iceServers: GUEST_SERVERS,
|
||||
ttl: 600,
|
||||
}),
|
||||
});
|
||||
const { peer } = makePeer();
|
||||
|
||||
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
|
||||
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
|
||||
|
||||
expect(fetchUrlMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('re-mints once the ttl has passed', async () => {
|
||||
routeFetch({
|
||||
'/peer/guest-turn': respond({
|
||||
iceServers: GUEST_SERVERS,
|
||||
ttl: 600,
|
||||
}),
|
||||
});
|
||||
const { peer } = makePeer();
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000);
|
||||
try {
|
||||
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
|
||||
now.mockReturnValue(1_000_000 + 601_000);
|
||||
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
}
|
||||
|
||||
expect(fetchUrlMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not throw when relays are unavailable', async () => {
|
||||
routeFetch({ '/peer/guest-turn': respond({}, false) });
|
||||
const { peer } = makePeer();
|
||||
|
||||
await expect(
|
||||
peer.ensureTurnRelays({ turnGrant: 'grant-1' }),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('stops asking after a failure for the same source', async () => {
|
||||
routeFetch({ '/peer/guest-turn': respond({}, false) });
|
||||
const { peer } = makePeer();
|
||||
|
||||
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
|
||||
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
|
||||
|
||||
expect(fetchUrlMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries once a grant arrives after an unauthenticated failure', async () => {
|
||||
// The guest case: the first attempt has no session and no grant, so it
|
||||
// fails; holding a grant has to be a fresh start, not a cached refusal.
|
||||
routeFetch({
|
||||
'/peer/generate-turn': respond({}, false),
|
||||
'/peer/guest-turn': respond({
|
||||
iceServers: GUEST_SERVERS,
|
||||
ttl: 600,
|
||||
}),
|
||||
});
|
||||
const { peer } = makePeer();
|
||||
|
||||
await peer.ensureTurnRelays();
|
||||
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
|
||||
|
||||
expect(callTo('/peer/generate-turn')).toBeDefined();
|
||||
expect(callTo('/peer/guest-turn')).toBeDefined();
|
||||
});
|
||||
|
||||
it('re-mints when the grant changes', async () => {
|
||||
routeFetch({
|
||||
'/peer/guest-turn': respond({
|
||||
iceServers: GUEST_SERVERS,
|
||||
ttl: 600,
|
||||
}),
|
||||
});
|
||||
const { peer } = makePeer();
|
||||
|
||||
await peer.ensureTurnRelays({ turnGrant: 'grant-1' });
|
||||
await peer.ensureTurnRelays({ turnGrant: 'grant-2' });
|
||||
|
||||
expect(fetchUrlMock).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
JSON.parse(fetchUrlMock.mock.calls.at(-1)[1].body),
|
||||
).toEqual({ grant: 'grant-2' });
|
||||
});
|
||||
});
|
||||
|
||||
// -- Guest join, end to end through connect() --------------------------
|
||||
|
||||
class FakeWebSocket {
|
||||
static latest = null;
|
||||
sent = [];
|
||||
onopen = null;
|
||||
onmessage = null;
|
||||
onerror = null;
|
||||
onclose = null;
|
||||
|
||||
constructor () {
|
||||
FakeWebSocket.latest = this;
|
||||
// Open on the next tick, the way a real socket resolves the handshake
|
||||
// after the caller has installed its handlers.
|
||||
queueMicrotask(() => this.onopen?.());
|
||||
}
|
||||
|
||||
send (data) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close () {}
|
||||
}
|
||||
|
||||
class FakeRTCPeerConnection {
|
||||
static latest = null;
|
||||
|
||||
constructor (config) {
|
||||
this.config = config;
|
||||
FakeRTCPeerConnection.latest = this;
|
||||
}
|
||||
|
||||
createDataChannel () {
|
||||
return {
|
||||
onmessage: null,
|
||||
onopen: null,
|
||||
onclose: null,
|
||||
onerror: null,
|
||||
send () {},
|
||||
close () {},
|
||||
};
|
||||
}
|
||||
|
||||
async createOffer () {
|
||||
return { type: 'offer', sdp: 'v=0' };
|
||||
}
|
||||
|
||||
async setLocalDescription () {}
|
||||
async setRemoteDescription () {}
|
||||
async addIceCandidate () {}
|
||||
close () {}
|
||||
}
|
||||
|
||||
describe('connect as a guest', () => {
|
||||
const origWebSocket = globalThis.WebSocket;
|
||||
const origRTC = globalThis.RTCPeerConnection;
|
||||
|
||||
beforeEach(() => {
|
||||
FakeWebSocket.latest = null;
|
||||
FakeRTCPeerConnection.latest = null;
|
||||
globalThis.WebSocket = FakeWebSocket;
|
||||
globalThis.RTCPeerConnection = FakeRTCPeerConnection;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = origWebSocket;
|
||||
globalThis.RTCPeerConnection = origRTC;
|
||||
});
|
||||
|
||||
const signallerInfo = respond({
|
||||
url: 'ws://signaller.test/',
|
||||
fallbackIce: [{ urls: 'stun:fallback.test' }],
|
||||
});
|
||||
|
||||
it('joins with a grant and no session, on the granted relays', async () => {
|
||||
routeFetch({
|
||||
'/peer/signaller-info': signallerInfo,
|
||||
'/peer/guest-turn': respond({
|
||||
iceServers: GUEST_SERVERS,
|
||||
ttl: 600,
|
||||
}),
|
||||
});
|
||||
const { peer, puter } = makePeer();
|
||||
|
||||
await peer.connect('HOST-1234', {
|
||||
anonToken: '11111111-2222-3333-4444-555555555555',
|
||||
turnGrant: 'grant-1',
|
||||
});
|
||||
|
||||
// No sign-in prompt, and the relays came from the host's grant.
|
||||
expect(puter.ui.authenticateWithPuter).not.toHaveBeenCalled();
|
||||
expect(FakeRTCPeerConnection.latest.config.iceServers).toEqual(
|
||||
GUEST_SERVERS,
|
||||
);
|
||||
|
||||
const sent = JSON.parse(FakeWebSocket.latest.sent[0]);
|
||||
expect(sent.client.connect).toMatchObject({
|
||||
anonToken: '11111111-2222-3333-4444-555555555555',
|
||||
invitecode: 'HOST-1234',
|
||||
});
|
||||
// Nothing to authenticate with; the anon token is the identity.
|
||||
expect(sent.client.connect.authToken ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the public ICE servers when the grant is refused', async () => {
|
||||
routeFetch({
|
||||
'/peer/signaller-info': signallerInfo,
|
||||
'/peer/guest-turn': respond({}, false),
|
||||
});
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { peer } = makePeer();
|
||||
try {
|
||||
await peer.connect('HOST-1234', {
|
||||
anonToken: '11111111-2222-3333-4444-555555555555',
|
||||
turnGrant: 'expired-grant',
|
||||
});
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
|
||||
expect(FakeRTCPeerConnection.latest.config.iceServers).toEqual([
|
||||
{ urls: 'stun:fallback.test' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('honors caller-supplied ICE servers without redeeming a grant', async () => {
|
||||
routeFetch({ '/peer/signaller-info': signallerInfo });
|
||||
const { peer } = makePeer();
|
||||
|
||||
await peer.connect('HOST-1234', {
|
||||
anonToken: '11111111-2222-3333-4444-555555555555',
|
||||
iceServers: [{ urls: 'turn:mine.test' }],
|
||||
});
|
||||
|
||||
expect(FakeRTCPeerConnection.latest.config.iceServers).toEqual([
|
||||
{ urls: 'turn:mine.test' },
|
||||
]);
|
||||
expect(callTo('/peer/guest-turn')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still mints against the session for an authenticated caller', async () => {
|
||||
routeFetch({
|
||||
'/peer/signaller-info': signallerInfo,
|
||||
'/peer/generate-turn': respond({
|
||||
iceServers: HOST_SERVERS,
|
||||
ttl: 3600,
|
||||
}),
|
||||
});
|
||||
const { peer } = makePeer({ authToken: 'user-token' });
|
||||
|
||||
await peer.connect('HOST-1234');
|
||||
|
||||
expect(FakeRTCPeerConnection.latest.config.iceServers).toEqual(
|
||||
HOST_SERVERS,
|
||||
);
|
||||
const sent = JSON.parse(FakeWebSocket.latest.sent[0]);
|
||||
expect(sent.client.connect.authToken).toBe('user-token');
|
||||
expect(callTo('/peer/guest-turn')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user