web-agent-bridge 3.16.0 → 3.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.ar.md +27 -8
  2. package/README.md +95 -0
  3. package/bin/wab-init.js +38 -0
  4. package/package.json +1 -1
  5. package/public/atp-semantics.html +216 -0
  6. package/public/benchmarks.html +151 -0
  7. package/public/dashboard.html +1 -0
  8. package/public/docs.html +113 -43
  9. package/public/index.html +142 -8
  10. package/public/key-rotation.html +184 -0
  11. package/public/llms.txt +54 -0
  12. package/public/notary.html +94 -0
  13. package/public/observatory.html +103 -0
  14. package/public/research.html +57 -0
  15. package/public/researchers.html +113 -0
  16. package/public/responsible-disclosure.html +294 -0
  17. package/public/robots.txt +17 -0
  18. package/public/security.html +157 -0
  19. package/public/threat-model.html +153 -0
  20. package/public/viral-coefficient.html +533 -0
  21. package/public/wab-dataset.html +501 -0
  22. package/public/wab-email.html +78 -0
  23. package/public/wab-lens.html +61 -0
  24. package/public/wab-p2p.html +96 -0
  25. package/public/wab-registry.html +481 -0
  26. package/public/wab-today.html +448 -0
  27. package/public/wab-uri.html +88 -0
  28. package/public/webhooks.html +181 -0
  29. package/script/ai-agent-bridge.js +24 -4
  30. package/server/index.js +1193 -827
  31. package/server/models/db.js +2 -1
  32. package/server/routes/admin-shieldlink.js +1 -1
  33. package/server/routes/admin-shieldqr.js +1 -1
  34. package/server/routes/admin-trust-monitor.js +1 -1
  35. package/server/routes/api-keys.js +2 -1
  36. package/server/routes/customer-shieldlink.js +1 -1
  37. package/server/routes/enterprise-mesh.js +2 -1
  38. package/server/routes/genius-bridge.js +256 -0
  39. package/server/routes/genius-gateway.js +137 -0
  40. package/server/routes/governance-saas.js +2 -1
  41. package/server/routes/notary.js +309 -0
  42. package/server/routes/observatory.js +109 -0
  43. package/server/routes/partners.js +2 -1
  44. package/server/routes/registry.js +352 -0
  45. package/server/routes/research.js +83 -0
  46. package/server/routes/ring4.js +2 -1
  47. package/server/routes/runtime.js +98 -25
  48. package/server/routes/security-researchers.js +161 -0
  49. package/server/routes/shieldqr.js +1 -1
  50. package/server/routes/traces.js +247 -0
  51. package/server/services/agent-tasks.js +9 -7
  52. package/server/services/email.js +50 -2
  53. package/server/services/marketplace.js +27 -8
  54. package/server/services/plans.js +1 -1
  55. package/server/services/shieldlink.js +1 -1
  56. package/server/services/ssl-ct-monitor.js +1 -1
  57. package/server/services/ssl-monitor.js +1 -1
  58. package/server/services/stripe.js +29 -4
  59. package/server/services/webhooks.js +61 -1
  60. package/server/utils/migrate.js +1 -1
  61. package/server/utils/safe-compare.js +26 -0
@@ -0,0 +1,309 @@
1
+ // WAB Notary — neutral third-party attestation service.
2
+ //
3
+ // Anyone can POST { host } and receive a server-signed JSON receipt stating
4
+ // what the notary observed at `https://<host>/.well-known/wab.json` at a
5
+ // given instant. Receipts are deterministic over (host, manifest bytes,
6
+ // observed_at minute) and cached so repeated calls are cheap.
7
+ //
8
+ // Receipts include:
9
+ // - status: "verified" | "enabled" | "missing" | "invalid"
10
+ // - manifest_sha256
11
+ // - signed: boolean (manifest carried an Ed25519 signature)
12
+ // - observed_at: ISO8601
13
+ // - notary: fingerprint of the server signing key
14
+ // - signature: Ed25519 signature over canonicalized payload
15
+ //
16
+ // The notary's signing key is generated on first use and persisted to
17
+ // `data/.notary-key.json`. The PUBLIC key is exposed at GET /api/notary/key
18
+ // and at /.well-known/wab-notary.json so clients can verify offline.
19
+
20
+ const express = require('express');
21
+ const crypto = require('crypto');
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const wabCrypto = require('../services/wab-crypto');
25
+ const { safeFetch } = require('../utils/safe-fetch');
26
+
27
+ const router = express.Router();
28
+ const KEY_PATH = path.join(__dirname, '..', '..', 'data', '.notary-key.json'); // legacy single-key (kept for migration)
29
+ const KEYS_PATH = path.join(__dirname, '..', '..', 'data', '.notary-keys.json'); // key history (rotation)
30
+ const PEERS_PATH = path.join(__dirname, '..', '..', 'data', 'notary-peers.json'); // web-of-trust peer notaries
31
+ const ADMIN_TOKEN = process.env.WAB_NOTARY_ADMIN_TOKEN || '';
32
+ const { safeEqual } = require('../utils/safe-compare');
33
+
34
+ function safeReadJson(p, fallback) {
35
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return fallback; }
36
+ }
37
+ function safeWriteJson(p, value, mode) {
38
+ fs.mkdirSync(path.dirname(p), { recursive: true });
39
+ fs.writeFileSync(p, JSON.stringify(value, null, 2), mode ? { mode } : undefined);
40
+ }
41
+
42
+ function loadKeys() {
43
+ // Migrate legacy single-key file → new history file on first load.
44
+ let keys = safeReadJson(KEYS_PATH, null);
45
+ if (Array.isArray(keys) && keys.length) return keys;
46
+ const legacy = safeReadJson(KEY_PATH, null);
47
+ if (legacy && legacy.private_key && legacy.public_key) {
48
+ keys = [{
49
+ id: 'k1',
50
+ public_key: legacy.public_key,
51
+ private_key: legacy.private_key,
52
+ created_at: new Date().toISOString(),
53
+ retired_at: null
54
+ }];
55
+ } else {
56
+ const kp = wabCrypto.generateKeyPair();
57
+ keys = [{
58
+ id: 'k1',
59
+ public_key: kp.public_key,
60
+ private_key: kp.private_key,
61
+ created_at: new Date().toISOString(),
62
+ retired_at: null
63
+ }];
64
+ }
65
+ safeWriteJson(KEYS_PATH, keys, 0o600);
66
+ return keys;
67
+ }
68
+ function activeKey(keys) { return keys.find(k => !k.retired_at) || keys[0]; }
69
+ function keyById(keys, id) { return keys.find(k => k.id === id) || null; }
70
+
71
+ let KEYS = loadKeys();
72
+ function CURRENT() { return activeKey(KEYS); }
73
+ function FP_OF(pub) { return wabCrypto.fingerprint(pub); }
74
+
75
+ function canon(obj) {
76
+ // RFC 8785 -ish: stable key order, no whitespace.
77
+ if (obj === null || typeof obj !== 'object') return JSON.stringify(obj);
78
+ if (Array.isArray(obj)) return '[' + obj.map(canon).join(',') + ']';
79
+ const keys = Object.keys(obj).sort();
80
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + canon(obj[k])).join(',') + '}';
81
+ }
82
+
83
+ function signPayload(payload) {
84
+ const k = CURRENT();
85
+ const priv = wabCrypto.rawToPrivateKey(k.private_key);
86
+ const sig = crypto.sign(null, Buffer.from(canon(payload)), priv);
87
+ return sig.toString('base64');
88
+ }
89
+
90
+ const _cache = new Map();
91
+ const HOST_RE = /^[a-z0-9.-]+\.[a-z]{2,}$/i;
92
+
93
+ async function observe(host) {
94
+ const cached = _cache.get(host);
95
+ if (cached && cached.exp > Date.now()) return cached.attestation;
96
+ let status = 'missing', signed = false, manifest_sha256 = null, manifest_size = 0;
97
+ try {
98
+ // SSRF-safe: safeFetch resolves DNS and blocks private/loopback/link-local IPs,
99
+ // and re-validates on every redirect hop. This blocks nip.io-style hostnames
100
+ // that pass regex but resolve to internal addresses.
101
+ const r = await safeFetch(`https://${host}/.well-known/wab.json`, {}, { timeoutMs: 4500, maxBytes: 256 * 1024, requireHttps: true });
102
+ if (r.ok) {
103
+ const body = await r.text();
104
+ manifest_size = body.length;
105
+ manifest_sha256 = crypto.createHash('sha256').update(body).digest('hex');
106
+ let j = null;
107
+ try { j = JSON.parse(body); } catch (_) {}
108
+ if (j) {
109
+ signed = !!(j.signature || (j.trust && j.trust.signed));
110
+ status = signed ? 'verified' : 'enabled';
111
+ } else {
112
+ status = 'invalid';
113
+ }
114
+ } else {
115
+ status = 'missing';
116
+ }
117
+ } catch (_) { status = 'missing'; }
118
+ finally { /* safeFetch manages its own timer */ }
119
+ const observed_at = new Date().toISOString();
120
+ const k = CURRENT();
121
+ const payload = { host, status, signed, manifest_sha256, manifest_size, observed_at, notary: FP_OF(k.public_key), key_id: k.id, version: 1 };
122
+ const attestation = { ...payload, algorithm: 'ed25519', signature: signPayload(payload) };
123
+ _cache.set(host, { attestation, exp: Date.now() + 5 * 60 * 1000 });
124
+ return attestation;
125
+ }
126
+
127
+ router.get('/key', (req, res) => {
128
+ const k = CURRENT();
129
+ res.json({
130
+ algorithm: 'ed25519',
131
+ key_id: k.id,
132
+ public_key: k.public_key,
133
+ fingerprint: FP_OF(k.public_key),
134
+ verify_hint: 'sig = ed25519_sign(canonicalize(payload), notary_private_key); canonicalize = sorted keys, no whitespace.'
135
+ });
136
+ });
137
+
138
+ // All keys (active + retired). Clients keep this list to verify historical
139
+ // attestations after the notary rotates. Public — never exposes private bytes.
140
+ router.get('/keys', (req, res) => {
141
+ res.set('Cache-Control', 'public, max-age=300');
142
+ res.set('Access-Control-Allow-Origin', '*');
143
+ res.json({
144
+ algorithm: 'ed25519',
145
+ keys: KEYS.map(k => ({
146
+ key_id: k.id,
147
+ public_key: k.public_key,
148
+ fingerprint: FP_OF(k.public_key),
149
+ created_at: k.created_at,
150
+ retired_at: k.retired_at || null,
151
+ active: !k.retired_at
152
+ }))
153
+ });
154
+ });
155
+
156
+ // Admin: rotate to a fresh key. Gated by WAB_NOTARY_ADMIN_TOKEN. The previous
157
+ // active key is marked `retired_at = now` so it stays available for
158
+ // verification of past receipts but is no longer used for signing.
159
+ router.post('/admin/rotate', express.json({ limit: '1kb' }), (req, res) => {
160
+ if (!ADMIN_TOKEN || !safeEqual(req.get('x-admin-token'), ADMIN_TOKEN)) {
161
+ return res.status(403).json({ error: 'forbidden' });
162
+ }
163
+ const now = new Date().toISOString();
164
+ for (const k of KEYS) if (!k.retired_at) k.retired_at = now;
165
+ const kp = wabCrypto.generateKeyPair();
166
+ const next = {
167
+ id: `k${KEYS.length + 1}`,
168
+ public_key: kp.public_key,
169
+ private_key: kp.private_key,
170
+ created_at: now,
171
+ retired_at: null
172
+ };
173
+ KEYS.push(next);
174
+ safeWriteJson(KEYS_PATH, KEYS, 0o600);
175
+ _cache.clear();
176
+ res.json({
177
+ rotated: true,
178
+ new_key_id: next.id,
179
+ new_fingerprint: FP_OF(next.public_key),
180
+ retired_count: KEYS.length - 1
181
+ });
182
+ });
183
+
184
+ router.post('/attest', express.json({ limit: '4kb' }), async (req, res) => {
185
+ const host = String(req.body && req.body.host || '').trim().toLowerCase()
186
+ .replace(/^https?:\/\//, '').replace(/\/.*$/, '');
187
+ if (!HOST_RE.test(host)) return res.status(400).json({ error: 'invalid_host' });
188
+ try {
189
+ const att = await observe(host);
190
+ res.set('Cache-Control', 'public, max-age=60');
191
+ return res.json(att);
192
+ } catch (e) {
193
+ return res.status(500).json({ error: 'attest_failed', detail: e.message });
194
+ }
195
+ });
196
+
197
+ router.get('/attest/:host', async (req, res) => {
198
+ const host = String(req.params.host || '').trim().toLowerCase()
199
+ .replace(/^https?:\/\//, '').replace(/\/.*$/, '');
200
+ if (!HOST_RE.test(host)) return res.status(400).json({ error: 'invalid_host' });
201
+ try {
202
+ const att = await observe(host);
203
+ res.set('Cache-Control', 'public, max-age=60');
204
+ res.set('Access-Control-Allow-Origin', '*');
205
+ return res.json(att);
206
+ } catch (e) {
207
+ return res.status(500).json({ error: 'attest_failed', detail: e.message });
208
+ }
209
+ });
210
+
211
+ router.post('/verify', express.json({ limit: '16kb' }), (req, res) => {
212
+ const att = req.body && req.body.attestation;
213
+ if (!att || !att.signature) return res.status(400).json({ error: 'missing_attestation' });
214
+ try {
215
+ const { signature, algorithm, ...payload } = att;
216
+ // Look up the signing key by key_id (preferred) or by notary fingerprint
217
+ // (fallback for older receipts that didn't include key_id).
218
+ let k = null;
219
+ if (payload.key_id) k = keyById(KEYS, payload.key_id);
220
+ if (!k && payload.notary) k = KEYS.find(x => FP_OF(x.public_key) === payload.notary);
221
+ if (!k) return res.status(404).json({ valid: false, error: 'unknown_key_id', key_id: payload.key_id || null });
222
+ const pub = wabCrypto.rawToPublicKey(k.public_key);
223
+ const ok = crypto.verify(null, Buffer.from(canon(payload)), pub, Buffer.from(signature, 'base64'));
224
+ return res.json({ valid: ok, key_id: k.id, notary: FP_OF(k.public_key), retired_at: k.retired_at || null, payload });
225
+ } catch (e) {
226
+ return res.status(400).json({ error: 'verify_failed', detail: e.message });
227
+ }
228
+ });
229
+
230
+ // ── Web of trust: cross-attestation across multiple notaries ─────────────
231
+ // `data/notary-peers.json` is an array of peer notary base URLs (e.g.
232
+ // "https://notary.other-example.org/api/notary"). POST /cross-attest fans out
233
+ // to each peer's /attest/:host (or /attest with JSON body), collects the
234
+ // signed receipts, and returns them alongside our own. The caller verifies
235
+ // each receipt independently using the matching notary's public key (which
236
+ // each peer publishes at <base>/keys).
237
+ async function fetchPeerAttestation(base, host) {
238
+ const ac = new AbortController();
239
+ const t = setTimeout(() => ac.abort(), 5000);
240
+ try {
241
+ const r = await fetch(`${base.replace(/\/+$/, '')}/attest/${encodeURIComponent(host)}`, {
242
+ signal: ac.signal,
243
+ headers: { accept: 'application/json' }
244
+ });
245
+ clearTimeout(t);
246
+ if (!r.ok) return { peer: base, error: `http_${r.status}` };
247
+ const j = await r.json();
248
+ return { peer: base, attestation: j };
249
+ } catch (e) {
250
+ return { peer: base, error: e.name === 'AbortError' ? 'timeout' : e.message };
251
+ }
252
+ }
253
+
254
+ router.post('/cross-attest', express.json({ limit: '4kb' }), async (req, res) => {
255
+ const host = String(req.body && req.body.host || '').trim().toLowerCase()
256
+ .replace(/^https?:\/\//, '').replace(/\/.*$/, '');
257
+ if (!HOST_RE.test(host)) return res.status(400).json({ error: 'invalid_host' });
258
+ const peers = safeReadJson(PEERS_PATH, []);
259
+ const own = await observe(host).catch(e => ({ error: e.message }));
260
+ const peerResults = peers.length
261
+ ? await Promise.all(peers.map(p => fetchPeerAttestation(p, host)))
262
+ : [];
263
+ // Consensus: how many notaries (including us) agree on status + sha256.
264
+ const all = [own, ...peerResults.filter(p => p.attestation).map(p => p.attestation)];
265
+ const buckets = new Map();
266
+ for (const a of all) {
267
+ if (!a || !a.status) continue;
268
+ const k = `${a.status}|${a.manifest_sha256 || ''}`;
269
+ buckets.set(k, (buckets.get(k) || 0) + 1);
270
+ }
271
+ let consensus = null, top = 0;
272
+ for (const [k, n] of buckets) if (n > top) { top = n; consensus = k; }
273
+ res.set('Cache-Control', 'public, max-age=60');
274
+ res.json({
275
+ host,
276
+ own: own,
277
+ peers: peerResults,
278
+ consensus: consensus ? {
279
+ status: consensus.split('|')[0],
280
+ manifest_sha256: consensus.split('|')[1] || null,
281
+ votes: top,
282
+ total: all.length
283
+ } : null,
284
+ generated_at: new Date().toISOString()
285
+ });
286
+ });
287
+
288
+ // Admin: list / replace peer list. Public GET shows just the URLs.
289
+ router.get('/peers', (req, res) => {
290
+ res.set('Cache-Control', 'public, max-age=300');
291
+ res.set('Access-Control-Allow-Origin', '*');
292
+ res.json({ peers: safeReadJson(PEERS_PATH, []) });
293
+ });
294
+ router.put('/admin/peers', express.json({ limit: '8kb' }), (req, res) => {
295
+ if (!ADMIN_TOKEN || !safeEqual(req.get('x-admin-token'), ADMIN_TOKEN)) {
296
+ return res.status(403).json({ error: 'forbidden' });
297
+ }
298
+ const peers = Array.isArray(req.body && req.body.peers) ? req.body.peers : null;
299
+ if (!peers) return res.status(400).json({ error: 'expected_peers_array' });
300
+ const clean = peers
301
+ .filter(p => typeof p === 'string' && /^https?:\/\//i.test(p))
302
+ .slice(0, 50);
303
+ safeWriteJson(PEERS_PATH, clean);
304
+ res.json({ saved: clean.length, peers: clean });
305
+ });
306
+
307
+ module.exports = router;
308
+ module.exports.currentPublicKey = () => CURRENT().public_key;
309
+ module.exports.currentFingerprint = () => FP_OF(CURRENT().public_key);
@@ -0,0 +1,109 @@
1
+ // WAB Observatory — public registry of known WAB-enabled domains.
2
+ //
3
+ // Sources (cheap, additive):
4
+ // 1. data/observatory-seed.json — manual seed list (curated).
5
+ // 2. data/observatory-cache.json — domains that have hit /api/notary,
6
+ // /badge/:domain, or /check at least once.
7
+ //
8
+ // Both files are JSON arrays of host strings. We deduplicate and re-probe
9
+ // each host every 30 minutes via the Notary attestation cache so the
10
+ // "verified / enabled / missing" status stays fresh.
11
+
12
+ const express = require('express');
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ const router = express.Router();
17
+
18
+ const SEED_PATH = path.join(__dirname, '..', '..', 'data', 'observatory-seed.json');
19
+ const CACHE_PATH = path.join(__dirname, '..', '..', 'data', 'observatory-cache.json');
20
+ const HOST_RE = /^[a-z0-9.-]+\.[a-z]{2,}$/i;
21
+
22
+ function readJsonArray(p) {
23
+ try {
24
+ if (!fs.existsSync(p)) return [];
25
+ const j = JSON.parse(fs.readFileSync(p, 'utf8'));
26
+ return Array.isArray(j) ? j.filter(h => typeof h === 'string' && HOST_RE.test(h)) : [];
27
+ } catch (_) { return []; }
28
+ }
29
+
30
+ function writeJsonArray(p, arr) {
31
+ try {
32
+ fs.mkdirSync(path.dirname(p), { recursive: true });
33
+ fs.writeFileSync(p, JSON.stringify(arr, null, 2));
34
+ } catch (_) {}
35
+ }
36
+
37
+ // In-memory status cache (host -> { status, signed, observed_at, exp })
38
+ const _status = new Map();
39
+ const TTL = 30 * 60 * 1000;
40
+
41
+ async function probe(host) {
42
+ const cached = _status.get(host);
43
+ if (cached && cached.exp > Date.now()) return cached;
44
+ const ac = new AbortController();
45
+ const t = setTimeout(() => ac.abort(), 3500);
46
+ let status = 'missing', signed = false;
47
+ try {
48
+ const r = await fetch(`https://${host}/.well-known/wab.json`, { signal: ac.signal, redirect: 'follow' });
49
+ if (r.ok) {
50
+ const j = await r.json().catch(() => null);
51
+ if (j) {
52
+ signed = !!(j.signature || (j.trust && j.trust.signed));
53
+ status = signed ? 'verified' : 'enabled';
54
+ } else { status = 'invalid'; }
55
+ }
56
+ } catch (_) {}
57
+ finally { clearTimeout(t); }
58
+ const rec = { host, status, signed, observed_at: new Date().toISOString(), exp: Date.now() + TTL };
59
+ _status.set(host, rec);
60
+ return rec;
61
+ }
62
+
63
+ function knownHosts() {
64
+ const seed = readJsonArray(SEED_PATH);
65
+ const cache = readJsonArray(CACHE_PATH);
66
+ return Array.from(new Set([...seed, ...cache])).sort();
67
+ }
68
+
69
+ router.post('/track', express.json({ limit: '1kb' }), (req, res) => {
70
+ const host = String(req.body && req.body.host || '').trim().toLowerCase()
71
+ .replace(/^https?:\/\//, '').replace(/\/.*$/, '');
72
+ if (!HOST_RE.test(host)) return res.status(400).json({ error: 'invalid_host' });
73
+ const cache = readJsonArray(CACHE_PATH);
74
+ if (!cache.includes(host)) {
75
+ cache.push(host);
76
+ writeJsonArray(CACHE_PATH, cache);
77
+ }
78
+ res.json({ tracked: true, host });
79
+ });
80
+
81
+ router.get('/domains', async (req, res) => {
82
+ const limit = Math.max(1, Math.min(500, Number(req.query.limit) || 200));
83
+ const hosts = knownHosts().slice(0, limit);
84
+ const results = await Promise.all(hosts.map(h => probe(h).catch(() => ({ host: h, status: 'missing', signed: false }))));
85
+ res.set('Cache-Control', 'public, max-age=120');
86
+ res.set('Access-Control-Allow-Origin', '*');
87
+ res.json({
88
+ count: results.length,
89
+ generated_at: new Date().toISOString(),
90
+ domains: results.map(({ exp, ...r }) => r)
91
+ });
92
+ });
93
+
94
+ router.get('/stats', async (req, res) => {
95
+ const hosts = knownHosts();
96
+ const results = await Promise.all(hosts.map(h => probe(h).catch(() => ({ status: 'missing', signed: false }))));
97
+ const by = { verified: 0, enabled: 0, missing: 0, invalid: 0 };
98
+ for (const r of results) by[r.status] = (by[r.status] || 0) + 1;
99
+ res.set('Cache-Control', 'public, max-age=120');
100
+ res.set('Access-Control-Allow-Origin', '*');
101
+ res.json({
102
+ total: results.length,
103
+ signed: results.filter(r => r.signed).length,
104
+ by_status: by,
105
+ generated_at: new Date().toISOString()
106
+ });
107
+ });
108
+
109
+ module.exports = router;
@@ -38,10 +38,11 @@ function hashIp(ip) {
38
38
  function clip(s, n = 2000) { return typeof s === 'string' ? s.slice(0, n) : ''; }
39
39
 
40
40
  function adminGate(req, res, next) {
41
+ const { safeEqual } = require('../utils/safe-compare');
41
42
  const expected = process.env.WAB_PARTNERS_ADMIN_TOKEN || process.env.WAB_RING4_ADMIN_TOKEN;
42
43
  if (!expected) return res.status(503).json({ error: 'admin_disabled' });
43
44
  const presented = req.headers['x-admin-token'] || '';
44
- if (presented !== expected) return res.status(401).json({ error: 'unauthorized' });
45
+ if (!safeEqual(presented, expected)) return res.status(401).json({ error: 'unauthorized' });
45
46
  next();
46
47
  }
47
48