Add automatic nearby discovery and simplify device presence UI

This commit is contained in:
2026-10-12 17:53:10 +09:00
parent 7e9bb1ec36
commit 5f431ee153
23 changed files with 1148 additions and 106 deletions

View File

@@ -110,6 +110,72 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
socket.join(`account:${socket.data.accountId}`);
socket.join(`account-session:${socket.data.accountSessionId}`);
}
const currentGuest = () => {
const guest = service.guest(app.parseCookie(socket.request.headers.cookie ?? '').hop_guest);
if (!guest) fail(401, 'SESSION_EXPIRED', '기기 세션이 종료되었습니다.');
return guest!;
};
socket.on('location:update', (input, acknowledge) => {
try {
const guest = currentGuest();
service.limiter.hit(`location-guest:${guest.id}`, 6);
service.limiter.hit(
`location-ip:${socket.data.ip ?? socket.request.socket.remoteAddress}`,
120,
);
if (!cfg.locationDiscoveryEnabled)
fail(409, 'LOCATION_DISABLED', '근처 검색을 사용할 수 없습니다.');
if (!service.device(guest.id).discoverable || service.activeRoom(guest.id))
fail(409, 'LOCATION_UNAVAILABLE', '현재 기기 상태에서는 근처 검색을 사용할 수 없습니다.');
const position = z
.object({
latitude: z.number().finite().min(-90).max(90),
longitude: z.number().finite().min(-180).max(180),
accuracy: z.number().finite().min(0).max(100000),
})
.strict()
.parse(input);
if (position.accuracy > Math.max(100, cfg.locationRadiusMeters))
fail(
409,
'LOCATION_INACCURATE',
'위치 오차가 커서 근처 검색을 켜지 못했어요. 다시 확인하거나 QR로 연결해 주세요.',
);
service.nearby.set(guest.id, socket.id, position);
if (typeof acknowledge === 'function') acknowledge({ ok: true });
} catch (error) {
if (typeof acknowledge === 'function')
acknowledge({
ok: false,
code: error instanceof ApiError ? error.code : 'LOCATION_INVALID',
message: error instanceof ApiError ? error.message : '위치를 확인하지 못했습니다.',
});
}
});
socket.on('location:accept', (input) => {
try {
const guest = currentGuest();
service.limiter.hit(`location-accept:${guest.id}`, 20);
const requestId = z.object({ requestId: z.uuid() }).strict().parse(input).requestId;
const request = service.request(guest, requestId);
if (
request.recipientGuestId === guest.id &&
request.status === 'pending' &&
request.expiresAt > Date.now() &&
cfg.locationDiscoveryEnabled &&
service.nearby.nearby(request.senderGuestId, guest.id) &&
service.canReceive(request.senderGuestId, guest.id)
)
service.locationAcceptances.set(requestId, {
guestId: guest.id,
until: Date.now() + 15000,
});
} catch {
/* Invalid messages cannot grant an acceptance. */
}
});
socket.on('location:clear', () => service.nearby.clear(socket.id));
socket.on('disconnect', () => service.nearby.clear(socket.id));
});
const auth = (req: FastifyRequest) => {
const guest = service.guest(req.cookies.hop_guest);
@@ -162,7 +228,10 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
.header('X-Content-Type-Options', 'nosniff')
.header('Referrer-Policy', 'no-referrer')
.header('X-Frame-Options', 'DENY')
.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), bluetooth=()');
.header(
'Permissions-Policy',
`camera=(), microphone=(), geolocation=${cfg.locationDiscoveryEnabled ? '(self)' : '()'}, bluetooth=()`,
);
reply.header(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'",
@@ -262,6 +331,11 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
device: service.device(g.id),
devices: service.peers(g),
sameNetworkDiscoveryEnabled: cfg.discoveryEnabled,
locationDiscovery: {
enabled: cfg.locationDiscoveryEnabled,
radiusMeters: cfg.locationRadiusMeters,
refreshMs: 120000,
},
requests: r,
items: list.items,
nextCursor: list.nextCursor,
@@ -868,12 +942,19 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
});
}
let storageCleanup: Promise<void> | undefined;
const cleanup = setInterval(() => {
service.cleanup();
service.auth.enforceSessions();
const cleanStorage = () => {
storageCleanup = service.storage
.cleanup()
.catch(() => app.log.error({ code: 'CLEANUP_FAILED' }, 'Storage cleanup failed'));
};
cleanStorage();
const storageTimer = setInterval(cleanStorage, 60000);
storageTimer.unref();
const cleanup = setInterval(() => {
for (const [id, permission] of service.locationAcceptances)
if (permission.until <= Date.now()) service.locationAcceptances.delete(id);
service.cleanup();
service.auth.enforceSessions();
}, 1000);
cleanup.unref();
app.addHook('preClose', async () => {
@@ -882,7 +963,9 @@ export async function createApp(cfg: Config, options: { mailer?: Mailer } = {})
});
app.addHook('onClose', async () => {
clearInterval(cleanup);
clearInterval(storageTimer);
presence.close();
service.nearby.close();
service.storage.close();
await storageCleanup;
await new Promise<void>((resolve) => io.close(() => resolve()));

View File

@@ -7,6 +7,8 @@ export type Config = {
sessionSecret: string;
discoverySecret: string;
discoveryEnabled: boolean;
locationDiscoveryEnabled: boolean;
locationRadiusMeters: number;
trustedProxies: string[];
clientIpSource: 'forwarded' | 'cloudflare';
proxyDiagnosticToken?: string;
@@ -63,6 +65,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
throw Error('Invalid CLIENT_IP_SOURCE');
if (env.PROXY_DIAGNOSTIC_TOKEN && env.PROXY_DIAGNOSTIC_TOKEN.length < 32)
throw Error('PROXY_DIAGNOSTIC_TOKEN must contain at least 32 characters');
if (num('LOCATION_DISCOVERY_RADIUS_METERS', 100) > 1000)
throw Error('LOCATION_DISCOVERY_RADIUS_METERS must not exceed 1000');
return {
production,
appUrl,
@@ -74,6 +78,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
env.SAME_NETWORK_DISCOVERY_ENABLED === undefined
? !production
: env.SAME_NETWORK_DISCOVERY_ENABLED === 'true',
locationDiscoveryEnabled: env.LOCATION_DISCOVERY_ENABLED !== 'false',
locationRadiusMeters: num('LOCATION_DISCOVERY_RADIUS_METERS', 100),
trustedProxies: (env.TRUSTED_PROXY_CIDRS ?? '')
.split(',')
.map((v) => v.trim())

94
apps/server/src/nearby.js Normal file
View File

@@ -0,0 +1,94 @@
// Consent-based location candidates. Coordinates stay in this process only.
export function metersBetween(a, b) {
const rad = Math.PI / 180;
const latitude = (b.latitude - a.latitude) * rad;
const longitude = (b.longitude - a.longitude) * rad;
const value =
Math.sin(latitude / 2) ** 2 +
Math.cos(a.latitude * rad) * Math.cos(b.latitude * rad) * Math.sin(longitude / 2) ** 2;
return 6371000 * 2 * Math.asin(Math.sqrt(Math.min(1, Math.max(0, value))));
}
export class NearbyLocations {
constructor(radius, changed) {
this.radius = radius;
this.changed = changed;
this.points = new Map();
this.guests = new Map();
this.timers = new Map();
}
nearby(a, b) {
const now = Date.now();
for (const left of this.guests.get(a)?.values() ?? []) {
if (left.until <= now) continue;
for (const right of this.guests.get(b)?.values() ?? []) {
if (right.until > now && metersBetween(left, right) <= this.radius) return true;
}
}
return false;
}
neighbors(id) {
return new Set(
[...this.guests.keys()].filter((other) => other !== id && this.nearby(id, other)),
);
}
notify(id, before) {
const after = this.neighbors(id);
const delta = [...before]
.filter((other) => !after.has(other))
.concat([...after].filter((other) => !before.has(other)));
if (delta.length) this.changed([id, ...delta]);
}
set(guestId, socketId, position) {
const before = this.neighbors(guestId);
clearTimeout(this.timers.get(socketId));
const point = {
guestId,
latitude: Math.round(position.latitude * 10000) / 10000,
longitude: Math.round(position.longitude * 10000) / 10000,
until: Date.now() + 150000,
};
this.points.set(socketId, point);
const sockets = this.guests.get(guestId) ?? new Map();
sockets.set(socketId, point);
this.guests.set(guestId, sockets);
const timer = setTimeout(() => this.clear(socketId), 150000);
timer.unref();
this.timers.set(socketId, timer);
this.notify(guestId, before);
}
clear(socketId) {
const point = this.points.get(socketId);
if (!point) return;
// Include expired relationships so their subscribers get removal events.
const now = Date.now();
const before = new Set(
[...this.guests.keys()].filter(
(other) =>
other !== point.guestId &&
[...(this.guests.get(point.guestId)?.values() ?? [])].some(
(left) =>
(left === point || left.until > now) &&
[...(this.guests.get(other)?.values() ?? [])].some(
(right) => right.until > now && metersBetween(left, right) <= this.radius,
),
),
),
);
clearTimeout(this.timers.get(socketId));
this.timers.delete(socketId);
this.points.delete(socketId);
const sockets = this.guests.get(point.guestId);
sockets?.delete(socketId);
if (!sockets?.size) this.guests.delete(point.guestId);
this.notify(point.guestId, before);
}
clearGuest(id) {
for (const socketId of this.guests.get(id)?.keys() ?? []) this.clear(socketId);
}
close() {
for (const timer of this.timers.values()) clearTimeout(timer);
this.timers.clear();
this.points.clear();
this.guests.clear();
}
}

View File

@@ -3,6 +3,13 @@ import type { Config } from './config.js';
import { hmac, publicIp } from './security.js';
export class Presence {
private connections = new Map<string, Map<string, string | null>>();
private deviceInfos = new Map<
string,
{ model: string; platform: string; manufacturer: string }
>();
info(id: string) {
return this.deviceInfos.get(id);
}
private graceIps = new Map<string, Set<string | null>>();
private timers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
@@ -18,6 +25,24 @@ export class Presence {
const sockets = this.connections.get(guestId) ?? new Map();
sockets.set(socket.id, ip);
this.connections.set(guestId, sockets);
const hint = socket.handshake.auth.deviceInfo;
if (hint && typeof hint === 'object') {
const clean = (value: unknown, max: number) =>
typeof value === 'string'
? value
.replace(/[\u0000-\u001f\u007f]/g, '')
.trim()
.slice(0, max)
: '';
const model = clean(hint.model, 80);
// Infer only known prefixes; never trust a client-provided manufacturer label.
const manufacturer = /^SM-|^GT-/i.test(model)
? 'Samsung'
: /^Pixel(?: |$)/i.test(model)
? 'Google'
: '';
this.deviceInfos.set(guestId, { model, platform: clean(hint.platform, 40), manufacturer });
}
socket.join(`guest:${guestId}`);
this.changed();
socket.on('disconnect', () => this.disconnect(guestId, socket.id));
@@ -30,6 +55,7 @@ export class Presence {
this.graceIps.set(guestId, new Set([oldIp]));
const timer = setTimeout(() => {
this.connections.delete(guestId);
this.deviceInfos.delete(guestId);
this.graceIps.delete(guestId);
this.timers.delete(guestId);
this.changed();
@@ -65,6 +91,7 @@ export class Presence {
revoke(id: string) {
this.io.in(`guest:${id}`).disconnectSockets(true);
this.connections.delete(id);
this.deviceInfos.delete(id);
this.graceIps.delete(id);
const timer = this.timers.get(id);
if (timer) clearTimeout(timer);
@@ -72,6 +99,7 @@ export class Presence {
this.changed();
}
close() {
this.deviceInfos.clear();
for (const t of this.timers.values()) clearTimeout(t);
}
}

View File

@@ -3,6 +3,7 @@ import type { Auth } from './auth.js';
import type { Accounts } from './accounts.js';
import { Storage } from './storage.js';
import { ResumableUploads } from './resumable.js';
import { NearbyLocations } from './nearby.js';
import { Vault } from './vault.js';
import { randomInt, randomUUID } from 'node:crypto';
import { and, eq, gt, isNull, or, desc, lt, inArray, sql } from 'drizzle-orm';
@@ -25,6 +26,8 @@ const day = 86400000;
export class Service {
readonly limiter = new Limiter();
presence!: Presence;
nearby: NearbyLocations;
readonly locationAcceptances = new Map<string, { guestId: string; until: number }>();
auth!: Auth;
accounts!: Accounts;
io?: Server;
@@ -37,6 +40,9 @@ export class Service {
) {
this.storage = new Storage(this);
this.resumable = new ResumableUploads(this);
this.nearby = new NearbyLocations(cfg.locationRadiusMeters, (ids: string[]) =>
this.refresh(ids),
);
this.vault = new Vault(this);
}
guest(token: string | undefined) {
@@ -56,7 +62,13 @@ export class Service {
device(guestId: string): Device {
const d = this.db.select().from(s.devices).where(eq(s.devices.guestId, guestId)).get();
if (!d) fail(401, 'SESSION_EXPIRED', '기기 세션이 만료되었습니다.');
return { id: d!.id, name: d!.name, code: d!.code, discoverable: d!.discoverable };
return {
id: d!.id,
name: d!.name,
code: d!.code,
discoverable: d!.discoverable,
info: this.presence?.info(guestId),
};
}
deviceGuest(id: string) {
return this.db.select().from(s.devices).where(eq(s.devices.id, id)).get();
@@ -111,6 +123,7 @@ export class Service {
.set({ ...input, lastSeen: Date.now() })
.where(eq(s.devices.guestId, g.id))
.run();
if (input.discoverable === false) this.nearby.clearGuest(g.id);
this.refresh();
}
reset(g: Guest) {
@@ -215,7 +228,10 @@ export class Service {
scope(a: string, b: string) {
if (this.isBlocked(a, b)) return false;
if (this.activeRoom(a) || this.activeRoom(b)) return this.sharedSpace(a, b);
return this.presence.sameNetwork(a, b);
return (
this.presence.sameNetwork(a, b) ||
(this.cfg.locationDiscoveryEnabled && this.nearby.nearby(a, b))
);
}
restrictTransfers(leaving: string[] = []) {
const active = this.db
@@ -274,12 +290,18 @@ export class Service {
? ['network' as const]
: []),
...(this.sharedSpace(g.id, id) ? ['space' as const] : []),
...(!this.activeRoom(g.id) &&
this.cfg.locationDiscoveryEnabled &&
this.nearby.nearby(g.id, id)
? ['nearby' as const]
: []),
],
}));
}
refresh() {
refresh(subset?: string[]) {
if (!this.presence) return;
for (const id of this.presence.ids()) {
for (const id of subset ?? this.presence.ids()) {
if (!this.presence.online(id)) continue;
const g = this.db.select().from(s.guests).where(eq(s.guests.id, id)).get();
if (!g || g.revokedAt || g.expiresAt <= Date.now()) {
this.presence.revoke(id);
@@ -384,6 +406,7 @@ export class Service {
})
.run();
});
this.nearby.clearGuest(g.id);
this.restrictTransfers();
this.refresh();
return { space: this.space(g, id), code, token };
@@ -431,6 +454,7 @@ export class Service {
})
.run();
});
this.nearby.clearGuest(g.id);
this.restrictTransfers();
this.refresh();
return { space: this.space(g, row!.id) };
@@ -638,7 +662,20 @@ export class Service {
if (row.recipientGuestId !== g.id) fail(403, 'FORBIDDEN', '수신 기기만 수락할 수 있습니다.');
if (row.status !== 'pending' || row.expiresAt <= Date.now())
fail(409, 'REQUEST_STATE', '수락할 수 없는 요청입니다.');
if (!this.canReceive(row.senderGuestId, g.id))
const permission = this.locationAcceptances.get(id);
const reserved =
this.cfg.locationDiscoveryEnabled &&
permission?.guestId === g.id &&
permission.until > Date.now() &&
!this.activeRoom(g.id) &&
!this.activeRoom(row.senderGuestId) &&
this.presence.active(g.id) &&
this.presence.active(row.senderGuestId) &&
this.device(g.id).discoverable &&
this.device(row.senderGuestId).discoverable &&
!this.isBlocked(row.senderGuestId, g.id);
this.locationAcceptances.delete(id);
if (!this.canReceive(row.senderGuestId, g.id) && !reserved)
fail(409, 'TARGET_UNAVAILABLE', '현재 연결 상태에서 수락할 수 없습니다.');
if (row.kind === 'file' && !this.vault.source(id)) {
const sender = this.db

View File

@@ -0,0 +1,37 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readDeviceInfo } from '../../web/src/deviceInfo.js';
test('모델 힌트가 없거나 거절된 브라우저도 정상 연결할 수 있다', async () => {
assert.deepEqual(await readDeviceInfo({}), { model: '', platform: '', manufacturer: '' });
assert.deepEqual(
await readDeviceInfo({
userAgentData: {
getHighEntropyValues: async () => {
throw Error('withheld');
},
},
}),
{},
);
});
test('Android 모델을 구분하고 브라우저 브랜드를 제조사로 사용하지 않는다', async () => {
const nav = {
userAgentData: {
getHighEntropyValues: async (hints) => {
assert.deepEqual(hints, ['model']);
return { model: 'SM-S938B', platform: 'Android', brands: [{ brand: 'Chrome' }] };
},
},
};
assert.deepEqual(await readDeviceInfo(nav), {
model: 'SM-S938B',
platform: 'Android',
manufacturer: 'Samsung',
});
nav.userAgentData.getHighEntropyValues = async () => ({
model: 'Unknown phone',
platform: 'Android',
});
assert.equal((await readDeviceInfo(nav)).manufacturer, '');
});

View File

@@ -0,0 +1,184 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { io } from 'socket.io-client';
import { randomUUID } from 'node:crypto';
import { fixture } from './helpers.js';
import { metersBetween, NearbyLocations } from '../src/nearby.js';
const point = { latitude: 37.5665, longitude: 126.978, accuracy: 20 };
test('거리 계산과 동의한 양쪽 좌표, 반경·만료·여러 탭·변경 알림을 검사한다', () => {
const changes = [],
locations = new NearbyLocations(100, (ids) => changes.push(ids));
try {
assert.equal(metersBetween(point, point), 0);
assert(
metersBetween({ latitude: 0, longitude: 179.9999 }, { latitude: 0, longitude: -179.9999 }) <
30,
);
locations.set('a', 'a1', point);
assert.equal(changes.length, 0, '혼자서는 불필요한 목록 갱신을 하지 않음');
locations.set('b', 'b1', { ...point, latitude: point.latitude + 0.0004 });
assert.equal(locations.nearby('a', 'b'), true);
const before = changes.length;
locations.set('b', 'b1', { ...point, latitude: point.latitude + 0.0004 });
assert.equal(changes.length, before, '같은 위치를 갱신해도 목록 재전송 없음');
locations.set('a', 'a2', point);
locations.clear('a1');
assert.equal(locations.nearby('a', 'b'), true);
locations.clear('a2');
assert.equal(locations.nearby('a', 'b'), false);
locations.set('a', 'a1', point);
locations.points.get('a1').until = Date.now() - 1;
assert.equal(locations.nearby('a', 'b'), false);
locations.clear('a1');
assert(changes.at(-1).includes('b'));
locations.set('a', 'a1', { ...point, latitude: point.latitude + 0.01 });
assert.equal(locations.nearby('a', 'b'), false);
} finally {
locations.close();
}
});
async function positioned(f, ip) {
const client = await f.create(ip, false);
client.socket = io(f.url, {
transports: ['websocket'],
reconnection: false,
auth: { csrfToken: client.csrf },
extraHeaders: { Origin: f.cfg.appUrl, Cookie: client.cookie, 'X-Forwarded-For': ip },
});
await new Promise((resolve, reject) => {
client.socket.once('connect', resolve);
client.socket.once('connect_error', reject);
});
return client;
}
const publish = (client, position) =>
client.socket.timeout(3000).emitWithAck('location:update', position);
test('다른 네트워크도 양쪽 위치 허용 시 발견·전송하고 좌표는 목록과 DB에 공개하지 않는다', async () => {
const f = await fixture(false),
clients = [];
try {
const a = await positioned(f, '8.8.8.8'),
b = await positioned(f, '1.1.1.1'),
c = await positioned(f, '9.9.9.9');
clients.push(a, b, c);
const list = async (client) => (await f.call(client, '/discovery/devices')).json().devices;
assert.equal((await list(a)).length, 0);
assert.equal((await publish(a, point)).ok, true);
assert.equal((await list(a)).length, 0);
assert.equal((await publish(b, { ...point, latitude: point.latitude + 0.0004 })).ok, true);
assert.equal((await publish(c, { ...point, latitude: point.latitude + 0.01 })).ok, true);
assert.deepEqual(
(await list(a)).map((device) => device.id),
[b.deviceId],
);
assert.deepEqual((await list(a))[0].sources, ['nearby']);
const snapshot = await f.call(a, '/device/snapshot');
assert(!snapshot.body.includes('latitude') && !snapshot.body.includes('longitude'));
assert(
!f.sqlite
.prepare("SELECT sql FROM sqlite_master WHERE type='table'")
.all()
.some((row) => /latitude|longitude/i.test(row.sql)),
);
const request = await f.request(a, b, 'https://example.com/nearby');
assert.equal(request.statusCode, 200, request.body);
assert.equal(
(
await f.call(
b,
`/transfer-requests/${request.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
200,
);
assert.equal(
(
await f.call(
a,
`/transfers/${request.json().requestId}/complete`,
'POST',
{ urls: ['https://example.com/nearby'] },
randomUUID(),
)
).statusCode,
200,
);
b.socket.emit('location:clear');
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal((await list(a)).length, 0);
assert.equal((await f.request(a, b, 'https://example.com/outside')).statusCode, 403);
} finally {
clients.forEach((client) => client.socket.disconnect());
await f.close();
}
});
test('기기 숨김·방 참여·세션 종료·부정확한 위치와 잘못된 위치를 검사한다', async () => {
const f = await fixture(false),
clients = [];
try {
const a = await positioned(f, '8.8.8.8'),
b = await positioned(f, '1.1.1.1');
clients.push(a, b);
assert.equal((await publish(a, { ...point, latitude: 91 })).ok, false);
assert.equal((await publish(a, { ...point, accuracy: 10000 })).code, 'LOCATION_INACCURATE');
await publish(a, point);
await publish(b, point);
await f.call(b, '/device/me', 'PATCH', { discoverable: false });
assert.equal(f.service.nearby.guests.has(f.service.guest(b.cookie.split('=')[1]).id), false);
assert.equal((await publish(b, point)).code, 'LOCATION_UNAVAILABLE');
await f.call(b, '/device/me', 'PATCH', { discoverable: true });
await f.space(a, b);
assert.equal((await publish(a, point)).code, 'LOCATION_UNAVAILABLE');
const d = await positioned(f, '9.9.9.9');
clients.push(d);
await publish(d, point);
assert.equal(
(await f.call(d, '/discovery/devices')).json().devices.length,
0,
'방 안 기기는 외부 위치 목록에 나오지 않음',
);
const guestId = f.service.guest(d.cookie.split('=')[1]).id;
d.socket.disconnect();
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(f.service.nearby.guests.has(guestId), false);
} finally {
clients.forEach((client) => client.socket.disconnect());
await f.close();
}
});
test('수락 클릭으로 새 탭이 열려 위치가 제거돼도 해당 요청만 완료할 수 있다', async () => {
const f = await fixture(false),
clients = [];
try {
const a = await positioned(f, '8.8.8.8'),
b = await positioned(f, '1.1.1.1');
clients.push(a, b);
await publish(a, point);
await publish(b, point);
const response = await f.request(a, b, 'https://example.com/accepted'),
id = response.json().requestId;
b.socket.emit('location:accept', { requestId: id });
b.socket.emit('location:clear');
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(
f.service.nearby.nearby(
f.service.guest(a.cookie.split('=')[1]).id,
f.service.guest(b.cookie.split('=')[1]).id,
),
false,
);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
assert.equal(f.service.locationAcceptances.has(id), false);
} finally {
clients.forEach((client) => client.socket.disconnect());
await f.close();
}
});