sporades 0.8.7 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGES.md +4 -25
- package/README.md +31 -0
- package/bin/sporades-host-helper.js +297 -11
- package/bin/sporades.js +3429 -471
- package/dist/access-key-contract.d.ts +5 -0
- package/dist/access-key-contract.d.ts.map +1 -0
- package/dist/access-key-contract.js +5 -0
- package/dist/access-key-contract.js.map +1 -0
- package/dist/access-keys-runtime.d.ts +195 -0
- package/dist/access-keys-runtime.d.ts.map +1 -0
- package/dist/access-keys-runtime.js +871 -0
- package/dist/access-keys-runtime.js.map +1 -0
- package/dist/acl-runtime.d.ts +5 -2
- package/dist/acl-runtime.d.ts.map +1 -1
- package/dist/acl-runtime.js +12 -5
- package/dist/acl-runtime.js.map +1 -1
- package/dist/auth-admission.d.ts +23 -0
- package/dist/auth-admission.d.ts.map +1 -0
- package/dist/auth-admission.js +189 -0
- package/dist/auth-admission.js.map +1 -0
- package/dist/auth-runtime.d.ts +34 -1
- package/dist/auth-runtime.d.ts.map +1 -1
- package/dist/auth-runtime.js +88 -25
- package/dist/auth-runtime.js.map +1 -1
- package/dist/bundle-pipeline.d.ts.map +1 -1
- package/dist/bundle-pipeline.js +7 -0
- package/dist/bundle-pipeline.js.map +1 -1
- package/dist/cli/access-key-operator-envelope.d.ts +9 -0
- package/dist/cli/access-key-operator-envelope.d.ts.map +1 -0
- package/dist/cli/access-key-operator-envelope.js +209 -0
- package/dist/cli/access-key-operator-envelope.js.map +1 -0
- package/dist/cli/cli-help.d.ts +9 -2
- package/dist/cli/cli-help.d.ts.map +1 -1
- package/dist/cli/cli-help.js +26 -0
- package/dist/cli/cli-help.js.map +1 -1
- package/dist/cli/cli-support.d.ts +1 -0
- package/dist/cli/cli-support.d.ts.map +1 -1
- package/dist/cli/cli-version.d.ts +1 -1
- package/dist/cli/cli-version.js +1 -1
- package/dist/cli/host-helper-requests.d.ts +8 -2
- package/dist/cli/host-helper-requests.d.ts.map +1 -1
- package/dist/cli/project-config.d.ts +1 -0
- package/dist/cli/project-config.d.ts.map +1 -1
- package/dist/cli/project-config.js +5 -0
- package/dist/cli/project-config.js.map +1 -1
- package/dist/cli/schedule-inspection-envelope.d.ts.map +1 -1
- package/dist/cli/schedule-inspection-envelope.js +46 -3
- package/dist/cli/schedule-inspection-envelope.js.map +1 -1
- package/dist/cli/sporades-host-helper.js +73 -9
- package/dist/cli/sporades-host-helper.js.map +1 -1
- package/dist/cli/sporades.js +195 -3
- package/dist/cli/sporades.js.map +1 -1
- package/dist/client-toolchain.js +11 -1
- package/dist/client-toolchain.js.map +1 -1
- package/dist/client.d.ts +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +1 -0
- package/dist/client.js.map +1 -1
- package/dist/database-runtime.d.ts.map +1 -1
- package/dist/database-runtime.js +229 -0
- package/dist/database-runtime.js.map +1 -1
- package/dist/file-storage-runtime.d.ts +1 -1
- package/dist/file-storage-runtime.d.ts.map +1 -1
- package/dist/file-storage-runtime.js +4 -4
- package/dist/file-storage-runtime.js.map +1 -1
- package/dist/generated-source-manifest.json +43 -2
- package/dist/http-runtime.d.ts +1 -1
- package/dist/http-runtime.d.ts.map +1 -1
- package/dist/http-runtime.js +100 -13
- package/dist/http-runtime.js.map +1 -1
- package/dist/jobs-runtime.d.ts +63 -0
- package/dist/jobs-runtime.d.ts.map +1 -1
- package/dist/jobs-runtime.js +164 -4
- package/dist/jobs-runtime.js.map +1 -1
- package/dist/server-runtime-source.d.ts +56 -2
- package/dist/server-runtime-source.d.ts.map +1 -1
- package/dist/server-runtime-source.js +507 -96
- package/dist/server-runtime-source.js.map +1 -1
- package/dist/server.d.ts +28 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +140 -6
- package/dist/server.js.map +1 -1
- package/dist/stripe-events-runtime.d.ts +16 -0
- package/dist/stripe-events-runtime.d.ts.map +1 -0
- package/dist/stripe-events-runtime.js +21 -0
- package/dist/stripe-events-runtime.js.map +1 -0
- package/dist/stripe-payment-config.d.ts +24 -0
- package/dist/stripe-payment-config.d.ts.map +1 -0
- package/dist/stripe-payment-config.js +143 -0
- package/dist/stripe-payment-config.js.map +1 -0
- package/dist/stripe-payment-integration.d.ts +11 -0
- package/dist/stripe-payment-integration.d.ts.map +1 -0
- package/dist/stripe-payment-integration.js +309 -0
- package/dist/stripe-payment-integration.js.map +1 -0
- package/dist/stripe-webhook-runtime.d.ts +22 -0
- package/dist/stripe-webhook-runtime.d.ts.map +1 -0
- package/dist/stripe-webhook-runtime.js +38 -0
- package/dist/stripe-webhook-runtime.js.map +1 -0
- package/dist/teams-runtime.d.ts.map +1 -1
- package/dist/teams-runtime.js +10 -1
- package/dist/teams-runtime.js.map +1 -1
- package/dist/templates/client-runtime-template.d.ts.map +1 -1
- package/dist/templates/client-runtime-template.js +44 -10
- package/dist/templates/client-runtime-template.js.map +1 -1
- package/dist/templates/scaffold-template.d.ts +3 -0
- package/dist/templates/scaffold-template.d.ts.map +1 -1
- package/dist/templates/scaffold-template.js +318 -11
- package/dist/templates/scaffold-template.js.map +1 -1
- package/dist/templates/server-bundle-entry.d.ts.map +1 -1
- package/dist/templates/server-bundle-entry.js +55 -7
- package/dist/templates/server-bundle-entry.js.map +1 -1
- package/dist/templates/server-bundle-module-graph.d.ts.map +1 -1
- package/dist/templates/server-bundle-module-graph.js +15 -4
- package/dist/templates/server-bundle-module-graph.js.map +1 -1
- package/package.json +10 -1
- package/src/types/client.d.ts +52 -1
- package/src/types/server.d.ts +176 -25
- package/src/types/stripe.d.ts +102 -0
package/CHANGES.md
CHANGED
|
@@ -1,32 +1,10 @@
|
|
|
1
1
|
# Changes
|
|
2
2
|
|
|
3
|
-
## Unreleased - 2026-08-
|
|
3
|
+
## Unreleased - 2026-08-24
|
|
4
4
|
|
|
5
|
-
Changes since v0.
|
|
5
|
+
Changes since v0.9.0.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
- Add transaction-bound trusted reads (9d23493).
|
|
10
|
-
|
|
11
|
-
### 🐛 Bug Fixes
|
|
12
|
-
|
|
13
|
-
- Keep admission logs in transaction (5a9d012).
|
|
14
|
-
- Revoke trusted reads at settlement (dca0fea).
|
|
15
|
-
- Reject trusted reads from snapshots (9abe441).
|
|
16
|
-
- Bind Join cancellation to commit (e37058d).
|
|
17
|
-
- Close Team admission boundary races (b12dbde).
|
|
18
|
-
- Trust Team Join admission reads (32b65ee).
|
|
19
|
-
- Close trusted-read review gaps (da0c453).
|
|
20
|
-
|
|
21
|
-
### 📝 Documentation
|
|
22
|
-
|
|
23
|
-
- Complete trusted Join admission ticket (0347c0e).
|
|
24
|
-
- Complete trusted-read foundation ticket (2a24b7b).
|
|
25
|
-
|
|
26
|
-
### 🧪 Tests
|
|
27
|
-
|
|
28
|
-
- Restore PostgreSQL SQL quoting audit (c1fcf7c).
|
|
29
|
-
- Tidy up test (f643360).
|
|
7
|
+
No changes detected.
|
|
30
8
|
|
|
31
9
|
## 0.8.5 - 2026-08-15
|
|
32
10
|
|
|
@@ -64,3 +42,4 @@ generated artifacts, and documentation.
|
|
|
64
42
|
not select a current Team or automatically partition Capsule data; Sporades
|
|
65
43
|
never sends Join-link email. See the [Built-in Teams reference](https://mgscox.github.io/sporades/reference/teams).
|
|
66
44
|
|
|
45
|
+
|
package/README.md
CHANGED
|
@@ -51,6 +51,9 @@ sporades host push --restart
|
|
|
51
51
|
logs, database inspection, and non-interactive commands are first-class.
|
|
52
52
|
- **Comprehensive SDK**: built-in authentication, database, user preferences,
|
|
53
53
|
and storage.
|
|
54
|
+
- **Named automation without fake users**: linked users can issue scoped Access
|
|
55
|
+
keys for API clients while Capsule code retains the real owner in `ctx.auth`
|
|
56
|
+
and distinguishes named API access through `ctx.credential`.
|
|
54
57
|
- **Built-in collaboration, explicitly authorized**: runtime-owned Teams,
|
|
55
58
|
email-bound Join links, membership application roles, and explicit Team ACL
|
|
56
59
|
decisions—without a hidden current Team or automatic data partitioning.
|
|
@@ -66,6 +69,9 @@ sporades host push --restart
|
|
|
66
69
|
command lookup, split into focused topic pages.
|
|
67
70
|
- [Built-in Teams](https://mgscox.github.io/sporades/reference/teams): collaboration model, Join
|
|
68
71
|
links, application roles, and explicit Team ACL decisions.
|
|
72
|
+
- [Access-key server and operator reference](https://mgscox.github.io/sporades/reference/server-runtime#gate-handlers-with-requireauth):
|
|
73
|
+
scoped Bearer admission, user-owned lifecycle, Credential provenance, and
|
|
74
|
+
the bounded operator CLI.
|
|
69
75
|
- [LLM documentation manifest](https://mgscox.github.io/sporades/llms.txt): release-matched Markdown
|
|
70
76
|
sources for agents and other documentation tools.
|
|
71
77
|
- [Architecture](https://mgscox.github.io/sporades/architecture): platform model, runtime modes, Host
|
|
@@ -146,6 +152,28 @@ Sporades currently includes:
|
|
|
146
152
|
- Inferno scaffolds across the complete template set, using native class-component
|
|
147
153
|
lifecycle and classic Inferno JSX without React compatibility packages. esbuild
|
|
148
154
|
remains the default; Vite is available explicitly with full-page Dev refresh.
|
|
155
|
+
- Every new blank Capsule includes a built-in but disabled Stripe payment
|
|
156
|
+
foundation: server-owned wiring, named Checkout and Customer Portal Jobs, shared Job state, an
|
|
157
|
+
empty Price catalogue, and no credentials, payment UI, active callback route, or
|
|
158
|
+
browser Stripe SDK. Complete activation supports an authorized, idempotent
|
|
159
|
+
one-time or recurring subscription Checkout through the same durable Job,
|
|
160
|
+
actor-scoped progress, safe retries, and validated Stripe-hosted redirects.
|
|
161
|
+
Portal remains linked-user-only: Capsule policy authorizes an opaque billing
|
|
162
|
+
holder and resolves its existing Customer before enqueue, then the Job repeats
|
|
163
|
+
both checks before provider access so revocation fails safely. No Customer
|
|
164
|
+
identity is stored in the Capsule intent or Job payload. It is the preferred
|
|
165
|
+
surface for ordinary customer-managed payment methods, invoices,
|
|
166
|
+
cancellations, and supported subscription changes. Complete activation also
|
|
167
|
+
registers one collision-checked callback path. Signed exact bytes are admitted
|
|
168
|
+
into one idempotent Privileged Job per Stripe Event before acknowledgment;
|
|
169
|
+
retries converge and no Capsule billing state changes automatically. The Job
|
|
170
|
+
delivers one Verified Stripe event to the blank Capsule's single
|
|
171
|
+
`stripeEvents: stripeEvent(...)` policy seam under the userless Privileged
|
|
172
|
+
audit lifecycle. Generated policy ignores unknown events and tells authors to
|
|
173
|
+
make deliberate consequences idempotent and order-independent, reject stale
|
|
174
|
+
later-arriving observations, and avoid logging or retaining raw provider data.
|
|
175
|
+
The catalogue owns each Price mode; browser input owns neither mode nor Stripe
|
|
176
|
+
identity. Existing and non-blank Capsules remain unchanged.
|
|
149
177
|
- Local Dev sessions with rebuilds, WebSocket reconnects, SQLite persistence,
|
|
150
178
|
logs, database inspection, auth helpers, and file storage. Every admitted
|
|
151
179
|
esbuild/Vite pair uses the same acknowledged Sporades full-page refresh
|
|
@@ -156,6 +184,9 @@ Sporades currently includes:
|
|
|
156
184
|
- Runtime-owned auth with anonymous sessions, email auth, Google OAuth,
|
|
157
185
|
Microsoft OpenID Connect, Sign in with Apple, Facebook Login, and
|
|
158
186
|
local identity simulation for tests and agents.
|
|
187
|
+
- User-owned named Access keys with Capsule-declared scopes, explicit Custom
|
|
188
|
+
endpoint and private-File opt-in, one-time secret disclosure, Session-only
|
|
189
|
+
owner management, durable Job provenance, and audited operator retirement.
|
|
159
190
|
- Runtime-owned current-user preferences exposed through `sporades/client`
|
|
160
191
|
without app preference tables. Preferences follow the Sporades user identity,
|
|
161
192
|
survive Anonymous account linking, and notify same-user connected clients when
|
|
@@ -163,8 +163,23 @@ function validatePublicTreeFileSet(files) {
|
|
|
163
163
|
// src/server-runtime-source.ts
|
|
164
164
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
165
165
|
|
|
166
|
+
// src/auth-admission.ts
|
|
167
|
+
var AUTH_REQUIREMENTS = Symbol.for("sporades.auth.requirements");
|
|
168
|
+
var ACCESS_KEY_SCOPE_LIMIT = 1024;
|
|
169
|
+
var ACCESS_KEY_SCOPE_BYTE_LIMIT = 256;
|
|
170
|
+
|
|
171
|
+
// src/access-key-contract.ts
|
|
172
|
+
var ACCESS_KEY_GRANT_LIMIT = 128;
|
|
173
|
+
var ACCESS_KEY_GRANT_BYTE_LIMIT = 256;
|
|
174
|
+
var ACCESS_KEY_GRANTS_JSON_BYTE_LIMIT = 32 * 1024;
|
|
175
|
+
var ACCESS_KEY_CLIENT_ADDRESS_HEADER = "x-sporades-client-address";
|
|
176
|
+
|
|
177
|
+
// src/access-keys-runtime.ts
|
|
178
|
+
var UNKNOWN_ACCESS_KEY_DIGEST = Buffer.from("4f7c77f7b9231094754542ed50fdfd62a2cf24a5e961b61f899b85b6fe33c72b", "hex");
|
|
179
|
+
|
|
166
180
|
// src/jobs-runtime.ts
|
|
167
181
|
var nodeCryptoModule = process.getBuiltinModule("node:crypto");
|
|
182
|
+
var JOB_AUTH_SNAPSHOT_MAX_BYTES = 8 * 1024;
|
|
168
183
|
var MAX_JOB_TIMESTAMP_MS = Date.parse("9999-12-31T23:59:59.999Z");
|
|
169
184
|
var MIN_JOB_TIMESTAMP_MS = Date.parse("0000-01-01T00:00:00.000Z");
|
|
170
185
|
|
|
@@ -219,7 +234,7 @@ function privilegedAuditLevelForOutcome(outcome) {
|
|
|
219
234
|
return "info";
|
|
220
235
|
}
|
|
221
236
|
function safePrivilegedAuditErrorCode(value, outcome = "started") {
|
|
222
|
-
const source = value && typeof value === "object"
|
|
237
|
+
const source = value && typeof value === "object" ? "code" in value ? value.code : null : value;
|
|
223
238
|
if (source === null || source === void 0 || source === "") {
|
|
224
239
|
if (outcome === "errored") {
|
|
225
240
|
return "UNKNOWN_ERROR";
|
|
@@ -262,6 +277,160 @@ var PASSWORD_RESET_DEFAULT_TTL_MS = 60 * 60 * 1e3;
|
|
|
262
277
|
var PASSWORD_RESET_MIN_TTL_MS = 5 * 60 * 1e3;
|
|
263
278
|
var PASSWORD_RESET_MAX_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
264
279
|
|
|
280
|
+
// src/cli/access-key-operator-envelope.ts
|
|
281
|
+
import { createInterface } from "node:readline/promises";
|
|
282
|
+
var ACCESS_KEY_OPERATOR_ACTIONS = [
|
|
283
|
+
"access-keys.list",
|
|
284
|
+
"access-keys.inspect",
|
|
285
|
+
"access-keys.revoke",
|
|
286
|
+
"access-keys.revoke-all",
|
|
287
|
+
"access-keys.delete"
|
|
288
|
+
];
|
|
289
|
+
var ACTIONS = new Set(ACCESS_KEY_OPERATOR_ACTIONS);
|
|
290
|
+
var STATUSES = /* @__PURE__ */ new Set(["active", "expired", "revoked"]);
|
|
291
|
+
var REVOCATION_CAUSES = /* @__PURE__ */ new Set(["owner", "operator", "password-reset", "owner-unlinked", "owner-deleted"]);
|
|
292
|
+
var ACCESS_KEY_OPERATOR_LIST_PAGE_LIMIT = 100;
|
|
293
|
+
var JSON_STRING_MAX_BYTE_EXPANSION = 6;
|
|
294
|
+
var ACCESS_KEY_OPERATOR_ENVELOPE_STRUCTURAL_HEADROOM = 8 * 1024 * 1024;
|
|
295
|
+
var ACCESS_KEY_OPERATOR_ENVELOPE_BYTE_LIMIT = JSON_STRING_MAX_BYTE_EXPANSION * ((ACCESS_KEY_OPERATOR_LIST_PAGE_LIMIT + 1) * ACCESS_KEY_SCOPE_LIMIT * ACCESS_KEY_SCOPE_BYTE_LIMIT + ACCESS_KEY_OPERATOR_LIST_PAGE_LIMIT * ACCESS_KEY_GRANT_LIMIT * ACCESS_KEY_GRANT_BYTE_LIMIT) + ACCESS_KEY_OPERATOR_ENVELOPE_STRUCTURAL_HEADROOM;
|
|
296
|
+
var ACCESS_KEY_OPERATOR_PROCESS_MAX_BUFFER = ACCESS_KEY_OPERATOR_ENVELOPE_BYTE_LIMIT + 1024 * 1024;
|
|
297
|
+
var SAFE_ERRORS = {
|
|
298
|
+
UNAUTHENTICATED: { message: "Authentication is required.", hint: "Use an authorized Session and retry the operation." },
|
|
299
|
+
FORBIDDEN: { message: "Access-key operation is forbidden.", hint: "Use an authorized operator context." },
|
|
300
|
+
ACCESS_KEY_DELETE_REQUIRES_REVOKED: { message: "Access key must be revoked before deletion.", hint: "Revoke the key, then delete its history." },
|
|
301
|
+
ACCESS_KEY_LIMIT_REACHED: { message: "Access-key limit reached.", hint: "Retire an existing key before retrying." },
|
|
302
|
+
ACCESS_KEY_NAME_CONFLICT: { message: "Access-key name is already in use.", hint: "Choose a unique name." },
|
|
303
|
+
ACCESS_KEY_NOT_ACTIVE: { message: "Access key is not active.", hint: "Inspect current metadata before retrying." },
|
|
304
|
+
ACCESS_KEY_NOT_FOUND: { message: "Access key was not found.", hint: "Refresh metadata and use an exact immutable key ID." },
|
|
305
|
+
ACCESS_KEY_REVISION_CONFLICT: { message: "Access-key revision changed.", hint: "Refresh metadata and retry." },
|
|
306
|
+
ACCESS_KEY_SECRET_CONFLICT: { message: "Access-key generation conflicted.", hint: "Retry the operation." },
|
|
307
|
+
INVALID_ACCESS_KEY_EXPIRY: { message: "Access-key expiry is invalid.", hint: "Use a valid future expiry." },
|
|
308
|
+
INVALID_ACCESS_KEY_GRANTS: { message: "Access-key grants are invalid.", hint: "Use the Capsule's declared scope vocabulary." },
|
|
309
|
+
INVALID_ACCESS_KEY_LIST_OPTIONS: { message: "Access-key list options are invalid.", hint: "Use supported cursor, limit, and status filters." },
|
|
310
|
+
INVALID_ACCESS_KEY_NAME: { message: "Access-key name is invalid.", hint: "Use a valid unique name." },
|
|
311
|
+
INVALID_ACCESS_KEY_ACTION_INPUT: { message: "Invalid Access-key operator action input.", hint: "Upgrade the Sporades CLI and generated Bundle together." },
|
|
312
|
+
ACCESS_KEY_ACTION_UNSUPPORTED: { message: "Unsupported Access-key operator action.", hint: "Upgrade the Sporades CLI and generated Bundle together." },
|
|
313
|
+
ACCESS_KEY_ACTION_FAILED: { message: "Access-key operator action failed.", hint: "Check the Privileged audit events and retry the operation." },
|
|
314
|
+
HOST_HELPER_UPGRADE_REQUIRED: { message: "The Host helper does not support this Access-key action.", hint: "Upgrade the Host helper, redeploy the Capsule, and retry." },
|
|
315
|
+
HOSTED_CAPSULE_NOT_RUNNING: { message: "The Hosted Capsule is not running.", hint: "Start the Hosted Capsule, then retry the operation." },
|
|
316
|
+
HOSTED_ACCESS_KEY_RESPONSE_INVALID: { message: "Hosted Access-key action returned an invalid response.", hint: "Upgrade the Host helper, redeploy the Capsule, and retry." }
|
|
317
|
+
};
|
|
318
|
+
function plain(value) {
|
|
319
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
320
|
+
const prototype = Object.getPrototypeOf(value);
|
|
321
|
+
return prototype === Object.prototype || prototype === null;
|
|
322
|
+
}
|
|
323
|
+
function exactKeys(value, required, optional = []) {
|
|
324
|
+
const keys = Object.keys(value);
|
|
325
|
+
return required.every((key) => keys.includes(key)) && keys.every((key) => required.includes(key) || optional.includes(key));
|
|
326
|
+
}
|
|
327
|
+
function boundedString(value, maximum = 256) {
|
|
328
|
+
return typeof value === "string" && value.length > 0 && Buffer.byteLength(value, "utf8") <= maximum;
|
|
329
|
+
}
|
|
330
|
+
function optionalString(value, maximum = 512) {
|
|
331
|
+
return value === null || boundedString(value, maximum);
|
|
332
|
+
}
|
|
333
|
+
function stringList(value, maximumItems, maximumItemBytes) {
|
|
334
|
+
return Array.isArray(value) && value.length <= maximumItems && value.every((item) => boundedString(item, maximumItemBytes));
|
|
335
|
+
}
|
|
336
|
+
function optionalRevocationCause(value) {
|
|
337
|
+
return value === null || typeof value === "string" && REVOCATION_CAUSES.has(value);
|
|
338
|
+
}
|
|
339
|
+
function encodedWithinLimit(value, maximum) {
|
|
340
|
+
try {
|
|
341
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8") <= maximum;
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function validateAccessKeyOperatorActionInput(action, value, invalid) {
|
|
347
|
+
if (typeof action !== "string" || !ACTIONS.has(action) || !plain(value) || !encodedWithinLimit(value, 16 * 1024)) return invalid();
|
|
348
|
+
if (action === "access-keys.list") {
|
|
349
|
+
if (!exactKeys(value, ["userId", "options"]) || !boundedString(value.userId) || !plain(value.options) || !exactKeys(value.options, [], ["cursor", "limit", "status"])) return invalid();
|
|
350
|
+
const options = {};
|
|
351
|
+
if (value.options.cursor !== void 0) {
|
|
352
|
+
if (!boundedString(value.options.cursor, 512)) return invalid();
|
|
353
|
+
options.cursor = value.options.cursor;
|
|
354
|
+
}
|
|
355
|
+
if (value.options.limit !== void 0) {
|
|
356
|
+
if (!Number.isInteger(value.options.limit) || value.options.limit < 1 || value.options.limit > 100) return invalid();
|
|
357
|
+
options.limit = value.options.limit;
|
|
358
|
+
}
|
|
359
|
+
if (value.options.status !== void 0) {
|
|
360
|
+
if (typeof value.options.status !== "string" || !STATUSES.has(value.options.status)) return invalid();
|
|
361
|
+
options.status = value.options.status;
|
|
362
|
+
}
|
|
363
|
+
return { userId: value.userId, options };
|
|
364
|
+
}
|
|
365
|
+
if (action === "access-keys.revoke-all") {
|
|
366
|
+
if (!exactKeys(value, ["userId"]) || !boundedString(value.userId)) return invalid();
|
|
367
|
+
return { userId: value.userId };
|
|
368
|
+
}
|
|
369
|
+
if (!exactKeys(value, ["keyId"]) || !boundedString(value.keyId)) return invalid();
|
|
370
|
+
return { keyId: value.keyId };
|
|
371
|
+
}
|
|
372
|
+
function canonicalCapsule(value, invalid) {
|
|
373
|
+
if (!plain(value) || !exactKeys(value, ["name"]) || !boundedString(value.name)) return invalid();
|
|
374
|
+
return { name: value.name };
|
|
375
|
+
}
|
|
376
|
+
function canonicalSummary(value, invalid) {
|
|
377
|
+
const fields = [
|
|
378
|
+
"id",
|
|
379
|
+
"ownerUserId",
|
|
380
|
+
"name",
|
|
381
|
+
"grants",
|
|
382
|
+
"effectiveScopes",
|
|
383
|
+
"status",
|
|
384
|
+
"createdAt",
|
|
385
|
+
"expiresAt",
|
|
386
|
+
"rotatedAt",
|
|
387
|
+
"revokedAt",
|
|
388
|
+
"revocationCause",
|
|
389
|
+
"lastUsedAt",
|
|
390
|
+
"lifecycleRevision"
|
|
391
|
+
];
|
|
392
|
+
if (!plain(value) || !exactKeys(value, fields) || !boundedString(value.id) || !boundedString(value.ownerUserId) || !boundedString(value.name, 512) || !stringList(value.grants, ACCESS_KEY_GRANT_LIMIT, ACCESS_KEY_GRANT_BYTE_LIMIT) || !stringList(value.effectiveScopes, ACCESS_KEY_SCOPE_LIMIT, ACCESS_KEY_SCOPE_BYTE_LIMIT) || typeof value.status !== "string" || !STATUSES.has(value.status) || !boundedString(value.createdAt, 64) || !optionalString(value.expiresAt, 64) || !optionalString(value.rotatedAt, 64) || !optionalString(value.revokedAt, 64) || !optionalRevocationCause(value.revocationCause) || !optionalString(value.lastUsedAt, 64) || !Number.isSafeInteger(value.lifecycleRevision) || value.lifecycleRevision < 1) return invalid();
|
|
393
|
+
return Object.fromEntries(fields.map((field) => [field, value[field]]));
|
|
394
|
+
}
|
|
395
|
+
function canonicalSuccessData(action, value, input, invalid) {
|
|
396
|
+
if (!plain(value)) return invalid();
|
|
397
|
+
const capsule = canonicalCapsule(value.capsule, invalid);
|
|
398
|
+
if (action === "access-keys.list") {
|
|
399
|
+
if (!exactKeys(value, ["capsule", "accessKeys", "declaredScopes", "nextCursor", "totalCount"]) || !Array.isArray(value.accessKeys) || value.accessKeys.length > 100 || !stringList(value.declaredScopes, ACCESS_KEY_SCOPE_LIMIT, ACCESS_KEY_SCOPE_BYTE_LIMIT) || !optionalString(value.nextCursor, 512) || !Number.isSafeInteger(value.totalCount) || value.totalCount < 0) return invalid();
|
|
400
|
+
const accessKeys = value.accessKeys.map((item) => canonicalSummary(item, invalid));
|
|
401
|
+
if (accessKeys.some((item) => item.ownerUserId !== input.userId)) return invalid();
|
|
402
|
+
return { capsule, accessKeys, declaredScopes: [...value.declaredScopes], nextCursor: value.nextCursor, totalCount: value.totalCount };
|
|
403
|
+
}
|
|
404
|
+
if (["access-keys.inspect", "access-keys.revoke"].includes(action)) {
|
|
405
|
+
if (!exactKeys(value, ["capsule", "accessKey"])) return invalid();
|
|
406
|
+
const accessKey = canonicalSummary(value.accessKey, invalid);
|
|
407
|
+
if (accessKey.id !== input.keyId || action === "access-keys.revoke" && accessKey.status !== "revoked") return invalid();
|
|
408
|
+
return { capsule, accessKey };
|
|
409
|
+
}
|
|
410
|
+
if (action === "access-keys.revoke-all") {
|
|
411
|
+
if (!exactKeys(value, ["capsule", "ownerUserId", "revokedCount", "accessKeys"]) || value.ownerUserId !== input.userId || !Number.isSafeInteger(value.revokedCount) || value.revokedCount < 0 || !Array.isArray(value.accessKeys) || value.accessKeys.length > 100) return invalid();
|
|
412
|
+
const accessKeys = value.accessKeys.map((item) => canonicalSummary(item, invalid));
|
|
413
|
+
if (accessKeys.some((item) => item.ownerUserId !== input.userId || item.status !== "revoked" || item.revocationCause !== "operator") || accessKeys.length !== value.revokedCount) return invalid();
|
|
414
|
+
return { capsule, ownerUserId: value.ownerUserId, revokedCount: value.revokedCount, accessKeys };
|
|
415
|
+
}
|
|
416
|
+
if (!exactKeys(value, ["capsule", "id", "ownerUserId", "deleted"]) || value.id !== input.keyId || !boundedString(value.ownerUserId) || value.deleted !== true) return invalid();
|
|
417
|
+
return { capsule, id: value.id, ownerUserId: value.ownerUserId, deleted: true };
|
|
418
|
+
}
|
|
419
|
+
function canonicalError(value, invalid) {
|
|
420
|
+
if (!plain(value) || !exactKeys(value, ["code", "message", "hint"]) || typeof value.code !== "string" || !SAFE_ERRORS[value.code] || !boundedString(value.message, 1024) || !boundedString(value.hint, 1024)) return invalid();
|
|
421
|
+
return { code: value.code, ...SAFE_ERRORS[value.code] };
|
|
422
|
+
}
|
|
423
|
+
function sanitizeAccessKeyOperatorEnvelope(value, action, input, invalid) {
|
|
424
|
+
if (!plain(value) || !encodedWithinLimit(value, ACCESS_KEY_OPERATOR_ENVELOPE_BYTE_LIMIT) || typeof value.ok !== "boolean") return invalid();
|
|
425
|
+
const boundedInput = validateAccessKeyOperatorActionInput(action, input, invalid);
|
|
426
|
+
if (value.ok) {
|
|
427
|
+
if (!exactKeys(value, ["ok", "data", "error"]) || value.error !== null) return invalid();
|
|
428
|
+
return { ok: true, data: canonicalSuccessData(String(action), value.data, boundedInput, invalid), error: null };
|
|
429
|
+
}
|
|
430
|
+
if (!exactKeys(value, ["ok", "data", "error"]) || value.data !== null) return invalid();
|
|
431
|
+
return { ok: false, data: null, error: canonicalError(value.error, invalid) };
|
|
432
|
+
}
|
|
433
|
+
|
|
265
434
|
// src/database-runtime.ts
|
|
266
435
|
var nodeCryptoModule4 = process.getBuiltinModule("node:crypto");
|
|
267
436
|
var nodeFsModule = process.getBuiltinModule("node:fs");
|
|
@@ -270,6 +439,7 @@ var transactionBeforeCommitChecks2 = Symbol.for("sporades.database.transactionBe
|
|
|
270
439
|
|
|
271
440
|
// src/server-runtime-source.ts
|
|
272
441
|
var trustedReadTransactionAdapter = Symbol("sporades.trustedReadTransactionAdapter");
|
|
442
|
+
var runtimeOwnedJobEnqueueHandler = Symbol("sporades.runtimeOwnedJobEnqueueHandler");
|
|
273
443
|
function logPayloadMaxBytes(config = {}) {
|
|
274
444
|
const configured = Number(config.logs?.payloadMaxBytes ?? config.logging?.payloadMaxBytes);
|
|
275
445
|
return Number.isInteger(configured) && configured > 0 ? configured : 4096;
|
|
@@ -425,13 +595,59 @@ function writeEnvelope(result, failed = false) {
|
|
|
425
595
|
}
|
|
426
596
|
|
|
427
597
|
// src/cli/cli-version.ts
|
|
428
|
-
var CLI_VERSION = "0.
|
|
598
|
+
var CLI_VERSION = "0.9.1";
|
|
429
599
|
|
|
430
600
|
// src/cli/schedule-inspection-envelope.ts
|
|
601
|
+
var SCHEDULE_DIAGNOSTIC_FIELDS = /* @__PURE__ */ new Set([
|
|
602
|
+
"name",
|
|
603
|
+
"expression",
|
|
604
|
+
"timezone",
|
|
605
|
+
"missedRun",
|
|
606
|
+
"enabled",
|
|
607
|
+
"exhausted",
|
|
608
|
+
"nextOccurrence",
|
|
609
|
+
"latestOccurrence",
|
|
610
|
+
"latestOccurrence.scheduledFor",
|
|
611
|
+
"latestOccurrence.jobId",
|
|
612
|
+
"latestOccurrence.errorCode",
|
|
613
|
+
"latestOccurrence.outcome"
|
|
614
|
+
]);
|
|
615
|
+
var SCHEDULE_DIAGNOSTIC_INLINE_NAME_BYTES = 4096;
|
|
616
|
+
var SCHEDULE_DIAGNOSTIC_NAME_PREFIX_LENGTH = 128;
|
|
617
|
+
function scheduleNameDigest(value) {
|
|
618
|
+
return process.getBuiltinModule("node:crypto").createHash("sha256").update(value, "utf8").digest("hex");
|
|
619
|
+
}
|
|
620
|
+
function boundedScheduleDiagnostic(candidate) {
|
|
621
|
+
const scheduleName = candidate?.scheduleName;
|
|
622
|
+
const field = candidate?.field;
|
|
623
|
+
if (candidate?.code !== "SCHEDULE_INSPECTION_INVALID_STATE" || typeof field !== "string" || !SCHEDULE_DIAGNOSTIC_FIELDS.has(field)) return void 0;
|
|
624
|
+
if (typeof scheduleName === "string" && /^[A-Za-z][A-Za-z0-9_-]*$/.test(scheduleName)) {
|
|
625
|
+
const scheduleNameBytes = Buffer.byteLength(scheduleName, "utf8");
|
|
626
|
+
if (scheduleNameBytes <= SCHEDULE_DIAGNOSTIC_INLINE_NAME_BYTES) return { code: candidate.code, scheduleName, field };
|
|
627
|
+
return {
|
|
628
|
+
code: candidate.code,
|
|
629
|
+
scheduleNamePrefix: scheduleName.slice(0, SCHEDULE_DIAGNOSTIC_NAME_PREFIX_LENGTH),
|
|
630
|
+
scheduleNameSha256: scheduleNameDigest(scheduleName),
|
|
631
|
+
scheduleNameBytes,
|
|
632
|
+
field
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
if (typeof candidate?.scheduleNamePrefix === "string" && /^[A-Za-z][A-Za-z0-9_-]*$/.test(candidate.scheduleNamePrefix) && candidate.scheduleNamePrefix.length === SCHEDULE_DIAGNOSTIC_NAME_PREFIX_LENGTH && typeof candidate?.scheduleNameSha256 === "string" && /^[a-f0-9]{64}$/.test(candidate.scheduleNameSha256) && Number.isInteger(candidate?.scheduleNameBytes) && candidate.scheduleNameBytes > SCHEDULE_DIAGNOSTIC_INLINE_NAME_BYTES) {
|
|
636
|
+
return {
|
|
637
|
+
code: candidate.code,
|
|
638
|
+
scheduleNamePrefix: candidate.scheduleNamePrefix,
|
|
639
|
+
scheduleNameSha256: candidate.scheduleNameSha256,
|
|
640
|
+
scheduleNameBytes: candidate.scheduleNameBytes,
|
|
641
|
+
field
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
return void 0;
|
|
645
|
+
}
|
|
431
646
|
function sanitizeScheduleInspectionEnvelope(envelope, invalid) {
|
|
432
647
|
if (envelope?.ok === false) {
|
|
433
648
|
const source = envelope.error;
|
|
434
|
-
const
|
|
649
|
+
const candidate = source?.diagnostics ?? source;
|
|
650
|
+
const diagnostics = boundedScheduleDiagnostic(candidate);
|
|
435
651
|
return { ok: false, data: null, error: {
|
|
436
652
|
message: diagnostics ? "Persisted Schedule state is malformed." : "Schedule inspection failed.",
|
|
437
653
|
hint: diagnostics ? "Repair or remove the malformed Schedule before retrying inspection." : "Inspect the Capsule and retry the command.",
|
|
@@ -1047,13 +1263,45 @@ function validateClaimedReleaseFiles(files) {
|
|
|
1047
1263
|
// src/cli/sporades-host-helper.ts
|
|
1048
1264
|
var CAPSULE_RUNTIME_HEALTH_PATH = "/__sporades/health/runtime";
|
|
1049
1265
|
var RUNTIME_PROBE_HEADER = "x-sporades-host-probe";
|
|
1266
|
+
var CLOUDFLARE_ORIGIN_IP_RANGES = Object.freeze([
|
|
1267
|
+
"173.245.48.0/20",
|
|
1268
|
+
"103.21.244.0/22",
|
|
1269
|
+
"103.22.200.0/22",
|
|
1270
|
+
"103.31.4.0/22",
|
|
1271
|
+
"141.101.64.0/18",
|
|
1272
|
+
"108.162.192.0/18",
|
|
1273
|
+
"190.93.240.0/20",
|
|
1274
|
+
"188.114.96.0/20",
|
|
1275
|
+
"197.234.240.0/22",
|
|
1276
|
+
"198.41.128.0/17",
|
|
1277
|
+
"162.158.0.0/15",
|
|
1278
|
+
"104.16.0.0/13",
|
|
1279
|
+
"104.24.0.0/14",
|
|
1280
|
+
"172.64.0.0/13",
|
|
1281
|
+
"131.0.72.0/22",
|
|
1282
|
+
"2400:cb00::/32",
|
|
1283
|
+
"2606:4700::/32",
|
|
1284
|
+
"2803:f800::/32",
|
|
1285
|
+
"2405:b500::/32",
|
|
1286
|
+
"2405:8100::/32",
|
|
1287
|
+
"2a06:98c0::/29",
|
|
1288
|
+
"2c0f:f248::/32"
|
|
1289
|
+
]);
|
|
1050
1290
|
var hostHelperConfig = defaultHostHelperConfig();
|
|
1291
|
+
var HOSTED_ACCESS_KEY_ACTIONS = /* @__PURE__ */ new Set([
|
|
1292
|
+
"access-keys.list",
|
|
1293
|
+
"access-keys.inspect",
|
|
1294
|
+
"access-keys.revoke",
|
|
1295
|
+
"access-keys.revoke-all",
|
|
1296
|
+
"access-keys.delete"
|
|
1297
|
+
]);
|
|
1051
1298
|
main().catch((error) => {
|
|
1052
1299
|
writeEnvelope(
|
|
1053
1300
|
{
|
|
1054
1301
|
ok: false,
|
|
1055
1302
|
data: null,
|
|
1056
1303
|
error: {
|
|
1304
|
+
...error.code ? { code: error.code } : {},
|
|
1057
1305
|
message: error.message,
|
|
1058
1306
|
hint: error.hint ?? "Check the Host helper request and retry the command.",
|
|
1059
1307
|
...error.diagnostics ? { diagnostics: error.diagnostics } : {}
|
|
@@ -1126,6 +1374,10 @@ async function main() {
|
|
|
1126
1374
|
inspectCapsuleSchedules(request);
|
|
1127
1375
|
return;
|
|
1128
1376
|
}
|
|
1377
|
+
if (HOSTED_ACCESS_KEY_ACTIONS.has(request.action)) {
|
|
1378
|
+
runCapsuleAccessKeyAction(request);
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1129
1381
|
if (request.action === "host.stats") {
|
|
1130
1382
|
await statsHost(request);
|
|
1131
1383
|
return;
|
|
@@ -1155,14 +1407,33 @@ function inspectCapsuleSchedules(request) {
|
|
|
1155
1407
|
validateScheduleInspectionRequest(request);
|
|
1156
1408
|
inspectCapsuleRuntime(request, "schedules.inspect", "Schedule", (envelope) => sanitizeScheduleInspectionEnvelope(envelope, () => {
|
|
1157
1409
|
throw helperError("Hosted Schedule inspection returned an invalid response.", "Run `sporades host upgrade`, redeploy the Capsule, and retry the command.");
|
|
1158
|
-
}));
|
|
1410
|
+
}), [], true);
|
|
1411
|
+
}
|
|
1412
|
+
function runCapsuleAccessKeyAction(request) {
|
|
1413
|
+
const exactKeys2 = (value, keys) => Object.keys(value).length === keys.length && Object.keys(value).every((key) => keys.includes(key));
|
|
1414
|
+
if (!exactKeys2(request, ["action", "host", "capsule", "accessKeys"]) || !request.host || typeof request.host !== "object" || Array.isArray(request.host) || !exactKeys2(request.host, ["alias", "domain", "scheme", "remoteRoot"]) || !request.capsule || typeof request.capsule !== "object" || Array.isArray(request.capsule) || !exactKeys2(request.capsule, ["subname"])) {
|
|
1415
|
+
throw Object.assign(helperError("Invalid Hosted Access-key action request.", "Upgrade the local Sporades CLI and Host helper together."), { code: "INVALID_ACCESS_KEY_ACTION_INPUT" });
|
|
1416
|
+
}
|
|
1417
|
+
const accessKeys = validateAccessKeyOperatorActionInput(request.action, request.accessKeys, () => {
|
|
1418
|
+
throw Object.assign(helperError("Invalid Hosted Access-key action request.", "Upgrade the local Sporades CLI and Host helper together."), { code: "INVALID_ACCESS_KEY_ACTION_INPUT" });
|
|
1419
|
+
});
|
|
1420
|
+
inspectCapsuleRuntime(request, request.action, "Access-key", (envelope) => sanitizeAccessKeyOperatorEnvelope(envelope, request.action, accessKeys, () => {
|
|
1421
|
+
throw Object.assign(helperError("Hosted Access-key action returned an invalid response.", "Run `sporades host upgrade`, redeploy the Capsule, and retry the command."), { code: "HOSTED_ACCESS_KEY_RESPONSE_INVALID" });
|
|
1422
|
+
}), [
|
|
1423
|
+
"--sporades-action-input",
|
|
1424
|
+
Buffer.from(JSON.stringify(accessKeys), "utf8").toString("base64url")
|
|
1425
|
+
]);
|
|
1159
1426
|
}
|
|
1160
|
-
function inspectCapsuleRuntime(request, action, label, sanitize = (envelope) => envelope) {
|
|
1427
|
+
function inspectCapsuleRuntime(request, action, label, sanitize = (envelope) => envelope, extraArgs = [], preserveBoundedDiagnostics = false) {
|
|
1161
1428
|
const containerName = createHostedContainerName(request.host.domain, request.capsule.subname);
|
|
1162
1429
|
if (!checkContainerRunning(containerName)) {
|
|
1163
|
-
|
|
1430
|
+
const error = helperError("The Hosted Capsule is not running.", `Run \`sporades host start ${request.capsule.subname} --host ${request.host.alias}\`, then retry the command.`);
|
|
1431
|
+
if (label === "Access-key") error.code = "HOSTED_CAPSULE_NOT_RUNNING";
|
|
1432
|
+
throw error;
|
|
1164
1433
|
}
|
|
1165
|
-
const result = runDocker(["exec", containerName, "node", "/app/server.mjs", "--sporades-action", action]
|
|
1434
|
+
const result = runDocker(["exec", containerName, "node", "/app/server.mjs", "--sporades-action", action, ...extraArgs], {
|
|
1435
|
+
maxBuffer: label === "Access-key" ? ACCESS_KEY_OPERATOR_PROCESS_MAX_BUFFER : void 0
|
|
1436
|
+
});
|
|
1166
1437
|
let envelope;
|
|
1167
1438
|
try {
|
|
1168
1439
|
envelope = JSON.parse(result.stdout.trim());
|
|
@@ -1170,7 +1441,11 @@ function inspectCapsuleRuntime(request, action, label, sanitize = (envelope) =>
|
|
|
1170
1441
|
throw helperError(`Hosted ${label} inspection returned invalid JSON.`, "Run `sporades host upgrade`, redeploy the Capsule, and retry the command.");
|
|
1171
1442
|
}
|
|
1172
1443
|
const bounded = sanitize(envelope);
|
|
1173
|
-
if (!bounded.ok)
|
|
1444
|
+
if (!bounded.ok) {
|
|
1445
|
+
const error = helperError(bounded.error.message, bounded.error.hint, preserveBoundedDiagnostics ? bounded.error.diagnostics : void 0);
|
|
1446
|
+
if (label === "Access-key" && bounded.error.code) error.code = bounded.error.code;
|
|
1447
|
+
throw error;
|
|
1448
|
+
}
|
|
1174
1449
|
writeEnvelope(bounded);
|
|
1175
1450
|
}
|
|
1176
1451
|
function versionHost(request) {
|
|
@@ -3865,7 +4140,7 @@ function ensureHostedBaseImage(lifecycle) {
|
|
|
3865
4140
|
}
|
|
3866
4141
|
}
|
|
3867
4142
|
function runDocker(args, options = {}) {
|
|
3868
|
-
const result = spawnSync2("docker", args, { encoding: "utf8" });
|
|
4143
|
+
const result = spawnSync2("docker", args, { encoding: "utf8", ...options.maxBuffer ? { maxBuffer: options.maxBuffer } : {} });
|
|
3869
4144
|
if (options.ignoreFailure) {
|
|
3870
4145
|
return { ok: result.status === 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
3871
4146
|
}
|
|
@@ -3951,11 +4226,22 @@ function loopbackRunningRoute(route, publishedPort) {
|
|
|
3951
4226
|
}
|
|
3952
4227
|
async function writeRunningRoute(lifecycle, route = lifecycle.routes.running) {
|
|
3953
4228
|
await provisionRouteLogFile(route);
|
|
3954
|
-
const
|
|
4229
|
+
const cloudflareOrigin = route.tls?.mode === "cloudflare-origin";
|
|
4230
|
+
const proxyLine = [
|
|
4231
|
+
`reverse_proxy ${route.upstream ?? `${route.containerName}:${route.port ?? 4e3}`} {`,
|
|
4232
|
+
` header_up ${ACCESS_KEY_CLIENT_ADDRESS_HEADER} ${cloudflareOrigin ? "{http.request.header.CF-Connecting-IP}" : "{http.request.remote.host}"}`,
|
|
4233
|
+
" }"
|
|
4234
|
+
].join("\n");
|
|
4235
|
+
const routeHandler = renderRunningRouteHandler(route, proxyLine);
|
|
4236
|
+
const guardedHandler = cloudflareOrigin ? [
|
|
4237
|
+
`@sporadesUntrustedCloudflareSource not remote_ip ${CLOUDFLARE_ORIGIN_IP_RANGES.join(" ")}`,
|
|
4238
|
+
"respond @sporadesUntrustedCloudflareSource 403",
|
|
4239
|
+
routeHandler
|
|
4240
|
+
].join("\n ") : routeHandler;
|
|
3955
4241
|
await applyManagedRoute(
|
|
3956
4242
|
lifecycle,
|
|
3957
4243
|
route.routeFile,
|
|
3958
|
-
renderRoute(route,
|
|
4244
|
+
renderRoute(route, guardedHandler)
|
|
3959
4245
|
);
|
|
3960
4246
|
}
|
|
3961
4247
|
function renderRunningRouteHandler(route, proxyLine) {
|