zitejs 0.10.0-start.5 → 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 (50) 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 +25 -2
  19. package/dist/esm/start/describe.js +55 -6
  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 +5 -21
  25. package/dist/esm/start/index.js +29 -37
  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/registration.d.ts +13 -0
  35. package/dist/esm/start/registration.js +21 -0
  36. package/dist/esm/start/router.d.ts +6 -3
  37. package/dist/esm/start/router.js +47 -7
  38. package/dist/esm/start/runLog.d.ts +7 -4
  39. package/dist/esm/start/runLog.js +64 -6
  40. package/dist/esm/start/server.d.ts +7 -3
  41. package/dist/esm/start/server.js +92 -21
  42. package/dist/esm/start/types.d.ts +28 -3
  43. package/dist/esm/start/version.d.ts +1 -0
  44. package/dist/esm/start/version.js +2 -0
  45. package/dist/esm/start/vite.js +31 -3
  46. package/dist/esm/start/workflow.d.ts +16 -2
  47. package/dist/esm/start/workflow.test-d.js +16 -1
  48. package/dist/esm/start/workflowRun.js +2 -1
  49. package/dist/esm/sync/lib.js +6 -6
  50. 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,3 +1,5 @@
1
+ import type { ZiteTrigger } from "../backend/index.js";
2
+ import { type AuthHookName } from "./authHooks.js";
1
3
  import type { LoadedWorkflow } from "./workflowRun.js";
2
4
  import type { WorkflowConfig } from "./workflow.js";
3
5
  export type Access = {
@@ -18,38 +20,59 @@ export type DescribedFunction = {
18
20
  };
19
21
  export type DescribedRoute = {
20
22
  path: string;
23
+ file?: string;
21
24
  methods: Record<string, Access>;
22
25
  problems: string[];
23
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
+ };
24
33
  export type DescribedWorkflow = {
25
34
  id: string;
26
35
  file: string;
27
36
  description?: string;
28
37
  schedule?: WorkflowConfig["schedule"];
29
- trigger?: WorkflowConfig["trigger"];
38
+ /** As declared, without a provider trigger's `subscribe` and `unsubscribe`. */
39
+ trigger?: ZiteTrigger;
40
+ /** A provider trigger's: whether it can be taken down, and a hash of both functions' code, so publish registers it again when they change. */
41
+ registration?: {
42
+ unsubscribe: boolean;
43
+ hash: string;
44
+ };
30
45
  problems: string[];
31
46
  };
32
47
  export type Description = {
33
48
  functions: DescribedFunction[];
34
49
  routes: DescribedRoute[];
50
+ pages: DescribedPage[];
35
51
  workflows: DescribedWorkflow[];
52
+ /** The hooks the app's `zite.auth.ts` defines. */
53
+ authHooks: AuthHookName[];
36
54
  };
37
55
  export declare function describeFunction(id: string, request: Request): Promise<DescribedFunction>;
38
56
  type RouteLike = {
57
+ id: string;
39
58
  fullPath: string;
40
59
  parentRoute?: RouteLike;
41
60
  options?: {
61
+ path?: string;
62
+ component?: unknown;
42
63
  server?: {
43
64
  middleware?: unknown[];
44
65
  handlers?: unknown;
45
66
  };
46
67
  };
47
68
  };
48
- 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[];
49
71
  export declare function describeWorkflows(workflows: LoadedWorkflow[]): DescribedWorkflow[];
50
72
  export declare function describeApp(opts: {
51
73
  fnIds: string[];
52
74
  descriptions?: Record<string, string>;
75
+ routeFiles?: Record<string, string>;
53
76
  getRouter: () => unknown;
54
77
  request: Request;
55
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,10 +134,24 @@ 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
  }
142
+ // cyrb53: enough to tell when the code changed, which is all publish asks.
143
+ const sourceHash = (text) => {
144
+ let h1 = 0xdeadbeef;
145
+ let h2 = 0x41c6ce57;
146
+ for (let i = 0; i < text.length; i++) {
147
+ const ch = text.charCodeAt(i);
148
+ h1 = Math.imul(h1 ^ ch, 2654435761);
149
+ h2 = Math.imul(h2 ^ ch, 1597334677);
150
+ }
151
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
152
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
153
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, "0");
154
+ };
126
155
  // A workflow's id is its export name: what the host invokes it by and the editor shows.
127
156
  export function describeWorkflows(workflows) {
128
157
  return workflows.map(({ id, file, workflow }) => {
@@ -133,7 +162,21 @@ export function describeWorkflows(workflows) {
133
162
  if (elsewhere)
134
163
  problems.push(`duplicate-workflow: ${id} is also exported from ${elsewhere.file}`);
135
164
  const { description, schedule, trigger } = workflow.config;
136
- return { id, file, description, ...(schedule ? { schedule } : { trigger }), problems };
165
+ if (schedule)
166
+ return { id, file, description, schedule, problems };
167
+ if (!trigger || trigger.every !== undefined || trigger.events !== undefined) {
168
+ return { id, file, description, trigger, problems };
169
+ }
170
+ const { subscribe, unsubscribe, ...declared } = trigger;
171
+ if (typeof subscribe !== "function") {
172
+ problems.push("workflow-trigger-unregistered: a provider trigger needs `subscribe` to register it with the provider");
173
+ return { id, file, description, trigger: declared, problems };
174
+ }
175
+ const registration = {
176
+ unsubscribe: typeof unsubscribe === "function",
177
+ hash: sourceHash(`${String(subscribe)}\n${typeof unsubscribe === "function" ? String(unsubscribe) : ""}`),
178
+ };
179
+ return { id, file, description, trigger: declared, registration, problems };
137
180
  });
138
181
  }
139
182
  export async function describeApp(opts) {
@@ -143,6 +186,12 @@ export async function describeApp(opts) {
143
186
  const description = opts.descriptions?.[id];
144
187
  functions.push(description ? { ...described, description } : described);
145
188
  }
146
- const router = (await opts.getRouter());
147
- 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
+ };
148
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,33 +1,17 @@
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
- export { createWorkflow, type Workflow, type WorkflowArgs, type WorkflowConfig } from "./workflow.js";
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";
6
- export type { ZiteTrigger } from "../backend/index.js";
7
+ export type { ZiteTrigger, ZiteTriggerSubscribe, ZiteTriggerSubscribeInput, ZiteTriggerSubscribeResult, ZiteTriggerUnsubscribe, ZiteTriggerUnsubscribeInput, } from "../backend/index.js";
7
8
  declare module "@tanstack/start-client-core" {
8
9
  interface ServerFnOptions {
9
10
  /** What this function does, in a sentence. Shown in the editor. */
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";