zitejs 0.10.0-start.6 → 0.10.0-start.7
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/dist/cjs/backend/index.d.ts +115 -3
- package/dist/cjs/backend/index.test.js +58 -0
- package/dist/cjs/integrations/rules.d.ts +13 -0
- package/dist/cjs/integrations/rules.js +29 -0
- package/dist/cjs/integrations/rules.test.js +1 -0
- package/dist/cjs/sync/lib.js +6 -6
- package/dist/esm/backend/index.d.ts +115 -3
- package/dist/esm/backend/index.test.js +58 -0
- package/dist/esm/integrations/rules.d.ts +13 -0
- package/dist/esm/integrations/rules.js +29 -0
- package/dist/esm/integrations/rules.test.js +1 -0
- package/dist/esm/start/authHooks.d.ts +7 -0
- package/dist/esm/start/authHooks.js +10 -0
- package/dist/esm/start/authHooksModule.d.ts +1 -0
- package/dist/esm/start/authHooksModule.js +3 -0
- package/dist/esm/start/clientErrorMessage.test.d.ts +1 -0
- package/dist/esm/start/clientErrorMessage.test.js +105 -0
- package/dist/esm/start/describe.d.ts +17 -1
- package/dist/esm/start/describe.js +27 -5
- package/dist/esm/start/errors.d.ts +46 -0
- package/dist/esm/start/errors.js +99 -0
- package/dist/esm/start/errors.test.d.ts +1 -0
- package/dist/esm/start/errors.test.js +23 -0
- package/dist/esm/start/index.d.ts +3 -19
- package/dist/esm/start/index.js +28 -36
- package/dist/esm/start/instance.d.ts +1 -2
- package/dist/esm/start/instance.js +11 -4
- package/dist/esm/start/previewClaim.d.ts +10 -0
- package/dist/esm/start/previewClaim.js +32 -0
- package/dist/esm/start/redactSecrets.d.ts +7 -0
- package/dist/esm/start/redactSecrets.js +131 -0
- package/dist/esm/start/redactSecrets.test.d.ts +1 -0
- package/dist/esm/start/redactSecrets.test.js +151 -0
- package/dist/esm/start/router.d.ts +6 -3
- package/dist/esm/start/router.js +47 -7
- package/dist/esm/start/runLog.d.ts +7 -4
- package/dist/esm/start/runLog.js +64 -6
- package/dist/esm/start/server.d.ts +7 -3
- package/dist/esm/start/server.js +74 -21
- package/dist/esm/start/types.d.ts +28 -3
- package/dist/esm/start/version.d.ts +1 -0
- package/dist/esm/start/version.js +2 -0
- package/dist/esm/start/vite.js +31 -3
- package/dist/esm/start/workflowRun.js +2 -1
- package/dist/esm/sync/lib.js +6 -6
- package/package.json +16 -8
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { type AnySerializationAdapter } from "@tanstack/react-router";
|
|
2
2
|
import type { SSROption } from "@tanstack/router-core";
|
|
3
3
|
import type { AnyFunctionMiddleware, AnyRequestMiddleware, StartInstance } from "@tanstack/start-client-core";
|
|
4
|
-
import { ZiteError } from "./index.js";
|
|
5
4
|
/**
|
|
6
5
|
* The start instance every Start app builds with: the platform's middleware and
|
|
7
6
|
* adapters first, then whatever the app's own `src/start.ts` sets.
|
|
8
7
|
*/
|
|
9
|
-
export declare function createZiteStart<const TAdapters extends ReadonlyArray<AnySerializationAdapter> = [], TDefaultSsr extends SSROption = SSROption, const TRequest extends ReadonlyArray<AnyRequestMiddleware> = [], const TFunction extends ReadonlyArray<AnyFunctionMiddleware> = []>(app?: StartInstance<TAdapters, TDefaultSsr, TRequest, TFunction>): StartInstance<readonly [import("@tanstack/react-router").SerializationAdapter<
|
|
8
|
+
export declare function createZiteStart<const TAdapters extends ReadonlyArray<AnySerializationAdapter> = [], TDefaultSsr extends SSROption = SSROption, const TRequest extends ReadonlyArray<AnyRequestMiddleware> = [], const TFunction extends ReadonlyArray<AnyFunctionMiddleware> = []>(app?: StartInstance<TAdapters, TDefaultSsr, TRequest, TFunction>): StartInstance<readonly [import("@tanstack/react-router").SerializationAdapter<Error, {
|
|
10
9
|
code: "BAD_REQUEST" | "UNAUTHORIZED" | "FORBIDDEN" | "NOT_FOUND" | "CONFLICT" | "RATE_LIMITED" | "INTERNAL_ERROR" | "INVALID_INPUT";
|
|
11
10
|
message: string;
|
|
12
11
|
}, never>, ...TAdapters], TDefaultSsr, readonly [import("@tanstack/react-start").RequestMiddlewareAfterServer<{}, undefined, undefined>, ...TRequest], readonly [import("@tanstack/react-start").FunctionMiddlewareAfterClient<{}, unknown, undefined, undefined, undefined>, import("@tanstack/react-start").FunctionMiddlewareAfterServer<{}, unknown, undefined, undefined, undefined, undefined, undefined>, ...TFunction]>;
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// `src/start.ts`, which the generated instance composes after the platform's.
|
|
3
3
|
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
|
|
4
4
|
import { createSerializationAdapter, redirect } from "@tanstack/react-router";
|
|
5
|
-
import {
|
|
5
|
+
import { toClientError, ZiteError } from "./errors.js";
|
|
6
|
+
import { isSignInRequired, loginHref } from "./index.js";
|
|
6
7
|
import { errorOf, preview } from "./runLog.js";
|
|
7
8
|
import { getHost, inScope } from "./scope.js";
|
|
8
9
|
// TanStack drops its default CSRF check once an app has a start instance.
|
|
@@ -47,11 +48,17 @@ const runCapture = createMiddleware({ type: "function" }).server(async ({ next,
|
|
|
47
48
|
throw error;
|
|
48
49
|
}
|
|
49
50
|
});
|
|
50
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Every error a server function or a page's loader sends the browser arrives as a ZiteError, with its code and the
|
|
53
|
+
* message classic would show. Anything else on it, a stack or an SDK's request, stays on the server.
|
|
54
|
+
*/
|
|
51
55
|
const ziteErrorAdapter = createSerializationAdapter({
|
|
52
56
|
key: "zite-error",
|
|
53
|
-
test: (value) => value instanceof
|
|
54
|
-
toSerializable: (error) =>
|
|
57
|
+
test: (value) => value instanceof Error,
|
|
58
|
+
toSerializable: (error) => {
|
|
59
|
+
const { code, message } = toClientError(error);
|
|
60
|
+
return { code, message };
|
|
61
|
+
},
|
|
55
62
|
fromSerializable: ({ code, message }) => new ZiteError(code, message),
|
|
56
63
|
});
|
|
57
64
|
/**
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare const PREVIEW_CLAIM_PARAM = "zite_preview";
|
|
2
|
+
export declare const PREVIEW_CLAIM_HEADER = "x-zite-preview";
|
|
3
|
+
/**
|
|
4
|
+
* An editor preview's access, which the host keeps on the URL because the editor's frame may drop its cookie (WebKit
|
|
5
|
+
* drops even Partitioned ones). On the server the host hands it over; in the browser it's on the URL the host sent
|
|
6
|
+
* the page to, read once, before the router hides it.
|
|
7
|
+
*/
|
|
8
|
+
export declare const previewClaim: () => string | undefined;
|
|
9
|
+
/** Every same-origin fetch from the page carries the claim: server functions and the app's own routes alike. */
|
|
10
|
+
export declare const carryPreviewClaim: (claim: string) => void;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { currentHost } from "./host.js";
|
|
2
|
+
// The host's names (apps/zite-host/src/previewClaim.ts).
|
|
3
|
+
export const PREVIEW_CLAIM_PARAM = "zite_preview";
|
|
4
|
+
export const PREVIEW_CLAIM_HEADER = "x-zite-preview";
|
|
5
|
+
let captured;
|
|
6
|
+
/**
|
|
7
|
+
* An editor preview's access, which the host keeps on the URL because the editor's frame may drop its cookie (WebKit
|
|
8
|
+
* drops even Partitioned ones). On the server the host hands it over; in the browser it's on the URL the host sent
|
|
9
|
+
* the page to, read once, before the router hides it.
|
|
10
|
+
*/
|
|
11
|
+
export const previewClaim = () => {
|
|
12
|
+
if (typeof window === "undefined")
|
|
13
|
+
return currentHost()?.previewClaim;
|
|
14
|
+
captured ??= new URL(window.location.href).searchParams.get(PREVIEW_CLAIM_PARAM);
|
|
15
|
+
return captured ?? undefined;
|
|
16
|
+
};
|
|
17
|
+
let carrying = false;
|
|
18
|
+
/** Every same-origin fetch from the page carries the claim: server functions and the app's own routes alike. */
|
|
19
|
+
export const carryPreviewClaim = (claim) => {
|
|
20
|
+
if (carrying || typeof window === "undefined")
|
|
21
|
+
return;
|
|
22
|
+
carrying = true;
|
|
23
|
+
const original = window.fetch.bind(window);
|
|
24
|
+
window.fetch = (input, init) => {
|
|
25
|
+
const target = new URL(input instanceof Request ? input.url : String(input), window.location.href);
|
|
26
|
+
if (target.origin !== window.location.origin)
|
|
27
|
+
return original(input, init);
|
|
28
|
+
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
|
|
29
|
+
headers.set(PREVIEW_CLAIM_HEADER, claim);
|
|
30
|
+
return original(input, { ...init, headers });
|
|
31
|
+
};
|
|
32
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const REDACTED = "REDACTED";
|
|
2
|
+
/**
|
|
3
|
+
* Replace any credentials in `text` with a placeholder, leaving the rest
|
|
4
|
+
* intact. Returns the input unchanged when there is nothing to redact, so
|
|
5
|
+
* callers that pass clean text see byte-identical output.
|
|
6
|
+
*/
|
|
7
|
+
export declare function redactSecrets(text: string): string;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Ported from restly's shared/src/redactSecrets.ts, so a live Start app redacts
|
|
2
|
+
// exactly what a classic one does. Keep the two in step.
|
|
3
|
+
export const REDACTED = "REDACTED";
|
|
4
|
+
/**
|
|
5
|
+
* A run of characters that can plausibly be a secret's value. Stops at
|
|
6
|
+
* whitespace and at the punctuation that normally *ends* a value in a sentence
|
|
7
|
+
* or a serialized payload, so the redaction does not swallow the rest of the
|
|
8
|
+
* text after it.
|
|
9
|
+
*/
|
|
10
|
+
const VALUE_RUN = String.raw `[^\s,;)\]}"'<>]+`;
|
|
11
|
+
const CREDENTIAL_KEY = String.raw `(?:password|passwd|secret|api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|auth[_-]?token|client[_-]?secret|private[_-]?key)`;
|
|
12
|
+
// The scheme is length-capped because, unbounded, every position in a long
|
|
13
|
+
// alphanumeric run (hex, a base64 blob) rescans to the end of the run —
|
|
14
|
+
// quadratic, and minutes of blocked event loop on a megabyte of output.
|
|
15
|
+
const URL_USERINFO = String.raw `([a-z][a-z0-9+.-]{0,31}:\/\/[^\s:/@]+:)`;
|
|
16
|
+
const JSON_MEMBER = String.raw `("${CREDENTIAL_KEY}"\s*:\s*")`;
|
|
17
|
+
const JSON_VALUE = String.raw `(?:[^"\\]|\\.)*`;
|
|
18
|
+
const ESCAPED_JSON_MEMBER = String.raw `(\\"${CREDENTIAL_KEY}\\"\s*:\s*\\")`;
|
|
19
|
+
const ESCAPED_JSON_VALUE = String.raw `(?:[^"\\]|\\\\(?:\\["\\]|[^"\\]))*`;
|
|
20
|
+
const PATTERNS = [
|
|
21
|
+
{
|
|
22
|
+
// URL userinfo: scheme://user:password@host. Keeps the scheme, the user,
|
|
23
|
+
// and the host — the parts that identify *which* connection failed.
|
|
24
|
+
pattern: new RegExp(URL_USERINFO + String.raw `[^\s@/]+(@)`, "gi"),
|
|
25
|
+
replace: `$1${REDACTED}$2`,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
// Bearer tokens. RFC 6750 `token68` permits a single character, so a length
|
|
29
|
+
// floor lets short opaque tokens through. Same shape guard as Basic below:
|
|
30
|
+
// reject a value that is one alphabetic word, so "Bearer token required"
|
|
31
|
+
// survives while anything mixing case or digits is redacted. Case-sensitive
|
|
32
|
+
// for the same reason as Basic, so the scheme is spelled out.
|
|
33
|
+
//
|
|
34
|
+
// `Token` is deliberately not treated as a scheme: it is a GitHub-ism
|
|
35
|
+
// rather than a registered one, and "Token" is a common enough English word
|
|
36
|
+
// that it matched the next word in prose. The credentials it precedes are
|
|
37
|
+
// already covered by the `gh*_` prefixes below.
|
|
38
|
+
pattern: new RegExp(String.raw `\b([Bb]earer)\s+(?![A-Za-z][a-z]*(?![A-Za-z0-9._~+/=-]))([A-Za-z0-9._~+/=-]+)`, "g"),
|
|
39
|
+
replace: `$1 ${REDACTED}`,
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
// Basic credentials are base64 of "user:pass" and can be legitimately
|
|
43
|
+
// short — base64("u:pw") is four characters — so the 16-char floor used for
|
|
44
|
+
// Bearer would let real credentials through.
|
|
45
|
+
//
|
|
46
|
+
// Length alone therefore cannot separate a credential from prose, and
|
|
47
|
+
// "Basic plan" / "Basic tier" / "Basic Auth" are exactly the kind of copy a
|
|
48
|
+
// subscription app writes. The guard is shape instead: reject the value if
|
|
49
|
+
// it is a single alphabetic word (one letter followed by lowercase to the
|
|
50
|
+
// end of the token). Base64 of any realistic "user:pass" mixes case or
|
|
51
|
+
// digits and so survives the guard, while an English word does not.
|
|
52
|
+
//
|
|
53
|
+
// Case-sensitive by necessity — an `i` flag would make `[a-z]` match
|
|
54
|
+
// uppercase and collapse that distinction — so the scheme is spelled out
|
|
55
|
+
// to still accept the lowercase form.
|
|
56
|
+
pattern: new RegExp(String.raw `\b([Bb]asic)\s+(?![A-Za-z][a-z]*(?![A-Za-z0-9+/=]))([A-Za-z0-9+/=]{4,})`, "g"),
|
|
57
|
+
replace: `$1 ${REDACTED}`,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
// JWTs, which carry claims and are frequently the credential itself. The
|
|
61
|
+
// header is capped for the same reason as the scheme above (a run of
|
|
62
|
+
// `-eyJ` fragments restarts at each one); a JWT has to fit in an HTTP
|
|
63
|
+
// header, so 8KB loses nothing real.
|
|
64
|
+
pattern: /\beyJ[A-Za-z0-9_-]{8,8192}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+/g,
|
|
65
|
+
replace: REDACTED,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
// Provider secret keys, matched on each vendor's published prefix.
|
|
69
|
+
// OpenAI/Anthropic `sk-`, Stripe `sk_live_`/`sk_test_`/`rk_live_`.
|
|
70
|
+
// Stripe's publishable `pk_` keys are deliberately absent: they are
|
|
71
|
+
// designed to ship to browsers, so redacting them only loses detail.
|
|
72
|
+
pattern: /\b(?:sk-(?:ant-)?|sk_live_|sk_test_|rk_live_)[A-Za-z0-9_-]{16,}/g,
|
|
73
|
+
replace: REDACTED,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
// AWS access key ids (AKIA long-lived, ASIA temporary) and Google API keys.
|
|
77
|
+
// Google's are documented as AIza + 35 chars; the open-ended quantifier
|
|
78
|
+
// means an unexpectedly long variant is redacted whole rather than having
|
|
79
|
+
// its first 39 characters replaced and the remainder left exposed.
|
|
80
|
+
pattern: /\b(?:A[KS]IA[0-9A-Z]{16}\b|AIza[0-9A-Za-z_-]{35,})/g,
|
|
81
|
+
replace: REDACTED,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
// GitHub (`ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`) and Slack (`xoxb-` etc).
|
|
85
|
+
pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|xox[abprs]-[A-Za-z0-9-]{10,})\b/g,
|
|
86
|
+
replace: REDACTED,
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
// Credential-bearing key/value pairs, in query strings, connection strings
|
|
90
|
+
// and serialized config alike. Anchored on the key name plus its separator,
|
|
91
|
+
// and the key must be a whole token so an app's "reset_password_url=..."
|
|
92
|
+
// style key is not mistaken for the secret itself.
|
|
93
|
+
//
|
|
94
|
+
// The leading boundary is a captured alternation rather than a lookbehind:
|
|
95
|
+
// this module is exported from @fillout/shared and so can be bundled into
|
|
96
|
+
// the frontend, where an unsupported lookbehind would throw while building
|
|
97
|
+
// the RegExp at module load and take the whole bundle down with it.
|
|
98
|
+
pattern: new RegExp(String.raw `(^|[^\w-])(${CREDENTIAL_KEY})(\s*[:=]\s*)` + VALUE_RUN, "gi"),
|
|
99
|
+
replace: `$1$2$3${REDACTED}`,
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
// The same keys as JSON string members. The closing quote after the key
|
|
103
|
+
// sits between it and the colon, so the pattern above never sees them.
|
|
104
|
+
pattern: new RegExp(JSON_MEMBER + JSON_VALUE + '"', "gi"),
|
|
105
|
+
replace: `$1${REDACTED}"`,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
// ...and as members of JSON serialized into another JSON string, where
|
|
109
|
+
// every quote arrives as \" and the value's own escapes are doubled, so a
|
|
110
|
+
// \\\" inside the value is an escaped quote rather than its end.
|
|
111
|
+
pattern: new RegExp(ESCAPED_JSON_MEMBER + ESCAPED_JSON_VALUE + String.raw `\\"`, "gi"),
|
|
112
|
+
replace: `$1${REDACTED}\\"`,
|
|
113
|
+
},
|
|
114
|
+
];
|
|
115
|
+
/**
|
|
116
|
+
* Replace any credentials in `text` with a placeholder, leaving the rest
|
|
117
|
+
* intact. Returns the input unchanged when there is nothing to redact, so
|
|
118
|
+
* callers that pass clean text see byte-identical output.
|
|
119
|
+
*/
|
|
120
|
+
export function redactSecrets(text) {
|
|
121
|
+
if (!text)
|
|
122
|
+
return text;
|
|
123
|
+
let result = text;
|
|
124
|
+
for (const { pattern, replace } of PATTERNS) {
|
|
125
|
+
// The patterns are global, so reset lastIndex — these RegExp objects are
|
|
126
|
+
// module-level and reused across calls.
|
|
127
|
+
pattern.lastIndex = 0;
|
|
128
|
+
result = result.replace(pattern, replace);
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Classic's cases (restly shared/src/redactSecrets.test.ts), for the port beside it.
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { redactSecrets, REDACTED } from './redactSecrets.js';
|
|
4
|
+
describe('redactSecrets', () => {
|
|
5
|
+
describe('keeps the surrounding message', () => {
|
|
6
|
+
it('redacts a connection string password but keeps scheme, user and host', () => {
|
|
7
|
+
expect(redactSecrets('Failed to sync roster: postgres://svc:hunter2@db.internal:5432/prod')).toBe(`Failed to sync roster: postgres://svc:${REDACTED}@db.internal:5432/prod`);
|
|
8
|
+
});
|
|
9
|
+
it('leaves the sentence readable around a redacted key', () => {
|
|
10
|
+
expect(redactSecrets('Stripe rejected the charge (key sk_live_abcdefghijklmnop1234) — retry later')).toBe(`Stripe rejected the charge (key ${REDACTED}) — retry later`);
|
|
11
|
+
});
|
|
12
|
+
it('does not swallow text following a key=value pair', () => {
|
|
13
|
+
expect(redactSecrets('config invalid: api_key=abc123def456, region=us-west-2')).toBe(`config invalid: api_key=${REDACTED}, region=us-west-2`);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('credential formats', () => {
|
|
17
|
+
it('redacts bearer tokens', () => {
|
|
18
|
+
expect(redactSecrets('Authorization: Bearer abcdef1234567890XYZ')).toBe(`Authorization: Bearer ${REDACTED}`);
|
|
19
|
+
});
|
|
20
|
+
it('redacts a short opaque bearer token', () => {
|
|
21
|
+
// RFC 6750 token68 permits values this short, so shape decides, not length.
|
|
22
|
+
expect(redactSecrets('Authorization: Bearer abc123')).toBe(`Authorization: Bearer ${REDACTED}`);
|
|
23
|
+
});
|
|
24
|
+
it('redacts short Basic credentials, which base64 makes legitimately tiny', () => {
|
|
25
|
+
// base64("u:pw") — four characters.
|
|
26
|
+
expect(redactSecrets('Authorization: Basic dTpw')).toBe(`Authorization: Basic ${REDACTED}`);
|
|
27
|
+
expect(redactSecrets('basic YWI6Y2Q=')).toBe(`basic ${REDACTED}`);
|
|
28
|
+
});
|
|
29
|
+
it('redacts a full-length Basic credential', () => {
|
|
30
|
+
expect(redactSecrets('Basic YWxhZGRpbjpvcGVuc2VzYW1l')).toBe(`Basic ${REDACTED}`);
|
|
31
|
+
});
|
|
32
|
+
it('redacts JWTs', () => {
|
|
33
|
+
const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dQw4w9WgXcQ';
|
|
34
|
+
expect(redactSecrets(`token rejected: ${jwt}`)).toBe(`token rejected: ${REDACTED}`);
|
|
35
|
+
});
|
|
36
|
+
it('redacts OpenAI and Anthropic style keys', () => {
|
|
37
|
+
expect(redactSecrets('sk-ant-api03-abcdefghijklmnopqrstuv')).toBe(REDACTED);
|
|
38
|
+
expect(redactSecrets('sk-abcdefghijklmnopqrstuvwxyz')).toBe(REDACTED);
|
|
39
|
+
});
|
|
40
|
+
it('redacts AWS access key ids and Google API keys', () => {
|
|
41
|
+
expect(redactSecrets('using AKIAIOSFODNN7EXAMPLE for s3')).toBe(`using ${REDACTED} for s3`);
|
|
42
|
+
expect(redactSecrets('key AIzaSyA1234567890abcdefghijklmnopqrstuvw rejected')).toBe(`key ${REDACTED} rejected`);
|
|
43
|
+
});
|
|
44
|
+
it('redacts GitHub and Slack tokens', () => {
|
|
45
|
+
expect(redactSecrets('ghp_abcdefghijklmnopqrstuvwxyz0123456789')).toBe(REDACTED);
|
|
46
|
+
expect(redactSecrets('xoxb-1234567890-abcdefghij')).toBe(REDACTED);
|
|
47
|
+
});
|
|
48
|
+
it('redacts credential key/value pairs in either separator style', () => {
|
|
49
|
+
expect(redactSecrets('password: hunter2')).toBe(`password: ${REDACTED}`);
|
|
50
|
+
expect(redactSecrets('client_secret=s3cr3tvalue')).toBe(`client_secret=${REDACTED}`);
|
|
51
|
+
});
|
|
52
|
+
it('redacts the secret in a URL-encoded token exchange body', () => {
|
|
53
|
+
expect(redactSecrets('grant_type=client_credentials&client_id=3MVG9abc&client_secret=8F2A1B9C')).toBe(`grant_type=client_credentials&client_id=3MVG9abc&client_secret=${REDACTED}`);
|
|
54
|
+
});
|
|
55
|
+
it('redacts credential members of a JSON body, keeping the rest', () => {
|
|
56
|
+
expect(redactSecrets('{"client_id":"3MVG9abc","client_secret":"8F2A1B9C","grant_type":"client_credentials"}')).toBe(`{"client_id":"3MVG9abc","client_secret":"${REDACTED}","grant_type":"client_credentials"}`);
|
|
57
|
+
expect(redactSecrets('{"refreshToken": "a\\"b", "page": 2}')).toBe(`{"refreshToken": "${REDACTED}", "page": 2}`);
|
|
58
|
+
});
|
|
59
|
+
it('redacts credential members of JSON serialized inside a JSON string', () => {
|
|
60
|
+
expect(redactSecrets(String.raw `{"body":"{\"client_secret\":\"8F2A1B9C\",\"grant_type\":\"client_credentials\"}"}`)).toBe(String.raw `{"body":"{\"client_secret\":\"${REDACTED}\",\"grant_type\":\"client_credentials\"}"}`);
|
|
61
|
+
expect(redactSecrets(String.raw `{\"password\":\"a\\\"b\\\\\",\"page\":2}`)).toBe(String.raw `{\"password\":\"${REDACTED}\",\"page\":2}`);
|
|
62
|
+
});
|
|
63
|
+
it('redacts a key at the start of a line in a multi-line message', () => {
|
|
64
|
+
expect(redactSecrets('connection failed\napi_key=abc123def456')).toBe(`connection failed\napi_key=${REDACTED}`);
|
|
65
|
+
});
|
|
66
|
+
it('redacts every occurrence, not just the first', () => {
|
|
67
|
+
expect(redactSecrets('api_key=aaa111 and api_key=bbb222')).toBe(`api_key=${REDACTED} and api_key=${REDACTED}`);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('leaves app-authored prose alone', () => {
|
|
71
|
+
it('does not touch an ordinary user-facing message', () => {
|
|
72
|
+
const message = 'CAPACITY EXCEEDED: The following week(s) are now full: Week #11. Please select different weeks or join the waitlist.';
|
|
73
|
+
expect(redactSecrets(message)).toBe(message);
|
|
74
|
+
});
|
|
75
|
+
it('does not touch a parseable frontend signal', () => {
|
|
76
|
+
const message = 'CLOTHES_CHANGE_PENDING:recSHyUztFA6rF5UA';
|
|
77
|
+
expect(redactSecrets(message)).toBe(message);
|
|
78
|
+
});
|
|
79
|
+
it('does not redact prose that merely mentions a password', () => {
|
|
80
|
+
const message = 'Your password must be at least 8 characters long.';
|
|
81
|
+
expect(redactSecrets(message)).toBe(message);
|
|
82
|
+
});
|
|
83
|
+
it('does not redact the word "Bearer" without a token-shaped value', () => {
|
|
84
|
+
const message = 'Include a Bearer token in the Authorization header.';
|
|
85
|
+
expect(redactSecrets(message)).toBe(message);
|
|
86
|
+
});
|
|
87
|
+
it('does not redact "Basic" followed by an ordinary word', () => {
|
|
88
|
+
// The reason the Basic matcher guards on shape rather than length: a
|
|
89
|
+
// subscription app's own copy reads exactly like a short credential.
|
|
90
|
+
for (const message of [
|
|
91
|
+
'Your Basic plan does not include this feature.',
|
|
92
|
+
'Basic tier limits reached.',
|
|
93
|
+
'Basic Auth is required for this endpoint.',
|
|
94
|
+
'Upgrade from Basic Plan to continue.',
|
|
95
|
+
]) {
|
|
96
|
+
expect(redactSecrets(message)).toBe(message);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
it('does not redact a Stripe publishable key, which is meant to be public', () => {
|
|
100
|
+
const message = 'pk_live_abcdefghijklmnop1234 is not a secret key';
|
|
101
|
+
expect(redactSecrets(message)).toBe(message);
|
|
102
|
+
});
|
|
103
|
+
it('does not treat a URL with a port as userinfo', () => {
|
|
104
|
+
const message = 'GET https://api.example.com:8443/v1/records failed';
|
|
105
|
+
expect(redactSecrets(message)).toBe(message);
|
|
106
|
+
});
|
|
107
|
+
it('does not match a credential key embedded in a longer name', () => {
|
|
108
|
+
// "reset_password_url" ends in "password" but names a link, not a secret.
|
|
109
|
+
const message = 'reset_password_url=https://example.org/reset';
|
|
110
|
+
expect(redactSecrets(message)).toBe(message);
|
|
111
|
+
const json = '{"reset_password_url":"https://example.org/reset"}';
|
|
112
|
+
expect(redactSecrets(json)).toBe(json);
|
|
113
|
+
});
|
|
114
|
+
it('returns short or empty input untouched', () => {
|
|
115
|
+
expect(redactSecrets('')).toBe('');
|
|
116
|
+
expect(redactSecrets('nope')).toBe('nope');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
describe('repeated use', () => {
|
|
120
|
+
it('is stable across calls despite module-level global regexes', () => {
|
|
121
|
+
const message = 'api_key=abc123def456';
|
|
122
|
+
const first = redactSecrets(message);
|
|
123
|
+
const second = redactSecrets(message);
|
|
124
|
+
expect(first).toBe(second);
|
|
125
|
+
expect(first).toBe(`api_key=${REDACTED}`);
|
|
126
|
+
});
|
|
127
|
+
it('is idempotent', () => {
|
|
128
|
+
const once = redactSecrets('postgres://svc:hunter2@db.internal/prod');
|
|
129
|
+
expect(redactSecrets(once)).toBe(once);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
describe('large input', () => {
|
|
133
|
+
// It runs synchronously on raw sandbox output, so anything worse than
|
|
134
|
+
// linear blocks the event loop long enough to fail health checks. Both
|
|
135
|
+
// inputs took several seconds at this size before the patterns were capped.
|
|
136
|
+
it.each([
|
|
137
|
+
['an unbroken alphanumeric run', 'aB3dE9fG1h'],
|
|
138
|
+
['a run of -eyJ fragments', '-eyJabcdefgh'],
|
|
139
|
+
])('stays fast on %s', (_, unit) => {
|
|
140
|
+
const text = unit.repeat(10_000);
|
|
141
|
+
const started = performance.now();
|
|
142
|
+
expect(redactSecrets(text)).toBe(text);
|
|
143
|
+
expect(performance.now() - started).toBeLessThan(2000);
|
|
144
|
+
});
|
|
145
|
+
it('still redacts a long scheme and a JWT after the caps', () => {
|
|
146
|
+
expect(redactSecrets('averyveryveryveryveryveryverylongscheme://svc:hunter2@db.internal')).toBe(`averyveryveryveryveryveryverylongscheme://svc:${REDACTED}@db.internal`);
|
|
147
|
+
const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
|
|
148
|
+
expect(redactSecrets(`x-${jwt}`)).toBe(`x-${REDACTED}`);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -1,17 +1,20 @@
|
|
|
1
|
+
import { QueryClient } from "@tanstack/react-query";
|
|
1
2
|
import { type AnyRoute, type RouterConstructorOptions, type RouterHistory } from "@tanstack/react-router";
|
|
2
3
|
import type { User } from "./types.js";
|
|
3
4
|
/** The router context every Zite app's root route declares: `createRootRouteWithContext<ZiteRouterContext>()`. */
|
|
4
5
|
export type ZiteRouterContext = {
|
|
5
6
|
user: User | null;
|
|
7
|
+
queryClient: QueryClient;
|
|
6
8
|
};
|
|
7
9
|
type Dehydrated = {
|
|
8
10
|
user: User | null;
|
|
9
11
|
};
|
|
10
12
|
type ZiteRouterOptions<TRouteTree extends AnyRoute> = Omit<RouterConstructorOptions<TRouteTree, "never", false, RouterHistory, Dehydrated>, "context" | "dehydrate" | "hydrate">;
|
|
11
13
|
/**
|
|
12
|
-
* The router for `src/router.tsx`. The signed-in user is in `context.user`: on
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* The router for `src/router.tsx`. The signed-in user is in `context.user`: on the server from the platform, in the
|
|
15
|
+
* browser from the page it rendered, so no request asks for it again. `context.queryClient` is new for each request
|
|
16
|
+
* on the server, so a page never renders with another visitor's cached queries, and its queries reach the browser
|
|
17
|
+
* with the page.
|
|
15
18
|
*/
|
|
16
19
|
export declare function createZiteRouter<TRouteTree extends AnyRoute>(options: ZiteRouterOptions<TRouteTree>): import("@tanstack/router-core").RouterCore<TRouteTree, "never", false, RouterHistory, Dehydrated>;
|
|
17
20
|
export {};
|
package/dist/esm/start/router.js
CHANGED
|
@@ -1,35 +1,75 @@
|
|
|
1
|
-
import { createElement as h } from "react";
|
|
1
|
+
import { createElement as h, useEffect, useState } from "react";
|
|
2
|
+
import { QueryClient } from "@tanstack/react-query";
|
|
2
3
|
import { createRouter, Link, useRouter, } from "@tanstack/react-router";
|
|
4
|
+
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
|
5
|
+
import { toClientError } from "./errors.js";
|
|
6
|
+
import { carryPreviewClaim, PREVIEW_CLAIM_PARAM, previewClaim } from "./previewClaim.js";
|
|
3
7
|
import { currentHost } from "./host.js";
|
|
8
|
+
const ziteWindow = () => (typeof window === "undefined" ? undefined : window);
|
|
4
9
|
const box = { maxWidth: 480, margin: "15vh auto", padding: 24, fontFamily: "system-ui, sans-serif", textAlign: "center" };
|
|
10
|
+
// As classic's crash screen: the message, "Fix it for me" in the editor, and a reload. The server renders from the
|
|
11
|
+
// app's own error and the browser from the one that crossed, already mapped, so both show the same text.
|
|
5
12
|
function ZiteErrorComponent({ error }) {
|
|
6
13
|
const router = useRouter();
|
|
7
|
-
const
|
|
8
|
-
|
|
14
|
+
const [allowAiFix, setAllowAiFix] = useState(false);
|
|
15
|
+
useEffect(() => setAllowAiFix(ziteWindow()?.__ziteAllowAiFix === true), []);
|
|
16
|
+
const message = typeof window === "undefined" ? toClientError(error).message : error instanceof Error ? error.message : String(error);
|
|
17
|
+
const reload = () => {
|
|
18
|
+
const onReload = ziteWindow()?._ziteOnReload;
|
|
19
|
+
if (onReload)
|
|
20
|
+
onReload();
|
|
21
|
+
else
|
|
22
|
+
router.invalidate();
|
|
23
|
+
};
|
|
24
|
+
return h("div", { role: "alert", style: box }, h("p", { style: { fontWeight: 600 } }, "Something went wrong"), h("pre", { style: { whiteSpace: "pre-wrap", textAlign: "left" } }, message), allowAiFix && h("button", { type: "button", onClick: () => ziteWindow()?._ziteOnFixError?.(error) }, "Fix it for me"), h("button", { type: "button", onClick: reload }, "Try again"));
|
|
9
25
|
}
|
|
10
26
|
function ZiteNotFound() {
|
|
11
27
|
return h("div", { style: box }, h("p", { style: { fontWeight: 600 } }, "Page not found"), h(Link, { to: "/" }, "Go home"));
|
|
12
28
|
}
|
|
29
|
+
function ZitePending() {
|
|
30
|
+
return h("div", { "aria-busy": true, style: { ...box, color: "#888" } }, "Loading…");
|
|
31
|
+
}
|
|
32
|
+
// The claim stays on every URL the router writes, and the app's own routes never see it.
|
|
33
|
+
const keepPreviewClaim = (claim, own) => ({
|
|
34
|
+
input: ({ url }) => {
|
|
35
|
+
url.searchParams.delete(PREVIEW_CLAIM_PARAM);
|
|
36
|
+
return own?.input?.({ url }) ?? url;
|
|
37
|
+
},
|
|
38
|
+
output: ({ url }) => {
|
|
39
|
+
const rewritten = own?.output?.({ url }) ?? url;
|
|
40
|
+
const out = new URL(rewritten.toString(), url);
|
|
41
|
+
out.searchParams.set(PREVIEW_CLAIM_PARAM, claim);
|
|
42
|
+
return out;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
13
45
|
/**
|
|
14
|
-
* The router for `src/router.tsx`. The signed-in user is in `context.user`: on
|
|
15
|
-
*
|
|
16
|
-
*
|
|
46
|
+
* The router for `src/router.tsx`. The signed-in user is in `context.user`: on the server from the platform, in the
|
|
47
|
+
* browser from the page it rendered, so no request asks for it again. `context.queryClient` is new for each request
|
|
48
|
+
* on the server, so a page never renders with another visitor's cached queries, and its queries reach the browser
|
|
49
|
+
* with the page.
|
|
17
50
|
*/
|
|
18
51
|
export function createZiteRouter(options) {
|
|
19
|
-
const
|
|
52
|
+
const queryClient = new QueryClient();
|
|
53
|
+
const claim = previewClaim();
|
|
54
|
+
if (claim)
|
|
55
|
+
carryPreviewClaim(claim);
|
|
56
|
+
const context = { user: currentHost()?.user ?? null, queryClient };
|
|
20
57
|
const router = createRouter({
|
|
21
58
|
defaultPreload: "intent",
|
|
22
59
|
defaultPreloadStaleTime: 0,
|
|
23
60
|
scrollRestoration: true,
|
|
24
61
|
defaultErrorComponent: ZiteErrorComponent,
|
|
25
62
|
defaultNotFoundComponent: ZiteNotFound,
|
|
63
|
+
defaultPendingComponent: ZitePending,
|
|
26
64
|
...options,
|
|
65
|
+
...(claim && { rewrite: keepPreviewClaim(claim, options.rewrite) }),
|
|
27
66
|
context,
|
|
28
67
|
dehydrate: () => ({ user: context.user }),
|
|
29
68
|
hydrate: (dehydrated) => {
|
|
30
69
|
context.user = dehydrated.user;
|
|
31
70
|
},
|
|
32
71
|
});
|
|
72
|
+
setupRouterSsrQueryIntegration({ router, queryClient });
|
|
33
73
|
const host = currentHost();
|
|
34
74
|
if (host)
|
|
35
75
|
host.router = router;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import type { RunRecord, ZiteRequestContext } from "./types.js";
|
|
1
2
|
export declare const preview: (value: unknown) => string | undefined;
|
|
2
|
-
export declare const errorOf: (error: unknown) =>
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
export declare const errorOf: (error: unknown) => NonNullable<RunRecord["error"]>;
|
|
4
|
+
/**
|
|
5
|
+
* A server route's run, which run capture doesn't see: its body in, and its status and body out. The bodies are
|
|
6
|
+
* read from copies after the fact, so the response never waits; the run log waits for them before it flushes.
|
|
7
|
+
*/
|
|
8
|
+
export declare function recordRoute<T>(host: ZiteRequestContext | undefined, request: Request, run: () => Promise<T>): Promise<T>;
|
package/dist/esm/start/runLog.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { asZiteError } from "./errors.js";
|
|
2
|
+
// Classic's per-field cap. The host caps again; this keeps a large value from crossing at all.
|
|
3
|
+
const RUN_FIELD_LIMIT = 50 * 1024;
|
|
4
|
+
const capped = (text) => (text.length > RUN_FIELD_LIMIT ? `${text.slice(0, RUN_FIELD_LIMIT)}…` : text);
|
|
3
5
|
export const preview = (value) => {
|
|
4
6
|
if (value === undefined)
|
|
5
7
|
return undefined;
|
|
@@ -7,12 +9,68 @@ export const preview = (value) => {
|
|
|
7
9
|
return "[stream]";
|
|
8
10
|
try {
|
|
9
11
|
const json = JSON.stringify(value);
|
|
10
|
-
return json
|
|
12
|
+
return json === undefined ? undefined : capped(json);
|
|
11
13
|
}
|
|
12
14
|
catch {
|
|
13
15
|
return "[unserializable]";
|
|
14
16
|
}
|
|
15
17
|
};
|
|
16
|
-
export const errorOf = (error) =>
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
export const errorOf = (error) => ({
|
|
19
|
+
code: asZiteError(error)?.code ?? "INTERNAL_ERROR",
|
|
20
|
+
message: error instanceof Error ? error.message : String(error),
|
|
21
|
+
...(error instanceof Error && error.stack ? { stack: capped(error.stack) } : {}),
|
|
22
|
+
});
|
|
23
|
+
const TEXT_BODY = /^(application\/(json|x-www-form-urlencoded)|text\/(?!event-stream))/i;
|
|
24
|
+
// Up to the cap of a copy of the body, so the app still reads the original. Uploads and streams aren't read.
|
|
25
|
+
const bodyText = async (message) => {
|
|
26
|
+
if (!message.body || !TEXT_BODY.test(message.headers.get("content-type") ?? ""))
|
|
27
|
+
return undefined;
|
|
28
|
+
const reader = message.clone().body.pipeThrough(new TextDecoderStream()).getReader();
|
|
29
|
+
let text = "";
|
|
30
|
+
try {
|
|
31
|
+
while (text.length <= RUN_FIELD_LIMIT) {
|
|
32
|
+
const { done, value } = await reader.read();
|
|
33
|
+
if (done)
|
|
34
|
+
break;
|
|
35
|
+
text += value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
reader.cancel().catch(() => { });
|
|
40
|
+
}
|
|
41
|
+
return capped(text);
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* A server route's run, which run capture doesn't see: its body in, and its status and body out. The bodies are
|
|
45
|
+
* read from copies after the fact, so the response never waits; the run log waits for them before it flushes.
|
|
46
|
+
*/
|
|
47
|
+
export async function recordRoute(host, request, run) {
|
|
48
|
+
if (!host)
|
|
49
|
+
return run();
|
|
50
|
+
const startedAt = Date.now();
|
|
51
|
+
const path = new URL(request.url).pathname;
|
|
52
|
+
const input = bodyText(request).catch(() => undefined);
|
|
53
|
+
const record = (outcome) => {
|
|
54
|
+
const durationMs = Date.now() - startedAt;
|
|
55
|
+
const recorded = (async () => {
|
|
56
|
+
const fields = { input: await input, ...(await outcome) };
|
|
57
|
+
host.runs.push({ fn: `route:${request.method} ${path}`, name: path, nested: false, startedAt, durationMs, ...fields });
|
|
58
|
+
})();
|
|
59
|
+
(host.pending ??= []).push(recorded.catch(() => { }));
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
const out = await run();
|
|
63
|
+
const response = out instanceof Response ? out : out?.response;
|
|
64
|
+
if (response instanceof Response) {
|
|
65
|
+
record(bodyText(response).then((body) => ({ output: preview({ status: response.status, body }) }), () => ({})));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
record(Promise.resolve({}));
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
record(Promise.resolve({ error: errorOf(error) }));
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -12,19 +12,23 @@ export declare function installPlatformTransport(): void;
|
|
|
12
12
|
/** What the host dispatched this request with. Headers are ignored: props are the only thing it alone can set. */
|
|
13
13
|
export declare function hostContextFromProps(request: Request, props: HostProps): ZiteRequestContext;
|
|
14
14
|
/**
|
|
15
|
-
* A request the host didn't dispatch: `zitejs describe` under `vite preview`,
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* A request the host didn't dispatch: the host's warm-up, `zitejs describe` under `vite preview`, and tests. Only the
|
|
16
|
+
* host can set props, so without them a build answers warm and describe and nobody is signed in. An entry that passes
|
|
17
|
+
* its own platform (tests, local dev) owns its requests and may say who is calling in `x-zite-*` headers.
|
|
18
18
|
*/
|
|
19
19
|
export declare function hostContextFromRequest(request: Request, platform?: PlatformBinding): ZiteRequestContext;
|
|
20
20
|
/** Every server function TanStack compiled, written in by `zite()` at build. Empty in dev, where TanStack doesn't call its id hook. */
|
|
21
21
|
export declare const builtFunctionIds: () => string[];
|
|
22
22
|
/** Each server function's `description` option, written in by `zite()` at build. */
|
|
23
23
|
export declare const builtFunctionDescriptions: () => Record<string, string>;
|
|
24
|
+
/** Each route's file, by route id, written in by `zite()` at build. */
|
|
25
|
+
export declare const builtRouteFiles: () => Record<string, string>;
|
|
24
26
|
type Fetch = (request: Request, ...rest: unknown[]) => Promise<Response> | Response;
|
|
25
27
|
type WorkerContext = {
|
|
26
28
|
waitUntil(promise: Promise<unknown>): void;
|
|
27
29
|
};
|
|
30
|
+
/** On describe's answer: the zitejs this build's server code was bundled from. */
|
|
31
|
+
export declare const RUNTIME_VERSION_HEADER = "x-zitejs-version";
|
|
28
32
|
export type ZiteServerOptions = {
|
|
29
33
|
handler: {
|
|
30
34
|
fetch: Fetch;
|