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.
Files changed (46) hide show
  1. package/dist/cjs/backend/index.d.ts +115 -3
  2. package/dist/cjs/backend/index.test.js +58 -0
  3. package/dist/cjs/integrations/rules.d.ts +13 -0
  4. package/dist/cjs/integrations/rules.js +29 -0
  5. package/dist/cjs/integrations/rules.test.js +1 -0
  6. package/dist/cjs/sync/lib.js +6 -6
  7. package/dist/esm/backend/index.d.ts +115 -3
  8. package/dist/esm/backend/index.test.js +58 -0
  9. package/dist/esm/integrations/rules.d.ts +13 -0
  10. package/dist/esm/integrations/rules.js +29 -0
  11. package/dist/esm/integrations/rules.test.js +1 -0
  12. package/dist/esm/start/authHooks.d.ts +7 -0
  13. package/dist/esm/start/authHooks.js +10 -0
  14. package/dist/esm/start/authHooksModule.d.ts +1 -0
  15. package/dist/esm/start/authHooksModule.js +3 -0
  16. package/dist/esm/start/clientErrorMessage.test.d.ts +1 -0
  17. package/dist/esm/start/clientErrorMessage.test.js +105 -0
  18. package/dist/esm/start/describe.d.ts +17 -1
  19. package/dist/esm/start/describe.js +27 -5
  20. package/dist/esm/start/errors.d.ts +46 -0
  21. package/dist/esm/start/errors.js +99 -0
  22. package/dist/esm/start/errors.test.d.ts +1 -0
  23. package/dist/esm/start/errors.test.js +23 -0
  24. package/dist/esm/start/index.d.ts +3 -19
  25. package/dist/esm/start/index.js +28 -36
  26. package/dist/esm/start/instance.d.ts +1 -2
  27. package/dist/esm/start/instance.js +11 -4
  28. package/dist/esm/start/previewClaim.d.ts +10 -0
  29. package/dist/esm/start/previewClaim.js +32 -0
  30. package/dist/esm/start/redactSecrets.d.ts +7 -0
  31. package/dist/esm/start/redactSecrets.js +131 -0
  32. package/dist/esm/start/redactSecrets.test.d.ts +1 -0
  33. package/dist/esm/start/redactSecrets.test.js +151 -0
  34. package/dist/esm/start/router.d.ts +6 -3
  35. package/dist/esm/start/router.js +47 -7
  36. package/dist/esm/start/runLog.d.ts +7 -4
  37. package/dist/esm/start/runLog.js +64 -6
  38. package/dist/esm/start/server.d.ts +7 -3
  39. package/dist/esm/start/server.js +74 -21
  40. package/dist/esm/start/types.d.ts +28 -3
  41. package/dist/esm/start/version.d.ts +1 -0
  42. package/dist/esm/start/version.js +2 -0
  43. package/dist/esm/start/vite.js +31 -3
  44. package/dist/esm/start/workflowRun.js +2 -1
  45. package/dist/esm/sync/lib.js +6 -6
  46. package/package.json +16 -8
@@ -44,15 +44,43 @@ const FIELDS_BY_TYPE = {
44
44
  jira: extras("cloudId", "siteUrl"),
45
45
  typeform: extras(),
46
46
  apollo: extras(),
47
+ clay: extras(),
47
48
  attio: extras(),
48
49
  granola: extras(),
50
+ clickup: extras(),
49
51
  dropbox: extras(),
52
+ // Every path is https://{subdomain}.zendesk.com/api/v2/..., and a token is
53
+ // only valid on the account it was issued for, so the subdomain travels with
54
+ // it rather than being configured per app.
55
+ zendesk: extras("subdomain"),
56
+ // Smartsheet's US, EU, AU and Gov regions are separate, and a token is only
57
+ // valid on its own region's host, so the host travels with it.
58
+ smartsheet: extras("apiBaseUrl"),
50
59
  // The IAM key alone reaches nothing: every call needs the region and the
51
60
  // bucket, and S3-compatible providers need the endpoint too.
52
61
  amazonS3: extras("keyId", "region", "bucket", "endpoint", "pathPrefix"),
53
62
  // The SQL API lives on each account's own host, and the token is pinned to
54
63
  // one role; the warehouse is optional and falls back to the user's default.
55
64
  snowflake: extras("accountUrl", "role", "warehouse"),
65
+ // A statement is addressed by workspace host and SQL warehouse, and the token
66
+ // derives neither.
67
+ databricks: extras("host", "warehouseId"),
68
+ // Cloud Confluence is addressed through api.atlassian.com by cloud id, the
69
+ // same as Jira; the site URL is only for links a human will click.
70
+ confluence: extras("cloudId", "siteUrl"),
71
+ // OAuth returns a per-company api_domain alongside the token, so there is no
72
+ // one host to call.
73
+ pipedrive: extras("apiBaseUrl"),
74
+ // Shopify has no global API host: every call goes to the store's own domain
75
+ // under a pinned API version. The storefront pair is the shopper-facing API,
76
+ // which installs predating those scopes do not have.
77
+ shopify: extras("shopDomain", "apiBaseUrl", "storefrontApiUrl", "storefrontAccessToken"),
78
+ sharepoint: extras(),
79
+ excel: extras(),
80
+ x: extras(),
81
+ zoomMeetings: extras(),
82
+ // Every request names the workspace in an Account-Id header beside the key.
83
+ smartsuite: extras("workspaceId"),
56
84
  };
57
85
  /** Per service type: `accessToken`, then its extra labels sorted (Salesforce `instanceUrl`). */
58
86
  export const CREDENTIAL_FIELDS = FIELDS_BY_TYPE;
@@ -65,6 +93,7 @@ export const OPTIONAL_CREDENTIAL_FIELDS = {
65
93
  // Only S3-compatible providers set an endpoint, and a prefix is opt-in.
66
94
  amazonS3: ["endpoint", "pathPrefix"],
67
95
  snowflake: ["warehouse"],
96
+ shopify: ["storefrontApiUrl", "storefrontAccessToken"],
68
97
  };
69
98
  /**
70
99
  * Types the runtime never serves: those with a generated client (Airtable, email,
@@ -16,6 +16,7 @@ describe("CREDENTIAL_FIELDS", () => {
16
16
  ]);
17
17
  expect(CREDENTIAL_FIELDS.jira).toEqual(["accessToken", "cloudId", "siteUrl"]);
18
18
  expect(CREDENTIAL_FIELDS.granola).toEqual(["accessToken"]);
19
+ expect(CREDENTIAL_FIELDS.smartsheet).toEqual(["accessToken", "apiBaseUrl"]);
19
20
  });
20
21
  it("has no type the runtime never serves", () => {
21
22
  for (const type of NON_CREDENTIAL_SERVICE_TYPES) {
@@ -0,0 +1,7 @@
1
+ export declare const AUTH_HOOK_NAMES: readonly ["beforeSignUp", "beforeSignIn", "afterSignUp", "afterSignIn", "verifySession"];
2
+ export type AuthHookName = (typeof AUTH_HOOK_NAMES)[number];
3
+ type AuthHook = (input: unknown) => unknown;
4
+ export declare const isAuthHookName: (name: string) => name is AuthHookName;
5
+ /** The hooks the app's `zite.auth.ts` defines, by name. */
6
+ export declare function loadAuthHooks(): Promise<Partial<Record<AuthHookName, AuthHook>>>;
7
+ export {};
@@ -0,0 +1,10 @@
1
+ import { authHooksModule } from "./authHooksModule.js";
2
+ export const AUTH_HOOK_NAMES = ["beforeSignUp", "beforeSignIn", "afterSignUp", "afterSignIn", "verifySession"];
3
+ export const isAuthHookName = (name) => AUTH_HOOK_NAMES.includes(name);
4
+ /** The hooks the app's `zite.auth.ts` defines, by name. */
5
+ export async function loadAuthHooks() {
6
+ if (!authHooksModule)
7
+ return {};
8
+ const hooks = (await authHooksModule()).default?.hooks ?? {};
9
+ return Object.fromEntries(AUTH_HOOK_NAMES.flatMap((name) => (typeof hooks[name] === "function" ? [[name, hooks[name]]] : [])));
10
+ }
@@ -0,0 +1 @@
1
+ export declare const authHooksModule: (() => Promise<Record<string, unknown>>) | null;
@@ -0,0 +1,3 @@
1
+ // Replaced at build by zite() with a lazy import of the app's `zite.auth.ts`,
2
+ // when it has one. Null wherever the plugin didn't build the app.
3
+ export const authHooksModule = null;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,105 @@
1
+ // Classic's cases (restly packages/endpoint-setup/src/endpointErrors.test.ts), for the port in errors.ts.
2
+ import { describe, it, expect } from 'vitest';
3
+ import { toClientErrorMessage } from './errors.js';
4
+ describe('toClientErrorMessage', () => {
5
+ describe('live mode', () => {
6
+ it('preserves a signal the app throws for its own frontend to parse', () => {
7
+ // The regression case: apps throw structured messages as control flow
8
+ // and parse them client-side. Blanking this breaks the app silently.
9
+ expect(toClientErrorMessage('CLOTHES_CHANGE_PENDING:recAbc,recDef', 'live')).toBe('CLOTHES_CHANGE_PENDING:recAbc,recDef');
10
+ });
11
+ it('preserves user-facing copy the app author wrote', () => {
12
+ const message = 'Registration is missing child information. Please contact team@example.org';
13
+ expect(toClientErrorMessage(message, 'live')).toBe(message);
14
+ });
15
+ it('preserves incidental errors, which are no worse than a generic string', () => {
16
+ expect(toClientErrorMessage('window is not defined', 'live')).toBe('window is not defined');
17
+ });
18
+ it('replaces private-range IP addresses', () => {
19
+ expect(toClientErrorMessage('connect ECONNREFUSED 10.205.134.213:2507', 'live')).toBe('A service this app depends on is temporarily unavailable. Please try again.');
20
+ });
21
+ it('replaces internal service hostnames', () => {
22
+ expect(toClientErrorMessage('ConnectTimeoutError (attempted address: zite-shard-restly-server:2500, timeout: 10000ms)', 'live')).toBe('A service this app depends on is temporarily unavailable. Please try again.');
23
+ });
24
+ it('replaces internal service hostnames with no port, as DNS failures report them', () => {
25
+ expect(toClientErrorMessage('getaddrinfo ENOTFOUND workflow-runner', 'live')).toBe('A service this app depends on is temporarily unavailable. Please try again.');
26
+ });
27
+ it('replaces a portless hostname carrying a pod suffix', () => {
28
+ expect(toClientErrorMessage('getaddrinfo EAI_AGAIN workflow-runner-3l4c', 'live')).toBe('A service this app depends on is temporarily unavailable. Please try again.');
29
+ });
30
+ it('does not match a service name embedded in a longer token', () => {
31
+ // "app-runner" sits inside this, but it is the app's own resource name.
32
+ const message = 'Deployment my-app-runner-config failed';
33
+ expect(toClientErrorMessage(message, 'live')).toBe(message);
34
+ });
35
+ it("replaces Zite's AI billing notice, which is addressed to the app owner", () => {
36
+ expect(toClientErrorMessage('402 AI is paused: your organization is over its AI credit limit. To resume, increase your credits (upgrade your plan) or connect your own AI provider key (BYOK).', 'live')).toBe('AI features are temporarily unavailable. Please contact the app owner.');
37
+ });
38
+ it("replaces an upstream provider's billing state", () => {
39
+ expect(toClientErrorMessage('429 You have no credits remaining. Add credits to continue using the API at https://platform.openai.com/settings/organization/billing/.', 'live')).toBe('AI features are temporarily unavailable. Please contact the app owner.');
40
+ });
41
+ it("does not swallow an app's own copy about credits or quotas", () => {
42
+ // Narrowness check: the billing patterns key on our prefix and on the
43
+ // provider's URLs, so an app writing about its users' credit stays intact.
44
+ const message = 'You have exceeded your credit limit for this month.';
45
+ expect(toClientErrorMessage(message, 'live')).toBe(message);
46
+ });
47
+ it('does not treat a public IP as internal', () => {
48
+ const message = 'Request to 203.0.113.10:443 failed';
49
+ expect(toClientErrorMessage(message, 'live')).toBe(message);
50
+ });
51
+ it('replaces link-local addresses, which reach cloud metadata services', () => {
52
+ expect(toClientErrorMessage('connect ETIMEDOUT 169.254.169.254:80', 'live')).toBe('A service this app depends on is temporarily unavailable. Please try again.');
53
+ });
54
+ it('replaces IPv6 loopback, unique-local and link-local addresses', () => {
55
+ const unavailable = 'A service this app depends on is temporarily unavailable. Please try again.';
56
+ expect(toClientErrorMessage('connect ECONNREFUSED ::1', 'live')).toBe(unavailable);
57
+ expect(toClientErrorMessage('no route to fd12:3456:789a::1', 'live')).toBe(unavailable);
58
+ // fe80::/10 — the IPv6 counterpart to 169.254, and it turns up in the
59
+ // same container-networking failures.
60
+ expect(toClientErrorMessage('connect ETIMEDOUT fe80::1', 'live')).toBe(unavailable);
61
+ expect(toClientErrorMessage('no route to feba:1234::abcd', 'live')).toBe(unavailable);
62
+ });
63
+ it('replaces a bare localhost reference', () => {
64
+ expect(toClientErrorMessage('connect ECONNREFUSED localhost:2506', 'live')).toBe('A service this app depends on is temporarily unavailable. Please try again.');
65
+ });
66
+ });
67
+ describe('live mode — credentials', () => {
68
+ it('redacts a secret but keeps the rest of the message', () => {
69
+ // The whole point of redacting in place: the sentence around the
70
+ // credential is the app author's and still has to reach the user.
71
+ expect(toClientErrorMessage('Could not reach the roster database: postgres://svc:hunter2@db.example.com/prod', 'live')).toBe('Could not reach the roster database: postgres://svc:REDACTED@db.example.com/prod');
72
+ });
73
+ it('redacts a provider key inside otherwise user-facing copy', () => {
74
+ expect(toClientErrorMessage('Payment provider rejected the request (sk_live_abcdefghijklmnop1234)', 'live')).toBe('Payment provider rejected the request (REDACTED)');
75
+ });
76
+ it('still replaces the message when infrastructure detail survives redaction', () => {
77
+ // Both concerns in one message: the secret goes, and the internal
78
+ // hostname then triggers wholesale replacement.
79
+ expect(toClientErrorMessage('connect ECONNREFUSED base-runner:2500 using api_key=abc123def456', 'live')).toBe('A service this app depends on is temporarily unavailable. Please try again.');
80
+ });
81
+ });
82
+ describe('userFacingMessage', () => {
83
+ it('prefers the author-written message over the technical one', () => {
84
+ expect(toClientErrorMessage('Airtable findAll failed for tblIx0DJvbXjjfA42 after 3 retries', 'live', 'We could not load this week’s roster. Please try again shortly.')).toBe('We could not load this week’s roster. Please try again shortly.');
85
+ });
86
+ it('keeps the technical message in preview so the developer can debug', () => {
87
+ expect(toClientErrorMessage('Airtable findAll failed for tblIx0DJvbXjjfA42 after 3 retries', 'preview', 'We could not load this week’s roster.')).toBe('Airtable findAll failed for tblIx0DJvbXjjfA42 after 3 retries');
88
+ });
89
+ it('falls back to the thrown message when the author set none', () => {
90
+ expect(toClientErrorMessage('CLOTHES_CHANGE_PENDING:recAbc', 'live', undefined)).toBe('CLOTHES_CHANGE_PENDING:recAbc');
91
+ });
92
+ it('ignores an empty userFacingMessage rather than blanking the response', () => {
93
+ expect(toClientErrorMessage('Week #11 is now full', 'live', '')).toBe('Week #11 is now full');
94
+ });
95
+ });
96
+ describe('preview mode', () => {
97
+ it('passes infrastructure detail through so the developer can debug', () => {
98
+ const message = 'connect ECONNREFUSED 10.205.134.213:2507';
99
+ expect(toClientErrorMessage(message, 'preview')).toBe(message);
100
+ });
101
+ });
102
+ it('treats an unspecified mode as live', () => {
103
+ expect(toClientErrorMessage('connect ECONNREFUSED 10.205.134.213:2507', undefined)).toBe('A service this app depends on is temporarily unavailable. Please try again.');
104
+ });
105
+ });
@@ -1,4 +1,5 @@
1
1
  import type { ZiteTrigger } from "../backend/index.js";
2
+ import { type AuthHookName } from "./authHooks.js";
2
3
  import type { LoadedWorkflow } from "./workflowRun.js";
3
4
  import type { WorkflowConfig } from "./workflow.js";
4
5
  export type Access = {
@@ -19,9 +20,16 @@ export type DescribedFunction = {
19
20
  };
20
21
  export type DescribedRoute = {
21
22
  path: string;
23
+ file?: string;
22
24
  methods: Record<string, Access>;
23
25
  problems: string[];
24
26
  };
27
+ /** A route that renders: what the editor's page picker and system map show. */
28
+ export type DescribedPage = {
29
+ id: string;
30
+ path: string;
31
+ file?: string;
32
+ };
25
33
  export type DescribedWorkflow = {
26
34
  id: string;
27
35
  file: string;
@@ -39,24 +47,32 @@ export type DescribedWorkflow = {
39
47
  export type Description = {
40
48
  functions: DescribedFunction[];
41
49
  routes: DescribedRoute[];
50
+ pages: DescribedPage[];
42
51
  workflows: DescribedWorkflow[];
52
+ /** The hooks the app's `zite.auth.ts` defines. */
53
+ authHooks: AuthHookName[];
43
54
  };
44
55
  export declare function describeFunction(id: string, request: Request): Promise<DescribedFunction>;
45
56
  type RouteLike = {
57
+ id: string;
46
58
  fullPath: string;
47
59
  parentRoute?: RouteLike;
48
60
  options?: {
61
+ path?: string;
62
+ component?: unknown;
49
63
  server?: {
50
64
  middleware?: unknown[];
51
65
  handlers?: unknown;
52
66
  };
53
67
  };
54
68
  };
55
- export declare function describeRoutes(routesById: Record<string, RouteLike>): DescribedRoute[];
69
+ export declare function describePages(routesById: Record<string, RouteLike>, files?: Record<string, string>): DescribedPage[];
70
+ export declare function describeRoutes(routesById: Record<string, RouteLike>, files?: Record<string, string>): DescribedRoute[];
56
71
  export declare function describeWorkflows(workflows: LoadedWorkflow[]): DescribedWorkflow[];
57
72
  export declare function describeApp(opts: {
58
73
  fnIds: string[];
59
74
  descriptions?: Record<string, string>;
75
+ routeFiles?: Record<string, string>;
60
76
  getRouter: () => unknown;
61
77
  request: Request;
62
78
  workflows?: LoadedWorkflow[];
@@ -5,6 +5,7 @@ import { createSsrRpc } from "@tanstack/react-start/ssr-rpc";
5
5
  import { runWithStartContext } from "@tanstack/start-storage-context";
6
6
  import { z } from "zod";
7
7
  import { ziteMiddlewareKind } from "./index.js";
8
+ import { loadAuthHooks } from "./authHooks.js";
8
9
  const accessOf = (chain, problems) => {
9
10
  const kinds = chain.map(ziteMiddlewareKind).filter((k) => !!k);
10
11
  const roles = kinds.flatMap((k) => (k.kind === "role" ? [k.role] : []));
@@ -86,6 +87,7 @@ export async function describeFunction(id, request) {
86
87
  if (!chain.some((m) => ziteMiddlewareKind(m)?.kind === "factory")) {
87
88
  problems.push("raw-server-fn: define it with signedInFn or publicFn from zitejs/start");
88
89
  }
90
+ // TanStack keeps the validator under `inputValidator` up to 1.168 and `validator` from 1.169 (Start 1.0).
89
91
  const handlerStep = chain.at(-1);
90
92
  return {
91
93
  id,
@@ -93,11 +95,24 @@ export async function describeFunction(id, request) {
93
95
  file: state.meta?.filename,
94
96
  method: state.method,
95
97
  access: accessOf(chain, problems),
96
- input: jsonSchemaOf(handlerStep?.options?.inputValidator),
98
+ input: jsonSchemaOf(handlerStep?.options?.validator ?? handlerStep?.options?.inputValidator),
97
99
  problems,
98
100
  };
99
101
  }
100
- export function describeRoutes(routesById) {
102
+ const ROOT_ROUTE_ID = "__root__";
103
+ // Pathless layouts have no path of their own, and a file with only server handlers is an API route.
104
+ export function describePages(routesById, files = {}) {
105
+ return Object.values(routesById).flatMap((route) => {
106
+ const options = route.options ?? {};
107
+ if (route.id === ROOT_ROUTE_ID || options.path === undefined)
108
+ return [];
109
+ if (options.server?.handlers && !options.component)
110
+ return [];
111
+ const file = files[route.id];
112
+ return [{ id: route.id, path: route.fullPath, ...(file && { file }) }];
113
+ });
114
+ }
115
+ export function describeRoutes(routesById, files = {}) {
101
116
  const out = [];
102
117
  for (const route of Object.values(routesById)) {
103
118
  const server = route.options?.server;
@@ -119,7 +134,8 @@ export function describeRoutes(routesById) {
119
134
  methods[method] = accessOf(chain, methodProblems);
120
135
  problems.push(...methodProblems.map((p) => `${method}: ${p}`));
121
136
  }
122
- out.push({ path: route.fullPath, methods, problems });
137
+ const file = files[route.id];
138
+ out.push({ path: route.fullPath, ...(file && { file }), methods, problems });
123
139
  }
124
140
  return out;
125
141
  }
@@ -170,6 +186,12 @@ export async function describeApp(opts) {
170
186
  const description = opts.descriptions?.[id];
171
187
  functions.push(description ? { ...described, description } : described);
172
188
  }
173
- const router = (await opts.getRouter());
174
- return { functions, routes: describeRoutes(router.routesById), workflows: describeWorkflows(opts.workflows ?? []) };
189
+ const { routesById } = (await opts.getRouter());
190
+ return {
191
+ functions,
192
+ routes: describeRoutes(routesById, opts.routeFiles),
193
+ pages: describePages(routesById, opts.routeFiles),
194
+ workflows: describeWorkflows(opts.workflows ?? []),
195
+ authHooks: Object.keys(await loadAuthHooks()),
196
+ };
175
197
  }
@@ -0,0 +1,46 @@
1
+ import type { HostMode } from "./types.js";
2
+ declare const STATUS: {
3
+ readonly BAD_REQUEST: 400;
4
+ readonly INVALID_INPUT: 400;
5
+ readonly UNAUTHORIZED: 401;
6
+ readonly FORBIDDEN: 403;
7
+ readonly NOT_FOUND: 404;
8
+ readonly CONFLICT: 409;
9
+ readonly RATE_LIMITED: 429;
10
+ readonly INTERNAL_ERROR: 500;
11
+ };
12
+ export type ZiteErrorCode = keyof typeof STATUS;
13
+ /** @internal The HTTP status each code answers with. */
14
+ export declare const ZITE_ERROR_STATUS: Readonly<Record<ZiteErrorCode, number>>;
15
+ export declare const isZiteErrorCode: (code: unknown) => code is ZiteErrorCode;
16
+ export type ZiteErrorOptions = {
17
+ code: ZiteErrorCode;
18
+ message: string;
19
+ userFacingMessage?: string;
20
+ };
21
+ /**
22
+ * Fail with a status: `throw new ZiteError('FORBIDDEN', 'Only the owner can edit this')`. On the live app,
23
+ * `userFacingMessage` is what users read in place of `message`, which stays for logs and the editor.
24
+ */
25
+ export declare class ZiteError extends Error {
26
+ readonly code: ZiteErrorCode;
27
+ readonly userFacingMessage?: string;
28
+ constructor(code: ZiteErrorCode, message?: string);
29
+ constructor(options: ZiteErrorOptions);
30
+ /** Classic's order, which code written for `zitejs/backend` uses. */
31
+ constructor(message: string, code?: ZiteErrorCode);
32
+ }
33
+ /**
34
+ * A ZiteError from any copy of zitejs as this one's. Integration SDKs and code written for classic throw
35
+ * `zitejs/backend`'s class, which `instanceof` doesn't recognise.
36
+ */
37
+ export declare const asZiteError: (error: unknown) => ZiteError | undefined;
38
+ /**
39
+ * What a failure may say to the app's users, as on classic. The message is the app author's, so it passes, with
40
+ * credentials redacted in place; one naming our infrastructure or billing is replaced whole. Preview is the editor,
41
+ * which reads it unchanged.
42
+ */
43
+ export declare function toClientErrorMessage(errorMessage: string, mode: HostMode | undefined, userFacingMessage?: string): string;
44
+ /** A thrown value as the browser receives it: its code, and only the message `toClientErrorMessage` allows. */
45
+ export declare const toClientError: (error: unknown) => ZiteError;
46
+ export {};
@@ -0,0 +1,99 @@
1
+ import { currentHost } from "./host.js";
2
+ import { redactSecrets } from "./redactSecrets.js";
3
+ const STATUS = {
4
+ BAD_REQUEST: 400,
5
+ INVALID_INPUT: 400,
6
+ UNAUTHORIZED: 401,
7
+ FORBIDDEN: 403,
8
+ NOT_FOUND: 404,
9
+ CONFLICT: 409,
10
+ RATE_LIMITED: 429,
11
+ INTERNAL_ERROR: 500,
12
+ };
13
+ /** @internal The HTTP status each code answers with. */
14
+ export const ZITE_ERROR_STATUS = STATUS;
15
+ export const isZiteErrorCode = (code) => typeof code === "string" && Object.prototype.hasOwnProperty.call(STATUS, code);
16
+ /**
17
+ * Fail with a status: `throw new ZiteError('FORBIDDEN', 'Only the owner can edit this')`. On the live app,
18
+ * `userFacingMessage` is what users read in place of `message`, which stays for logs and the editor.
19
+ */
20
+ export class ZiteError extends Error {
21
+ code;
22
+ userFacingMessage;
23
+ constructor(first, second) {
24
+ const options = typeof first === "object"
25
+ ? first
26
+ : isZiteErrorCode(first)
27
+ ? { code: first, message: second ?? first }
28
+ : { code: isZiteErrorCode(second) ? second : "INTERNAL_ERROR", message: first };
29
+ super(options.message);
30
+ this.name = "ZiteError";
31
+ this.code = options.code;
32
+ if (options.userFacingMessage)
33
+ this.userFacingMessage = options.userFacingMessage;
34
+ }
35
+ }
36
+ /**
37
+ * A ZiteError from any copy of zitejs as this one's. Integration SDKs and code written for classic throw
38
+ * `zitejs/backend`'s class, which `instanceof` doesn't recognise.
39
+ */
40
+ export const asZiteError = (error) => {
41
+ if (error instanceof ZiteError)
42
+ return error;
43
+ if (!(error instanceof Error) || error.name !== "ZiteError")
44
+ return undefined;
45
+ const { code, userFacingMessage } = error;
46
+ if (!isZiteErrorCode(code))
47
+ return undefined;
48
+ return new ZiteError({
49
+ code,
50
+ message: error.message,
51
+ ...(typeof userFacingMessage === "string" && { userFacingMessage }),
52
+ });
53
+ };
54
+ // Ported from restly's packages/endpoint-setup/src/endpointErrors.ts; keep the two in step. The fences are consumed
55
+ // characters rather than lookbehinds, which older Safari can't parse: this module ships to the browser.
56
+ const INTERNAL_SERVICE_NAMES = [
57
+ "zite-shard-restly-server",
58
+ "restly-server",
59
+ "workflow-runner",
60
+ "base-runner",
61
+ "app-runner",
62
+ "utility-service",
63
+ ];
64
+ const PRIVATE_IPV4 = String.raw `\b(?:10|127)\.\d{1,3}\.\d{1,3}\.\d{1,3}\b|\b172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}\b|\b192\.168\.\d{1,3}\.\d{1,3}\b|\b169\.254\.\d{1,3}\.\d{1,3}\b`;
65
+ const PRIVATE_IPV6 = String.raw `(?:^|[^\w:])(?:::1|(?:f[cd][0-9a-f]{2}|fe[89ab][0-9a-f]):[0-9a-f:]+)(?![\w:])`;
66
+ const INTERNAL_HOSTNAME = String.raw `(?:^|[^\w.-])(?:${INTERNAL_SERVICE_NAMES.join("|")}|localhost)(?:-\w+)*(?::\d+)?(?![\w.-])`;
67
+ const UNAVAILABLE = "A service this app depends on is temporarily unavailable. Please try again.";
68
+ const AI_UNAVAILABLE = "AI features are temporarily unavailable. Please contact the app owner.";
69
+ const INFRASTRUCTURE_PATTERNS = [
70
+ { pattern: new RegExp(`${PRIVATE_IPV4}|${PRIVATE_IPV6}|${INTERNAL_HOSTNAME}`, "i"), replacement: UNAVAILABLE },
71
+ { pattern: /AI is paused: your organization is over its AI credit limit/i, replacement: AI_UNAVAILABLE },
72
+ {
73
+ pattern: /platform\.openai\.com\/(?:settings\/organization\/billing|docs\/guides\/error-codes)/i,
74
+ replacement: AI_UNAVAILABLE,
75
+ },
76
+ ];
77
+ /**
78
+ * What a failure may say to the app's users, as on classic. The message is the app author's, so it passes, with
79
+ * credentials redacted in place; one naming our infrastructure or billing is replaced whole. Preview is the editor,
80
+ * which reads it unchanged.
81
+ */
82
+ export function toClientErrorMessage(errorMessage, mode, userFacingMessage) {
83
+ if (mode === "preview")
84
+ return errorMessage;
85
+ if (userFacingMessage)
86
+ return userFacingMessage;
87
+ const redacted = redactSecrets(errorMessage);
88
+ for (const { pattern, replacement } of INFRASTRUCTURE_PATTERNS) {
89
+ if (pattern.test(redacted))
90
+ return replacement;
91
+ }
92
+ return redacted;
93
+ }
94
+ /** A thrown value as the browser receives it: its code, and only the message `toClientErrorMessage` allows. */
95
+ export const toClientError = (error) => {
96
+ const zite = asZiteError(error);
97
+ const message = error instanceof Error ? error.message : String(error);
98
+ return new ZiteError(zite?.code ?? "INTERNAL_ERROR", toClientErrorMessage(message, currentHost()?.mode, zite?.userFacingMessage));
99
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { asZiteError, ZiteError } from "./errors.js";
3
+ describe("ZiteError", () => {
4
+ it("takes Start's order, classic's order and classic's options", () => {
5
+ expect(new ZiteError("FORBIDDEN", "Owners only")).toMatchObject({ code: "FORBIDDEN", message: "Owners only" });
6
+ expect(new ZiteError("NOT_FOUND")).toMatchObject({ code: "NOT_FOUND", message: "NOT_FOUND" });
7
+ expect(new ZiteError("Owners only", "FORBIDDEN")).toMatchObject({ code: "FORBIDDEN", message: "Owners only" });
8
+ expect(new ZiteError("Something broke")).toMatchObject({ code: "INTERNAL_ERROR", message: "Something broke" });
9
+ expect(new ZiteError({ code: "CONFLICT", message: "Row 3 changed", userFacingMessage: "Someone else edited this." }))
10
+ .toMatchObject({ code: "CONFLICT", message: "Row 3 changed", userFacingMessage: "Someone else edited this." });
11
+ });
12
+ });
13
+ describe("asZiteError", () => {
14
+ it("recognises another copy's ZiteError by name and code", () => {
15
+ const classic = Object.assign(new Error("No such deal"), { name: "ZiteError", code: "NOT_FOUND", userFacingMessage: "Gone." });
16
+ expect(asZiteError(classic)).toMatchObject({ code: "NOT_FOUND", message: "No such deal", userFacingMessage: "Gone." });
17
+ });
18
+ it("refuses anything else", () => {
19
+ expect(asZiteError(new Error("plain"))).toBeUndefined();
20
+ expect(asZiteError(Object.assign(new Error("x"), { name: "ZiteError", code: "toString" }))).toBeUndefined();
21
+ expect(asZiteError({ name: "ZiteError", code: "NOT_FOUND", message: "not an Error" })).toBeUndefined();
22
+ });
23
+ });
@@ -1,5 +1,6 @@
1
1
  import type { User } from "./types.js";
2
- export type { User, ZiteRequestContext } from "./types.js";
2
+ export type { Role, User, ZiteRequestContext } from "./types.js";
3
+ export { ZiteError, ZITE_ERROR_STATUS, type ZiteErrorCode, type ZiteErrorOptions } from "./errors.js";
3
4
  export { createZiteRouter, type ZiteRouterContext } from "./router.js";
4
5
  export { createWorkflow, type StartProviderTrigger, type StartTrigger, type Workflow, type WorkflowArgs, type WorkflowConfig, } from "./workflow.js";
5
6
  export type { ZiteSchedule } from "../schedules/index.js";
@@ -10,24 +11,7 @@ declare module "@tanstack/start-client-core" {
10
11
  description?: string;
11
12
  }
12
13
  }
13
- declare const STATUS: {
14
- readonly BAD_REQUEST: 400;
15
- readonly INVALID_INPUT: 400;
16
- readonly UNAUTHORIZED: 401;
17
- readonly FORBIDDEN: 403;
18
- readonly NOT_FOUND: 404;
19
- readonly CONFLICT: 409;
20
- readonly RATE_LIMITED: 429;
21
- readonly INTERNAL_ERROR: 500;
22
- };
23
- export type ZiteErrorCode = keyof typeof STATUS;
24
- /** @internal The HTTP status each code answers with. */
25
- export declare const ZITE_ERROR_STATUS: Readonly<Record<ZiteErrorCode, number>>;
26
- /** Fail with a status: `throw new ZiteError('FORBIDDEN', 'Only the owner can edit this')`. */
27
- export declare class ZiteError extends Error {
28
- readonly code: ZiteErrorCode;
29
- constructor(code: ZiteErrorCode, message?: string);
30
- }
14
+ /** By the role's id or its name. */
31
15
  export declare const hasRole: (user: User, role: string) => boolean;
32
16
  /** The message a server function fails with when it needs a session; the platform's client middleware sends the user to sign in. */
33
17
  export declare const SIGN_IN_REQUIRED = "ZITE_SIGN_IN_REQUIRED";
@@ -1,32 +1,16 @@
1
1
  import { createMiddleware, createServerFn } from "@tanstack/react-start";
2
2
  import { setResponseStatus } from "@tanstack/react-start/server";
3
3
  import { USAGE_TOKEN_QUERY_PARAM } from "../auth/constants.js";
4
+ import { asZiteError, toClientErrorMessage, ZiteError, ZITE_ERROR_STATUS } from "./errors.js";
4
5
  import { currentHost } from "./host.js";
6
+ import { PREVIEW_CLAIM_PARAM, previewClaim } from "./previewClaim.js";
7
+ import { recordRoute } from "./runLog.js";
5
8
  import { getHost, inScope, runInScope } from "./scope.js";
9
+ export { ZiteError, ZITE_ERROR_STATUS } from "./errors.js";
6
10
  export { createZiteRouter } from "./router.js";
7
11
  export { createWorkflow, } from "./workflow.js";
8
- const STATUS = {
9
- BAD_REQUEST: 400,
10
- INVALID_INPUT: 400,
11
- UNAUTHORIZED: 401,
12
- FORBIDDEN: 403,
13
- NOT_FOUND: 404,
14
- CONFLICT: 409,
15
- RATE_LIMITED: 429,
16
- INTERNAL_ERROR: 500,
17
- };
18
- /** @internal The HTTP status each code answers with. */
19
- export const ZITE_ERROR_STATUS = STATUS;
20
- /** Fail with a status: `throw new ZiteError('FORBIDDEN', 'Only the owner can edit this')`. */
21
- export class ZiteError extends Error {
22
- code;
23
- constructor(code, message) {
24
- super(message ?? code);
25
- this.name = "ZiteError";
26
- this.code = code;
27
- }
28
- }
29
- export const hasRole = (user, role) => user.roles.includes(role);
12
+ /** By the role's id or its name. */
13
+ export const hasRole = (user, role) => user.roles.some(({ id, name }) => id === role || name === role);
30
14
  /** The message a server function fails with when it needs a session; the platform's client middleware sends the user to sign in. */
31
15
  export const SIGN_IN_REQUIRED = "ZITE_SIGN_IN_REQUIRED";
32
16
  export const isSignInRequired = (error) => error instanceof Error && error.message === SIGN_IN_REQUIRED;
@@ -44,10 +28,13 @@ export function loginHref(redirectTo) {
44
28
  params.set("redirectUrl", target.toString());
45
29
  else if (!target && redirectTo)
46
30
  params.set("redirectUrl", redirectTo);
47
- // The editor preview's sign-in page needs the preview's token.
31
+ // The editor preview's sign-in page needs the preview's token, and its claim where the frame dropped the cookie.
48
32
  const usageToken = current ? new URL(current).searchParams.get(USAGE_TOKEN_QUERY_PARAM) : null;
49
33
  if (usageToken)
50
34
  params.set(USAGE_TOKEN_QUERY_PARAM, usageToken);
35
+ const claim = previewClaim();
36
+ if (claim)
37
+ params.set(PREVIEW_CLAIM_PARAM, claim);
51
38
  const query = params.toString();
52
39
  return query ? `/auth/login?${query}` : "/auth/login";
53
40
  }
@@ -81,7 +68,7 @@ const validationIssues = (error) => {
81
68
  }
82
69
  };
83
70
  const invalidInput = (issues) => {
84
- if (getHost()?.mode === "live")
71
+ if (getHost()?.mode !== "preview")
85
72
  return new ZiteError("INVALID_INPUT", "Invalid input");
86
73
  const describe = ({ message, path = [] }) => {
87
74
  const at = path.map((segment) => String(typeof segment === "object" ? segment.key : segment)).join(".");
@@ -98,32 +85,37 @@ const withStatus = async (isServerFn, fn) => {
98
85
  }
99
86
  catch (caught) {
100
87
  const issues = validationIssues(caught);
101
- const error = issues ? invalidInput(issues) : caught;
102
- if (!(error instanceof ZiteError))
103
- throw error;
104
- if (!isServerFn)
105
- return new Response(error.message, { status: STATUS[error.code] });
106
- if (!nested)
107
- setResponseStatus(STATUS[error.code]);
108
- throw error;
88
+ const error = issues ? invalidInput(issues) : asZiteError(caught);
89
+ if (!isServerFn) {
90
+ if (!error)
91
+ throw caught;
92
+ const message = toClientErrorMessage(error.message, getHost()?.mode, error.userFacingMessage);
93
+ return new Response(message, { status: ZITE_ERROR_STATUS[error.code] });
94
+ }
95
+ // TanStack answers a failed server function 200, with the error in the body. Redirects and notFound aren't Errors.
96
+ if (!nested && (error || caught instanceof Error))
97
+ setResponseStatus(error ? ZITE_ERROR_STATUS[error.code] : 500);
98
+ throw error ?? caught;
109
99
  }
110
100
  };
101
+ // A server route's run is recorded around its access decision, so a refusal shows with its status.
102
+ const decide = (serverFnMeta, request, fn) => serverFnMeta ? withStatus(true, fn) : recordRoute(getHost(), request, () => withStatus(false, fn));
111
103
  /** Only signed-in users. Handlers get `context.user: User`. */
112
- export const signedIn = tag(createMiddleware().server(({ next, serverFnMeta }) => withStatus(!!serverFnMeta, async () => {
104
+ export const signedIn = tag(createMiddleware().server(({ next, serverFnMeta, request }) => decide(serverFnMeta, request, async () => {
113
105
  const { user } = host();
114
106
  if (!user)
115
107
  throw new ZiteError("UNAUTHORIZED", SIGN_IN_REQUIRED);
116
108
  return runInScope({ kind: "signedIn", user }, () => next({ context: { user } }));
117
109
  })), { kind: "signedIn" });
118
110
  /** Anyone, signed in or not. Handlers get `context.user: User | null`. */
119
- export const anyone = tag(createMiddleware().server(({ next, serverFnMeta }) => withStatus(!!serverFnMeta, async () => {
111
+ export const anyone = tag(createMiddleware().server(({ next, serverFnMeta, request }) => decide(serverFnMeta, request, async () => {
120
112
  const { user } = host();
121
113
  return runInScope({ kind: "anyone", user }, () => next({ context: { user } }));
122
114
  })), { kind: "anyone" });
123
- const webhookMiddleware = (paused) => tag(createMiddleware().server(({ next }) => {
115
+ const webhookMiddleware = (paused) => tag(createMiddleware().server(({ next, request }) => {
124
116
  if (host().invoke?.kind !== "webhook")
125
117
  return new Response("Not found", { status: 404 });
126
- return runInScope({ kind: "webhook", user: null }, () => next());
118
+ return recordRoute(getHost(), request, async () => runInScope({ kind: "webhook", user: null }, () => next()));
127
119
  }), paused ? { kind: "webhook", paused: true } : { kind: "webhook" });
128
120
  /**
129
121
  * Webhooks delivered by the platform, which already checked the URL token.