@curless/hotel-storefront 0.55.8 → 0.56.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.
Files changed (42) hide show
  1. package/dist/account/fake-mall.d.ts +42 -0
  2. package/dist/account/fake-mall.d.ts.map +1 -0
  3. package/dist/account/fake-mall.js +168 -0
  4. package/dist/account/fake-mall.js.map +1 -0
  5. package/dist/account/index.d.ts +109 -0
  6. package/dist/account/index.d.ts.map +1 -0
  7. package/dist/account/index.js +871 -0
  8. package/dist/account/index.js.map +1 -0
  9. package/dist/account/mall.d.ts +83 -0
  10. package/dist/account/mall.d.ts.map +1 -0
  11. package/dist/account/mall.js +224 -0
  12. package/dist/account/mall.js.map +1 -0
  13. package/dist/account/pages.d.ts +118 -0
  14. package/dist/account/pages.d.ts.map +1 -0
  15. package/dist/account/pages.js +295 -0
  16. package/dist/account/pages.js.map +1 -0
  17. package/dist/account/requests.d.ts +68 -0
  18. package/dist/account/requests.d.ts.map +1 -0
  19. package/dist/account/requests.js +178 -0
  20. package/dist/account/requests.js.map +1 -0
  21. package/dist/account/store.d.ts +67 -0
  22. package/dist/account/store.d.ts.map +1 -0
  23. package/dist/account/store.js +151 -0
  24. package/dist/account/store.js.map +1 -0
  25. package/dist/card.generated.d.ts +1 -1
  26. package/dist/card.generated.js +2 -2
  27. package/dist/card.generated.js.map +1 -1
  28. package/dist/config.d.ts +9 -0
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js.map +1 -1
  31. package/dist/legal.d.ts.map +1 -1
  32. package/dist/legal.js +15 -3
  33. package/dist/legal.js.map +1 -1
  34. package/dist/server.d.ts +2 -2
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +249 -51
  37. package/dist/server.js.map +1 -1
  38. package/dist/tools.d.ts +231 -2
  39. package/dist/tools.d.ts.map +1 -1
  40. package/dist/tools.js +6 -0
  41. package/dist/tools.js.map +1 -1
  42. package/package.json +1 -1
@@ -0,0 +1,871 @@
1
+ import { createHmac, randomBytes } from 'node:crypto';
2
+ import { bodyLimit } from 'hono/body-limit';
3
+ import { WindowCounter } from '../limits.js';
4
+ import { LIMITS, MallError, makeMall, } from './mall.js';
5
+ import { PAGE_HEADERS, accountPage, loginPage, say, signedInPage, statePage, } from './pages.js';
6
+ import { CODE, createSignIns, hashOf, same, } from './requests.js';
7
+ import { AccountStore, WEB_IDLE_MS, WebSessions, shownOf } from './store.js';
8
+ /**
9
+ * The tools, by the deployment's prefix: three the assistant calls -
10
+ * `sign_in_otaless_account` - and one the card calls (`card`, app-only).
11
+ *
12
+ * ⚠️ THE ASSISTANT'S THREE NAME NO CONVERSATION (security review of 0.56.0,
13
+ * P2-1): they act on the conversation they are called in and no other - a
14
+ * trip id handed to them by a page or a shop is refused (server.ts). Only the
15
+ * card names the conversation it was drawn for (its trip), through `card`, a
16
+ * tool the host keeps from the assistant; a client that draws no card is
17
+ * refused it.
18
+ */
19
+ export const accountToolNames = (prefix) => ({
20
+ signIn: `sign_in_${prefix}account`,
21
+ view: `view_${prefix}account`,
22
+ signOut: `sign_out_${prefix}account`,
23
+ card: `card_${prefix}account`,
24
+ });
25
+ /** Beside every answer of a deployment with an account, for the card's header. */
26
+ export const ACCOUNT_META_KEY = 'curless/account';
27
+ /** Beside a sign-in's answer where a card is drawn: the link and its short code, the card's alone. */
28
+ export const SIGN_IN_META_KEY = 'curless/account-sign-in';
29
+ /** The mall is asked at most this often whether a sign-in is still theirs. */
30
+ export const CHECK_EVERY_MS = 5 * 60_000;
31
+ export const COOKIE = '__Host-hotel_account';
32
+ /** How many of what, and over how long - each refusal said as "too many requests". */
33
+ export const ACCOUNT_LIMITS = {
34
+ /** Sign-in links one conversation may ask for in ten minutes. */
35
+ linksPerConversation: 10,
36
+ /**
37
+ * …one address in a minute, and everyone (review P3-2: one address with two
38
+ * hundred sessions filled the table). Wide per address: every Claude user
39
+ * arrives from a handful of Anthropic's.
40
+ */
41
+ linksPerIp: 120,
42
+ linksPerMinute: 600,
43
+ /** Page views from one address in a minute. */
44
+ pagesPerIp: 120,
45
+ /** Form posts from one address in ten minutes. */
46
+ postsPerIp: 30,
47
+ /** Sign-ins (or accounts made) by one user name in ten minutes. */
48
+ perName: 8,
49
+ /** Everyone's sign-ins in a minute, and accounts made. */
50
+ signInsPerMinute: 180,
51
+ registersPerMinute: 90,
52
+ };
53
+ /** The largest form our pages post (Caddy is told 16KB). */
54
+ export const FORM_MAX_BYTES = 8 * 1024;
55
+ /** The conversation, on a log line: never the key itself. */
56
+ const tag = (key) => hashOf(key).slice(0, 8);
57
+ /** The address the edge proxy saw - the LAST X-Forwarded-For entry (server.ts `clientIp`). */
58
+ const ipOf = (c) => addressKey((c.req.header('x-forwarded-for') ?? '').split(',').at(-1)?.trim() || 'local');
59
+ /** An IPv6 address by its /64 (review P3-1, as Nexva counts it): one machine has a whole /64. */
60
+ export const addressKey = (ip) => {
61
+ const a = ip.replace(/^\[|\]$/g, '').replace(/%.*$/, '');
62
+ // IPv4, or IPv4 written in IPv6 (::ffff:1.2.3.4): as it is.
63
+ if (!a.includes(':') || a.includes('.'))
64
+ return ip;
65
+ // ⚠️ `::` FILLED IN FIRST (re-review P3-1): 2001:db8::1 cut at four groups was
66
+ // 2001:db8::1, a bucket of its own for every address of the /64.
67
+ const at = a.indexOf('::');
68
+ const head = (at < 0 ? a : a.slice(0, at)).split(':').filter(Boolean);
69
+ const tail = at < 0
70
+ ? []
71
+ : a
72
+ .slice(at + 2)
73
+ .split(':')
74
+ .filter(Boolean);
75
+ const groups = at < 0
76
+ ? head
77
+ : [...head, ...Array(Math.max(0, 8 - head.length - tail.length)).fill('0'), ...tail];
78
+ return `${groups
79
+ .slice(0, 4)
80
+ .map((g) => g.toLowerCase().replace(/^0+(?=.)/, ''))
81
+ .join(':')}::/64`;
82
+ };
83
+ const langOf = (v) => {
84
+ if (typeof v !== 'string')
85
+ return undefined;
86
+ const t = v.trim().toLowerCase();
87
+ if (t === 'zh' || t.startsWith('zh-') || t.startsWith('zh_'))
88
+ return 'zh';
89
+ if (t === 'en' || t.startsWith('en-'))
90
+ return 'en';
91
+ return undefined;
92
+ };
93
+ const acceptLang = (h) => {
94
+ for (const part of String(h ?? '').split(',')) {
95
+ const l = part.split(';')[0]?.trim().toLowerCase() ?? '';
96
+ if (l.startsWith('zh'))
97
+ return 'zh';
98
+ if (l.startsWith('en'))
99
+ return 'en';
100
+ }
101
+ return 'en';
102
+ };
103
+ /** A mall failure that says the sign-in is not theirs any more - not a blip on the way. */
104
+ const refused = (e) => e instanceof MallError && (e.envelope || e.code === 401 || e.code === 403);
105
+ /** The mall did not answer - not a no. */
106
+ const notThere = (e) => e instanceof MallError &&
107
+ (e.code === 'timeout' ||
108
+ e.code === 'unreachable' ||
109
+ (typeof e.code === 'number' && e.code >= 500 && !e.envelope));
110
+ export const makeAccount = (opts) => {
111
+ const now = opts.now ?? (() => Date.now());
112
+ const env = opts.env ?? process.env;
113
+ const prefix = opts.account.envPrefix ?? 'ACCOUNT';
114
+ const baseUrl = env[`${prefix}_API_URL`] || opts.account.baseUrl;
115
+ const tenant = env[`${prefix}_TENANT_ID`] || opts.account.tenant;
116
+ if (!baseUrl || !tenant)
117
+ throw new Error(`account: no mall address or tenant (account.baseUrl / account.tenant, or ${prefix}_API_URL / ${prefix}_TENANT_ID)`);
118
+ const mall = makeMall({
119
+ baseUrl,
120
+ tenant,
121
+ ...(opts.fetch ? { fetch: opts.fetch } : {}),
122
+ now,
123
+ });
124
+ const log = opts.log ?? ((l) => console.log(l));
125
+ const names = accountToolNames(opts.prefix);
126
+ const requests = createSignIns({ now });
127
+ // A sign-in forgotten here - left two hours, past its tokens, or pushed out -
128
+ // is ended at the mall too (review P3-3).
129
+ const store = new AccountStore(now, (s) => end(s));
130
+ const web = new WebSessions(now);
131
+ // The form's token, from a secret of this process: a restart voids open
132
+ // forms, as it voids the requests they post for.
133
+ const secretKey = randomBytes(32);
134
+ const loginCsrf = (r) => createHmac('sha256', secretKey).update(`login:${r.codeHash}`).digest('base64url').slice(0, 32);
135
+ const counters = {
136
+ links: new WindowCounter(10 * 60_000, now),
137
+ linksIp: new WindowCounter(60_000, now),
138
+ linksAll: new WindowCounter(60_000, now),
139
+ pages: new WindowCounter(60_000, now),
140
+ posts: new WindowCounter(10 * 60_000, now),
141
+ names: new WindowCounter(10 * 60_000, now),
142
+ all: new WindowCounter(60_000, now),
143
+ };
144
+ const refusals = {
145
+ links: 0,
146
+ linksIp: 0,
147
+ linksAll: 0,
148
+ pages: 0,
149
+ posts: 0,
150
+ names: 0,
151
+ all: 0,
152
+ origin: 0,
153
+ csrf: 0,
154
+ full: 0,
155
+ };
156
+ const publicUrl = opts.publicUrl.replace(/\/+$/, '');
157
+ const origin = new URL(publicUrl).origin;
158
+ const base = new URL(publicUrl).pathname.replace(/\/+$/, '');
159
+ const brandPage = {
160
+ brand: opts.brand,
161
+ base,
162
+ ...(opts.logoUrl
163
+ ? { logo: new URL(opts.logoUrl).pathname + new URL(opts.logoUrl).search }
164
+ : {}),
165
+ ...(opts.logoDarkUrl
166
+ ? { logoDark: new URL(opts.logoDarkUrl).pathname + new URL(opts.logoDarkUrl).search }
167
+ : {}),
168
+ ...(opts.operator ? { operator: opts.operator } : {}),
169
+ };
170
+ const brand = opts.brand;
171
+ /** "an Otaless", "a Shangri-La". */
172
+ const aBrand = `${/^[aeiou]/i.test(brand) ? 'an' : 'a'} ${brand}`;
173
+ // ── Signing in and out ───────────────────────────────────────────────────
174
+ /** Signed in now, for this conversation: whatever was there before is ended. */
175
+ const signInWith = (key, member, tokens) => {
176
+ const was = store.clear(key);
177
+ if (was)
178
+ end(was);
179
+ requests.cancelFor(key);
180
+ return store.set(key, member, tokens);
181
+ };
182
+ /** A sign-in that is over: its pages closed, its tokens ended at the mall (not waited for). */
183
+ const end = (s) => {
184
+ web.dropFor(s.sid);
185
+ mall
186
+ .logout(s.tokens)
187
+ .catch((e) => log(`[account] the mall's sign-out did not answer (${codeOf(e)})`));
188
+ };
189
+ const signOut = (key) => {
190
+ const was = store.clear(key);
191
+ requests.cancelFor(key);
192
+ if (was)
193
+ end(was);
194
+ return was;
195
+ };
196
+ const codeOf = (e) => e instanceof MallError ? String(e.code).slice(0, 16) : e instanceof Error ? e.name : 'error';
197
+ /**
198
+ * Still theirs? Asked of the mall at most every five minutes: a token
199
+ * revoked there - signed out on another device - answers as nobody, and the
200
+ * sign-in here goes. A blip on the way is not that: kept, asked next time.
201
+ */
202
+ // ⚠️ ONE LOOK AT A TIME FOR A CONVERSATION (review P3-5): two at once spent
203
+ // the same refresh token, and the mall, rotating it, refused the second -
204
+ // read as signed out.
205
+ const looking = new Map();
206
+ const checked = (key) => {
207
+ const s = store.get(key);
208
+ if (!s || now() - s.checkedAt < CHECK_EVERY_MS)
209
+ return Promise.resolve(s);
210
+ const out = looking.get(key);
211
+ if (out)
212
+ return out;
213
+ const p = look(key, s).finally(() => looking.delete(key));
214
+ looking.set(key, p);
215
+ return p;
216
+ };
217
+ const look = async (key, s) => {
218
+ // ⚠️ ONLY A REFUSED RENEWAL, OR A TOKEN THE MALL SAYS IS NOBODY'S, SIGNS OUT:
219
+ // their envelope says 500 for a fault of theirs too (Nexva's audit M-18).
220
+ const GONE = new Error('gone');
221
+ const renew = async () => {
222
+ try {
223
+ s.tokens = await mall.refresh(s.tokens);
224
+ }
225
+ catch (e) {
226
+ throw refused(e) ? GONE : e;
227
+ }
228
+ };
229
+ const whoIs = () => mall.info(s.tokens).catch((e) => {
230
+ if (e instanceof MallError && (e.code === 401 || e.code === 403))
231
+ return null;
232
+ throw e;
233
+ });
234
+ try {
235
+ if (s.tokens.accessExpires - now() < 60_000)
236
+ await renew();
237
+ let who = await whoIs();
238
+ if (!who || who.id !== s.shown.id) {
239
+ await renew();
240
+ who = await whoIs();
241
+ }
242
+ if (!who || who.id !== s.shown.id)
243
+ throw GONE;
244
+ s.shown = shownOf(who);
245
+ s.checkedAt = now();
246
+ return s;
247
+ }
248
+ catch (e) {
249
+ // ⚠️ ONLY THE SIGN-IN THIS LOOK WAS FOR (re-review P3-2): signed out and in
250
+ // again while the mall kept it waiting, the conversation has a new one,
251
+ // which an old token's refusal says nothing about.
252
+ const nowIn = store.get(key);
253
+ if (nowIn?.sid !== s.sid)
254
+ return nowIn;
255
+ if (e === GONE) {
256
+ log(`[account] ${tag(key)} is not signed in at the mall any more: signed out here`);
257
+ signOut(key);
258
+ return undefined;
259
+ }
260
+ // Not now: the sign-in stands, and is asked about again next time.
261
+ log(`[account] ${tag(key)}: the mall could not be asked (${codeOf(e)}) - still signed in`);
262
+ return nowIn;
263
+ }
264
+ };
265
+ // ── The tools ────────────────────────────────────────────────────────────
266
+ const NO_PRICE = `Being signed in changes no price or room shown here: ${brand} has no member rate here yet.`;
267
+ const NEVER_IN_CHAT = 'never in this chat: do not ask for, or accept, a password, an email or a phone number here, and never write back one the guest typed - not even to say which to change';
268
+ const minutes = Math.round(requests.ttlMs / 60_000);
269
+ const accountUrl = `${publicUrl}/account`;
270
+ // ⚠️ THE NAME THE ACCOUNT GOES BY, AND NOTHING ELSE (review P2-2): the user
271
+ // name signs in - in front of a forwarded link's sender it would let them
272
+ // lock the account out with wrong tries - and the real name, the email and
273
+ // the phone are the member's. Those are on the account page, in the browser
274
+ // that typed the password. No nickname: no name at all.
275
+ const shownFor = (s) => s.shown.displayName ? { displayName: s.shown.displayName } : {};
276
+ const whoSaid = (s) => s.shown.displayName ? `the ${brand} account of ${s.shown.displayName}` : `${aBrand} account`;
277
+ const signedInAnswer = (s, lead) => ({
278
+ text: `${lead} Signed in to ${whoSaid(s)}. ${NO_PRICE} The guest sees the whole account - its user name, and its email and phone partly hidden - on ${brand}'s account page, ${accountUrl}, in the browser they signed in with, for ${WEB_IDLE_MS / 60_000} minutes after. ${names.signOut} signs them out.`.trim(),
279
+ structured: { view: 'account', brand, signedIn: true, account: shownFor(s), accountUrl },
280
+ });
281
+ const handle = async (name, args, call) => {
282
+ const { key, noCards } = call;
283
+ // The card's own: the same three, by `action`.
284
+ if (name === names.card) {
285
+ const act = args.action === 'sign_in'
286
+ ? names.signIn
287
+ : args.action === 'sign_out'
288
+ ? names.signOut
289
+ : names.view;
290
+ return handle(act, args, { ...call, noCards: false, fromCard: true });
291
+ }
292
+ if (name === names.view) {
293
+ const s = await checked(key);
294
+ if (s)
295
+ return signedInAnswer(s, '');
296
+ const waiting = requests.openFor(key);
297
+ // ⚠️ THE ASSISTANT, WHERE A CARD IS DRAWN, IS NOT TOLD "NOBODY" (0.56.1,
298
+ // robin 11-06): claude.ai sent one conversation's calls by several
299
+ // connections - the card signed the guest in on one, and the assistant's
300
+ // look on another found nothing, and said "not signed in" under a card
301
+ // that said signed in. What it finds is said as what it is: nothing here,
302
+ // the card is right.
303
+ if (!noCards && !call.fromCard)
304
+ return {
305
+ text: `No ${brand} sign-in can be looked up from here. If the ${brand} card shows the guest signed in, the card is right: they are - tell them so plainly, for example 「这里查不到你的登录状态,以卡片上显示的为准」 or "I can't see your sign-in from here - what the card shows is right". If the card shows Sign in, they sign in there, or ${names.signIn} gives a one-time page. ${NEVER_IN_CHAT[0]?.toUpperCase()}${NEVER_IN_CHAT.slice(1)}. ${NO_PRICE}`,
306
+ structured: {
307
+ view: 'account',
308
+ brand,
309
+ signedIn: false,
310
+ cardIsRight: true,
311
+ ...(waiting ? { waiting: true } : {}),
312
+ },
313
+ };
314
+ return {
315
+ text: `Nobody is signed in to ${aBrand} account in this conversation${waiting ? ': a sign-in link is open and not used yet' : ''}. ${names.signIn} gives the guest a one-time page to sign in or create an account on - ${NEVER_IN_CHAT}. ${NO_PRICE}`,
316
+ structured: {
317
+ view: 'account',
318
+ brand,
319
+ signedIn: false,
320
+ ...(waiting ? { waiting: true } : {}),
321
+ },
322
+ };
323
+ }
324
+ if (name === names.signOut) {
325
+ const was = signOut(key);
326
+ if (!was)
327
+ return {
328
+ text: !noCards && !call.fromCard
329
+ ? `No ${brand} sign-in can be found from here to sign out of. If the ${brand} card shows the guest signed in, they sign out on the card: its account button, then Sign out.`
330
+ : `Nobody was signed in to ${aBrand} account in this conversation, so nothing changed.`,
331
+ structured: { view: 'account-signed-out', brand, signedIn: false, was: false },
332
+ };
333
+ log(`[account] ${tag(key)} signed out`);
334
+ return {
335
+ text: `Signed out of ${whoSaid(was)} in this conversation, and at ${brand}. The trip is left as it is.`,
336
+ structured: { view: 'account-signed-out', brand, signedIn: false, was: true },
337
+ };
338
+ }
339
+ if (name !== names.signIn)
340
+ throw new Error(`not an account tool: ${name}`);
341
+ const s = await checked(key);
342
+ if (s)
343
+ return {
344
+ ...signedInAnswer(s, ''),
345
+ text: `Already signed in to ${whoSaid(s)}: there is nothing to open. To use another account, ${names.signOut} first. ${NO_PRICE}`,
346
+ };
347
+ if (counters.links.count(key) >= ACCOUNT_LIMITS.linksPerConversation) {
348
+ refusals.links++;
349
+ return {
350
+ text: `Too many sign-in links were asked for in this conversation just now. Wait a few minutes, then ask again. ${NEVER_IN_CHAT[0]?.toUpperCase()}${NEVER_IN_CHAT.slice(1)}.`,
351
+ structured: { view: 'error', brand, code: 'too_many_links' },
352
+ isError: true,
353
+ };
354
+ }
355
+ // …and one address, and everyone - after the conversation's own count.
356
+ const ip = call.ip ?? 'unknown';
357
+ const gate = (which, k, max) => {
358
+ if (counters[which].count(k) >= max) {
359
+ refusals[which]++;
360
+ return true;
361
+ }
362
+ return false;
363
+ };
364
+ if (gate('linksIp', ip, ACCOUNT_LIMITS.linksPerIp) ||
365
+ gate('linksAll', 'all', ACCOUNT_LIMITS.linksPerMinute))
366
+ return {
367
+ text: 'Too many sign-ins are being started just now. Try again in a minute.',
368
+ structured: { view: 'error', brand, code: 'busy' },
369
+ isError: true,
370
+ };
371
+ counters.links.add(key);
372
+ counters.linksIp.add(ip);
373
+ counters.linksAll.add('all');
374
+ const lang = langOf(args.lang);
375
+ const made = requests.create({ key, ...(lang ? { lang } : {}), words: !!noCards });
376
+ if (!made) {
377
+ log('[account] a sign-in link refused: the table is full of open ones');
378
+ return {
379
+ text: 'Too many sign-ins are open just now. Try again in a minute.',
380
+ structured: { view: 'error', brand, code: 'busy' },
381
+ isError: true,
382
+ };
383
+ }
384
+ const url = `${publicUrl}/login?code=${made.code}`;
385
+ const expiresAt = new Date(made.expiresAt).toISOString();
386
+ log(`[account] a sign-in link for ${tag(key)} (${requests.open()} open)${noCards ? ' in words' : ''}`);
387
+ if (noCards)
388
+ return {
389
+ text: `A one-time ${brand} sign-in page is ready: ${url} - the guest opens it in their browser and signs in, or creates ${aBrand} account, on that page, ${NEVER_IN_CHAT}. The page shows the code ${made.short}: tell the guest it must match the code you give them here, and to close the page if it does not. The link works once, for ${minutes} minutes. When they say they are done, ${names.view} shows that they are signed in. ${NO_PRICE}`,
390
+ structured: {
391
+ view: 'account-sign-in',
392
+ brand,
393
+ signedIn: false,
394
+ url,
395
+ shortCode: made.short,
396
+ expiresAt,
397
+ expiresInSeconds: Math.round(requests.ttlMs / 1000),
398
+ },
399
+ };
400
+ // ⚠️ THE LINK AND THE CODE ARE THE CARD'S (Nexva's review P1-1): the model is
401
+ // never handed the short code where a card is drawn, so text a page slipped
402
+ // into the chat cannot show a matching one.
403
+ return {
404
+ text: `A one-time ${brand} sign-in page is ready for the guest, opened from the card: they sign in, or create ${aBrand} account, on that page, ${NEVER_IN_CHAT}. The card shows a code the page shows too, and shows them signed in when they are done; the link works once, for ${minutes} minutes. ${NO_PRICE}`,
405
+ structured: {
406
+ view: 'account-sign-in',
407
+ brand,
408
+ signedIn: false,
409
+ expiresAt,
410
+ expiresInSeconds: Math.round(requests.ttlMs / 1000),
411
+ },
412
+ meta: { [SIGN_IN_META_KEY]: { url, shortCode: made.short, expiresAt } },
413
+ };
414
+ };
415
+ const STR = { type: 'string' };
416
+ const tools = () => [
417
+ {
418
+ name: names.signIn,
419
+ // ⚠️ NOTHING ABOUT THE GUEST IS ASKED FOR HERE - see the top of this file.
420
+ description: `Starts signing the guest in to their ${brand} account, or creating one, on ${brand}'s own web page: answers with a one-time link and a short code the page shows too. Takes no personal details - the guest types them on that page, never in the chat. Being signed in changes no price shown here.`,
421
+ annotations: {
422
+ title: `Sign in to ${aBrand} account`,
423
+ readOnlyHint: false,
424
+ destructiveHint: false,
425
+ idempotentHint: false,
426
+ openWorldHint: true,
427
+ },
428
+ inputSchema: {
429
+ type: 'object',
430
+ properties: {
431
+ lang: {
432
+ type: 'string',
433
+ enum: ['zh', 'en'],
434
+ description: 'The language the page opens in: zh for a guest writing Chinese, en otherwise. The page has a switch.',
435
+ },
436
+ },
437
+ },
438
+ },
439
+ {
440
+ name: names.view,
441
+ description: `Whether the guest is signed in to their ${brand} account in this conversation, and the name the account goes by. Its user name and contact details are not given: they are on ${brand}'s account page.`,
442
+ annotations: {
443
+ title: `My ${brand} account`,
444
+ readOnlyHint: true,
445
+ destructiveHint: false,
446
+ idempotentHint: true,
447
+ openWorldHint: true,
448
+ },
449
+ inputSchema: { type: 'object', properties: {} },
450
+ },
451
+ {
452
+ name: names.signOut,
453
+ description: `Signs the guest out of their ${brand} account in this conversation, and ends that sign-in at ${brand}. The trip is left as it is.`,
454
+ annotations: {
455
+ title: `Sign out of the ${brand} account`,
456
+ readOnlyHint: false,
457
+ destructiveHint: false,
458
+ idempotentHint: true,
459
+ openWorldHint: true,
460
+ },
461
+ inputSchema: { type: 'object', properties: {} },
462
+ },
463
+ {
464
+ name: names.card,
465
+ // The card's own (app-only, server.ts): never offered to the assistant.
466
+ description: `The booking card's own way to sign in to the ${brand} account, see who is signed in, or sign out - for the conversation the card was drawn for. The assistant uses ${names.signIn}, ${names.view} and ${names.signOut}.`,
467
+ annotations: {
468
+ title: `${brand} account (card)`,
469
+ readOnlyHint: false,
470
+ destructiveHint: false,
471
+ idempotentHint: false,
472
+ openWorldHint: true,
473
+ },
474
+ inputSchema: {
475
+ type: 'object',
476
+ properties: {
477
+ action: { type: 'string', enum: ['sign_in', 'view', 'sign_out'] },
478
+ lang: { type: 'string', enum: ['zh', 'en'] },
479
+ },
480
+ required: ['action'],
481
+ },
482
+ },
483
+ ];
484
+ const ACCOUNT_VIEWS = ['account-sign-in', 'account', 'account-signed-out', 'error'];
485
+ const outputSchemas = () => ({
486
+ [names.card]: {
487
+ type: 'object',
488
+ properties: {
489
+ view: { enum: ACCOUNT_VIEWS },
490
+ brand: STR,
491
+ signedIn: { type: 'boolean' },
492
+ waiting: { type: 'boolean' },
493
+ was: { type: 'boolean' },
494
+ expiresAt: STR,
495
+ expiresInSeconds: { type: 'number' },
496
+ account: { type: 'object' },
497
+ accountUrl: STR,
498
+ code: STR,
499
+ },
500
+ required: ['view'],
501
+ },
502
+ [names.signIn]: {
503
+ type: 'object',
504
+ properties: {
505
+ view: { enum: ['account-sign-in', 'account', 'error'] },
506
+ brand: STR,
507
+ signedIn: { type: 'boolean' },
508
+ url: STR,
509
+ shortCode: STR,
510
+ expiresAt: STR,
511
+ expiresInSeconds: { type: 'number' },
512
+ account: { type: 'object' },
513
+ accountUrl: STR,
514
+ code: STR,
515
+ },
516
+ required: ['view'],
517
+ },
518
+ [names.view]: {
519
+ type: 'object',
520
+ properties: {
521
+ view: { const: 'account' },
522
+ brand: STR,
523
+ signedIn: { type: 'boolean' },
524
+ waiting: { type: 'boolean' },
525
+ cardIsRight: { type: 'boolean' },
526
+ account: { type: 'object' },
527
+ accountUrl: STR,
528
+ },
529
+ required: ['view'],
530
+ },
531
+ [names.signOut]: {
532
+ type: 'object',
533
+ properties: {
534
+ view: { const: 'account-signed-out' },
535
+ brand: STR,
536
+ signedIn: { type: 'boolean' },
537
+ was: { type: 'boolean' },
538
+ },
539
+ required: ['view'],
540
+ },
541
+ });
542
+ const instructions = (noCards) => noCards
543
+ ? `${brand} ACCOUNT: a guest who wants to sign in to their ${brand} account, or create one, is given a one-time page by ${names.signIn}: pass on its link and its code exactly, and say they sign in or create the account on that page. Never ask for, or accept, a password, an email or a phone number in the chat; if the guest types one, never write it back - not even to say which one to change: call it \"the password you typed\" - and tell them to use the page and, as it went into a chat, to choose another. When they say they are done, ${names.view} shows who is signed in; ${names.signOut} signs them out. Being signed in changes no price here: say nothing of member rates.`
544
+ : `${brand} ACCOUNT: the card's Sign in opens ${brand}'s own page, where the guest signs in or creates an account; ${names.signIn} does the same when they ask in words. The card tells you when the guest signs in or out on it - take its word, and do not look again to check. Never ask for, or accept, a password, an email or a phone number in the chat; if the guest types one, never write it back - not even to say which one to change: call it \"the password you typed\" - and tell them to use the page and, as it went into a chat, to choose another. ${names.view} shows who is signed in; ${names.signOut} signs them out. Being signed in changes no price here: say nothing of member rates.`;
545
+ // ── The pages ────────────────────────────────────────────────────────────
546
+ const send = (c, status, html) => c.html(html, status, PAGE_HEADERS);
547
+ const langFor = (c, r, form) => langOf(c.req.query('lang')) ??
548
+ langOf(form?.lang) ??
549
+ r?.lang ??
550
+ acceptLang(c.req.header('accept-language'));
551
+ const linkHref = (code) => (l) => `${base}/login?code=${encodeURIComponent(code)}&lang=${l}`;
552
+ const plainHref = (path) => (l) => `${base}${path}?lang=${l}`;
553
+ const over = (c, which, key, max) => {
554
+ if (counters[which].count(key) >= max) {
555
+ refusals[which]++;
556
+ return true;
557
+ }
558
+ counters[which].add(key);
559
+ return false;
560
+ };
561
+ /**
562
+ * ⚠️ A POST COMES FROM OUR OWN PAGE (and with the form's token): its Origin is
563
+ * this site - the public origin, or the Host it was sent to (https; http only
564
+ * for a local address). A browser that sends `Origin: null` or none is asked
565
+ * for Sec-Fetch-Site instead, which a page cannot set.
566
+ */
567
+ const fromOurPage = (c) => {
568
+ const o = c.req.header('origin');
569
+ if (o && o !== 'null') {
570
+ const host = c.req.header('host') ?? '';
571
+ if (o === origin)
572
+ return true;
573
+ if (host && o === `https://${host}`)
574
+ return true;
575
+ return /^(?:localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/.test(host) && o === `http://${host}`;
576
+ }
577
+ return c.req.header('sec-fetch-site') === 'same-origin';
578
+ };
579
+ const cookieOf = (c) => {
580
+ for (const part of String(c.req.header('cookie') ?? '').split(';')) {
581
+ const at = part.indexOf('=');
582
+ if (at > 0 && part.slice(0, at).trim() === COOKIE)
583
+ return part.slice(at + 1).trim();
584
+ }
585
+ return null;
586
+ };
587
+ const setCookie = (c, id) => c.header('Set-Cookie', `${COOKIE}=${id}; Path=/; Max-Age=${WEB_IDLE_MS / 1000}; HttpOnly; Secure; SameSite=Strict`, { append: true });
588
+ const clearCookie = (c) => c.header('Set-Cookie', `${COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict`, {
589
+ append: true,
590
+ });
591
+ const statusOf = (state) => state === 'unknown'
592
+ ? 404
593
+ : state === 'tries' || state === 'limited'
594
+ ? 429
595
+ : state === 'pending'
596
+ ? 202
597
+ : state === 'checked'
598
+ ? 403
599
+ : 410;
600
+ const isSignedIn = (r) => !!r && !!store.get(r.key);
601
+ const formStr = (v, max) => typeof v === 'string' ? v.slice(0, max) : '';
602
+ const errorOf = (lang, code) => say(lang, code, brand);
603
+ /** The web session this request names, while its sign-in stands. */
604
+ const sessionOf = (c) => {
605
+ const id = cookieOf(c);
606
+ const ws = web.get(id);
607
+ if (!ws)
608
+ return { id };
609
+ const s = store.get(ws.key);
610
+ if (!s || s.sid !== ws.sid) {
611
+ web.drop(id);
612
+ return { id };
613
+ }
614
+ return { id, ws, s };
615
+ };
616
+ const mount = (app) => {
617
+ const form = bodyLimit({
618
+ maxSize: FORM_MAX_BYTES,
619
+ onError: (c) => send(c, 413, statePage(brandPage, langFor(c), 'checked', plainHref('/login'))),
620
+ });
621
+ const readForm = async (c) => {
622
+ if (!/^application\/x-www-form-urlencoded\b/i.test(c.req.header('content-type') ?? ''))
623
+ return null;
624
+ try {
625
+ const b = await c.req.parseBody();
626
+ return Object.keys(b).length > 20 ? null : b;
627
+ }
628
+ catch {
629
+ return null;
630
+ }
631
+ };
632
+ // A link being posted right now: "one moment", and a look again in two
633
+ // seconds; that look finding it used says it is done - nothing of whose.
634
+ const notOpen = (c, code, state, lang, wait) => {
635
+ const href = CODE.test(code) ? linkHref(code) : plainHref('/login');
636
+ if (state === 'pending')
637
+ return send(c, 202, statePage(brandPage, lang, 'pending', href, `${base}/login?code=${encodeURIComponent(code)}&lang=${lang}&wait=1`));
638
+ if (state === 'used' && wait)
639
+ return send(c, 200, statePage(brandPage, lang, 'finished', href));
640
+ return send(c, statusOf(state), statePage(brandPage, lang, state, href));
641
+ };
642
+ app.get('/login', (c) => {
643
+ const code = c.req.query('code') ?? '';
644
+ const r = requests.find(code);
645
+ const lang = langFor(c, r);
646
+ if (over(c, 'pages', ipOf(c), ACCOUNT_LIMITS.pagesPerIp))
647
+ return send(c, 429, statePage(brandPage, lang, 'limited', plainHref('/login')));
648
+ const state = requests.state(r, isSignedIn(r));
649
+ if (state !== 'open' || !r)
650
+ return notOpen(c, code, state === 'open' ? 'unknown' : state, lang, c.req.query('wait') === '1');
651
+ // Nothing is used up by looking: a link scanner's GET leaves it as it was.
652
+ return send(c, 200, loginPage(brandPage, { lang, short: r.short, code, csrf: loginCsrf(r), words: r.words }));
653
+ });
654
+ app.post('/login', form, async (c) => {
655
+ const body = await readForm(c);
656
+ const code = formStr(body?.code, 64);
657
+ const r = requests.find(code);
658
+ const lang = langFor(c, r, body ?? undefined);
659
+ if (over(c, 'posts', ipOf(c), ACCOUNT_LIMITS.postsPerIp))
660
+ return send(c, 429, statePage(brandPage, lang, 'limited', plainHref('/login')));
661
+ if (!body)
662
+ return send(c, 400, statePage(brandPage, lang, 'checked', plainHref('/login')));
663
+ if (!fromOurPage(c)) {
664
+ refusals.origin++;
665
+ return send(c, 403, statePage(brandPage, lang, 'checked', plainHref('/login')));
666
+ }
667
+ const state = requests.state(r, isSignedIn(r));
668
+ if (state !== 'open' || !r)
669
+ return notOpen(c, code, state === 'open' ? 'unknown' : state, lang, false);
670
+ if (!same(body.csrf, loginCsrf(r))) {
671
+ refusals.csrf++;
672
+ return send(c, 403, statePage(brandPage, lang, 'checked', linkHref(code)));
673
+ }
674
+ if (!requests.tried(r)) {
675
+ log(`[account] a sign-in link for ${tag(r.key)} refused after too many tries`);
676
+ return send(c, 429, statePage(brandPage, lang, 'tries', linkHref(code)));
677
+ }
678
+ const up = body.form === 'up';
679
+ const values = up
680
+ ? {
681
+ userName: formStr(body.userName, LIMITS.userName).trim(),
682
+ email: formStr(body.email, LIMITS.email).trim(),
683
+ mobile: formStr(body.mobile, LIMITS.mobile).trim(),
684
+ nickname: formStr(body.nickname, LIMITS.nickname).trim(),
685
+ }
686
+ : { account: formStr(body.account, LIMITS.account).trim() };
687
+ const password = formStr(body.password, LIMITS.password);
688
+ const again = (error, as = up ? 'up' : 'in', kept = values) => send(c, 200, loginPage(brandPage, {
689
+ lang,
690
+ short: r.short,
691
+ code,
692
+ csrf: loginCsrf(r),
693
+ words: r.words,
694
+ form: as,
695
+ values: kept,
696
+ error: errorOf(lang, error),
697
+ }));
698
+ // What the form itself can tell, without asking the mall.
699
+ const mobile = (values.mobile ?? '').replace(/[\s().-]/g, '');
700
+ if (up) {
701
+ if (!values.userName || !values.email || !password)
702
+ return again('missing_up');
703
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(values.email))
704
+ return again('bad_email');
705
+ if (mobile && !/^\+\d{6,15}$/.test(mobile))
706
+ return again('bad_mobile');
707
+ if (password.length < 6)
708
+ return again('short_password');
709
+ }
710
+ else if (!values.account || !password)
711
+ return again('missing_in');
712
+ // ⚠️ BY THE NAME TOO (Nexva's member_sign_in): one user name's guesses,
713
+ // from however many links and addresses.
714
+ const who = `${up ? 'r' : 'a'}:${(up ? values.userName : values.account)?.toLowerCase()}`;
715
+ if (over(c, 'names', who, ACCOUNT_LIMITS.perName))
716
+ return again('too_many');
717
+ // ⚠️ AND FOR EVERYONE (Nexva's review P2-2) - taken only here, after the
718
+ // link, the Origin, the form's token and the link's tries have passed, so
719
+ // posts with no good link cannot spend it for everyone.
720
+ if (over(c, 'all', up ? 'up' : 'in', up ? ACCOUNT_LIMITS.registersPerMinute : ACCOUNT_LIMITS.signInsPerMinute))
721
+ return send(c, 429, statePage(brandPage, lang, 'limited', linkHref(code)));
722
+ // One POST at a time on a link: another meanwhile is told to wait.
723
+ if (!requests.begin(r))
724
+ return notOpen(c, code, 'pending', lang, false);
725
+ let tokens;
726
+ let member;
727
+ try {
728
+ if (up) {
729
+ try {
730
+ await mall.register({
731
+ userName: values.userName ?? '',
732
+ email: values.email ?? '',
733
+ password,
734
+ ...(mobile ? { mobile } : {}),
735
+ ...(values.nickname ? { nickname: values.nickname } : {}),
736
+ });
737
+ }
738
+ catch (e) {
739
+ requests.reopen(r);
740
+ log(`[account] an account for ${tag(r.key)} refused (${codeOf(e)})`);
741
+ return again(notThere(e) ? 'busy' : 'register_failed');
742
+ }
743
+ try {
744
+ ({ tokens, member } = await mall.login({ account: values.userName ?? '', password }));
745
+ }
746
+ catch (e) {
747
+ requests.reopen(r);
748
+ log(`[account] an account was made for ${tag(r.key)}; signing in refused (${codeOf(e)})`);
749
+ return again('registered_not_signed_in', 'in', { account: values.userName ?? '' });
750
+ }
751
+ }
752
+ else {
753
+ ({ tokens, member } = await mall.login({ account: values.account ?? '', password }));
754
+ }
755
+ }
756
+ catch (e) {
757
+ requests.reopen(r);
758
+ log(`[account] a sign-in for ${tag(r.key)} refused (${codeOf(e)})`);
759
+ // ⚠️ OUR WORDS, NOT THE MALL'S (Nexva's review P3): theirs told a taken
760
+ // user name from a wrong password - a way to probe for accounts.
761
+ return again(notThere(e) ? 'busy' : 'sign_in_failed');
762
+ }
763
+ // The whole account, for its page: their sign-in's user_info is short of
764
+ // the email and phone. Theirs only if it is the same member.
765
+ const full = await mall.info(tokens).catch(() => null);
766
+ const m = full && (!member || full.id === member.id) ? full : member;
767
+ if (!m) {
768
+ requests.reopen(r);
769
+ mall.logout(tokens).catch(() => { });
770
+ log(`[account] a sign-in for ${tag(r.key)}: the mall named no member`);
771
+ return again('sign_in_failed');
772
+ }
773
+ const s = signInWith(r.key, m, tokens);
774
+ // Every place taken by a live sign-in: none is pushed out (review P3-3).
775
+ if (!s) {
776
+ requests.reopen(r);
777
+ refusals.full++;
778
+ mall.logout(tokens).catch(() => { });
779
+ log('[account] a sign-in refused: every place is taken by a live one');
780
+ return again('busy');
781
+ }
782
+ requests.done(r);
783
+ const id = web.open(r.key, s.sid);
784
+ if (id)
785
+ setCookie(c, id);
786
+ log(`[account] ${up ? 'an account was made and signed in' : 'signed in'} for ${tag(r.key)}`);
787
+ return send(c, 200, signedInPage(brandPage, {
788
+ lang,
789
+ name: s.shown.displayName || s.shown.userName || '',
790
+ account: !!id,
791
+ }));
792
+ });
793
+ app.get('/account', (c) => {
794
+ const lang = langFor(c);
795
+ if (over(c, 'pages', ipOf(c), ACCOUNT_LIMITS.pagesPerIp))
796
+ return send(c, 429, statePage(brandPage, lang, 'limited', plainHref('/account')));
797
+ const at = sessionOf(c);
798
+ if (!at.ws || !at.s) {
799
+ if (at.id)
800
+ clearCookie(c);
801
+ return send(c, 401, statePage(brandPage, lang, 'nosession', plainHref('/account')));
802
+ }
803
+ if (at.id)
804
+ setCookie(c, at.id);
805
+ return send(c, 200, accountPage(brandPage, { lang, shown: at.s.shown, csrf: at.ws.csrf }));
806
+ });
807
+ app.post('/account', form, async (c) => {
808
+ const body = await readForm(c);
809
+ const lang = langFor(c, null, body ?? undefined);
810
+ if (over(c, 'posts', ipOf(c), ACCOUNT_LIMITS.postsPerIp))
811
+ return send(c, 429, statePage(brandPage, lang, 'limited', plainHref('/account')));
812
+ if (!body)
813
+ return send(c, 400, statePage(brandPage, lang, 'checked', plainHref('/account')));
814
+ if (!fromOurPage(c)) {
815
+ refusals.origin++;
816
+ return send(c, 403, statePage(brandPage, lang, 'checked', plainHref('/account')));
817
+ }
818
+ const at = sessionOf(c);
819
+ if (!at.ws || !at.s) {
820
+ if (at.id)
821
+ clearCookie(c);
822
+ return send(c, 401, statePage(brandPage, lang, 'nosession', plainHref('/account')));
823
+ }
824
+ if (!same(body.csrf, at.ws.csrf)) {
825
+ refusals.csrf++;
826
+ return send(c, 403, statePage(brandPage, lang, 'checked', plainHref('/account')));
827
+ }
828
+ if (body.action !== 'signout')
829
+ return send(c, 400, statePage(brandPage, lang, 'checked', plainHref('/account')));
830
+ signOut(at.ws.key);
831
+ web.drop(at.id);
832
+ clearCookie(c);
833
+ log(`[account] ${tag(at.ws.key)} signed out on the account page`);
834
+ return send(c, 200, statePage(brandPage, lang, 'signedout', plainHref('/account')));
835
+ });
836
+ };
837
+ return {
838
+ names,
839
+ tools,
840
+ outputSchemas,
841
+ has: (name) => name === names.signIn || name === names.view || name === names.signOut || name === names.card,
842
+ handle,
843
+ meta: (key, cards) => {
844
+ const s = store.get(key);
845
+ return {
846
+ [ACCOUNT_META_KEY]: {
847
+ from: opts.from,
848
+ tools: names,
849
+ signedIn: !!s,
850
+ // ⚠️ THE USER NAME ON THE CARD ONLY (robin 11-06: 「登录完,我们卡片不显示
851
+ // 用户名之类的吗」; 总线程 0.56.1): where a card is drawn its name is the
852
+ // nickname, else the user name; where none is - and a client may hand
853
+ // `_meta` to its model - the nickname or nothing, as every answer the
854
+ // model reads (review P2-2).
855
+ ...(s
856
+ ? { name: cards ? s.shown.displayName || s.shown.userName || '' : s.shown.displayName }
857
+ : {}),
858
+ },
859
+ };
860
+ },
861
+ instructions,
862
+ mount,
863
+ health: () => ({
864
+ accountsSignedIn: store.size,
865
+ accountLinksOpen: requests.open(),
866
+ accountPages: web.size,
867
+ ...Object.fromEntries(Object.entries(refusals).map(([k, v]) => [`accountRefused_${k}`, v])),
868
+ }),
869
+ };
870
+ };
871
+ //# sourceMappingURL=index.js.map