create-ailk 0.11.1 → 0.11.3
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/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/content-payloads.js +2 -2
- package/dist/module-architecture.js +11 -0
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +42 -3
- package/templates/apps/api/.env.example +5 -0
- package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/server.test.ts +5 -1
- package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
- package/templates/apps/api/src/config/index.ts +136 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/auth.ts +19 -21
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
- package/templates/apps/api/src/server.ts +49 -16
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +98 -84
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +102 -18
- package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
- package/templates/apps/web/components/signed-in-nav.tsx +58 -0
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +147 -21
- package/templates/database/CHANGELOG.md +7 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
|
@@ -13,12 +13,15 @@
|
|
|
13
13
|
* Spec: docs/internal/specs/draft/ailk-mcp-mvp-pb-b5-rate-limit-spec.md
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import Fastify from "fastify";
|
|
16
|
+
import Fastify, { type FastifyInstance } from "fastify";
|
|
17
17
|
import {
|
|
18
18
|
rateLimitMiddleware,
|
|
19
19
|
__resetBuckets,
|
|
20
20
|
__bucketCount,
|
|
21
|
+
buildTrustedKeyDigests,
|
|
21
22
|
DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
23
|
+
resolveRateLimitIdentity,
|
|
24
|
+
type RateLimitOptions,
|
|
22
25
|
} from "../rate-limit.js";
|
|
23
26
|
|
|
24
27
|
function buildApp(
|
|
@@ -527,3 +530,528 @@ describe("rate-limit warn log — no query-string credential (#5970)", () => {
|
|
|
527
530
|
}
|
|
528
531
|
});
|
|
529
532
|
});
|
|
533
|
+
|
|
534
|
+
// #7394 — per-visitor leads limit for a server-to-server spoke
|
|
535
|
+
// (docs/specs/7394-the-leads-rate-limit-scope-spec.md). Every route below is
|
|
536
|
+
// `POST /v1/leads/submit` unless a case says otherwise, so it is in
|
|
537
|
+
// `FORWARDED_VISITOR_ROUTES`. "The key" is one 64-character key passed
|
|
538
|
+
// through `buildTrustedKeyDigests`.
|
|
539
|
+
describe("per-visitor forwarding, scope namespace and per-scope env limit (#7394)", () => {
|
|
540
|
+
const KEY = "0123456789abcdef".repeat(4); // gitleaks:allow — test fixture, not a credential (#7394)
|
|
541
|
+
const SPOKE_HOST = "198.51.100.7";
|
|
542
|
+
const LEADS_SUBMIT = "/v1/leads/submit";
|
|
543
|
+
const ENV_KEYS = [
|
|
544
|
+
"AILK_RATE_LIMIT_LEADS_PER_MINUTE",
|
|
545
|
+
"AILK_RATE_LIMIT_LEADS_PER_KEY_PER_MINUTE",
|
|
546
|
+
"AILK_RATE_LIMIT_PER_IP_PER_MINUTE",
|
|
547
|
+
] as const;
|
|
548
|
+
const priorEnv = new Map<string, string | undefined>(
|
|
549
|
+
ENV_KEYS.map((name) => [name, process.env[name]]),
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
const noWarn = () => undefined;
|
|
553
|
+
const digests = () => buildTrustedKeyDigests(KEY, noWarn);
|
|
554
|
+
|
|
555
|
+
let app: FastifyInstance | undefined;
|
|
556
|
+
let logLines: Array<Record<string, unknown>>;
|
|
557
|
+
|
|
558
|
+
function forwardApp(
|
|
559
|
+
opts: RateLimitOptions,
|
|
560
|
+
routes: readonly string[] = [LEADS_SUBMIT],
|
|
561
|
+
): FastifyInstance {
|
|
562
|
+
const instance = Fastify({
|
|
563
|
+
trustProxy: true,
|
|
564
|
+
logger: {
|
|
565
|
+
level: "warn",
|
|
566
|
+
stream: {
|
|
567
|
+
write(msg: string) {
|
|
568
|
+
logLines.push(JSON.parse(msg) as Record<string, unknown>);
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
instance.register(rateLimitMiddleware, opts);
|
|
574
|
+
for (const url of routes) instance.post(url, async () => ({ ok: true }));
|
|
575
|
+
return instance;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function keyed(visitorIp: string, key: string = KEY): Record<string, string> {
|
|
579
|
+
return {
|
|
580
|
+
"x-forwarded-for": SPOKE_HOST,
|
|
581
|
+
"x-ailk-visitor-forward-key": key,
|
|
582
|
+
"x-ailk-visitor-ip": visitorIp,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function post(
|
|
587
|
+
headers: Record<string, string>,
|
|
588
|
+
url: string = LEADS_SUBMIT,
|
|
589
|
+
): Promise<number> {
|
|
590
|
+
if (!app) throw new Error("no app");
|
|
591
|
+
const res = await app.inject({ method: "POST", url, headers });
|
|
592
|
+
return res.statusCode;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function rateLimitedLines(): Array<Record<string, unknown>> {
|
|
596
|
+
return logLines.filter((line) => line["msg"] === "rate_limited");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
beforeEach(() => {
|
|
600
|
+
__resetBuckets();
|
|
601
|
+
logLines = [];
|
|
602
|
+
for (const name of ENV_KEYS) delete process.env[name];
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
afterEach(async () => {
|
|
606
|
+
if (app) await app.close();
|
|
607
|
+
app = undefined;
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
afterAll(() => {
|
|
611
|
+
for (const [name, value] of priorEnv) {
|
|
612
|
+
if (value === undefined) delete process.env[name];
|
|
613
|
+
else process.env[name] = value;
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
describe("AC-1 — AILK_RATE_LIMIT_<SCOPE>_PER_MINUTE overrides the code value", () => {
|
|
618
|
+
async function firstRefused(): Promise<number> {
|
|
619
|
+
const headers = { "x-forwarded-for": "203.0.113.50" };
|
|
620
|
+
for (let i = 1; i <= 50; i++) {
|
|
621
|
+
if ((await post(headers)) === 429) return i;
|
|
622
|
+
}
|
|
623
|
+
return -1;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
it("the env var wins over perIpPerMinute", async () => {
|
|
627
|
+
process.env.AILK_RATE_LIMIT_LEADS_PER_MINUTE = "3";
|
|
628
|
+
app = forwardApp({ scope: "leads", perIpPerMinute: 10 });
|
|
629
|
+
await app.ready();
|
|
630
|
+
expect(await firstRefused()).toBe(4);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("with the env var unset, the code value applies", async () => {
|
|
634
|
+
app = forwardApp({ scope: "leads", perIpPerMinute: 10 });
|
|
635
|
+
await app.ready();
|
|
636
|
+
expect(await firstRefused()).toBe(11);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("an invalid env var is ignored, with one warning naming the variable", async () => {
|
|
640
|
+
process.env.AILK_RATE_LIMIT_LEADS_PER_MINUTE = "abc";
|
|
641
|
+
app = forwardApp({ scope: "leads", perIpPerMinute: 10 });
|
|
642
|
+
await app.ready();
|
|
643
|
+
expect(await firstRefused()).toBe(11);
|
|
644
|
+
const warnings = logLines.filter((line) =>
|
|
645
|
+
String(line["msg"]).includes("AILK_RATE_LIMIT_LEADS_PER_MINUTE"),
|
|
646
|
+
);
|
|
647
|
+
expect(warnings).toHaveLength(1);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
it.each(["0", "-5", "2.5", "1000000"])(
|
|
651
|
+
"ignores the out-of-range value %s",
|
|
652
|
+
async (value) => {
|
|
653
|
+
process.env.AILK_RATE_LIMIT_LEADS_PER_MINUTE = value;
|
|
654
|
+
app = forwardApp({ scope: "leads", perIpPerMinute: 10 });
|
|
655
|
+
await app.ready();
|
|
656
|
+
expect(await firstRefused()).toBe(11);
|
|
657
|
+
},
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
it("does not read AILK_RATE_LIMIT_PER_IP_PER_MINUTE for a named scope with a code value", async () => {
|
|
661
|
+
process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE = "2";
|
|
662
|
+
app = forwardApp({ scope: "leads", perIpPerMinute: 10 });
|
|
663
|
+
await app.ready();
|
|
664
|
+
expect(await firstRefused()).toBe(11);
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
describe("AC-2 — a named scope gets its own bucket namespace", () => {
|
|
669
|
+
function twoScopes(second: RateLimitOptions): FastifyInstance {
|
|
670
|
+
const instance = Fastify({ trustProxy: true });
|
|
671
|
+
instance.register(async (child) => {
|
|
672
|
+
child.register(rateLimitMiddleware, { perIpPerMinute: 10 });
|
|
673
|
+
child.post("/a", async () => ({ ok: true }));
|
|
674
|
+
});
|
|
675
|
+
instance.register(async (child) => {
|
|
676
|
+
child.register(rateLimitMiddleware, second);
|
|
677
|
+
child.post("/b", async () => ({ ok: true }));
|
|
678
|
+
});
|
|
679
|
+
return instance;
|
|
680
|
+
}
|
|
681
|
+
const headers = { "x-forwarded-for": "203.0.113.60" };
|
|
682
|
+
|
|
683
|
+
it("ten /a requests do not spend the leads scope's budget", async () => {
|
|
684
|
+
app = twoScopes({ scope: "leads", perIpPerMinute: 10 });
|
|
685
|
+
await app.ready();
|
|
686
|
+
for (let i = 0; i < 10; i++) expect(await post(headers, "/a")).toBe(200);
|
|
687
|
+
for (let i = 0; i < 10; i++) expect(await post(headers, "/b")).toBe(200);
|
|
688
|
+
expect(await post(headers, "/b")).toBe(429);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("two unnamed registrations still share their count, as at the pin", async () => {
|
|
692
|
+
app = twoScopes({ perIpPerMinute: 10 });
|
|
693
|
+
await app.ready();
|
|
694
|
+
for (let i = 0; i < 10; i++) expect(await post(headers, "/a")).toBe(200);
|
|
695
|
+
expect(await post(headers, "/b")).toBe(429);
|
|
696
|
+
});
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
describe("AC-3 — a keyed lead write is counted per visitor", () => {
|
|
700
|
+
it("limits each forwarded visitor separately behind one spoke host", async () => {
|
|
701
|
+
app = forwardApp({
|
|
702
|
+
scope: "leads",
|
|
703
|
+
perIpPerMinute: 10,
|
|
704
|
+
perKeyPerMinute: 300,
|
|
705
|
+
trustedKeyDigests: digests(),
|
|
706
|
+
});
|
|
707
|
+
await app.ready();
|
|
708
|
+
for (let i = 0; i < 10; i++) expect(await post(keyed("203.0.113.1"))).toBe(200);
|
|
709
|
+
expect(await post(keyed("203.0.113.1"))).toBe(429);
|
|
710
|
+
expect(await post(keyed("203.0.113.2"))).toBe(200);
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
describe("AC-4 — the visitor header is ignored without the key, or off the lead writes", () => {
|
|
715
|
+
const WRONG_SAME_LENGTH = "f".repeat(64);
|
|
716
|
+
const ONE_CHAR_CHANGED = `${KEY.slice(0, -1)}0`;
|
|
717
|
+
|
|
718
|
+
async function elevenDistinctVisitors(
|
|
719
|
+
headersFor: (visitorIp: string) => Record<string, string>,
|
|
720
|
+
url: string = LEADS_SUBMIT,
|
|
721
|
+
): Promise<number[]> {
|
|
722
|
+
const codes: number[] = [];
|
|
723
|
+
for (let i = 1; i <= 11; i++) codes.push(await post(headersFor(`203.0.113.${i}`), url));
|
|
724
|
+
return codes;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const base = {
|
|
728
|
+
scope: "leads",
|
|
729
|
+
perIpPerMinute: 10,
|
|
730
|
+
perKeyPerMinute: 300,
|
|
731
|
+
} as const;
|
|
732
|
+
|
|
733
|
+
it.each([
|
|
734
|
+
[
|
|
735
|
+
"(i) no key",
|
|
736
|
+
(ip: string) => ({ "x-forwarded-for": SPOKE_HOST, "x-ailk-visitor-ip": ip }),
|
|
737
|
+
],
|
|
738
|
+
["(ii) a wrong key of the same length", (ip: string) => keyed(ip, WRONG_SAME_LENGTH)],
|
|
739
|
+
["(iii) the right key with one character changed", (ip: string) => keyed(ip, ONE_CHAR_CHANGED)],
|
|
740
|
+
])("%s: the eleventh request is 429", async (_label, headersFor) => {
|
|
741
|
+
app = forwardApp({ ...base, trustedKeyDigests: digests() });
|
|
742
|
+
await app.ready();
|
|
743
|
+
const codes = await elevenDistinctVisitors(headersFor);
|
|
744
|
+
expect(codes.slice(0, 10).every((code) => code === 200)).toBe(true);
|
|
745
|
+
expect(codes[10]).toBe(429);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("(iv) the valid key, but no trustedKeyDigests passed: the eleventh request is 429", async () => {
|
|
749
|
+
app = forwardApp(base);
|
|
750
|
+
await app.ready();
|
|
751
|
+
const codes = await elevenDistinctVisitors((ip) => keyed(ip));
|
|
752
|
+
expect(codes[10]).toBe(429);
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
it("(v) the valid key on POST /v1/project-listings in the same scope: the eleventh request is 429", async () => {
|
|
756
|
+
app = forwardApp({ ...base, trustedKeyDigests: digests() }, [
|
|
757
|
+
LEADS_SUBMIT,
|
|
758
|
+
"/v1/project-listings",
|
|
759
|
+
]);
|
|
760
|
+
await app.ready();
|
|
761
|
+
const codes = await elevenDistinctVisitors((ip) => keyed(ip), "/v1/project-listings");
|
|
762
|
+
expect(codes.slice(0, 10).every((code) => code === 200)).toBe(true);
|
|
763
|
+
expect(codes[10]).toBe(429);
|
|
764
|
+
});
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
describe("AC-5 — visitor IP validation and normalisation", () => {
|
|
768
|
+
function identityApp(): FastifyInstance {
|
|
769
|
+
const instance = Fastify({ trustProxy: true });
|
|
770
|
+
const trusted = digests();
|
|
771
|
+
instance.post(LEADS_SUBMIT, async (req) => resolveRateLimitIdentity(req, trusted));
|
|
772
|
+
return instance;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async function identityFor(visitorIp: string): Promise<unknown> {
|
|
776
|
+
if (!app) throw new Error("no app");
|
|
777
|
+
const res = await app.inject({ method: "POST", url: LEADS_SUBMIT, headers: keyed(visitorIp) });
|
|
778
|
+
return res.json();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
it.each(["not-an-ip", "203.0.113.1, 198.51.100.1", "a".repeat(46), "fe80::1%eth0", ""])(
|
|
782
|
+
"falls back to req.ip for %j",
|
|
783
|
+
async (visitorIp) => {
|
|
784
|
+
app = identityApp();
|
|
785
|
+
await app.ready();
|
|
786
|
+
expect(await identityFor(visitorIp)).toEqual({ identity: SPOKE_HOST, keyIndex: null });
|
|
787
|
+
},
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
it("maps an IPv4-mapped IPv6 address to its IPv4 form", async () => {
|
|
791
|
+
app = identityApp();
|
|
792
|
+
await app.ready();
|
|
793
|
+
expect(await identityFor("::ffff:203.0.113.9")).toEqual({
|
|
794
|
+
identity: "v0,203.0.113.9",
|
|
795
|
+
keyIndex: 0,
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
it.each(["2001:db8:1:2::1", "2001:DB8:1:2::ffff"])(
|
|
800
|
+
"groups the IPv6 address %s by its /64",
|
|
801
|
+
async (visitorIp) => {
|
|
802
|
+
app = identityApp();
|
|
803
|
+
await app.ready();
|
|
804
|
+
expect(await identityFor(visitorIp)).toEqual({
|
|
805
|
+
identity: "v0,2001:0db8:0001:0002::/64",
|
|
806
|
+
keyIndex: 0,
|
|
807
|
+
});
|
|
808
|
+
},
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
it("keeps a valid IPv4 address as it is", async () => {
|
|
812
|
+
app = identityApp();
|
|
813
|
+
await app.ready();
|
|
814
|
+
expect(await identityFor(" 203.0.113.10 ")).toEqual({
|
|
815
|
+
identity: "v0,203.0.113.10",
|
|
816
|
+
keyIndex: 0,
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
it("ignores the key on a method other than POST", async () => {
|
|
821
|
+
const trusted = digests();
|
|
822
|
+
app = Fastify({ trustProxy: true });
|
|
823
|
+
app.get(LEADS_SUBMIT, async (req) => resolveRateLimitIdentity(req, trusted));
|
|
824
|
+
await app.ready();
|
|
825
|
+
const res = await app.inject({ method: "GET", url: LEADS_SUBMIT, headers: keyed("203.0.113.1") });
|
|
826
|
+
expect(res.json()).toEqual({ identity: SPOKE_HOST, keyIndex: null });
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
describe("AC-6 — buildTrustedKeyDigests keeps positions and never logs an entry", () => {
|
|
831
|
+
it("drops a short entry with one warning, keeps empty slots, and digests the real key", () => {
|
|
832
|
+
const warn = jest.fn();
|
|
833
|
+
const slots = buildTrustedKeyDigests(`short, ,${KEY}`, warn);
|
|
834
|
+
expect(slots).toHaveLength(3);
|
|
835
|
+
expect(slots[0]).toBeNull();
|
|
836
|
+
expect(slots[1]).toBeNull();
|
|
837
|
+
expect(Buffer.isBuffer(slots[2])).toBe(true);
|
|
838
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
839
|
+
const message = String(warn.mock.calls[0]?.[0]);
|
|
840
|
+
expect(message).toContain("AILK_VISITOR_FORWARD_KEYS");
|
|
841
|
+
expect(message).not.toContain("short");
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it("returns [] and warns nothing when the variable is unset or empty", () => {
|
|
845
|
+
const warn = jest.fn();
|
|
846
|
+
expect(buildTrustedKeyDigests(undefined, warn)).toEqual([]);
|
|
847
|
+
expect(buildTrustedKeyDigests("", warn)).toEqual([]);
|
|
848
|
+
expect(warn).not.toHaveBeenCalled();
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
it("counts a request presenting the short entry under req.ip", async () => {
|
|
852
|
+
app = forwardApp({
|
|
853
|
+
scope: "leads",
|
|
854
|
+
perIpPerMinute: 1,
|
|
855
|
+
trustedKeyDigests: buildTrustedKeyDigests(`short, ,${KEY}`, noWarn),
|
|
856
|
+
});
|
|
857
|
+
await app.ready();
|
|
858
|
+
expect(await post(keyed("203.0.113.1", "short"))).toBe(200);
|
|
859
|
+
expect(await post(keyed("203.0.113.2", "short"))).toBe(429);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
it("gives the third entry's visitors a v2, identity and their own buckets", async () => {
|
|
863
|
+
const trusted = buildTrustedKeyDigests(`short, ,${KEY}`, noWarn);
|
|
864
|
+
app = Fastify({ trustProxy: true });
|
|
865
|
+
app.register(async (child) => {
|
|
866
|
+
child.register(rateLimitMiddleware, {
|
|
867
|
+
scope: "leads",
|
|
868
|
+
perIpPerMinute: 10,
|
|
869
|
+
trustedKeyDigests: trusted,
|
|
870
|
+
});
|
|
871
|
+
child.post(LEADS_SUBMIT, async (req) => resolveRateLimitIdentity(req, trusted));
|
|
872
|
+
});
|
|
873
|
+
await app.ready();
|
|
874
|
+
const first = await app.inject({ method: "POST", url: LEADS_SUBMIT, headers: keyed("203.0.113.1") });
|
|
875
|
+
expect((first.json() as { identity: string }).identity).toBe("v2,203.0.113.1");
|
|
876
|
+
for (let i = 0; i < 9; i++) expect(await post(keyed("203.0.113.1"))).toBe(200);
|
|
877
|
+
expect(await post(keyed("203.0.113.1"))).toBe(429);
|
|
878
|
+
expect(await post(keyed("203.0.113.2"))).toBe(200);
|
|
879
|
+
});
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
describe("AC-10 — the 429 log line names the bucket and key position, never the key", () => {
|
|
883
|
+
it("logs the namespaced identity, bucket and keyIndex for a visitor-bucket 429", async () => {
|
|
884
|
+
app = forwardApp({
|
|
885
|
+
scope: "leads",
|
|
886
|
+
perIpPerMinute: 10,
|
|
887
|
+
perKeyPerMinute: 300,
|
|
888
|
+
trustedKeyDigests: digests(),
|
|
889
|
+
});
|
|
890
|
+
await app.ready();
|
|
891
|
+
for (let i = 0; i < 10; i++) await post(keyed("203.0.113.1"));
|
|
892
|
+
expect(await post(keyed("203.0.113.1"))).toBe(429);
|
|
893
|
+
|
|
894
|
+
const [line] = rateLimitedLines();
|
|
895
|
+
expect(line?.["ip"]).toBe("v0,203.0.113.1");
|
|
896
|
+
expect(line?.["bucket"]).toBe("visitor");
|
|
897
|
+
expect(line?.["keyIndex"]).toBe(0);
|
|
898
|
+
const text = JSON.stringify(logLines);
|
|
899
|
+
expect(text).not.toContain(KEY);
|
|
900
|
+
expect(text).not.toContain("forward-key");
|
|
901
|
+
});
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
describe("AC-11 — each key has its own ceiling", () => {
|
|
905
|
+
const options = (): RateLimitOptions => ({
|
|
906
|
+
scope: "leads",
|
|
907
|
+
perIpPerMinute: 10,
|
|
908
|
+
perKeyPerMinute: 5,
|
|
909
|
+
trustedKeyDigests: digests(),
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
it("refuses the sixth keyed request across distinct visitors, with bucket: key", async () => {
|
|
913
|
+
app = forwardApp(options());
|
|
914
|
+
await app.ready();
|
|
915
|
+
for (let i = 1; i <= 5; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(200);
|
|
916
|
+
const sixth = await app.inject({
|
|
917
|
+
method: "POST",
|
|
918
|
+
url: LEADS_SUBMIT,
|
|
919
|
+
headers: keyed("203.0.113.6"),
|
|
920
|
+
});
|
|
921
|
+
expect(sixth.statusCode).toBe(429);
|
|
922
|
+
const retryAfter = Number(sixth.headers["retry-after"]);
|
|
923
|
+
expect(retryAfter).toBeGreaterThanOrEqual(1);
|
|
924
|
+
expect(retryAfter).toBeLessThanOrEqual(60);
|
|
925
|
+
const [line] = rateLimitedLines();
|
|
926
|
+
expect(line?.["bucket"]).toBe("key");
|
|
927
|
+
expect(line?.["keyIndex"]).toBe(0);
|
|
928
|
+
expect(line?.["ip"]).toBe("v0,203.0.113.6");
|
|
929
|
+
|
|
930
|
+
// A request with no key, from a seventh visitor, is unaffected.
|
|
931
|
+
expect(await post({ "x-forwarded-for": "203.0.113.7" })).toBe(200);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it("reads AILK_RATE_LIMIT_LEADS_PER_KEY_PER_MINUTE over the option", async () => {
|
|
935
|
+
process.env.AILK_RATE_LIMIT_LEADS_PER_KEY_PER_MINUTE = "7";
|
|
936
|
+
app = forwardApp(options());
|
|
937
|
+
await app.ready();
|
|
938
|
+
for (let i = 1; i <= 7; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(200);
|
|
939
|
+
expect(await post(keyed("203.0.113.8"))).toBe(429);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it("does not read the per-visitor variable for the per-key ceiling", async () => {
|
|
943
|
+
process.env.AILK_RATE_LIMIT_LEADS_PER_MINUTE = "50";
|
|
944
|
+
app = forwardApp(options());
|
|
945
|
+
await app.ready();
|
|
946
|
+
for (let i = 1; i <= 5; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(200);
|
|
947
|
+
expect(await post(keyed("203.0.113.6"))).toBe(429);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
it("visitor churn past maxBuckets cannot evict the key bucket and reset its ceiling", async () => {
|
|
951
|
+
app = forwardApp({ ...options(), maxBuckets: 3 });
|
|
952
|
+
await app.ready();
|
|
953
|
+
for (let i = 1; i <= 5; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(200);
|
|
954
|
+
// Each refused request still adds a visitor bucket; with maxBuckets 3
|
|
955
|
+
// the oldest entries are evicted over and over.
|
|
956
|
+
for (let i = 6; i <= 20; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(429);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it("a key over its ceiling opens no new visitor bucket, so it cannot evict another scope's counter (security review R-1)", async () => {
|
|
960
|
+
app = Fastify({ trustProxy: true });
|
|
961
|
+
app.register(async (child) => {
|
|
962
|
+
child.register(rateLimitMiddleware, { perIpPerMinute: 1, maxBuckets: 10 });
|
|
963
|
+
child.post("/v1/auth/email", async () => ({ ok: true }));
|
|
964
|
+
});
|
|
965
|
+
app.register(async (child) => {
|
|
966
|
+
child.register(rateLimitMiddleware, { ...options(), maxBuckets: 10 });
|
|
967
|
+
child.post(LEADS_SUBMIT, async () => ({ ok: true }));
|
|
968
|
+
});
|
|
969
|
+
await app.ready();
|
|
970
|
+
const victim = { "x-forwarded-for": "198.51.100.99" };
|
|
971
|
+
expect(await post(victim, "/v1/auth/email")).toBe(200);
|
|
972
|
+
expect(await post(victim, "/v1/auth/email")).toBe(429);
|
|
973
|
+
|
|
974
|
+
for (let i = 1; i <= 5; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(200);
|
|
975
|
+
for (let i = 6; i <= 60; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(429);
|
|
976
|
+
|
|
977
|
+
// The victim's exhausted counter survived the keyed churn.
|
|
978
|
+
expect(await post(victim, "/v1/auth/email")).toBe(429);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
it("a key over its ceiling still counts a visitor whose bucket is already open", async () => {
|
|
982
|
+
app = forwardApp({ ...options(), perIpPerMinute: 2 });
|
|
983
|
+
await app.ready();
|
|
984
|
+
expect(await post(keyed("203.0.113.1"))).toBe(200);
|
|
985
|
+
for (let i = 2; i <= 5; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(200);
|
|
986
|
+
// Key now at its ceiling of 5; visitor 1 has an open bucket at count 1.
|
|
987
|
+
expect(await post(keyed("203.0.113.1"))).toBe(429);
|
|
988
|
+
const [line] = rateLimitedLines();
|
|
989
|
+
expect(line?.["bucket"]).toBe("key");
|
|
990
|
+
// Visitor 1's own bucket was counted (now 2), so a third request trips
|
|
991
|
+
// the visitor bucket even once the key bucket is ignored.
|
|
992
|
+
expect(await post(keyed("203.0.113.1"))).toBe(429);
|
|
993
|
+
expect(rateLimitedLines()[1]?.["bucket"]).toBe("visitor");
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
it("defaults the per-key ceiling to 300 when no option is passed", async () => {
|
|
997
|
+
app = forwardApp({ scope: "leads", perIpPerMinute: 10, trustedKeyDigests: digests() });
|
|
998
|
+
await app.ready();
|
|
999
|
+
const codes: number[] = [];
|
|
1000
|
+
for (let i = 0; i < 301; i++) {
|
|
1001
|
+
codes.push(await post(keyed(`10.${Math.floor(i / 250)}.${i % 250}.1`)));
|
|
1002
|
+
}
|
|
1003
|
+
expect(codes.slice(0, 300).every((code) => code === 200)).toBe(true);
|
|
1004
|
+
expect(codes[300]).toBe(429);
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
describe("AC-12 — no unkeyed req.ip can reach a keyed bucket", () => {
|
|
1009
|
+
const options = (): RateLimitOptions => ({
|
|
1010
|
+
scope: "leads",
|
|
1011
|
+
perIpPerMinute: 10,
|
|
1012
|
+
perKeyPerMinute: 5,
|
|
1013
|
+
trustedKeyDigests: digests(),
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it("unkeyed traffic shaped like a keyed bucket key adds nothing to the per-key bucket", async () => {
|
|
1017
|
+
app = forwardApp(options());
|
|
1018
|
+
await app.ready();
|
|
1019
|
+
for (const forwarded of ["key:0", "key", "v0:203.0.113.1", "v0"]) {
|
|
1020
|
+
for (let i = 0; i < 6; i++) await post({ "x-forwarded-for": forwarded });
|
|
1021
|
+
}
|
|
1022
|
+
for (let i = 1; i <= 5; i++) expect(await post(keyed(`203.0.113.${i}`))).toBe(200);
|
|
1023
|
+
expect(await post(keyed("203.0.113.6"))).toBe(429);
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it("ten unkeyed requests from v0:203.0.113.1 do not stop that keyed visitor", async () => {
|
|
1027
|
+
app = forwardApp(options());
|
|
1028
|
+
await app.ready();
|
|
1029
|
+
for (let i = 0; i < 10; i++) await post({ "x-forwarded-for": "v0:203.0.113.1" });
|
|
1030
|
+
expect(await post(keyed("203.0.113.1"))).toBe(200);
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
describe("registration guards", () => {
|
|
1035
|
+
it.each(["Leads", "1leads", "le-ads", ""])("rejects the scope name %j", async (scope) => {
|
|
1036
|
+
app = forwardApp({ scope });
|
|
1037
|
+
await expect(app.ready()).rejects.toThrow(/scope/);
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
it("rejects trustedKeyDigests without a scope", async () => {
|
|
1041
|
+
app = forwardApp({ trustedKeyDigests: digests() });
|
|
1042
|
+
await expect(app.ready()).rejects.toThrow(/scope/);
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
it("lets a caller-supplied ipFromRequest win over the forwarded identity", async () => {
|
|
1046
|
+
app = forwardApp({
|
|
1047
|
+
scope: "leads",
|
|
1048
|
+
perIpPerMinute: 1,
|
|
1049
|
+
trustedKeyDigests: digests(),
|
|
1050
|
+
ipFromRequest: () => "fixed",
|
|
1051
|
+
});
|
|
1052
|
+
await app.ready();
|
|
1053
|
+
expect(await post(keyed("203.0.113.1"))).toBe(200);
|
|
1054
|
+
expect(await post(keyed("203.0.113.2"))).toBe(429);
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
});
|
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
* Looked up directly against the `Session` table (the row Better Auth's
|
|
9
9
|
* database adapter creates on sign-in). Cookie wins when both modes are
|
|
10
10
|
* present (locked by brief decision OQ-D / spec §3 "Both present").
|
|
11
|
-
* Non-GET requests with a cookie are required to carry
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Non-GET requests with a cookie are required to carry an Origin header
|
|
12
|
+
* that is exactly one of the configured trusted origins (#7395) — the
|
|
13
|
+
* lightweight CSRF mechanism agreed in OQ-D. Bearer-authenticated callers
|
|
14
|
+
* skip the origin check (non-browser flow).
|
|
14
15
|
*
|
|
15
16
|
* 2. Bearer path — Authorization: Bearer <token>. The token is sha256-hashed
|
|
16
17
|
* and matched against `ApiToken.token` (stored as hash). Revoked / expired
|
|
@@ -120,27 +121,21 @@ function readBearer(request: FastifyRequest): string | undefined {
|
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
/**
|
|
123
|
-
*
|
|
124
|
+
* CSRF check for cookie-authenticated mutating requests (#7395).
|
|
124
125
|
* Returns true if the request is safe (GET/HEAD/OPTIONS) or the Origin header
|
|
125
|
-
*
|
|
126
|
+
* is exactly one of the trusted origins. False otherwise — including for any
|
|
127
|
+
* write when the list is empty, in every environment.
|
|
126
128
|
*/
|
|
127
129
|
function isOriginAllowed(
|
|
128
130
|
request: FastifyRequest,
|
|
129
|
-
|
|
131
|
+
trustedOrigins: readonly string[],
|
|
130
132
|
): boolean {
|
|
131
133
|
const method = request.method.toUpperCase();
|
|
132
134
|
if (method === "GET" || method === "HEAD" || method === "OPTIONS")
|
|
133
135
|
return true;
|
|
134
|
-
if (!frontendOrigin) {
|
|
135
|
-
// No FRONTEND_URL configured: fail OPEN in dev/test (zero-config OSS
|
|
136
|
-
// boot), fail CLOSED in production (security review M1 — with the
|
|
137
|
-
// multi-tenant module's state-changing routes, an unset origin must not
|
|
138
|
-
// silently disable the CSRF check in a real deployment).
|
|
139
|
-
return process.env.NODE_ENV !== "production";
|
|
140
|
-
}
|
|
141
136
|
const origin = request.headers.origin;
|
|
142
137
|
if (!origin) return false;
|
|
143
|
-
return origin
|
|
138
|
+
return trustedOrigins.includes(origin);
|
|
144
139
|
}
|
|
145
140
|
|
|
146
141
|
type SessionRow = {
|
|
@@ -187,8 +182,11 @@ async function resolveBearerToken(
|
|
|
187
182
|
}
|
|
188
183
|
|
|
189
184
|
export type AuthPreHandlerOptions = {
|
|
190
|
-
/**
|
|
191
|
-
|
|
185
|
+
/**
|
|
186
|
+
* The browser origins a cookie-authenticated write may come from — exact
|
|
187
|
+
* matches only. server.ts passes `config.TRUSTED_ORIGINS` (#7395).
|
|
188
|
+
*/
|
|
189
|
+
trustedOrigins: readonly string[];
|
|
192
190
|
};
|
|
193
191
|
|
|
194
192
|
/**
|
|
@@ -214,7 +212,7 @@ export type OptionalCookieUser =
|
|
|
214
212
|
* route's buyer stamp).
|
|
215
213
|
*
|
|
216
214
|
* Applies the cookie path's own rules — the same private cookie reader,
|
|
217
|
-
* Session lookup, expiry comparison and
|
|
215
|
+
* Session lookup, expiry comparison and trusted-origin check as
|
|
218
216
|
* `makeAuthPreHandler` — so the two paths cannot drift. Reads the COOKIE only:
|
|
219
217
|
* a bearer `ApiToken` never identifies a user here. Never writes a reply and
|
|
220
218
|
* never throws; every failure is a `none` with its reason. The cookie is
|
|
@@ -238,7 +236,7 @@ export async function resolveOptionalCookieUser(
|
|
|
238
236
|
if (session.expiresAt.getTime() <= getClock().now()) {
|
|
239
237
|
return { kind: "none", reason: "expired" };
|
|
240
238
|
}
|
|
241
|
-
if (!isOriginAllowed(request, opts.
|
|
239
|
+
if (!isOriginAllowed(request, opts.trustedOrigins)) {
|
|
242
240
|
return { kind: "none", reason: "cross_origin" };
|
|
243
241
|
}
|
|
244
242
|
// Brand assertion at the Prisma read edge (ADR-0031 rule 5), as above.
|
|
@@ -251,8 +249,8 @@ export async function resolveOptionalCookieUser(
|
|
|
251
249
|
* Exported standalone so route-test suites can register it directly without
|
|
252
250
|
* spinning up the full server module.
|
|
253
251
|
*/
|
|
254
|
-
export function makeAuthPreHandler(opts: AuthPreHandlerOptions
|
|
255
|
-
const {
|
|
252
|
+
export function makeAuthPreHandler(opts: AuthPreHandlerOptions) {
|
|
253
|
+
const { trustedOrigins } = opts;
|
|
256
254
|
return async function authPreHandler(
|
|
257
255
|
request: FastifyRequest,
|
|
258
256
|
reply: FastifyReply,
|
|
@@ -272,7 +270,7 @@ export function makeAuthPreHandler(opts: AuthPreHandlerOptions = {}) {
|
|
|
272
270
|
() => undefined,
|
|
273
271
|
);
|
|
274
272
|
if (session && session.expiresAt.getTime() > now) {
|
|
275
|
-
if (!isOriginAllowed(request,
|
|
273
|
+
if (!isOriginAllowed(request, trustedOrigins)) {
|
|
276
274
|
reply.code(401).send(UNAUTHORIZED);
|
|
277
275
|
return;
|
|
278
276
|
}
|