run402 4.83.0 → 4.84.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.
- package/gitvault-surface.json +1 -1
- package/lib/deploy-v2.mjs +30 -68
- package/lib/doctor-source-scan.mjs +2 -547
- package/lib/doctor.mjs +28 -8
- package/lib/sdk-errors.mjs +7 -0
- package/lib/status.mjs +1 -1
- package/lib/up.mjs +27 -10
- package/lib/update-check.mjs +11 -6
- package/lib/update-check.test.mjs +18 -5
- package/package.json +1 -1
- package/sdk/dist/actions.d.ts +16 -3
- package/sdk/dist/actions.d.ts.map +1 -1
- package/sdk/dist/config.d.ts +1 -1
- package/sdk/dist/config.d.ts.map +1 -1
- package/sdk/dist/config.js.map +1 -1
- package/sdk/dist/index.d.ts +2 -0
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +2 -0
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/namespaces/deploy.d.ts.map +1 -1
- package/sdk/dist/namespaces/deploy.js +2 -1
- package/sdk/dist/namespaces/deploy.js.map +1 -1
- package/sdk/dist/namespaces/deploy.types.d.ts +6 -0
- package/sdk/dist/namespaces/deploy.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/edge-evidence.d.ts +22 -0
- package/sdk/dist/namespaces/edge-evidence.d.ts.map +1 -0
- package/sdk/dist/namespaces/edge-evidence.js +38 -0
- package/sdk/dist/namespaces/edge-evidence.js.map +1 -0
- package/sdk/dist/namespaces/projects.d.ts.map +1 -1
- package/sdk/dist/namespaces/projects.js +9 -7
- package/sdk/dist/namespaces/projects.js.map +1 -1
- package/sdk/dist/node/actions-node.d.ts.map +1 -1
- package/sdk/dist/node/actions-node.js +71 -57
- package/sdk/dist/node/actions-node.js.map +1 -1
- package/sdk/dist/node/app-scope.d.ts +24 -0
- package/sdk/dist/node/app-scope.d.ts.map +1 -0
- package/sdk/dist/node/app-scope.js +47 -0
- package/sdk/dist/node/app-scope.js.map +1 -0
- package/sdk/dist/node/deploy-manifest.d.ts +6 -0
- package/sdk/dist/node/deploy-manifest.d.ts.map +1 -1
- package/sdk/dist/node/deploy-manifest.js +61 -0
- package/sdk/dist/node/deploy-manifest.js.map +1 -1
- package/sdk/dist/node/index.d.ts +5 -0
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +5 -0
- package/sdk/dist/node/index.js.map +1 -1
- package/sdk/dist/node/manifest-export.d.ts +5 -0
- package/sdk/dist/node/manifest-export.d.ts.map +1 -0
- package/sdk/dist/node/manifest-export.js +78 -0
- package/sdk/dist/node/manifest-export.js.map +1 -0
- package/sdk/dist/node/preflight.d.ts +65 -0
- package/sdk/dist/node/preflight.d.ts.map +1 -0
- package/sdk/dist/node/preflight.js +23 -0
- package/sdk/dist/node/preflight.js.map +1 -0
- package/sdk/dist/node/source-scan.d.ts +73 -0
- package/sdk/dist/node/source-scan.d.ts.map +1 -0
- package/sdk/dist/node/source-scan.js +552 -0
- package/sdk/dist/node/source-scan.js.map +1 -0
- package/sdk/dist/rest-diagnostics.d.ts +15 -0
- package/sdk/dist/rest-diagnostics.d.ts.map +1 -0
- package/sdk/dist/rest-diagnostics.js +19 -0
- package/sdk/dist/rest-diagnostics.js.map +1 -0
|
@@ -1,547 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Walks the project's `src/` directory and reports patterns the
|
|
5
|
-
* auth-aware-ssr design specifies as deploy-failing OR runtime warnings:
|
|
6
|
-
*
|
|
7
|
-
* - **Hallucinated SDK names.** `getUser`, `getSession`, `currentUser`,
|
|
8
|
-
* `getCurrentUser`, `getServerSession`, `auth.protect`, `auth.signIn`,
|
|
9
|
-
* `auth.logout`, `auth.middleware`, etc. Each hit emits
|
|
10
|
-
* `R402_AUTH_UNKNOWN_EXPORT` with a structured fix-it (attempted name,
|
|
11
|
-
* canonical replacement, import line, docs URL).
|
|
12
|
-
*
|
|
13
|
-
* - **State-changing GET handlers.** Astro pages that export a GET
|
|
14
|
-
* handler containing DB-mutation patterns (`db().insert`, `db().update`,
|
|
15
|
-
* `db().delete`, `adminDb().sql("UPDATE"`, etc.). Emit
|
|
16
|
-
* `R402_AUTH_STATE_CHANGING_GET`.
|
|
17
|
-
*
|
|
18
|
-
* - **`auth.*` calls in prerendered pages.** Astro pages declaring
|
|
19
|
-
* `export const prerender = true` that also call `auth.*` helpers.
|
|
20
|
-
* Emit `R402_AUTH_PRERENDERED`.
|
|
21
|
-
*
|
|
22
|
-
* - **Direct `internal.sessions.authz_version` mutation.** Consumer
|
|
23
|
-
* migrations that try to `UPDATE internal.sessions SET authz_version`
|
|
24
|
-
* manually. Emit `R402_AUTH_AUTHZ_VERSION_PROHIBITED`.
|
|
25
|
-
*
|
|
26
|
-
* The scanner is regex-based — fast, dependency-free, and good enough
|
|
27
|
-
* for the canonical patterns. The `run402 doctor --json` mode emits the
|
|
28
|
-
* structured envelope; the default mode prints a readable per-finding
|
|
29
|
-
* summary. Wired into `run402 deploy` pre-flight as a deploy-failing
|
|
30
|
-
* gate for the error severities; non-blocking for warning severities.
|
|
31
|
-
*
|
|
32
|
-
* @see the auth-aware-ssr OpenSpec change (functions-sdk-auth-model)
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
36
|
-
import { extname, join, relative } from "node:path";
|
|
37
|
-
|
|
38
|
-
/** Severity ladder for scanner findings. `error` blocks deploy; `warn`
|
|
39
|
-
* reports but doesn't block. The `run402 doctor` exit code is non-zero
|
|
40
|
-
* whenever any `error`-severity finding is present. */
|
|
41
|
-
export const SCAN_SEVERITY = Object.freeze({
|
|
42
|
-
ERROR: "error",
|
|
43
|
-
WARN: "warn",
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
/** File extensions scanned. Astro frontmatter + TS/JS are the primary
|
|
47
|
-
* surface; the regex matchers fire equally on all of them. `.astro`
|
|
48
|
-
* matters because consumers write SSR pages there. */
|
|
49
|
-
const SCANNED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".astro"]);
|
|
50
|
-
|
|
51
|
-
/** Directories the scanner refuses to descend into. We never report
|
|
52
|
-
* on platform-generated code or vendored dependencies. */
|
|
53
|
-
const SKIPPED_DIRECTORIES = new Set([
|
|
54
|
-
"node_modules",
|
|
55
|
-
".git",
|
|
56
|
-
".vscode",
|
|
57
|
-
".idea",
|
|
58
|
-
"dist",
|
|
59
|
-
"build",
|
|
60
|
-
"out",
|
|
61
|
-
".astro",
|
|
62
|
-
".next",
|
|
63
|
-
".vercel",
|
|
64
|
-
".netlify",
|
|
65
|
-
"coverage",
|
|
66
|
-
]);
|
|
67
|
-
|
|
68
|
-
/** Hallucinated-name registry from the auth-aware-ssr spec. Each entry
|
|
69
|
-
* carries the canonical replacement so the fix-it is actionable. The
|
|
70
|
-
* list is intentionally exhaustive — every name flagged here came up
|
|
71
|
-
* in pre-launch LLM hallucination samples. */
|
|
72
|
-
const HALLUCINATED_NAMES = [
|
|
73
|
-
// ESM named imports — caught by regex on import lines AND by call-site
|
|
74
|
-
// matching when consumers paste from training-data examples.
|
|
75
|
-
{ name: "getUser", canonical: "auth.user() / auth.requireUser()", origin: "supabase / clerk / nextauth legacy" },
|
|
76
|
-
{ name: "getUserId", canonical: "(await auth.user())?.id", origin: "run402 v0.x" },
|
|
77
|
-
{ name: "getRole", canonical: "auth.requireRole(role)", origin: "run402 v0.x" },
|
|
78
|
-
{ name: "getSession", canonical: "auth.user()", origin: "next-auth / nextauth" },
|
|
79
|
-
{ name: "currentUser", canonical: "auth.user()", origin: "clerk" },
|
|
80
|
-
{ name: "currentSession", canonical: "auth.user()", origin: "clerk" },
|
|
81
|
-
{ name: "getCurrentUser", canonical: "auth.user()", origin: "generic" },
|
|
82
|
-
{ name: "getCurrentSession", canonical: "auth.user()", origin: "generic" },
|
|
83
|
-
{ name: "getServerSession", canonical: "auth.user()", origin: "next-auth" },
|
|
84
|
-
{ name: "getAuth", canonical: "auth.user() / auth.requireUser()", origin: "clerk" },
|
|
85
|
-
{ name: "requireAuth", canonical: "auth.requireUser()", origin: "generic" },
|
|
86
|
-
{ name: "withAuth", canonical: "auth.requireUser() inside the handler", origin: "next-auth-style HOC" },
|
|
87
|
-
{ name: "protectRoute", canonical: "auth.requireUser() inside the handler", origin: "generic" },
|
|
88
|
-
{ name: "useUser", canonical: "auth.user() (note: server-only; not a React hook)", origin: "clerk / supabase" },
|
|
89
|
-
{ name: "useSession", canonical: "auth.user() (note: server-only)", origin: "next-auth / clerk" },
|
|
90
|
-
{ name: "createServerClient", canonical: "db() (use the bundled SDK; no client setup needed)", origin: "supabase" },
|
|
91
|
-
{ name: "clerkClient", canonical: "auth.user() + db()", origin: "clerk" },
|
|
92
|
-
];
|
|
93
|
-
|
|
94
|
-
/** Property-access hallucinations on the `auth` object. The SDK's
|
|
95
|
-
* Proxy catches these at runtime, but the source scanner fires
|
|
96
|
-
* earlier so the deploy fails before the bundle ships. */
|
|
97
|
-
const HALLUCINATED_AUTH_PROPERTIES = [
|
|
98
|
-
{ name: "auth.session", canonical: "auth.user() then read .sessionId" },
|
|
99
|
-
{ name: "auth.getSession", canonical: "auth.user()" },
|
|
100
|
-
{ name: "auth.currentUser", canonical: "auth.user()" },
|
|
101
|
-
{ name: "auth.currentSession", canonical: "auth.user()" },
|
|
102
|
-
{ name: "auth.requireAuth", canonical: "auth.requireUser()" },
|
|
103
|
-
{ name: "auth.middleware", canonical: "auth.csrfField() / @run402/astro middleware" },
|
|
104
|
-
{ name: "auth.signIn", canonical: "POST /auth/sign-in or auth.sessions.createResponseFromIdentity({...})" },
|
|
105
|
-
{ name: "auth.signOut", canonical: "auth.sessions.endResponse()" },
|
|
106
|
-
{ name: "auth.signout", canonical: "auth.sessions.endResponse()" },
|
|
107
|
-
{ name: "auth.logout", canonical: "auth.sessions.endResponse()" },
|
|
108
|
-
{ name: "auth.login", canonical: "auth.sessions.createResponseFromIdentity({...})" },
|
|
109
|
-
{ name: "auth.redirectToSignIn", canonical: "auth.requireUser() — platform handles redirect" },
|
|
110
|
-
{ name: "auth.getUser", canonical: "auth.user()" },
|
|
111
|
-
{ name: "auth.getToken", canonical: "auth.requireUser() then read .sessionId (tokens not exposed)" },
|
|
112
|
-
{ name: "auth.protect", canonical: "auth.requireUser() / auth.requireRole(...)" },
|
|
113
|
-
];
|
|
114
|
-
|
|
115
|
-
/** Browser-only patterns that should NEVER appear in SSR / Lambda code.
|
|
116
|
-
* These are caught at scan time because the SDK doesn't ship a
|
|
117
|
-
* shim — the line just fails to execute. */
|
|
118
|
-
const BROWSER_ONLY_PATTERNS = [
|
|
119
|
-
{
|
|
120
|
-
pattern: /localStorage\.getItem\(\s*['"]wl_session['"]\s*\)/g,
|
|
121
|
-
name: "localStorage.wl_session",
|
|
122
|
-
canonical: "auth.user() (browser sessions are HttpOnly cookies; no localStorage)",
|
|
123
|
-
},
|
|
124
|
-
{
|
|
125
|
-
// Matches: `Authorization: "Bearer ..."` (bare key + string value)
|
|
126
|
-
// `"Authorization": "Bearer ..."` (string key + string value)
|
|
127
|
-
// `'Authorization': 'Bearer ...'` (single quotes)
|
|
128
|
-
pattern: /['"]?Authorization['"]?\s*[:,]\s*['"]Bearer\s/g,
|
|
129
|
-
name: "Authorization: Bearer (in browser code)",
|
|
130
|
-
canonical: "Browser code doesn't carry JWTs. Use auth.fetch() for same-origin SSR fetches.",
|
|
131
|
-
severity: SCAN_SEVERITY.WARN, // Bearer is fine in server-side machine code; gated by file path.
|
|
132
|
-
},
|
|
133
|
-
];
|
|
134
|
-
|
|
135
|
-
/** Scan a single file's content. Returns the array of findings (zero
|
|
136
|
-
* or more). Pure / no I/O — tests pass strings directly. */
|
|
137
|
-
export function scanFileContent(content, opts = {}) {
|
|
138
|
-
const filePath = opts.filePath ?? "<inline>";
|
|
139
|
-
const findings = [];
|
|
140
|
-
|
|
141
|
-
// 1) Hallucinated bare names — import { getSession } from ... OR
|
|
142
|
-
// bare call sites `await getSession(...)`.
|
|
143
|
-
for (const entry of HALLUCINATED_NAMES) {
|
|
144
|
-
// Match in an `import { … }` statement OR a bare function-call site.
|
|
145
|
-
// Negative-lookahead: don't fire on `auth.getSession` etc. (caught
|
|
146
|
-
// separately by the auth-property scanner below).
|
|
147
|
-
const importRegex = new RegExp(
|
|
148
|
-
`import\\s*\\{[^}]*\\b${escapeRegex(entry.name)}\\b[^}]*\\}\\s*from\\s*['"]@run402/functions['"]`,
|
|
149
|
-
"g",
|
|
150
|
-
);
|
|
151
|
-
const callRegex = new RegExp(
|
|
152
|
-
`(?<![.\\w])${escapeRegex(entry.name)}\\s*\\(`,
|
|
153
|
-
"g",
|
|
154
|
-
);
|
|
155
|
-
let match;
|
|
156
|
-
while ((match = importRegex.exec(content)) !== null) {
|
|
157
|
-
findings.push({
|
|
158
|
-
code: "R402_AUTH_UNKNOWN_EXPORT",
|
|
159
|
-
severity: SCAN_SEVERITY.ERROR,
|
|
160
|
-
file: filePath,
|
|
161
|
-
line: lineNumberFor(content, match.index),
|
|
162
|
-
attempted_name: entry.name,
|
|
163
|
-
canonical_name: entry.canonical,
|
|
164
|
-
import_line: 'import { auth } from "@run402/functions"',
|
|
165
|
-
docs: "https://docs.run402.com/auth/sdk",
|
|
166
|
-
message: `Import '${entry.name}' from @run402/functions is not a working export (origin: ${entry.origin}). Use ${entry.canonical}.`,
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
while ((match = callRegex.exec(content)) !== null) {
|
|
170
|
-
findings.push({
|
|
171
|
-
code: "R402_AUTH_UNKNOWN_EXPORT",
|
|
172
|
-
severity: SCAN_SEVERITY.ERROR,
|
|
173
|
-
file: filePath,
|
|
174
|
-
line: lineNumberFor(content, match.index),
|
|
175
|
-
attempted_name: entry.name,
|
|
176
|
-
canonical_name: entry.canonical,
|
|
177
|
-
import_line: 'import { auth } from "@run402/functions"',
|
|
178
|
-
docs: "https://docs.run402.com/auth/sdk",
|
|
179
|
-
message: `Call to '${entry.name}()' will throw R402_AUTH_UNKNOWN_EXPORT at runtime. Use ${entry.canonical}.`,
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// 2) Hallucinated property access on `auth.*`. The SDK Proxy catches
|
|
185
|
-
// these at runtime; we catch earlier at deploy.
|
|
186
|
-
for (const entry of HALLUCINATED_AUTH_PROPERTIES) {
|
|
187
|
-
const regex = new RegExp(`(?<![\\w.])${escapeRegex(entry.name)}\\b`, "g");
|
|
188
|
-
let match;
|
|
189
|
-
while ((match = regex.exec(content)) !== null) {
|
|
190
|
-
findings.push({
|
|
191
|
-
code: "R402_AUTH_UNKNOWN_EXPORT",
|
|
192
|
-
severity: SCAN_SEVERITY.ERROR,
|
|
193
|
-
file: filePath,
|
|
194
|
-
line: lineNumberFor(content, match.index),
|
|
195
|
-
attempted_name: entry.name,
|
|
196
|
-
canonical_name: entry.canonical,
|
|
197
|
-
import_line: 'import { auth } from "@run402/functions"',
|
|
198
|
-
docs: "https://docs.run402.com/auth/sdk",
|
|
199
|
-
message: `'${entry.name}' is not a valid auth.* helper. Use ${entry.canonical}.`,
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// 3) Browser-only / wrong-environment patterns.
|
|
205
|
-
for (const entry of BROWSER_ONLY_PATTERNS) {
|
|
206
|
-
let match;
|
|
207
|
-
while ((match = entry.pattern.exec(content)) !== null) {
|
|
208
|
-
findings.push({
|
|
209
|
-
code: "R402_AUTH_UNKNOWN_EXPORT",
|
|
210
|
-
severity: entry.severity ?? SCAN_SEVERITY.ERROR,
|
|
211
|
-
file: filePath,
|
|
212
|
-
line: lineNumberFor(content, match.index),
|
|
213
|
-
attempted_name: entry.name,
|
|
214
|
-
canonical_name: entry.canonical,
|
|
215
|
-
message: `'${entry.name}' is not supported. ${entry.canonical}.`,
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
// 4) Prerendered pages calling auth.*. The Astro adapter throws
|
|
221
|
-
// R402_AUTH_PRERENDERED at build time; this catches earlier.
|
|
222
|
-
if (filePath.endsWith(".astro") || filePath.endsWith(".ts") || filePath.endsWith(".tsx")) {
|
|
223
|
-
const declaresPrerender = /export\s+const\s+prerender\s*=\s*true/.test(content);
|
|
224
|
-
if (declaresPrerender) {
|
|
225
|
-
const authCallRegex = /\bauth\.(user|requireUser|requireRole|requireMembership|requireFresh|fetch|csrfToken|csrfField|sessions|identities)\b/g;
|
|
226
|
-
let match;
|
|
227
|
-
while ((match = authCallRegex.exec(content)) !== null) {
|
|
228
|
-
findings.push({
|
|
229
|
-
code: "R402_AUTH_PRERENDERED",
|
|
230
|
-
severity: SCAN_SEVERITY.ERROR,
|
|
231
|
-
file: filePath,
|
|
232
|
-
line: lineNumberFor(content, match.index),
|
|
233
|
-
message: `auth.${match[1]} called from a prerendered page. Convert to SSR (\`export const prerender = false\`) or use a server island.`,
|
|
234
|
-
docs: "https://docs.run402.com/auth/rendering-modes",
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// 5) State-changing GET handlers. Heuristic: an Astro `export const GET`
|
|
241
|
-
// or a `GET` Web-handler containing db-mutation patterns.
|
|
242
|
-
const getHandlerRegex = /export\s+(?:async\s+)?(?:const\s+|function\s+)GET\s*[=(]/g;
|
|
243
|
-
const mutationInGetRegex = /\b(?:db|adminDb)\s*\(\s*\)?[^)]*\)?\s*\.(?:insert|update|delete)\s*\(/g;
|
|
244
|
-
const sqlMutationRegex = /\.sql\s*\(\s*['"`]\s*(?:UPDATE|INSERT|DELETE)\b/gi;
|
|
245
|
-
if (getHandlerRegex.test(content)) {
|
|
246
|
-
let match;
|
|
247
|
-
while ((match = mutationInGetRegex.exec(content)) !== null) {
|
|
248
|
-
findings.push({
|
|
249
|
-
code: "R402_AUTH_STATE_CHANGING_GET",
|
|
250
|
-
severity: SCAN_SEVERITY.ERROR,
|
|
251
|
-
file: filePath,
|
|
252
|
-
line: lineNumberFor(content, match.index),
|
|
253
|
-
message: "GET handler mutates state. Move the mutation to POST.",
|
|
254
|
-
docs: "https://docs.run402.com/auth/hosted-ui#post-only",
|
|
255
|
-
});
|
|
256
|
-
}
|
|
257
|
-
while ((match = sqlMutationRegex.exec(content)) !== null) {
|
|
258
|
-
findings.push({
|
|
259
|
-
code: "R402_AUTH_STATE_CHANGING_GET",
|
|
260
|
-
severity: SCAN_SEVERITY.ERROR,
|
|
261
|
-
file: filePath,
|
|
262
|
-
line: lineNumberFor(content, match.index),
|
|
263
|
-
message: "GET handler runs UPDATE/INSERT/DELETE SQL. Move the mutation to POST.",
|
|
264
|
-
docs: "https://docs.run402.com/auth/hosted-ui#post-only",
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// 6) Direct mutation of internal.sessions.authz_version in consumer
|
|
270
|
-
// migrations or SQL strings. The platform is the sole writer.
|
|
271
|
-
const authzVersionRegex = /UPDATE\s+internal\.sessions\s+SET\s+authz_version\b/gi;
|
|
272
|
-
let m;
|
|
273
|
-
while ((m = authzVersionRegex.exec(content)) !== null) {
|
|
274
|
-
findings.push({
|
|
275
|
-
code: "R402_AUTH_AUTHZ_VERSION_PROHIBITED",
|
|
276
|
-
severity: SCAN_SEVERITY.ERROR,
|
|
277
|
-
file: filePath,
|
|
278
|
-
line: lineNumberFor(content, m.index),
|
|
279
|
-
message: "Consumer code may not mutate internal.sessions.authz_version directly. Register your grants table in the authz manifest so the platform installs the bump trigger.",
|
|
280
|
-
docs: "https://docs.run402.com/auth/db-actor-context#authz-version",
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// 7) Redundant `.eq("user_id", user.id)` against RLS-bound tables.
|
|
285
|
-
// db() propagates the actor via the run402.actor.* settings — PostgREST
|
|
286
|
-
// enforces ownership in RLS. Filtering on user_id again is at best a
|
|
287
|
-
// no-op and at worst a code smell that suggests the developer doesn't
|
|
288
|
-
// trust RLS. Catch the most common shapes:
|
|
289
|
-
//
|
|
290
|
-
// .eq("user_id", user.id)
|
|
291
|
-
// .eq('user_id', actor.id)
|
|
292
|
-
// .eq("user_id", await auth.user()).id (rare; covered by the suffix)
|
|
293
|
-
//
|
|
294
|
-
// Opt-out via inline annotation comment on the preceding or same line:
|
|
295
|
-
// // run402-allow-user-filter: <reason>
|
|
296
|
-
// .eq("user_id", joinedRowOwner)
|
|
297
|
-
//
|
|
298
|
-
// Pattern is intentionally narrow (`user_id` literal column name + a
|
|
299
|
-
// value expression matching `<ident>.id`) to keep the false-positive
|
|
300
|
-
// rate low. Heuristic — RLS-binding is unknown at scan time; the rule
|
|
301
|
-
// fires on the shape, and the operator either annotates or fixes.
|
|
302
|
-
const redundantFilterRegex =
|
|
303
|
-
/\.eq\s*\(\s*['"]user_id['"]\s*,\s*([a-zA-Z_$][\w$]*)\.id\s*\)/g;
|
|
304
|
-
const lines = content.split(/\r?\n/);
|
|
305
|
-
const cumulativeOffsets = (() => {
|
|
306
|
-
const offsets = [0];
|
|
307
|
-
for (let i = 0; i < lines.length; i++) {
|
|
308
|
-
// +1 for the trailing newline we split on
|
|
309
|
-
offsets.push(offsets[i] + lines[i].length + 1);
|
|
310
|
-
}
|
|
311
|
-
return offsets;
|
|
312
|
-
})();
|
|
313
|
-
function lineIndexFor(charIndex) {
|
|
314
|
-
// Binary search would be faster; lines are typically <2k.
|
|
315
|
-
for (let i = 0; i < cumulativeOffsets.length - 1; i++) {
|
|
316
|
-
if (charIndex < cumulativeOffsets[i + 1]) return i;
|
|
317
|
-
}
|
|
318
|
-
return cumulativeOffsets.length - 2;
|
|
319
|
-
}
|
|
320
|
-
let f;
|
|
321
|
-
while ((f = redundantFilterRegex.exec(content)) !== null) {
|
|
322
|
-
const lineIdx = lineIndexFor(f.index);
|
|
323
|
-
const thisLine = lines[lineIdx] ?? "";
|
|
324
|
-
const prevLine = lineIdx > 0 ? (lines[lineIdx - 1] ?? "") : "";
|
|
325
|
-
const annotated =
|
|
326
|
-
/\/\/\s*run402-allow-user-filter/i.test(thisLine) ||
|
|
327
|
-
/\/\/\s*run402-allow-user-filter/i.test(prevLine);
|
|
328
|
-
if (annotated) continue;
|
|
329
|
-
findings.push({
|
|
330
|
-
code: "R402_AUTH_REDUNDANT_USER_FILTER",
|
|
331
|
-
severity: SCAN_SEVERITY.WARN,
|
|
332
|
-
file: filePath,
|
|
333
|
-
line: lineIdx + 1,
|
|
334
|
-
message:
|
|
335
|
-
`Redundant '.eq(\"user_id\", ${f[1]}.id)'. db() propagates the actor — PostgREST RLS enforces ownership server-side. ` +
|
|
336
|
-
`If this is intentional (e.g., the table's RLS scopes on something else and you want to filter additionally), ` +
|
|
337
|
-
`silence with: // run402-allow-user-filter: <reason>`,
|
|
338
|
-
docs: "https://run402.com/errors/#R402_AUTH_REDUNDANT_USER_FILTER",
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
// 8) Tenant-assertion session-mint call without the declared capability.
|
|
343
|
-
// `auth.sessions.createResponseFromTenantAssertion(...)` mints a browser
|
|
344
|
-
// session from a tenant's vouching. It works ONLY in a function whose
|
|
345
|
-
// deploy/apply spec declares `capabilities: ["auth.sessionMint"]`
|
|
346
|
-
// (FunctionSpec.capabilities — sibling to `config`, since the platform
|
|
347
|
-
// has no code-export metadata channel). Service-key presence is NOT
|
|
348
|
-
// sufficient. Without the capability the gateway returns
|
|
349
|
-
// R402_AUTH_UNTRUSTED_CONTEXT at runtime and mints no session.
|
|
350
|
-
//
|
|
351
|
-
// The pure file scanner can't see the per-function spec, so the caller
|
|
352
|
-
// threads `opts.declaredCapabilities` (the union of capabilities declared
|
|
353
|
-
// across run402.config.json function entries — see readDeclaredCapabilities).
|
|
354
|
-
// We suppress the finding when "auth.sessionMint" is present anywhere in
|
|
355
|
-
// that union. Global-union (not per-file) is a deliberate precision
|
|
356
|
-
// trade-off: the file→function-entry mapping isn't reliable from source,
|
|
357
|
-
// and the runtime gate catches the rare "function A declared it, function
|
|
358
|
-
// B forgot" case. WARN severity (never block deploy): an inline/SDK spec
|
|
359
|
-
// the doctor can't read might declare the capability.
|
|
360
|
-
const declaredCaps =
|
|
361
|
-
opts.declaredCapabilities instanceof Set
|
|
362
|
-
? opts.declaredCapabilities
|
|
363
|
-
: new Set(Array.isArray(opts.declaredCapabilities) ? opts.declaredCapabilities : []);
|
|
364
|
-
if (!declaredCaps.has("auth.sessionMint")) {
|
|
365
|
-
const mintCallRegex = /\bcreateResponseFromTenantAssertion\s*\(/g;
|
|
366
|
-
let mintMatch;
|
|
367
|
-
while ((mintMatch = mintCallRegex.exec(content)) !== null) {
|
|
368
|
-
findings.push({
|
|
369
|
-
code: "R402_DOCTOR_AUTH_SESSION_MINT_CAPABILITY_MISSING",
|
|
370
|
-
severity: SCAN_SEVERITY.WARN,
|
|
371
|
-
file: filePath,
|
|
372
|
-
line: lineNumberFor(content, mintMatch.index),
|
|
373
|
-
message:
|
|
374
|
-
"createResponseFromTenantAssertion (tenant-assertion session mint) requires the " +
|
|
375
|
-
'"auth.sessionMint" capability, which no function declares in run402.config.json. ' +
|
|
376
|
-
"Without it the gateway returns R402_AUTH_UNTRUSTED_CONTEXT at runtime and mints no session.",
|
|
377
|
-
fix:
|
|
378
|
-
'Add "capabilities": ["auth.sessionMint"] to this function\'s entry in run402.config.json ' +
|
|
379
|
-
'(under functions.replace.<name>, a sibling to "config"). A service key is NOT sufficient.',
|
|
380
|
-
docs: "https://docs.run402.com/auth/tenant-assertion#capability",
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
return findings;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** Recursively walk `srcDir` and scan every file with a relevant
|
|
389
|
-
* extension. Returns the combined findings list, sorted by file +
|
|
390
|
-
* line for stable output. */
|
|
391
|
-
export function scanSourceTree(srcDir, opts = {}) {
|
|
392
|
-
const findings = [];
|
|
393
|
-
// Capability picture for the tenant-assertion mint check (#8). Read from
|
|
394
|
-
// run402.config.json unless the caller passed it explicitly (tests do).
|
|
395
|
-
const declaredCapabilities =
|
|
396
|
-
opts.declaredCapabilities ?? readDeclaredCapabilities(opts.cwd ?? srcDir);
|
|
397
|
-
walk(srcDir, (filePath) => {
|
|
398
|
-
if (!SCANNED_EXTENSIONS.has(extname(filePath))) return;
|
|
399
|
-
let content;
|
|
400
|
-
try {
|
|
401
|
-
content = readFileSync(filePath, "utf8");
|
|
402
|
-
} catch (err) {
|
|
403
|
-
findings.push({
|
|
404
|
-
code: "R402_AUTH_SOURCE_SCAN_ERROR",
|
|
405
|
-
severity: SCAN_SEVERITY.WARN,
|
|
406
|
-
file: relative(opts.cwd ?? srcDir, filePath),
|
|
407
|
-
message: `failed to read file: ${err instanceof Error ? err.message : String(err)}`,
|
|
408
|
-
});
|
|
409
|
-
return;
|
|
410
|
-
}
|
|
411
|
-
findings.push(
|
|
412
|
-
...scanFileContent(content, {
|
|
413
|
-
filePath: relative(opts.cwd ?? srcDir, filePath),
|
|
414
|
-
declaredCapabilities,
|
|
415
|
-
}),
|
|
416
|
-
);
|
|
417
|
-
});
|
|
418
|
-
findings.sort((a, b) => {
|
|
419
|
-
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
|
|
420
|
-
return (a.line ?? 0) - (b.line ?? 0);
|
|
421
|
-
});
|
|
422
|
-
return findings;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/** Scan an explicit list of on-disk file paths — no directory walk.
|
|
426
|
-
* Used by `run402 deploy apply` for manifest/spec/stdin deploys, where
|
|
427
|
-
* the artifact is exactly the set of files the manifest references, NOT
|
|
428
|
-
* whatever happens to live under cwd/src. Files without a
|
|
429
|
-
* scannable extension are ignored; unreadable files become a WARN
|
|
430
|
-
* finding (never throw). Returns the combined findings list, sorted by
|
|
431
|
-
* file + line for stable output, exactly like `scanSourceTree`. */
|
|
432
|
-
export function scanSourceFiles(filePaths, opts = {}) {
|
|
433
|
-
const findings = [];
|
|
434
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
435
|
-
// Same capability picture as scanSourceTree (#8 mint check).
|
|
436
|
-
const declaredCapabilities =
|
|
437
|
-
opts.declaredCapabilities ?? readDeclaredCapabilities(cwd);
|
|
438
|
-
for (const filePath of filePaths) {
|
|
439
|
-
if (!SCANNED_EXTENSIONS.has(extname(filePath))) continue;
|
|
440
|
-
let content;
|
|
441
|
-
try {
|
|
442
|
-
content = readFileSync(filePath, "utf8");
|
|
443
|
-
} catch (err) {
|
|
444
|
-
findings.push({
|
|
445
|
-
code: "R402_AUTH_SOURCE_SCAN_ERROR",
|
|
446
|
-
severity: SCAN_SEVERITY.WARN,
|
|
447
|
-
file: relative(cwd, filePath),
|
|
448
|
-
message: `failed to read file: ${err instanceof Error ? err.message : String(err)}`,
|
|
449
|
-
});
|
|
450
|
-
continue;
|
|
451
|
-
}
|
|
452
|
-
findings.push(
|
|
453
|
-
...scanFileContent(content, {
|
|
454
|
-
filePath: relative(cwd, filePath),
|
|
455
|
-
declaredCapabilities,
|
|
456
|
-
}),
|
|
457
|
-
);
|
|
458
|
-
}
|
|
459
|
-
findings.sort((a, b) => {
|
|
460
|
-
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
|
|
461
|
-
return (a.line ?? 0) - (b.line ?? 0);
|
|
462
|
-
});
|
|
463
|
-
return findings;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
function walk(dir, visitor) {
|
|
467
|
-
let entries;
|
|
468
|
-
try {
|
|
469
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
470
|
-
} catch {
|
|
471
|
-
return;
|
|
472
|
-
}
|
|
473
|
-
for (const entry of entries) {
|
|
474
|
-
if (entry.isDirectory()) {
|
|
475
|
-
if (SKIPPED_DIRECTORIES.has(entry.name)) continue;
|
|
476
|
-
walk(join(dir, entry.name), visitor);
|
|
477
|
-
} else if (entry.isFile()) {
|
|
478
|
-
visitor(join(dir, entry.name));
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function lineNumberFor(content, index) {
|
|
484
|
-
let line = 1;
|
|
485
|
-
for (let i = 0; i < index; i++) {
|
|
486
|
-
if (content.charCodeAt(i) === 10) line++;
|
|
487
|
-
}
|
|
488
|
-
return line;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function escapeRegex(s) {
|
|
492
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/** Convenience for tests: synchronous, no FS access. */
|
|
496
|
-
export function _testOnly_hallucinatedNames() {
|
|
497
|
-
return HALLUCINATED_NAMES.slice();
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
export function _testOnly_authProperties() {
|
|
501
|
-
return HALLUCINATED_AUTH_PROPERTIES.slice();
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/** Read the union of `capabilities` declared across all function entries in
|
|
505
|
-
* `run402.config.json` (the apply spec). Used by the tenant-assertion mint
|
|
506
|
-
* check (#8) to suppress the warning when "auth.sessionMint" is declared.
|
|
507
|
-
*
|
|
508
|
-
* Functions live under `functions.replace.<name>` / `functions.set.<name>`
|
|
509
|
-
* with `capabilities?: string[]` as a sibling to `config`. Best-effort:
|
|
510
|
-
* a missing or malformed config returns an empty set (the scanner then
|
|
511
|
-
* warns, which is the safe default — the runtime gate is the hard
|
|
512
|
-
* enforcement). Returns a `Set<string>`. */
|
|
513
|
-
export function readDeclaredCapabilities(cwd = process.cwd()) {
|
|
514
|
-
const caps = new Set();
|
|
515
|
-
let parsed;
|
|
516
|
-
try {
|
|
517
|
-
parsed = JSON.parse(readFileSync(join(cwd, "run402.config.json"), "utf8"));
|
|
518
|
-
} catch {
|
|
519
|
-
return caps; // no config / unreadable / malformed → nothing declared
|
|
520
|
-
}
|
|
521
|
-
const fns = parsed?.functions;
|
|
522
|
-
if (!fns || typeof fns !== "object") return caps;
|
|
523
|
-
for (const bucket of ["replace", "set", "patch"]) {
|
|
524
|
-
const entries = fns[bucket];
|
|
525
|
-
if (!entries || typeof entries !== "object") continue;
|
|
526
|
-
for (const entry of Object.values(entries)) {
|
|
527
|
-
const declared = entry?.capabilities;
|
|
528
|
-
if (Array.isArray(declared)) {
|
|
529
|
-
for (const cap of declared) if (typeof cap === "string") caps.add(cap);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
return caps;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/** Resolve the project's src/ directory. Astro convention is `<root>/src`;
|
|
537
|
-
* bare Node projects use `<root>/src` or `<root>`. We prefer `src/` if
|
|
538
|
-
* it exists. */
|
|
539
|
-
export function resolveScanRoot(cwd = process.cwd()) {
|
|
540
|
-
const srcDir = join(cwd, "src");
|
|
541
|
-
try {
|
|
542
|
-
if (statSync(srcDir).isDirectory()) return srcDir;
|
|
543
|
-
} catch {
|
|
544
|
-
// Fall through.
|
|
545
|
-
}
|
|
546
|
-
return cwd;
|
|
547
|
-
}
|
|
1
|
+
// Shared with Node up, deploy apply, and scoped doctor.
|
|
2
|
+
export { scanFileContent, scanSourceTree, scanSourceFiles, resolveScanRoot, readDeclaredCapabilities, SCAN_SEVERITY, _testOnly_hallucinatedNames, _testOnly_authProperties } from "#sdk/node";
|
package/lib/doctor.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveApplicationScope, loadApplicationScanInput, scanDeploymentSources } from "#sdk/node";
|
|
1
2
|
/**
|
|
2
3
|
* run402 doctor — Health and config diagnostics.
|
|
3
4
|
*
|
|
@@ -28,7 +29,7 @@ import { fail } from "./sdk-errors.mjs";
|
|
|
28
29
|
import { normalizeArgv, assertKnownFlags, flagValue } from "./argparse.mjs";
|
|
29
30
|
|
|
30
31
|
/** Value-taking flags — the flag set doctor actually parses; anything else is BAD_USAGE via assertKnownFlags, never silently ignored. */
|
|
31
|
-
const DOCTOR_VALUE_FLAGS = ["--scan-dir", "--buzz-agent", "--project", "--only"];
|
|
32
|
+
const DOCTOR_VALUE_FLAGS = ["--scan-dir", "--dir", "--manifest", "--buzz-agent", "--project", "--only"];
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* The stable, complete registry of ordinary-mode check names. One entry
|
|
@@ -113,7 +114,9 @@ Options:
|
|
|
113
114
|
reports fresh/age_ms/refresh_attempted/refresh_failed.
|
|
114
115
|
--no-scan Skip the source-tree scan (config / health checks only). Implied
|
|
115
116
|
by any --only that omits source_scan.
|
|
116
|
-
--
|
|
117
|
+
--dir D Select the application directory for deployment diagnostics.
|
|
118
|
+
--manifest P Explicitly select a manifest (executable configs are trusted code).
|
|
119
|
+
--scan-dir D Advanced arbitrary scan; does not claim deployment readiness
|
|
117
120
|
--project <id> Target THIS project's gitvault check instead of the repo-standing
|
|
118
121
|
default (the 4.38.0 pin / run402 remote / RUN402_PROJECT_ID / active
|
|
119
122
|
project, in that order — see \`gitvault-target.mjs\`). Scoped to the
|
|
@@ -265,6 +268,8 @@ function redactAllowanceForDiagnostics(allowance) {
|
|
|
265
268
|
if (!allowance || typeof allowance !== "object") return allowance;
|
|
266
269
|
const safe = { ...allowance };
|
|
267
270
|
delete safe.privateKey;
|
|
271
|
+
if (typeof safe.funded === "boolean") safe.faucet_used = safe.funded;
|
|
272
|
+
delete safe.funded;
|
|
268
273
|
return safe;
|
|
269
274
|
}
|
|
270
275
|
|
|
@@ -506,7 +511,7 @@ export async function run(sub, args = []) {
|
|
|
506
511
|
lifecycle,
|
|
507
512
|
active,
|
|
508
513
|
organization_lifecycle_state: lifecycle,
|
|
509
|
-
lease_expires_at: tier?.lease_expires_at ?? null,
|
|
514
|
+
lease_expires_at: tier?.lease_perpetual === true ? null : tier?.lease_expires_at ?? null,
|
|
510
515
|
reachable_projects: reachableProjects,
|
|
511
516
|
};
|
|
512
517
|
if (status === "ok") {
|
|
@@ -942,17 +947,30 @@ export async function run(sub, args = []) {
|
|
|
942
947
|
// otherwise bury the gitvault diagnosis under thousands of hits.
|
|
943
948
|
if (!skipScan && wanted("source_scan")) {
|
|
944
949
|
try {
|
|
945
|
-
const
|
|
946
|
-
|
|
950
|
+
const scope = await resolveApplicationScope({ dir: flagValue(all, "--dir") ?? undefined, manifest: flagValue(all, "--manifest") ?? undefined });
|
|
951
|
+
if (!scanDirOverride && !scope.selected) {
|
|
952
|
+
checks.push({ name: "source_scan", status: "skipped", value: { scope: "unscoped", app_root: scope.app_root }, message: "No application selected. Run doctor --dir <app> or --manifest <path>; sibling applications are not deployment blockers." });
|
|
953
|
+
} else {
|
|
954
|
+
const scanRoot = scanDirOverride ?? resolveScanRoot(scope.app_root);
|
|
955
|
+
const scanContext = { scope: scanDirOverride ? "explicit_scan_directory" : "application", app_root: scope.app_root, manifest_path: scope.manifest_path };
|
|
956
|
+
let findings;
|
|
957
|
+
if (scanDirOverride) findings = scanSourceTree(scanRoot, { cwd: scope.app_root });
|
|
958
|
+
else {
|
|
959
|
+
const selected = await loadApplicationScanInput(scope.manifest_path);
|
|
960
|
+
scanContext.build_outputs = selected.build_deferred ? "deferred_until_build" : "not_deferred";
|
|
961
|
+
findings = scanDeploymentSources(selected.spec, scope.app_root).findings;
|
|
962
|
+
}
|
|
947
963
|
const errorFindings = findings.filter((f) => f.severity === SCAN_SEVERITY.ERROR);
|
|
948
964
|
const warnFindings = findings.filter((f) => f.severity === SCAN_SEVERITY.WARN);
|
|
949
965
|
if (findings.length === 0) {
|
|
950
|
-
checks.push({ name: "source_scan", status: "ok", value: { scan_root: scanRoot, file_count_with_findings: 0 } });
|
|
966
|
+
checks.push({ name: "source_scan", status: "ok", value: { ...scanContext, scan_root: scanRoot, file_count_with_findings: 0 } });
|
|
951
967
|
} else {
|
|
952
968
|
checks.push({
|
|
953
969
|
name: "source_scan",
|
|
954
970
|
status: errorFindings.length > 0 ? "error" : "warning",
|
|
971
|
+
...(scanDirOverride ? { severity: "advisory" } : {}),
|
|
955
972
|
value: {
|
|
973
|
+
...scanContext,
|
|
956
974
|
scan_root: scanRoot,
|
|
957
975
|
findings: errorFindings.length + warnFindings.length,
|
|
958
976
|
errors: errorFindings.length,
|
|
@@ -960,14 +978,16 @@ export async function run(sub, args = []) {
|
|
|
960
978
|
details: findings,
|
|
961
979
|
},
|
|
962
980
|
hint: errorFindings.length > 0
|
|
963
|
-
? "
|
|
981
|
+
? scanDirOverride ? "Findings are from the explicit arbitrary scan directory; they do not establish that an application deploy will be refused." : "Fix the findings in this application. The same scoped source scan gates up and deploy apply."
|
|
964
982
|
: "Source scan emitted warnings (non-blocking). Review and address when convenient.",
|
|
965
983
|
});
|
|
966
984
|
}
|
|
985
|
+
}
|
|
967
986
|
} catch (err) {
|
|
968
987
|
checks.push({
|
|
969
988
|
name: "source_scan",
|
|
970
|
-
status: "
|
|
989
|
+
status: "error",
|
|
990
|
+
value: { code: err?.code ?? "APPLICATION_SCAN_FAILED", details: err?.details ?? null },
|
|
971
991
|
message: err instanceof Error ? err.message : String(err),
|
|
972
992
|
});
|
|
973
993
|
}
|
package/lib/sdk-errors.mjs
CHANGED
|
@@ -123,6 +123,13 @@ export function reportSdkError(err) {
|
|
|
123
123
|
mergeStructuredErrorFields(payload, err);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
if (err?.code === "REST_PERMISSION_DENIED") {
|
|
127
|
+
payload.code = err.code;
|
|
128
|
+
payload.message = err.message;
|
|
129
|
+
payload.details = err.details;
|
|
130
|
+
payload.upstream_body = err.body;
|
|
131
|
+
}
|
|
132
|
+
|
|
126
133
|
// Org-owned control plane (gateway v1.77+): a NOT_AUTHORIZED denial means the
|
|
127
134
|
// wallet authenticated but the resolved principal lacks the org role/grant for
|
|
128
135
|
// this control-plane action — distinct from a missing-auth or payment error.
|
package/lib/status.mjs
CHANGED
|
@@ -158,7 +158,7 @@ export async function run(args = []) {
|
|
|
158
158
|
held_usd_micros: hasBilling ? (billing.held_usd_micros ?? 0) : null,
|
|
159
159
|
},
|
|
160
160
|
tier: tier && tier.tier
|
|
161
|
-
? { name: tier.tier, status: tier.status, expires: tier.lease_expires_at }
|
|
161
|
+
? { name: tier.tier, status: tier.status, expires: tier.lease_perpetual === true ? null : tier.lease_expires_at }
|
|
162
162
|
: null,
|
|
163
163
|
// v1.57: lifecycle state and the per-organization escape hatch moved to the
|
|
164
164
|
// organization. Surface them at the top level so agents don't have to
|