@agnt-sdk/simon-server 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +317 -2
- package/dist/account.d.ts +59 -0
- package/dist/account.js +244 -0
- package/dist/auth.d.ts +42 -0
- package/dist/auth.js +96 -0
- package/dist/config.d.ts +219 -0
- package/dist/config.js +300 -0
- package/dist/context.d.ts +57 -0
- package/dist/context.js +2 -0
- package/dist/continuity.d.ts +35 -0
- package/dist/continuity.js +74 -0
- package/dist/deliver.d.ts +22 -0
- package/dist/deliver.js +208 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +11 -0
- package/dist/extensions.d.ts +216 -0
- package/dist/extensions.js +75 -0
- package/dist/fanout.d.ts +32 -0
- package/dist/fanout.js +79 -0
- package/dist/http.d.ts +69 -0
- package/dist/http.js +112 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +12 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +41 -0
- package/dist/net/dns.d.ts +31 -0
- package/dist/net/dns.js +100 -0
- package/dist/net/fetch.d.ts +48 -0
- package/dist/net/fetch.js +173 -0
- package/dist/net/ip.d.ts +38 -0
- package/dist/net/ip.js +159 -0
- package/dist/net/psl.d.ts +22 -0
- package/dist/net/psl.js +91 -0
- package/dist/net/public-suffixes.d.ts +10 -0
- package/dist/net/public-suffixes.js +6885 -0
- package/dist/notice.d.ts +5 -0
- package/dist/notice.js +61 -0
- package/dist/outbox.d.ts +14 -0
- package/dist/outbox.js +356 -0
- package/dist/parts.d.ts +29 -0
- package/dist/parts.js +443 -0
- package/dist/public.d.ts +43 -0
- package/dist/public.js +134 -0
- package/dist/published.d.ts +3 -0
- package/dist/published.js +140 -0
- package/dist/rate.d.ts +28 -0
- package/dist/rate.js +77 -0
- package/dist/readers.d.ts +31 -0
- package/dist/readers.js +48 -0
- package/dist/register.d.ts +4 -0
- package/dist/register.js +401 -0
- package/dist/resolver.d.ts +140 -0
- package/dist/resolver.js +386 -0
- package/dist/send.d.ts +30 -0
- package/dist/send.js +216 -0
- package/dist/server.d.ts +52 -0
- package/dist/server.js +234 -0
- package/dist/signal.d.ts +5 -0
- package/dist/signal.js +137 -0
- package/dist/store/contract.d.ts +4 -0
- package/dist/store/contract.js +1177 -0
- package/dist/store/sqlite-migration.d.ts +82 -0
- package/dist/store/sqlite-migration.js +1019 -0
- package/dist/store/sqlite.d.ts +108 -0
- package/dist/store/sqlite.js +992 -0
- package/dist/store/store.d.ts +400 -0
- package/dist/store/store.js +23 -0
- package/dist/sync.d.ts +19 -0
- package/dist/sync.js +199 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +87 -0
- package/dist/webhooks.d.ts +10 -0
- package/dist/webhooks.js +162 -0
- package/dist/worker.d.ts +15 -0
- package/dist/worker.js +33 -0
- package/package.json +57 -4
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Published messages: messages written here with an empty `to` (spec section 7), served to whoever their
|
|
3
|
+
* audience admits (readers.ts):
|
|
4
|
+
*
|
|
5
|
+
* GET /simon/messages/<id> the message and its author's keys valid at its `sentAt`
|
|
6
|
+
* GET /simon/feeds/<address>/<feed> the author's published messages in that feed, newest first
|
|
7
|
+
* GET /simon/messages/<id>/related the relations to it marked listed that this server shows, newest first
|
|
8
|
+
*
|
|
9
|
+
* A message a reader may not see, an unknown one and a retired author's are answered alike: `404` for a
|
|
10
|
+
* message and its relations, an empty page for a feed. Listings page with an opaque cursor, `before`.
|
|
11
|
+
*/
|
|
12
|
+
import { header, invalid, MAX_CURSOR_LENGTH, MAX_LISTING_LIMIT, DEFAULT_LISTING_LIMIT, parseTimestamp, PUBLIC_RELATIONS, validAt, VERSION, } from '@agnt-sdk/simon-protocol';
|
|
13
|
+
import { notFound } from "./errors.js";
|
|
14
|
+
import { json } from "./http.js";
|
|
15
|
+
import { askerOf, mayRead } from "./readers.js";
|
|
16
|
+
/**
|
|
17
|
+
* Records read from the store for one page at most, so a listing of hidden messages costs a bounded scan. A
|
|
18
|
+
* scan that ends there ends the listing too: its next cursor would be a hidden message's.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_SCANNED = 5000;
|
|
21
|
+
/** The feed of a published message that names none (spec section 7.3). */
|
|
22
|
+
const DEFAULT_FEED = 'posts';
|
|
23
|
+
const CURSOR = new RegExp(`^[\\x21-\\x7e]{1,${MAX_CURSOR_LENGTH}}$`);
|
|
24
|
+
const EMPTY = { messages: [], cursor: null, more: false };
|
|
25
|
+
const v1Header = (m, name) => (m.v === VERSION ? header(m, name) : null);
|
|
26
|
+
/** The published message written here by an agent not retired, or null. */
|
|
27
|
+
async function published(ctx, id) {
|
|
28
|
+
const record = await ctx.store.message(id);
|
|
29
|
+
if (!record || record.carrier !== ctx.config.origin || record.message.to.length !== 0)
|
|
30
|
+
return null;
|
|
31
|
+
const author = await ctx.store.agentByAddress(record.message.from);
|
|
32
|
+
return author && !author.retiredAt ? record : null;
|
|
33
|
+
}
|
|
34
|
+
/** `before` and `limit` of a listing request. */
|
|
35
|
+
function paging(req) {
|
|
36
|
+
const before = req.query.get('before');
|
|
37
|
+
if (before !== null && !CURSOR.test(before)) {
|
|
38
|
+
throw invalid(`before is a cursor this server gave: 1 to ${MAX_CURSOR_LENGTH} printable ASCII characters.`, {
|
|
39
|
+
field: 'before',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const limitText = req.query.get('limit');
|
|
43
|
+
const limit = limitText === null ? DEFAULT_LISTING_LIMIT : /^[0-9]{1,3}$/.test(limitText) ? Number(limitText) : 0;
|
|
44
|
+
if (limit < 1 || limit > MAX_LISTING_LIMIT) {
|
|
45
|
+
throw invalid(`limit is 1 to ${MAX_LISTING_LIMIT}.`, { field: 'limit' });
|
|
46
|
+
}
|
|
47
|
+
return { before, limit };
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One page of a listing: the records `next` pages through, newest first, that `shows` keeps, up to `limit`.
|
|
51
|
+
* The cursor is the last record shown, never one the reader may not see, and is null at the end.
|
|
52
|
+
*/
|
|
53
|
+
async function listing(next, shows, before, limit) {
|
|
54
|
+
const shown = [];
|
|
55
|
+
let cursor = before;
|
|
56
|
+
scan: for (let scanned = 0; scanned < MAX_SCANNED;) {
|
|
57
|
+
const page = await next(cursor, limit + 1);
|
|
58
|
+
for (const r of page.records) {
|
|
59
|
+
scanned++;
|
|
60
|
+
cursor = r.cursor;
|
|
61
|
+
if (await shows(r.message))
|
|
62
|
+
shown.push({ cursor: r.cursor, message: r.message.message });
|
|
63
|
+
if (shown.length > limit)
|
|
64
|
+
break scan;
|
|
65
|
+
}
|
|
66
|
+
if (!page.more)
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
const page = shown.slice(0, limit);
|
|
70
|
+
const more = shown.length > limit;
|
|
71
|
+
return { messages: page.map(s => s.message), cursor: more ? page.at(-1).cursor : null, more };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Whether a relation to a published message is one its author's server lists (spec section 7.4): its writer
|
|
75
|
+
* marked it `listed`, a plain reply never is; of those, the relations this server shows (PUBLIC_RELATIONS);
|
|
76
|
+
* and not retracted by its writer since.
|
|
77
|
+
*/
|
|
78
|
+
async function listable(ctx, author, record) {
|
|
79
|
+
const rel = v1Header(record.message, 'rel');
|
|
80
|
+
if (v1Header(record.message, 'listed') !== 'yes')
|
|
81
|
+
return false;
|
|
82
|
+
if (rel === null || !PUBLIC_RELATIONS.includes(rel))
|
|
83
|
+
return false;
|
|
84
|
+
return !(await ctx.store.listingRetracted(author, record.id, record.message.from));
|
|
85
|
+
}
|
|
86
|
+
export function publishedRoutes(ctx) {
|
|
87
|
+
const readable = async (id, asker) => {
|
|
88
|
+
const record = await published(ctx, id);
|
|
89
|
+
return record && (await mayRead(ctx, record.message, asker)) ? record : null;
|
|
90
|
+
};
|
|
91
|
+
return [
|
|
92
|
+
{
|
|
93
|
+
method: 'GET',
|
|
94
|
+
path: '/simon/messages/:id',
|
|
95
|
+
async handle(req) {
|
|
96
|
+
const record = await readable(req.params.id, await askerOf(ctx, req));
|
|
97
|
+
if (!record)
|
|
98
|
+
throw notFound('No published message has this id here.');
|
|
99
|
+
const author = (await ctx.store.agentByAddress(record.message.from));
|
|
100
|
+
const at = new Date(parseTimestamp(record.message.sentAt));
|
|
101
|
+
const claims = record.signedClaims
|
|
102
|
+
? { claims: record.signedClaims.claims, claimsSignature: record.signedClaims.signature }
|
|
103
|
+
: {};
|
|
104
|
+
return json({
|
|
105
|
+
message: record.message,
|
|
106
|
+
keys: author.keys.filter(k => validAt(k, at)).map(({ key, added, revoked }) => ({ key, added, revoked })),
|
|
107
|
+
...claims,
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
method: 'GET',
|
|
113
|
+
path: '/simon/feeds/:address/:feed',
|
|
114
|
+
async handle(req) {
|
|
115
|
+
const { before, limit } = paging(req);
|
|
116
|
+
const { address, feed } = req.params;
|
|
117
|
+
const asker = await askerOf(ctx, req);
|
|
118
|
+
const author = await ctx.store.agentByAddress(address);
|
|
119
|
+
if (!author || author.retiredAt)
|
|
120
|
+
return json(EMPTY);
|
|
121
|
+
const inFeed = (m) => (v1Header(m, 'feed') ?? DEFAULT_FEED).split(',').includes(feed);
|
|
122
|
+
const shows = async (r) => inFeed(r.message) && (await mayRead(ctx, r.message, asker));
|
|
123
|
+
return json(await listing((b, n) => ctx.store.published(address, b, n), shows, before, limit));
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
method: 'GET',
|
|
128
|
+
path: '/simon/messages/:id/related',
|
|
129
|
+
async handle(req) {
|
|
130
|
+
const { before, limit } = paging(req);
|
|
131
|
+
const record = await readable(req.params.id, await askerOf(ctx, req));
|
|
132
|
+
if (!record)
|
|
133
|
+
throw notFound('No published message has this id here.');
|
|
134
|
+
const author = record.message.from;
|
|
135
|
+
const page = (b, n) => ctx.store.repliesTo(author, record.id, b, n);
|
|
136
|
+
return json(await listing(page, r => listable(ctx, author, r), before, limit));
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
}
|
package/dist/rate.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limits: fixed windows, in this process's memory. Each limit is a count per window per key; a client
|
|
3
|
+
* IP is keyed by `clientKey` (IPv6 by /64). Memory is bounded: past `maxKeys`, expired windows go first,
|
|
4
|
+
* then the oldest. A server run as several processes divides each limit by their number, or keeps these
|
|
5
|
+
* counts in a shared store.
|
|
6
|
+
*/
|
|
7
|
+
import type { Rate, RateName } from './config.ts';
|
|
8
|
+
import { type Network } from './net/ip.ts';
|
|
9
|
+
/**
|
|
10
|
+
* The rates counted per client that a client's network may be exempt from. `lookup` and `badSignature`
|
|
11
|
+
* are never exempt: they bound the work deliveries from other servers make, which no exempt client needs.
|
|
12
|
+
*/
|
|
13
|
+
export type ClientRateName = 'request' | 'register' | 'codeClient' | 'agentMiss';
|
|
14
|
+
export declare class RateLimiter {
|
|
15
|
+
#private;
|
|
16
|
+
/** `exempt`: networks whose clients the client rates do not count, such as a hosting service's own. */
|
|
17
|
+
constructor(rates: Record<RateName, Rate>, now: () => Date, exempt?: readonly Network[], maxKeys?: number);
|
|
18
|
+
/** Count one event, or refuse with 429 when the window is full. */
|
|
19
|
+
hit(name: RateName, key: string): void;
|
|
20
|
+
/** Count one event for the client at `ip`, unless its network is exempt. */
|
|
21
|
+
hitClient(name: ClientRateName, ip: string): void;
|
|
22
|
+
/** Take back an event `hitClient` counted that turned out not to be one of what the rate counts. */
|
|
23
|
+
refundClient(name: ClientRateName, ip: string): void;
|
|
24
|
+
/** Refuse with 429 when the window is full, without counting. */
|
|
25
|
+
check(name: RateName, key: string): void;
|
|
26
|
+
/** Count only values not seen in this window (destinations, say), all or none. */
|
|
27
|
+
distinct(name: RateName, key: string, values: string[]): void;
|
|
28
|
+
}
|
package/dist/rate.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { rateLimited } from "./errors.js";
|
|
2
|
+
import { clientKey, inNetworks } from "./net/ip.js";
|
|
3
|
+
export class RateLimiter {
|
|
4
|
+
#windows = new Map();
|
|
5
|
+
#rates;
|
|
6
|
+
#now;
|
|
7
|
+
#maxKeys;
|
|
8
|
+
#exempt;
|
|
9
|
+
/** `exempt`: networks whose clients the client rates do not count, such as a hosting service's own. */
|
|
10
|
+
constructor(rates, now, exempt = [], maxKeys = 100_000) {
|
|
11
|
+
this.#rates = rates;
|
|
12
|
+
this.#now = now;
|
|
13
|
+
this.#exempt = exempt;
|
|
14
|
+
this.#maxKeys = maxKeys;
|
|
15
|
+
}
|
|
16
|
+
#window(name, key) {
|
|
17
|
+
const id = `${name}\n${key}`;
|
|
18
|
+
const now = this.#now().getTime();
|
|
19
|
+
let w = this.#windows.get(id);
|
|
20
|
+
if (!w || w.resetAt <= now) {
|
|
21
|
+
this.#windows.delete(id);
|
|
22
|
+
if (this.#windows.size >= this.#maxKeys)
|
|
23
|
+
this.#evict(now);
|
|
24
|
+
w = { resetAt: now + this.#rates[name].seconds * 1000, count: 0 };
|
|
25
|
+
this.#windows.set(id, w);
|
|
26
|
+
}
|
|
27
|
+
return w;
|
|
28
|
+
}
|
|
29
|
+
#evict(now) {
|
|
30
|
+
for (const [id, w] of this.#windows)
|
|
31
|
+
if (w.resetAt <= now)
|
|
32
|
+
this.#windows.delete(id);
|
|
33
|
+
for (const id of this.#windows.keys()) {
|
|
34
|
+
if (this.#windows.size < this.#maxKeys)
|
|
35
|
+
break;
|
|
36
|
+
this.#windows.delete(id);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
#refuse(w) {
|
|
40
|
+
throw rateLimited(Math.max(1, Math.ceil((w.resetAt - this.#now().getTime()) / 1000)));
|
|
41
|
+
}
|
|
42
|
+
/** Count one event, or refuse with 429 when the window is full. */
|
|
43
|
+
hit(name, key) {
|
|
44
|
+
const w = this.#window(name, key);
|
|
45
|
+
if (w.count >= this.#rates[name].limit)
|
|
46
|
+
this.#refuse(w);
|
|
47
|
+
w.count++;
|
|
48
|
+
}
|
|
49
|
+
/** Count one event for the client at `ip`, unless its network is exempt. */
|
|
50
|
+
hitClient(name, ip) {
|
|
51
|
+
if (!inNetworks(ip, this.#exempt))
|
|
52
|
+
this.hit(name, clientKey(ip));
|
|
53
|
+
}
|
|
54
|
+
/** Take back an event `hitClient` counted that turned out not to be one of what the rate counts. */
|
|
55
|
+
refundClient(name, ip) {
|
|
56
|
+
if (inNetworks(ip, this.#exempt))
|
|
57
|
+
return;
|
|
58
|
+
const w = this.#window(name, clientKey(ip));
|
|
59
|
+
w.count = Math.max(0, w.count - 1);
|
|
60
|
+
}
|
|
61
|
+
/** Refuse with 429 when the window is full, without counting. */
|
|
62
|
+
check(name, key) {
|
|
63
|
+
const w = this.#window(name, key);
|
|
64
|
+
if (w.count >= this.#rates[name].limit)
|
|
65
|
+
this.#refuse(w);
|
|
66
|
+
}
|
|
67
|
+
/** Count only values not seen in this window (destinations, say), all or none. */
|
|
68
|
+
distinct(name, key, values) {
|
|
69
|
+
const w = this.#window(name, key);
|
|
70
|
+
w.values ??= new Set();
|
|
71
|
+
const fresh = [...new Set(values)].filter(v => !w.values.has(v));
|
|
72
|
+
if (w.values.size + fresh.length > this.#rates[name].limit)
|
|
73
|
+
this.#refuse(w);
|
|
74
|
+
for (const v of fresh)
|
|
75
|
+
w.values.add(v);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who reads a published message, and whether its audience admits them (spec section 7.1).
|
|
3
|
+
*
|
|
4
|
+
* A reader proves who it is the way an author does: it signs the request with a key of its own agent
|
|
5
|
+
* document and names itself in `simon-agent` (section 8). A request that is not signed, or whose signature
|
|
6
|
+
* fails, asks as nobody in particular, and is answered exactly as one, so a refusal tells nothing about
|
|
7
|
+
* the message.
|
|
8
|
+
*
|
|
9
|
+
* A message with no `audience` header is for anyone. One with a label is for whoever the extensions'
|
|
10
|
+
* `admitsReader` chain admits; with no extension deciding, for no one but its author.
|
|
11
|
+
*/
|
|
12
|
+
import { type AnyMessage } from '@agnt-sdk/simon-protocol';
|
|
13
|
+
import type { Context } from './context.ts';
|
|
14
|
+
import type { Request } from './http.ts';
|
|
15
|
+
/** Who asks: the reader that signed, null for nobody in particular. */
|
|
16
|
+
export interface Asker {
|
|
17
|
+
reader(): Promise<string | null>;
|
|
18
|
+
}
|
|
19
|
+
/** An asker already known: an agent here whose own signature this server checked. */
|
|
20
|
+
export declare const asReader: (address: string) => Asker;
|
|
21
|
+
/**
|
|
22
|
+
* Who asks, settled before anything is looked up for the request: a request naming `simon-agent` is
|
|
23
|
+
* authenticated at once, its reader's document fetched and charged to the client, whatever it asks for and
|
|
24
|
+
* whether or not anything matches, so neither the answer nor the fetches it makes tell which ids, feeds or
|
|
25
|
+
* parts hold audience-limited posts (spec section 7.1).
|
|
26
|
+
*/
|
|
27
|
+
export declare function askerOf(ctx: Context, req: Request): Promise<Asker>;
|
|
28
|
+
/** The audience label a published version 1 message carries, or null for anyone. */
|
|
29
|
+
export declare const audienceOf: (m: AnyMessage) => string | null;
|
|
30
|
+
/** Whether a published message written here may be shown to this asker. */
|
|
31
|
+
export declare function mayRead(ctx: Context, m: AnyMessage, asker: Asker): Promise<boolean>;
|
package/dist/readers.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who reads a published message, and whether its audience admits them (spec section 7.1).
|
|
3
|
+
*
|
|
4
|
+
* A reader proves who it is the way an author does: it signs the request with a key of its own agent
|
|
5
|
+
* document and names itself in `simon-agent` (section 8). A request that is not signed, or whose signature
|
|
6
|
+
* fails, asks as nobody in particular, and is answered exactly as one, so a refusal tells nothing about
|
|
7
|
+
* the message.
|
|
8
|
+
*
|
|
9
|
+
* A message with no `audience` header is for anyone. One with a label is for whoever the extensions'
|
|
10
|
+
* `admitsReader` chain admits; with no extension deciding, for no one but its author.
|
|
11
|
+
*/
|
|
12
|
+
import { header, VERSION, WireError } from '@agnt-sdk/simon-protocol';
|
|
13
|
+
import { authenticateReader, isAgentSigned } from "./auth.js";
|
|
14
|
+
const NOBODY = { reader: async () => null };
|
|
15
|
+
/** An asker already known: an agent here whose own signature this server checked. */
|
|
16
|
+
export const asReader = (address) => ({ reader: async () => address });
|
|
17
|
+
/**
|
|
18
|
+
* Who asks, settled before anything is looked up for the request: a request naming `simon-agent` is
|
|
19
|
+
* authenticated at once, its reader's document fetched and charged to the client, whatever it asks for and
|
|
20
|
+
* whether or not anything matches, so neither the answer nor the fetches it makes tell which ids, feeds or
|
|
21
|
+
* parts hold audience-limited posts (spec section 7.1).
|
|
22
|
+
*/
|
|
23
|
+
export async function askerOf(ctx, req) {
|
|
24
|
+
if (!isAgentSigned(req))
|
|
25
|
+
return NOBODY;
|
|
26
|
+
try {
|
|
27
|
+
return asReader(await authenticateReader(ctx, req));
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
// A signature that fails asks as nobody, and so does a reader whose document could not be looked up (a
|
|
31
|
+
// client out of lookups, a pool that is full), as the spec's reader whose document cannot be read for now.
|
|
32
|
+
if (e instanceof WireError && [401, 429, 503].includes(e.status))
|
|
33
|
+
return NOBODY;
|
|
34
|
+
throw e;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** The audience label a published version 1 message carries, or null for anyone. */
|
|
38
|
+
export const audienceOf = (m) => (m.v === VERSION ? header(m, 'audience') : null);
|
|
39
|
+
/** Whether a published message written here may be shown to this asker. */
|
|
40
|
+
export async function mayRead(ctx, m, asker) {
|
|
41
|
+
const audience = audienceOf(m);
|
|
42
|
+
if (audience === null)
|
|
43
|
+
return true;
|
|
44
|
+
const reader = await asker.reader();
|
|
45
|
+
if (reader === m.from)
|
|
46
|
+
return true;
|
|
47
|
+
return (await ctx.hooks.admitsReader({ message: m, audience, reader })) === 'admit';
|
|
48
|
+
}
|