create-sailor 1.9.6 → 1.10.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.
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ // Committed bin entry for `create-sailor`.
3
+ //
4
+ // pnpm links bins before any lifecycle script or build runs, so package.json#bin
5
+ // must point at a file that exists in a fresh checkout — dist/ does not. This
6
+ // file exists at link time and hands off to the tsup output once it is built.
7
+ // Guarded by tests/architecture/workspace-bin-stubs.test.ts.
8
+ import { existsSync } from "node:fs";
9
+ import { dirname, resolve } from "node:path";
10
+ import { fileURLToPath, pathToFileURL } from "node:url";
11
+
12
+ const entry = resolve(dirname(fileURLToPath(import.meta.url)), "../dist/index.js");
13
+
14
+ if (!existsSync(entry)) {
15
+ process.stderr.write(
16
+ [
17
+ `create-sailor: built entry not found at ${entry}`,
18
+ "The package has not been built yet. Run `pnpm --filter create-sailor build` and retry.",
19
+ "",
20
+ ].join("\n"),
21
+ );
22
+ process.exit(1);
23
+ }
24
+
25
+ // Keep argv identical to a direct `node dist/index.js …` so commander and any
26
+ // main-module check in the built entry see exactly what they saw before.
27
+ process.argv[1] = entry;
28
+ await import(pathToFileURL(entry).href);
package/dist/index.js CHANGED
@@ -270,7 +270,7 @@ gdp("send");
270
270
  }
271
271
  `)}function Hs(){return`# GrowingIO \u2014 \u5168\u57CB\u70B9 CDP (\u4E2D\u56FD\u533A)
272
272
  NEXT_PUBLIC_GROWINGIO_ACCOUNT_ID=
273
- `}async function io(e,t,n="global"){let o=so(t);if(!o||o.id==="none")return;let r=B.join(e,"apps","web");if(!nt.existsSync(r))return;let s=o.id;try{switch(s){case"posthog":Ls(r),ke(e,Us());return;case"plausible":Ds(r),ke(e,Ms());return;case"umami":Fs(r),ke(e,js());return;case"mixpanel":Bs(r),ke(e,$s());return;case"baidu":Vs(r),ke(e,Ks());return;case"sensors":Ys(r),ke(e,qs());return;case"growingio":Gs(r),ke(e,Hs());return;default:return}}catch(i){throw console.error(`Failed to apply analytics selection "${t}":`,i),new Error(`Analytics scaffold for "${t}" failed \u2014 see stderr for details.`)}}var Ws={name:"Nebutra",nameCn:"\u4E91\u6BD3\u667A\u80FD",nameFull:"\u65E0\u9521\u4E91\u6BD3\u667A\u80FD\u79D1\u6280\u6709\u9650\u516C\u53F8",nameFullEn:"Wuxi Nebutra Intelligence Technology Co., Ltd.",tagline:"Ship AI products, not boilerplate.",taglineCn:"AI\u539F\u751F\xB7\u5FEB\u901F\u51FA\u6D77\xB7\u5373\u523B\u4EA4\u4ED8",description:"Production-ready Next.js monorepo template for AI SaaS products. Auth, billing, multi-tenancy, AI services, design system, and enterprise infrastructure \u2014 pre-configured.",descriptionCn:"\u9762\u5411AI\u521B\u4E1A\u8005\u7684\u4E00\u4F53\u5316SaaS\u57FA\u7840\u8BBE\u65BD\u6A21\u677F\uFF0C\u8986\u76D6\u8BA4\u8BC1\u3001\u8BA1\u8D39\u3001\u591A\u79DF\u6237\u3001AI\u670D\u52A1\u4E0E\u8BBE\u8BA1\u7CFB\u7EDF\uFF0C\u5F00\u7BB1\u5373\u4EA7\u54C1",story:{concept:"Logo\u4EE5\u9996\u5B57\u6BCDN\u7684\u57FA\u7840\u9020\u578B\u6982\u5FF5\u4E3A\u4E3B\u8981\u8BBE\u8BA1\u6846\u67B6\uFF0C\u901A\u8FC7\u51E0\u4F55\u6B63\u8D1F\u7A7A\u95F4\u6784\u5EFA\u9690\u5F62'N'\uFF0C\u5F62\u6210\u8FD1\u4F3C\u516D\u8FB9\u5F62\u7684\u7A33\u5B9A\u7ED3\u6784",colorMeaning:"\u84DD\u7EFF\u6E10\u53D8\u4F53\u73B0\u672A\u6765\u611F\u4E0E\u79D1\u6280\u950B\u8292\uFF0C'\u4E91'\u4EE3\u8868\u4E91\u7AEF\u5E73\u53F0\uFF0C'\u6BD3'\u5BD3\u610F\u5B55\u80B2\u4E0E\u8F6C\u5316",values:["AI Native","Ship Fast","Open by Default","Global-Ready","Enterprise-Grade"],missionStatement:"Help AI founders and SaaS teams go from idea to production 10x faster by providing the infrastructure layer they shouldn't have to build."},domains:{landing:"nebutra.com",app:"app.nebutra.com",api:"api.nebutra.com",auth:"auth.nebutra.com",sso:"sso.nebutra.com",docs:"docs.nebutra.com",studio:"studio.nebutra.com",cdn:"cdn.nebutra.com",router:"router.nebutra.com",forge:"forge.nebutra.com",design:"design.nebutra.com",status:"status.nebutra.com",admin:"admin.nebutra.com",analytics:"analytics.nebutra.com",pebble:"pebble.nebutra.com",carina:"carina.nebutra.com",origin:"origin.nebutra.com"},social:{twitter:"https://twitter.com/nebutra",github:"https://github.com/nebutra",discord:"https://discord.gg/nebutra",linkedin:"https://linkedin.com/company/nebutra"}};function ao(e){let t=Ws.domains[e];if(!t?.trim())throw new Error(`brand.domains.${String(e)} is empty \u2014 run brand:apply`);return`https://${t.replace(/^https?:\/\//,"").replace(/\/+$/,"")}`}var Xs=ao("analytics");function zs(e={}){if(e.noTelemetry===!0)return!0;let t=process.env.NEBUTRA_TELEMETRY;return t==="0"||t==="false"}function co(e,t={}){zs(t)||(async()=>{try{let n=await import("@nebutra/analytics");if(typeof n.createProductAnalyticsClient!="function")return;let o=n.createProductAnalyticsClient({posthog:{apiKey:process.env.POSTHOG_KEY??process.env.NEXT_PUBLIC_POSTHOG_KEY??process.env.NEBUTRA_POSTHOG_KEY??"",host:process.env.POSTHOG_HOST??process.env.NEXT_PUBLIC_POSTHOG_HOST??process.env.NEBUTRA_POSTHOG_HOST??Xs},onError:()=>{}});if(typeof o?.track!="function")return;let r=o.track("scaffold.completed",e);r&&typeof r.then=="function"&&await r.catch(()=>{})}catch{}})()}import G from"fs";import k from"path";function $(e){G.existsSync(e)&&G.rmSync(e,{recursive:!0,force:!0})}function xt(e,t){let n=k.join(e,".env.example");G.existsSync(n)?G.appendFileSync(n,`
273
+ `}async function io(e,t,n="global"){let o=so(t);if(!o||o.id==="none")return;let r=B.join(e,"apps","web");if(!nt.existsSync(r))return;let s=o.id;try{switch(s){case"posthog":Ls(r),ke(e,Us());return;case"plausible":Ds(r),ke(e,Ms());return;case"umami":Fs(r),ke(e,js());return;case"mixpanel":Bs(r),ke(e,$s());return;case"baidu":Vs(r),ke(e,Ks());return;case"sensors":Ys(r),ke(e,qs());return;case"growingio":Gs(r),ke(e,Hs());return;default:return}}catch(i){throw console.error(`Failed to apply analytics selection "${t}":`,i),new Error(`Analytics scaffold for "${t}" failed \u2014 see stderr for details.`)}}var Ws={name:"Nebutra",nameCn:"\u4E91\u6BD3\u667A\u80FD",nameFull:"\u65E0\u9521\u4E91\u6BD3\u667A\u80FD\u79D1\u6280\u6709\u9650\u516C\u53F8",nameFullEn:"Wuxi Nebutra Intelligence Technology Co., Ltd.",tagline:"Ship AI products, not boilerplate.",taglineCn:"AI\u539F\u751F\xB7\u5FEB\u901F\u51FA\u6D77\xB7\u5373\u523B\u4EA4\u4ED8",description:"Production-ready Next.js monorepo template for AI SaaS products. Auth, billing, multi-tenancy, AI services, design system, and enterprise infrastructure \u2014 pre-configured.",descriptionCn:"\u9762\u5411AI\u521B\u4E1A\u8005\u7684\u4E00\u4F53\u5316SaaS\u57FA\u7840\u8BBE\u65BD\u6A21\u677F\uFF0C\u8986\u76D6\u8BA4\u8BC1\u3001\u8BA1\u8D39\u3001\u591A\u79DF\u6237\u3001AI\u670D\u52A1\u4E0E\u8BBE\u8BA1\u7CFB\u7EDF\uFF0C\u5F00\u7BB1\u5373\u4EA7\u54C1",story:{concept:"Logo\u4EE5\u9996\u5B57\u6BCDN\u7684\u57FA\u7840\u9020\u578B\u6982\u5FF5\u4E3A\u4E3B\u8981\u8BBE\u8BA1\u6846\u67B6\uFF0C\u901A\u8FC7\u51E0\u4F55\u6B63\u8D1F\u7A7A\u95F4\u6784\u5EFA\u9690\u5F62'N'\uFF0C\u5F62\u6210\u8FD1\u4F3C\u516D\u8FB9\u5F62\u7684\u7A33\u5B9A\u7ED3\u6784",colorMeaning:"\u84DD\u7EFF\u6E10\u53D8\u4F53\u73B0\u672A\u6765\u611F\u4E0E\u79D1\u6280\u950B\u8292\uFF0C'\u4E91'\u4EE3\u8868\u4E91\u7AEF\u5E73\u53F0\uFF0C'\u6BD3'\u5BD3\u610F\u5B55\u80B2\u4E0E\u8F6C\u5316",values:["AI Native","Ship Fast","Open by Default","Global-Ready","Enterprise-Grade"],missionStatement:"Help AI founders and SaaS teams go from idea to production 10x faster by providing the infrastructure layer they shouldn't have to build."},domains:{landing:"nebutra.com",app:"app.nebutra.com",api:"api.nebutra.com",auth:"auth.nebutra.com",sso:"sso.nebutra.com",docs:"docs.nebutra.com",studio:"studio.nebutra.com",cdn:"cdn.nebutra.com",router:"router.nebutra.com",forge:"forge.nebutra.com",design:"design.nebutra.com",status:"status.nebutra.com",open:"open.nebutra.com",admin:"admin.nebutra.com",analytics:"analytics.nebutra.com",pebble:"pebble.nebutra.com",carina:"carina.nebutra.com",kuanlan:"kuanlan.nebutra.com",origin:"origin.nebutra.com"},social:{twitter:"https://twitter.com/nebutra",github:"https://github.com/nebutra",discord:"https://discord.gg/nebutra",linkedin:"https://linkedin.com/company/nebutra"}};function ao(e){let t=Ws.domains[e];if(!t?.trim())throw new Error(`brand.domains.${String(e)} is empty \u2014 run brand:apply`);return`https://${t.replace(/^https?:\/\//,"").replace(/\/+$/,"")}`}var Xs=ao("analytics");function zs(e={}){if(e.noTelemetry===!0)return!0;let t=process.env.NEBUTRA_TELEMETRY;return t==="0"||t==="false"}function co(e,t={}){zs(t)||(async()=>{try{let n=await import("@nebutra/analytics");if(typeof n.createProductAnalyticsClient!="function")return;let o=n.createProductAnalyticsClient({posthog:{apiKey:process.env.POSTHOG_KEY??process.env.NEXT_PUBLIC_POSTHOG_KEY??process.env.NEBUTRA_POSTHOG_KEY??"",host:process.env.POSTHOG_HOST??process.env.NEXT_PUBLIC_POSTHOG_HOST??process.env.NEBUTRA_POSTHOG_HOST??Xs},onError:()=>{}});if(typeof o?.track!="function")return;let r=o.track("scaffold.completed",e);r&&typeof r.then=="function"&&await r.catch(()=>{})}catch{}})()}import G from"fs";import k from"path";function $(e){G.existsSync(e)&&G.rmSync(e,{recursive:!0,force:!0})}function xt(e,t){let n=k.join(e,".env.example");G.existsSync(n)?G.appendFileSync(n,`
274
274
  `+t):G.writeFileSync(n,t)}function Rt(e,t){if(!G.existsSync(e))return;let n=G.readFileSync(e,"utf8"),o=n.replace(/export type AuthProviderId\s*=\s*(?:"[^"]+"\s*\|\s*)+"[^"]+";/,`export type AuthProviderId = "${t}";`);o!==n&&G.writeFileSync(e,o)}function Js(e,t){let n=k.join(e,"README.md");if(!G.existsSync(n)){G.writeFileSync(n,t);return}G.appendFileSync(n,`
275
275
  `+t)}async function po(e,t){let n=k.join(e,"packages","iam","auth");if(!G.existsSync(n))return;if(t==="none"){$(n),Js(e,`
276
276
  ## Auth
@@ -434,7 +434,7 @@ ${o[e.id]}
434
434
  * page. The OAuth callback lives at \`/api/auth/callback/<id>\`.
435
435
  */
436
436
 
437
- import { Button } from "@nebutra/ui/components";
437
+ import { Button } from "@nebutra/ui/primitives";
438
438
 
439
439
  interface SocialProvider {
440
440
  id: string;
package/package.json CHANGED
@@ -1,13 +1,19 @@
1
1
  {
2
2
  "name": "create-sailor",
3
- "version": "1.9.6",
3
+ "version": "1.10.0",
4
+ "nebutra": {
5
+ "status": "foundation",
6
+ "graph": "core",
7
+ "productionReady": false
8
+ },
4
9
  "description": "Governed AI-native SaaS scaffolder for Nebutra Sailor. Bootstrap a production-ready Next.js + Hono + Prisma monorepo with multi-tenant foundations, region-aware defaults, and AI integrations.",
5
10
  "type": "module",
6
11
  "main": "dist/index.js",
7
12
  "bin": {
8
- "create-sailor": "dist/index.js"
13
+ "create-sailor": "./bin/create-sailor.js"
9
14
  },
10
15
  "files": [
16
+ "bin",
11
17
  "dist",
12
18
  "templates",
13
19
  "README.md",
@@ -1,3 +1,18 @@
1
1
  # infra/ops
2
2
 
3
3
  Operational scripts and runbooks — backup, restore, on-call playbooks, incident response.
4
+
5
+ ## Declared provider state
6
+
7
+ `platform-expected.example.json` declares the settings that live only in a
8
+ provider dashboard — Vercel build machine and ignore step, Git links, env vars
9
+ that must not be flagged Sensitive, Fly secret names that must exist or must
10
+ not, GitHub repository variables, Cloudflare Worker bindings. Copy it to
11
+ `platform-expected.json`, replace the names, delete the sections you do not
12
+ use, and run the read-only engine from Nebutra-Sailor against it on a schedule:
13
+
14
+ ```bash
15
+ node scripts/ops/platform-reconcile.mjs infra/ops/platform-expected.json --strict
16
+ ```
17
+
18
+ Exit 1 is the alert. Only names are ever declared or printed, never values.
@@ -0,0 +1,45 @@
1
+ {
2
+ "$comment": "Declare the provider settings that live only in a dashboard, then run scripts/ops/platform-reconcile.mjs against this file on a schedule. Delete every section you do not use; every check is optional. Never put a secret value here — only names.",
3
+ "version": 1,
4
+ "vercel": {
5
+ "projects": [
6
+ {
7
+ "name": "{PRODUCT_NAME}-landing",
8
+ "buildMachineType": "standard",
9
+ "ignoreBuildStep": "exit 0",
10
+ "gitLinked": false,
11
+ "envNotSensitive": {
12
+ "production": ["NEXT_PUBLIC_SITE_URL", "NEXT_PUBLIC_API_URL"]
13
+ }
14
+ },
15
+ {
16
+ "name": "{PRODUCT_NAME}-web",
17
+ "buildMachineType": "standard",
18
+ "ignoreBuildStep": "exit 0"
19
+ }
20
+ ]
21
+ },
22
+ "fly": {
23
+ "apps": [
24
+ {
25
+ "name": "{PRODUCT_NAME}-gateway",
26
+ "secretsPresent": ["QUEUE_PROVIDER", "UPSTASH_REDIS_REST_URL", "UPSTASH_REDIS_REST_TOKEN"],
27
+ "secretsAbsent": ["REDIS_URL", "ALLOW_MEMORY_QUEUE_IN_PRODUCTION"]
28
+ }
29
+ ]
30
+ },
31
+ "github": {
32
+ "repo": "your-org/{PRODUCT_NAME}",
33
+ "variables": {
34
+ "DEPLOY_TARGET_GATEWAY": "cloudflare-workers"
35
+ }
36
+ },
37
+ "cloudflare": {
38
+ "workers": [
39
+ {
40
+ "name": "{PRODUCT_NAME}-gateway-edge",
41
+ "bindings": [{ "name": "IP_LIMITER", "type": "ratelimit" }]
42
+ }
43
+ ]
44
+ }
45
+ }
@@ -6,3 +6,21 @@ Cross-cutting tests that don't belong to a single package.
6
6
  |--------|------|---------|
7
7
  | `architecture/` | vitest | Architecture rules — import boundaries, layering, no-cycle |
8
8
  | `load/` | k6 | Load + perf tests against deployed environments |
9
+
10
+ ## `degradation.test.example.ts`
11
+
12
+ A template, not a live test — no vitest glob matches `.example.ts`, so it is
13
+ inert until you activate it. It is the gateway degradation suite: one
14
+ `describe` per dependency outage (Redis failing every command, Redis
15
+ credentials missing, Redis healthy, database down), each `it` stating the
16
+ behaviour the gateway must have while the outage lasts — which routes keep
17
+ answering, what the health endpoint reports, which failures are logged rather
18
+ than surfaced.
19
+
20
+ To activate it, move the file to `backends/gateway/src/__tests__/degradation.test.ts`
21
+ (drop `.example`) and follow the numbered steps in its header: point the
22
+ imports at your middleware chain and health routes, mounted in the same order
23
+ as your `index.ts`, and point the `vi.mock()` specifiers at whatever your
24
+ gateway imports for cache, database and logging. The point of the suite is
25
+ that a misconfigured dependency has a tested behaviour before it has an
26
+ incident; add a `describe` for every dependency your gateway gains.
@@ -0,0 +1,469 @@
1
+ /**
2
+ * Gateway degradation suite — template.
3
+ *
4
+ * A dependency outage is a code path like any other, and it needs a test that
5
+ * states the intended behaviour: which routes keep answering, what the health
6
+ * endpoint reports, and which failures are logged rather than surfaced. This
7
+ * file is the shape Nebutra-Sailor uses for that in
8
+ * `backends/gateway/src/__tests__/degradation.test.ts`.
9
+ *
10
+ * To activate it in a scaffold:
11
+ *
12
+ * 1. Move it to `backends/gateway/src/__tests__/degradation.test.ts` — drop
13
+ * the `.example` so the gateway's vitest `include` picks it up.
14
+ * 2. Point the imports in `buildGateway()` at your middleware chain and
15
+ * health routes, mounted in the same order and on the same path scopes as
16
+ * your `index.ts`. Do not import `index.ts` itself if its module body
17
+ * boots telemetry or route groups; compose the pieces instead.
18
+ * 3. Point the `vi.mock()` specifiers at whatever your gateway imports for
19
+ * cache, database and logging. The doubles below match the
20
+ * `@nebutra/cache` client surface (get/set/del/eval/ping/incr/incrby/expire).
21
+ * 4. Replace `s2sHeaders` with however your tenant middleware resolves a
22
+ * tenant in tests, so rate-limit and metering keys are real, not
23
+ * "anonymous".
24
+ *
25
+ * Scenarios, one `describe` each:
26
+ *
27
+ * (a) Redis constructs but every command fails (malformed URL, bad token,
28
+ * outage): rate-limited routes answer from the in-memory bucket and
29
+ * carry rate-limit headers; health is 200 degraded with cache down.
30
+ * (b) Redis credentials missing entirely: same outcome as (a).
31
+ * (c) Redis healthy: remaining tokens decrease across requests and the
32
+ * limiter issues exactly one EVAL per request.
33
+ * (d) Database down: health is 200 degraded with database down; routes that
34
+ * do not touch the database keep answering; with Redis also down health
35
+ * is 503 unhealthy while those routes still answer.
36
+ *
37
+ * When you add a readiness route, it belongs here next to the health route.
38
+ */
39
+
40
+ import { Hono } from "hono";
41
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
42
+ import { s2sHeaders, TEST_SERVICE_SECRET } from "./helpers/s2s-token.js";
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Module mocks. Hoisted handles survive `vi.resetModules()`, which re-runs the
46
+ // factories below; each re-run hands back the same vi.fn instances.
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const { getRedis, queryRaw, logger } = vi.hoisted(() => ({
50
+ getRedis: vi.fn<() => Promise<unknown>>(),
51
+ queryRaw: vi.fn<() => Promise<unknown>>(),
52
+ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
53
+ }));
54
+
55
+ vi.mock("@nebutra/cache", () => ({
56
+ getRedis: () => getRedis(),
57
+ }));
58
+
59
+ vi.mock("@nebutra/db", () => ({
60
+ getSystemDb: () => ({ $queryRaw: queryRaw }),
61
+ prisma: { $queryRaw: queryRaw },
62
+ }));
63
+
64
+ vi.mock("@nebutra/logger", () => ({ logger }));
65
+
66
+ // tenantContext imports the auth provider factory statically. It is only
67
+ // invoked for Bearer tokens, which this suite never sends; mocking it keeps
68
+ // the provider SDKs out of the module graph.
69
+ vi.mock("@nebutra/auth/server", () => ({
70
+ createAuth: vi.fn(),
71
+ }));
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Redis doubles
75
+ // ---------------------------------------------------------------------------
76
+
77
+ type RedisDouble = {
78
+ get: ReturnType<typeof vi.fn>;
79
+ set: ReturnType<typeof vi.fn>;
80
+ del: ReturnType<typeof vi.fn>;
81
+ eval: ReturnType<typeof vi.fn>;
82
+ ping: ReturnType<typeof vi.fn>;
83
+ incr: ReturnType<typeof vi.fn>;
84
+ incrby: ReturnType<typeof vi.fn>;
85
+ expire: ReturnType<typeof vi.fn>;
86
+ };
87
+
88
+ /**
89
+ * A client that constructs but fails every command. This is what a malformed
90
+ * URL, a bad token or an outage looks like from the caller: `getRedis()`
91
+ * resolves, the first command rejects.
92
+ */
93
+ function brokenRedis(): RedisDouble {
94
+ const rejecting = () => vi.fn(async () => Promise.reject(new TypeError("Invalid URL")));
95
+ return {
96
+ get: rejecting(),
97
+ set: rejecting(),
98
+ del: rejecting(),
99
+ eval: rejecting(),
100
+ ping: rejecting(),
101
+ incr: rejecting(),
102
+ incrby: rejecting(),
103
+ expire: rejecting(),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * A working client with just enough state to answer the chain: the token
109
+ * bucket EVAL decrements by the request cost and reports what is left, the
110
+ * counters increment, everything else is a key-value map. No refill — the
111
+ * assertions want determinism, not a second bucket implementation.
112
+ */
113
+ function healthyRedis(): RedisDouble {
114
+ const buckets = new Map<string, number>();
115
+ const kv = new Map<string, unknown>();
116
+ return {
117
+ eval: vi.fn(async (_script: string, keys: string[], args: Array<string | number>) => {
118
+ const key = keys[0] ?? "";
119
+ // ARGV: now (ms), maxTokens, refillRate, refillInterval (ms), cost, ttl (s)
120
+ const now = Number(args[0]);
121
+ const maxTokens = Number(args[1]);
122
+ const cost = Number(args[4]);
123
+ const tokens = (buckets.get(key) ?? maxTokens) - cost;
124
+ buckets.set(key, Math.max(tokens, 0));
125
+ return [tokens >= 0 ? 1 : 0, Math.max(tokens, 0), now];
126
+ }),
127
+ get: vi.fn(async (key: string) => kv.get(key) ?? null),
128
+ set: vi.fn(async (key: string, value: unknown) => {
129
+ kv.set(key, value);
130
+ return "OK";
131
+ }),
132
+ del: vi.fn(async (key: string) => (kv.delete(key) ? 1 : 0)),
133
+ incr: vi.fn(async (key: string) => {
134
+ const next = Number(kv.get(key) ?? 0) + 1;
135
+ kv.set(key, next);
136
+ return next;
137
+ }),
138
+ incrby: vi.fn(async (key: string, by: number) => {
139
+ const next = Number(kv.get(key) ?? 0) + by;
140
+ kv.set(key, next);
141
+ return next;
142
+ }),
143
+ expire: vi.fn(async () => 1),
144
+ ping: vi.fn(async () => "PONG"),
145
+ };
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // App under test — adapt the imports and mounts to your gateway.
150
+ // ---------------------------------------------------------------------------
151
+
152
+ async function buildGateway() {
153
+ vi.resetModules();
154
+
155
+ const [
156
+ { tenantContextMiddleware },
157
+ { usageMeteringMiddleware },
158
+ { idempotencyMiddleware },
159
+ { rateLimitMiddleware },
160
+ { shouldSkipGlobalRateLimit },
161
+ { healthRoutes },
162
+ ] = await Promise.all([
163
+ import("../middlewares/tenantContext.js"),
164
+ import("../middlewares/usageMetering.js"),
165
+ import("../middlewares/idempotency.js"),
166
+ import("../middlewares/rateLimit.js"),
167
+ import("../middlewares/rateLimitSkip.js"),
168
+ import("../routes/misc/health.js"),
169
+ ]);
170
+
171
+ const handled = vi.fn();
172
+ const app = new Hono();
173
+
174
+ // Same mounts, same order, same path scopes as index.ts.
175
+ app.use("*", tenantContextMiddleware);
176
+ app.use("/api/v1/*", usageMeteringMiddleware);
177
+ app.use("/api/v1/*", idempotencyMiddleware);
178
+ app.use("/api/*", async (c, next) => {
179
+ const path = new URL(c.req.url).pathname;
180
+ if (shouldSkipGlobalRateLimit(path)) return next();
181
+ return rateLimitMiddleware(c, next);
182
+ });
183
+
184
+ app.route("/api/misc", healthRoutes);
185
+ app.get("/api/v1/things", (c) => {
186
+ handled();
187
+ return c.json({ items: [] });
188
+ });
189
+ app.post("/api/v1/things", (c) => {
190
+ handled();
191
+ return c.json({ id: "thing_1" }, 201);
192
+ });
193
+
194
+ return { app, handled };
195
+ }
196
+
197
+ type Gateway = Awaited<ReturnType<typeof buildGateway>>;
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Helpers
201
+ // ---------------------------------------------------------------------------
202
+
203
+ const REDIS_ENV = [
204
+ "UPSTASH_REDIS_REST_URL",
205
+ "UPSTASH_REDIS_REST_TOKEN",
206
+ "UPSTASH_REDIS_URL",
207
+ "UPSTASH_REDIS_TOKEN",
208
+ ] as const;
209
+
210
+ function redisConfigured(present: boolean) {
211
+ for (const name of REDIS_ENV) vi.stubEnv(name, undefined);
212
+ if (present) {
213
+ vi.stubEnv("UPSTASH_REDIS_REST_URL", "https://x.upstash.io");
214
+ vi.stubEnv("UPSTASH_REDIS_REST_TOKEN", "t");
215
+ }
216
+ }
217
+
218
+ /** A resolved tenant, so rate-limit and metering keys are real, not "anonymous". */
219
+ function tenantHeaders() {
220
+ return s2sHeaders({
221
+ userId: "user_degradation",
222
+ orgId: "org_degradation",
223
+ role: "org:member",
224
+ plan: "FREE",
225
+ });
226
+ }
227
+
228
+ async function getThings(app: Gateway["app"]) {
229
+ return app.request("/api/v1/things", { method: "GET", headers: await tenantHeaders() });
230
+ }
231
+
232
+ async function getHealth(app: Gateway["app"]) {
233
+ return app.request("/api/misc/health", { method: "GET" });
234
+ }
235
+
236
+ /** Metering writes behind `void (async () => …)()`; let the microtasks drain. */
237
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
238
+
239
+ const FREE_LIMIT = "100";
240
+
241
+ beforeEach(() => {
242
+ vi.stubEnv("SERVICE_SECRET", TEST_SERVICE_SECRET);
243
+ queryRaw.mockResolvedValue([{ "?column?": 1 }]);
244
+ });
245
+
246
+ afterEach(() => {
247
+ vi.unstubAllEnvs();
248
+ vi.clearAllMocks();
249
+ });
250
+
251
+ // ===========================================================================
252
+ // (a) Redis constructs but every command fails
253
+ // ===========================================================================
254
+
255
+ describe("Redis constructs but every command fails (TypeError: Invalid URL)", () => {
256
+ let redis: RedisDouble;
257
+ let gateway: Gateway;
258
+
259
+ beforeEach(async () => {
260
+ redisConfigured(true);
261
+ redis = brokenRedis();
262
+ getRedis.mockResolvedValue(redis);
263
+ gateway = await buildGateway();
264
+ });
265
+
266
+ it("GET /api/v1/things answers from the route, rate-limited by the in-memory bucket", async () => {
267
+ const res = await getThings(gateway.app);
268
+
269
+ expect(res.status).toBe(200);
270
+ expect(await res.json()).toEqual({ items: [] });
271
+ expect(gateway.handled).toHaveBeenCalledOnce();
272
+
273
+ // The store was tried and failed — this is the fallback, not a skip.
274
+ expect(redis.eval).toHaveBeenCalled();
275
+ expect(res.headers.get("x-ratelimit-limit")).toBe(FREE_LIMIT);
276
+ const remaining = Number(res.headers.get("x-ratelimit-remaining"));
277
+ expect(remaining).toBeGreaterThanOrEqual(0);
278
+ expect(remaining).toBeLessThan(Number(FREE_LIMIT));
279
+ expect(res.headers.get("x-ratelimit-reset")).toMatch(/^\d+$/);
280
+ });
281
+
282
+ it("keeps serving after repeated failures — the fallback is per request, not per process", async () => {
283
+ const first = await getThings(gateway.app);
284
+ const second = await getThings(gateway.app);
285
+
286
+ expect(first.status).toBe(200);
287
+ expect(second.status).toBe(200);
288
+ expect(gateway.handled).toHaveBeenCalledTimes(2);
289
+ expect(Number(second.headers.get("x-ratelimit-remaining"))).toBeLessThan(
290
+ Number(first.headers.get("x-ratelimit-remaining")),
291
+ );
292
+ });
293
+
294
+ it("logs the metering write failure instead of surfacing it", async () => {
295
+ const res = await getThings(gateway.app);
296
+ await flush();
297
+
298
+ expect(res.status).toBe(200);
299
+ expect(redis.incr).toHaveBeenCalledOnce();
300
+ expect(logger.warn).toHaveBeenCalledWith(
301
+ "Usage metering write failed",
302
+ expect.objectContaining({ tenantId: "org_degradation" }),
303
+ );
304
+ });
305
+
306
+ it("GET /api/misc/health reports cache down and stays 200 degraded", async () => {
307
+ const res = await getHealth(gateway.app);
308
+ const body = await res.json();
309
+
310
+ expect(res.status).toBe(200);
311
+ expect(body.status).toBe("degraded");
312
+ expect(body.dependencies.cache.status).toBe("down");
313
+ expect(body.dependencies.database.status).toBe("up");
314
+ });
315
+
316
+ it.todo(
317
+ "POST /api/v1/things with an Idempotency-Key needs a decided behaviour while Redis is down — an idempotency store with no fallback surfaces the outage as a 500; fail-closed (503 + Retry-After) or fail-open is a product call, pin it here once made",
318
+ );
319
+ });
320
+
321
+ // ===========================================================================
322
+ // (b) Redis credentials are missing entirely
323
+ // ===========================================================================
324
+
325
+ describe("Redis credentials are missing entirely", () => {
326
+ let gateway: Gateway;
327
+
328
+ beforeEach(async () => {
329
+ redisConfigured(false);
330
+ // What @nebutra/cache's getRedisConfig() throws when no URL/token is set.
331
+ getRedis.mockRejectedValue(new Error("Redis credentials not configured"));
332
+ gateway = await buildGateway();
333
+ });
334
+
335
+ it("GET /api/v1/things answers from the route, rate-limited by the in-memory bucket", async () => {
336
+ const res = await getThings(gateway.app);
337
+
338
+ expect(res.status).toBe(200);
339
+ expect(await res.json()).toEqual({ items: [] });
340
+ expect(gateway.handled).toHaveBeenCalledOnce();
341
+ expect(res.headers.get("x-ratelimit-limit")).toBe(FREE_LIMIT);
342
+ expect(Number(res.headers.get("x-ratelimit-remaining"))).toBeLessThan(Number(FREE_LIMIT));
343
+ });
344
+
345
+ it("does not surface the metering no-op as an error", async () => {
346
+ const res = await getThings(gateway.app);
347
+ await flush();
348
+
349
+ expect(res.status).toBe(200);
350
+ // usageMetering resolves the client once and treats "not configured" as
351
+ // a local-dev no-op: nothing to warn about.
352
+ expect(logger.warn).not.toHaveBeenCalledWith("Usage metering write failed", expect.anything());
353
+ });
354
+
355
+ it("GET /api/misc/health reports cache down and stays 200 degraded", async () => {
356
+ const res = await getHealth(gateway.app);
357
+ const body = await res.json();
358
+
359
+ expect(res.status).toBe(200);
360
+ expect(body.status).toBe("degraded");
361
+ expect(body.dependencies.cache.status).toBe("down");
362
+ expect(body.dependencies.database.status).toBe("up");
363
+ });
364
+ });
365
+
366
+ // ===========================================================================
367
+ // (c) Redis is healthy
368
+ // ===========================================================================
369
+
370
+ describe("Redis is healthy", () => {
371
+ let redis: RedisDouble;
372
+ let gateway: Gateway;
373
+
374
+ beforeEach(async () => {
375
+ redisConfigured(true);
376
+ redis = healthyRedis();
377
+ getRedis.mockResolvedValue(redis);
378
+ gateway = await buildGateway();
379
+ });
380
+
381
+ it("x-ratelimit-remaining decreases across two requests, one EVAL per request", async () => {
382
+ const first = await getThings(gateway.app);
383
+ expect(first.status).toBe(200);
384
+ expect(redis.eval).toHaveBeenCalledTimes(1);
385
+
386
+ const second = await getThings(gateway.app);
387
+ expect(second.status).toBe(200);
388
+ expect(redis.eval).toHaveBeenCalledTimes(2);
389
+
390
+ // Default API weight is 2 tokens; FREE starts at 100.
391
+ expect(first.headers.get("x-ratelimit-limit")).toBe(FREE_LIMIT);
392
+ expect(first.headers.get("x-ratelimit-remaining")).toBe("98");
393
+ expect(second.headers.get("x-ratelimit-remaining")).toBe("96");
394
+
395
+ // The atomic script is the only rate-limit command: no GET+SET fallback ran.
396
+ expect(redis.get).not.toHaveBeenCalled();
397
+ expect(redis.set).not.toHaveBeenCalled();
398
+ expect(gateway.handled).toHaveBeenCalledTimes(2);
399
+ });
400
+
401
+ it("meters each request once under the tenant's billing-period key", async () => {
402
+ await getThings(gateway.app);
403
+ await getThings(gateway.app);
404
+ await flush();
405
+
406
+ expect(redis.incr).toHaveBeenCalledTimes(2);
407
+ expect(redis.incr.mock.calls[0]?.[0]).toMatch(/^usage:org_degradation:\d{4}-\d{2}:api_calls$/);
408
+ expect(logger.warn).not.toHaveBeenCalledWith("Usage metering write failed", expect.anything());
409
+ });
410
+
411
+ it("GET /api/misc/health is 200 healthy with both dependencies up", async () => {
412
+ const res = await getHealth(gateway.app);
413
+ const body = await res.json();
414
+
415
+ expect(res.status).toBe(200);
416
+ expect(body.status).toBe("healthy");
417
+ expect(body.dependencies.database.status).toBe("up");
418
+ expect(body.dependencies.cache.status).toBe("up");
419
+ expect(redis.ping).toHaveBeenCalledOnce();
420
+ });
421
+ });
422
+
423
+ // ===========================================================================
424
+ // (d) The database is down
425
+ // ===========================================================================
426
+
427
+ describe("The database is down", () => {
428
+ let gateway: Gateway;
429
+
430
+ beforeEach(async () => {
431
+ redisConfigured(true);
432
+ getRedis.mockResolvedValue(healthyRedis());
433
+ queryRaw.mockRejectedValue(new Error("connection refused"));
434
+ gateway = await buildGateway();
435
+ });
436
+
437
+ it("GET /api/misc/health reports database down and stays 200 degraded", async () => {
438
+ const res = await getHealth(gateway.app);
439
+ const body = await res.json();
440
+
441
+ expect(res.status).toBe(200);
442
+ expect(body.status).toBe("degraded");
443
+ expect(body.dependencies.database.status).toBe("down");
444
+ expect(body.dependencies.cache.status).toBe("up");
445
+ });
446
+
447
+ it("routes that do not touch the database keep answering", async () => {
448
+ const res = await getThings(gateway.app);
449
+
450
+ expect(res.status).toBe(200);
451
+ expect(gateway.handled).toHaveBeenCalledOnce();
452
+ });
453
+
454
+ it("with Redis also down, health is 503 unhealthy while the route still answers", async () => {
455
+ getRedis.mockResolvedValue(brokenRedis());
456
+ gateway = await buildGateway();
457
+
458
+ const health = await getHealth(gateway.app);
459
+ const body = await health.json();
460
+ expect(health.status).toBe(503);
461
+ expect(body.status).toBe("unhealthy");
462
+ expect(body.dependencies.database.status).toBe("down");
463
+ expect(body.dependencies.cache.status).toBe("down");
464
+
465
+ const things = await getThings(gateway.app);
466
+ expect(things.status).toBe(200);
467
+ expect(things.headers.get("x-ratelimit-limit")).toBe(FREE_LIMIT);
468
+ });
469
+ });