zitejs 0.9.118 → 0.9.120

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ /**
5
+ * The signed-out redirect on internal apps.
6
+ *
7
+ * A separate file from `index.test.ts` because it has to replace
8
+ * `better-auth/react` wholesale to drive `useSession`, and that would strip the
9
+ * real client the export tests next door assert against.
10
+ *
11
+ * The value here is entirely in WHICH signed-out renders it reacts to. An
12
+ * external app has a real logged-out state, and an app built before the
13
+ * platform sent an access mode tells us nothing — redirecting either turns a
14
+ * working public page into a forced sign-in.
15
+ */
16
+ const { useSessionMock } = vitest_1.vi.hoisted(() => ({ useSessionMock: vitest_1.vi.fn() }));
17
+ vitest_1.vi.mock('better-auth/react', () => ({
18
+ createAuthClient: () => ({
19
+ useSession: useSessionMock,
20
+ signIn: {},
21
+ signUp: {},
22
+ signOut: vitest_1.vi.fn(),
23
+ updateUser: vitest_1.vi.fn(),
24
+ }),
25
+ }));
26
+ vitest_1.vi.mock('better-auth/client/plugins', () => ({
27
+ magicLinkClient: () => ({}),
28
+ inferAdditionalFields: () => ({}),
29
+ }));
30
+ // `useAuth` takes only `useEffect` from React. Running it inline is the whole
31
+ // of what a render would do here, and avoids pulling in a renderer.
32
+ vitest_1.vi.mock('react', () => ({ useEffect: (fn) => fn() }));
33
+ const index_js_1 = require("./index.js");
34
+ const APP_URL = 'https://app.zite.so/dashboard';
35
+ function stubLocation(href) {
36
+ const location = { pathname: new URL(href).pathname, href };
37
+ vitest_1.vi.stubGlobal('window', { location });
38
+ return location;
39
+ }
40
+ const signedOut = { data: null, isPending: false };
41
+ (0, vitest_1.beforeEach)(() => {
42
+ delete process.env.ZITE_ACCESS_MODE;
43
+ });
44
+ (0, vitest_1.afterEach)(() => {
45
+ delete process.env.ZITE_ACCESS_MODE;
46
+ vitest_1.vi.unstubAllGlobals();
47
+ useSessionMock.mockReset();
48
+ });
49
+ const render = (session, { accessMode, href = APP_URL } = {}) => {
50
+ if (accessMode !== undefined)
51
+ process.env.ZITE_ACCESS_MODE = accessMode;
52
+ const location = stubLocation(href);
53
+ useSessionMock.mockReturnValue(session);
54
+ return { result: (0, index_js_1.useAuth)(), location };
55
+ };
56
+ (0, vitest_1.describe)('useAuth signed-out handling', () => {
57
+ (0, vitest_1.it)('sends a signed-out visitor on an internal app to sign-in', () => {
58
+ const { result, location } = render(signedOut, { accessMode: 'internal' });
59
+ (0, vitest_1.expect)(location.href.startsWith('/auth/login')).toBe(true);
60
+ // Reported as loading rather than signed out: the signed-out branch of an
61
+ // internal app is the empty screen this exists to prevent.
62
+ (0, vitest_1.expect)(result.isLoading).toBe(true);
63
+ });
64
+ (0, vitest_1.it)('leaves a signed-out visitor on an external app alone', () => {
65
+ const { result, location } = render(signedOut, { accessMode: 'external' });
66
+ (0, vitest_1.expect)(location.href).toBe(APP_URL);
67
+ (0, vitest_1.expect)(result.isLoading).toBe(false);
68
+ (0, vitest_1.expect)(result.user).toBe(null);
69
+ });
70
+ // Every app published before the platform started sending an access mode.
71
+ (0, vitest_1.it)('leaves an app that never declared an access mode alone', () => {
72
+ const { result, location } = render(signedOut);
73
+ (0, vitest_1.expect)(location.href).toBe(APP_URL);
74
+ (0, vitest_1.expect)(result.isLoading).toBe(false);
75
+ });
76
+ (0, vitest_1.it)('does not redirect while the session is still resolving', () => {
77
+ const { result, location } = render({ data: null, isPending: true }, { accessMode: 'internal' });
78
+ (0, vitest_1.expect)(location.href).toBe(APP_URL);
79
+ (0, vitest_1.expect)(result.isLoading).toBe(true);
80
+ });
81
+ (0, vitest_1.it)('does not redirect a signed-in visitor', () => {
82
+ const { result, location } = render({ data: { user: { id: 'u1', email: 'a@b.com' } }, isPending: false }, { accessMode: 'internal' });
83
+ (0, vitest_1.expect)(location.href).toBe(APP_URL);
84
+ (0, vitest_1.expect)(result.isLoading).toBe(false);
85
+ (0, vitest_1.expect)(result.user).toMatchObject({ id: 'u1' });
86
+ });
87
+ // `loginWithRedirect` refuses to navigate away from an auth page, so claiming
88
+ // a redirect here would hang the caller on `isLoading` forever.
89
+ (0, vitest_1.it)('does not claim to be loading on an auth page it cannot leave', () => {
90
+ const { result } = render(signedOut, {
91
+ accessMode: 'internal',
92
+ href: 'https://app.zite.so/auth/login',
93
+ });
94
+ (0, vitest_1.expect)(result.isLoading).toBe(false);
95
+ });
96
+ });
@@ -33,24 +33,18 @@ function getFlowId(appDir) {
33
33
  catch { }
34
34
  return undefined;
35
35
  }
36
- /**
37
- * Find the connected email integration's key in an app's zite.config.json,
38
- * if any. The key is the integrationId used by the runtime SDK bridge.
39
- */
40
- function getEmailIntegrationId(appDir) {
36
+ const readJsonFile = (path) => {
41
37
  try {
42
- const configPath = (0, path_1.join)("apps", appDir, "zite.config.json");
43
- if (!(0, fs_2.existsSync)(configPath))
44
- return undefined;
45
- const config = JSON.parse((0, fs_2.readFileSync)(configPath, "utf-8"));
46
- const integrations = config.integrations ?? {};
47
- for (const [id, int] of Object.entries(integrations)) {
48
- if (int?.type === "email")
49
- return id;
50
- }
38
+ return (0, fs_2.existsSync)(path)
39
+ ? JSON.parse((0, fs_2.readFileSync)(path, "utf-8"))
40
+ : undefined;
51
41
  }
52
- catch { }
53
- return undefined;
42
+ catch {
43
+ return undefined;
44
+ }
45
+ };
46
+ function getEmailIntegrationId(appDir) {
47
+ return (0, lib_js_1.findEmailIntegrationId)(readJsonFile((0, path_1.join)("apps", appDir, "zite.config.json")), readJsonFile("zite.config.json"));
54
48
  }
55
49
  function getDeclaredEnvVarNames(appDir) {
56
50
  try {
@@ -82,6 +82,12 @@ export declare function normalizeAirtableLockNames(lock: AirtableLock): {
82
82
  };
83
83
  export declare function generateAirtableTs(inputLock: AirtableLock): string | null;
84
84
  export declare function generateBackendWrapperTs(envVarNames?: string[]): string;
85
+ /**
86
+ * The key of the email integration an app sends through, which is the
87
+ * integrationId the runtime SDK bridge dispatches on. The app's own entry wins;
88
+ * otherwise it is the workspace email the app has `integrationSettings` for.
89
+ */
90
+ export declare function findEmailIntegrationId(appConfig: unknown, workspaceConfig: unknown): string | undefined;
85
91
  /**
86
92
  * Generate `.zite/integrations/email.ts` — the `Email` client for an app with
87
93
  * an email integration connected. Mirrors the airtable SDK generation: a thin
@@ -7,6 +7,7 @@ exports.generateApiTs = generateApiTs;
7
7
  exports.normalizeAirtableLockNames = normalizeAirtableLockNames;
8
8
  exports.generateAirtableTs = generateAirtableTs;
9
9
  exports.generateBackendWrapperTs = generateBackendWrapperTs;
10
+ exports.findEmailIntegrationId = findEmailIntegrationId;
10
11
  exports.generateEmailSdk = generateEmailSdk;
11
12
  const parser_1 = require("@babel/parser");
12
13
  const sdkNames_js_1 = require("./sdkNames.js");
@@ -1346,6 +1347,18 @@ function generateBackendWrapperTs(envVarNames = []) {
1346
1347
  "",
1347
1348
  ].join("\n");
1348
1349
  }
1350
+ /**
1351
+ * The key of the email integration an app sends through, which is the
1352
+ * integrationId the runtime SDK bridge dispatches on. The app's own entry wins;
1353
+ * otherwise it is the workspace email the app has `integrationSettings` for.
1354
+ */
1355
+ function findEmailIntegrationId(appConfig, workspaceConfig) {
1356
+ const app = (appConfig ?? {});
1357
+ const workspace = (workspaceConfig ?? {});
1358
+ const isEmail = (config, id) => config.integrations?.[id]?.type === "email";
1359
+ return (Object.keys(app.integrations ?? {}).find((id) => isEmail(app, id)) ??
1360
+ Object.keys(app.integrationSettings ?? {}).find((id) => isEmail(workspace, id)));
1361
+ }
1349
1362
  /**
1350
1363
  * Generate `.zite/integrations/email.ts` — the `Email` client for an app with
1351
1364
  * an email integration connected. Mirrors the airtable SDK generation: a thin
@@ -385,3 +385,31 @@ const duplicateIdentifierErrorsIn = (source) => {
385
385
  (0, vitest_1.expect)(output).toContain('ZiteError');
386
386
  });
387
387
  });
388
+ (0, vitest_1.describe)('findEmailIntegrationId', () => {
389
+ const workspace = {
390
+ integrations: {
391
+ slack: { type: 'slack' },
392
+ 'team-email': { type: 'email' },
393
+ },
394
+ };
395
+ (0, vitest_1.it)('finds the app\'s own email entry', () => {
396
+ (0, vitest_1.expect)((0, lib_js_1.findEmailIntegrationId)({ integrations: { mail: { type: 'email' } } }, workspace)).toBe('mail');
397
+ });
398
+ (0, vitest_1.it)('finds the workspace email the app has settings for', () => {
399
+ (0, vitest_1.expect)((0, lib_js_1.findEmailIntegrationId)({ integrationSettings: { slack: {}, 'team-email': { fromName: 'Ops' } } }, workspace)).toBe('team-email');
400
+ });
401
+ // An app that never opted in generates no client, so `zitejs/email` stays
402
+ // unresolved there instead of sending as a sender it never chose.
403
+ (0, vitest_1.it)('ignores a workspace email the app has no settings for', () => {
404
+ (0, vitest_1.expect)((0, lib_js_1.findEmailIntegrationId)({}, workspace)).toBeUndefined();
405
+ });
406
+ (0, vitest_1.it)('prefers the app\'s own entry over a workspace one', () => {
407
+ (0, vitest_1.expect)((0, lib_js_1.findEmailIntegrationId)({
408
+ integrations: { mail: { type: 'email' } },
409
+ integrationSettings: { 'team-email': {} },
410
+ }, workspace)).toBe('mail');
411
+ });
412
+ (0, vitest_1.it)('tolerates missing configs', () => {
413
+ (0, vitest_1.expect)((0, lib_js_1.findEmailIntegrationId)(undefined, undefined)).toBeUndefined();
414
+ });
415
+ });
@@ -2,6 +2,7 @@ export type UploadData = string | Blob | ArrayBuffer | File;
2
2
  export declare class FileUploadError extends Error {
3
3
  constructor(message: string);
4
4
  }
5
+ export declare function toUploadBlob(data: UploadData): Blob;
5
6
  export declare function uploadFile({ data, filename, }: {
6
7
  data: UploadData;
7
8
  filename: string;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.FileUploadError = void 0;
4
+ exports.toUploadBlob = toUploadBlob;
4
5
  exports.uploadFile = uploadFile;
5
6
  exports.useUpload = useUpload;
6
7
  const react_1 = require("react");
@@ -24,33 +25,35 @@ function getZiteAppMode(hostname) {
24
25
  }
25
26
  return 'live';
26
27
  }
27
- function readBlobAsDataUrl(blob) {
28
- return new Promise((resolve, reject) => {
29
- const reader = new FileReader();
30
- reader.onload = () => resolve(reader.result);
31
- reader.onerror = () => reject(reader.error ?? new Error('Failed to read file'));
32
- reader.readAsDataURL(blob);
33
- });
28
+ function decodeDataUrl(dataUrl) {
29
+ const comma = dataUrl.indexOf(',');
30
+ if (comma === -1) {
31
+ throw new FileUploadError('Invalid data URL');
32
+ }
33
+ const header = dataUrl.slice(0, comma);
34
+ const payload = dataUrl.slice(comma + 1);
35
+ const mimeMatch = /^data:([^;,]*)/.exec(header);
36
+ const mimeType = mimeMatch?.[1] || 'application/octet-stream';
37
+ const bytes = Uint8Array.from(atob(payload), c => c.charCodeAt(0));
38
+ return new Blob([bytes], { type: mimeType });
34
39
  }
35
- async function toDataUrl(data) {
36
- if (data instanceof Blob) {
37
- return readBlobAsDataUrl(data);
40
+ function toUploadBlob(data) {
41
+ if (typeof Blob !== 'undefined' && data instanceof Blob) {
42
+ return data;
38
43
  }
39
44
  if (data instanceof ArrayBuffer) {
40
- const bytes = new Uint8Array(data);
41
- let binary = '';
42
- for (let i = 0; i < bytes.byteLength; i++) {
43
- binary += String.fromCharCode(bytes[i]);
44
- }
45
- return 'data:application/octet-stream;base64,' + btoa(binary);
45
+ return new Blob([data], { type: 'application/octet-stream' });
46
46
  }
47
47
  if (typeof data === 'string') {
48
48
  if (data.startsWith('data:'))
49
- return data;
50
- if (/^[A-Za-z0-9+/]+=*$/.test(data)) {
51
- return 'data:application/octet-stream;base64,' + data;
49
+ return decodeDataUrl(data);
50
+ // Only treat as raw base64 when the string is padded/aligned — otherwise
51
+ // short text like "hello" matches the alphabet and atob throws.
52
+ if (data.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
53
+ const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));
54
+ return new Blob([bytes], { type: 'application/octet-stream' });
52
55
  }
53
- return 'data:text/plain;base64,' + btoa(unescape(encodeURIComponent(data)));
56
+ return new Blob([data], { type: 'text/plain' });
54
57
  }
55
58
  throw new FileUploadError('Invalid data format. Expected string, Blob, ArrayBuffer, or File.');
56
59
  }
@@ -62,36 +65,55 @@ function resolveFilename(data, filename) {
62
65
  }
63
66
  throw new FileUploadError('A filename is required unless the uploaded data is a File.');
64
67
  }
65
- async function performUpload(data, filename) {
66
- const body = await toDataUrl(data);
67
- const mode = getZiteAppMode(window.location.hostname);
68
- const flowId = (0, config_js_1.getFlowId)();
68
+ async function readErrorMessage(res, fallback) {
69
+ try {
70
+ const err = (await res.json());
71
+ if (err.message)
72
+ return err.message;
73
+ }
74
+ catch { }
75
+ return fallback;
76
+ }
77
+ function authHeaders() {
69
78
  const token = typeof localStorage !== 'undefined'
70
79
  ? localStorage.getItem('zite.auth.token')
71
80
  : null;
72
- const res = await fetch((0, config_js_1.getApiUrl)() + '/v1/zite/public/' + flowId + '/upload?mode=' + mode, {
81
+ return token ? { Authorization: 'Bearer ' + token } : {};
82
+ }
83
+ async function performUpload(data, filename) {
84
+ const blob = toUploadBlob(data);
85
+ const mode = getZiteAppMode(window.location.hostname);
86
+ const flowId = (0, config_js_1.getFlowId)();
87
+ const sessionRes = await fetch((0, config_js_1.getApiUrl)() + '/v1/zite/public/' + flowId + '/upload-session?mode=' + mode, {
73
88
  method: 'POST',
74
89
  headers: {
75
90
  'Content-Type': 'application/json',
76
- ...(token ? { Authorization: 'Bearer ' + token } : {}),
91
+ ...authHeaders(),
77
92
  },
78
- body: JSON.stringify({ data: body, filename }),
93
+ body: JSON.stringify({
94
+ filename,
95
+ uploadLength: blob.size,
96
+ contentType: blob.type || 'application/octet-stream',
97
+ }),
79
98
  });
80
- if (!res.ok) {
81
- let message = 'Upload failed';
82
- try {
83
- const err = (await res.json());
84
- if (err.message)
85
- message = err.message;
86
- }
87
- catch { }
88
- throw new FileUploadError(message);
99
+ if (!sessionRes.ok) {
100
+ throw new FileUploadError(await readErrorMessage(sessionRes, 'Upload failed'));
89
101
  }
90
- const result = (await res.json());
91
- if (!result.success || !result.fileUrl) {
92
- throw new FileUploadError(result.message ?? 'Upload failed');
102
+ const session = (await sessionRes.json());
103
+ if (!session.success || !session.presignedUrl || !session.fileUrl) {
104
+ throw new FileUploadError(session.message ?? 'Upload failed');
105
+ }
106
+ const putRes = await fetch(session.presignedUrl, {
107
+ method: 'PUT',
108
+ headers: {
109
+ 'Content-Type': session.contentType || blob.type || 'application/octet-stream',
110
+ },
111
+ body: blob,
112
+ });
113
+ if (!putRes.ok) {
114
+ throw new FileUploadError('Upload failed');
93
115
  }
94
- return result.fileUrl;
116
+ return session.fileUrl;
95
117
  }
96
118
  async function uploadFile({ data, filename, }) {
97
119
  return { fileUrl: await performUpload(data, filename) };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ const index_js_1 = require("./index.js");
5
+ vitest_1.vi.mock('../auth/config.js', () => ({
6
+ getApiUrl: () => 'https://api.example.com',
7
+ getFlowId: () => 'app_123',
8
+ }));
9
+ const fetchMock = vitest_1.vi.fn();
10
+ (0, vitest_1.beforeEach)(() => {
11
+ fetchMock.mockReset();
12
+ vitest_1.vi.stubGlobal('fetch', fetchMock);
13
+ vitest_1.vi.stubGlobal('window', { location: { hostname: 'portal.acme.com' } });
14
+ vitest_1.vi.stubGlobal('localStorage', { getItem: () => null });
15
+ });
16
+ (0, vitest_1.afterEach)(() => {
17
+ vitest_1.vi.unstubAllGlobals();
18
+ });
19
+ (0, vitest_1.describe)('toUploadBlob', () => {
20
+ (0, vitest_1.it)('wraps a string as text/plain and preserves File/Blob size', () => {
21
+ (0, vitest_1.expect)((0, index_js_1.toUploadBlob)('hello').size).toBe(5);
22
+ (0, vitest_1.expect)((0, index_js_1.toUploadBlob)('hello').type).toBe('text/plain');
23
+ const file = new File(['abc'], 'note.txt', { type: 'text/plain' });
24
+ (0, vitest_1.expect)((0, index_js_1.toUploadBlob)(file).size).toBe(3);
25
+ });
26
+ });
27
+ (0, vitest_1.describe)('uploadFile', () => {
28
+ (0, vitest_1.it)('requests a session then PUTs the bytes to the presigned URL', async () => {
29
+ const file = new File(['hello-world'], 'hello.txt', { type: 'text/plain' });
30
+ fetchMock
31
+ .mockResolvedValueOnce({
32
+ ok: true,
33
+ json: async () => ({
34
+ success: true,
35
+ presignedUrl: 'https://s3.example.com/put',
36
+ fileUrl: 'https://uploads.zite.com/orgid-1/zite-uploads/app_123/hello.txt',
37
+ contentType: 'text/plain',
38
+ }),
39
+ })
40
+ .mockResolvedValueOnce({ ok: true });
41
+ const result = await (0, index_js_1.uploadFile)({ data: file, filename: 'hello.txt' });
42
+ (0, vitest_1.expect)(result.fileUrl).toContain('hello.txt');
43
+ (0, vitest_1.expect)(fetchMock).toHaveBeenCalledTimes(2);
44
+ (0, vitest_1.expect)(fetchMock.mock.calls[0][0]).toBe('https://api.example.com/v1/zite/public/app_123/upload-session?mode=live');
45
+ const sessionInit = fetchMock.mock.calls[0][1];
46
+ (0, vitest_1.expect)(JSON.parse(sessionInit.body)).toEqual({
47
+ filename: 'hello.txt',
48
+ uploadLength: 11,
49
+ contentType: 'text/plain',
50
+ });
51
+ (0, vitest_1.expect)(fetchMock.mock.calls[1][0]).toBe('https://s3.example.com/put');
52
+ (0, vitest_1.expect)(fetchMock.mock.calls[1][1].method).toBe('PUT');
53
+ });
54
+ (0, vitest_1.it)('surfaces the plan-limit message from the session endpoint', async () => {
55
+ fetchMock.mockResolvedValueOnce({
56
+ ok: false,
57
+ json: async () => ({
58
+ message: 'File size exceeds maximum of 20 MB',
59
+ }),
60
+ });
61
+ const upload = (0, index_js_1.uploadFile)({
62
+ data: new File(['x'], 'big.bin'),
63
+ filename: 'big.bin',
64
+ });
65
+ await (0, vitest_1.expect)(upload).rejects.toBeInstanceOf(index_js_1.FileUploadError);
66
+ await (0, vitest_1.expect)(upload).rejects.toThrow('File size exceeds maximum of 20 MB');
67
+ });
68
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
2
+ /**
3
+ * The signed-out redirect on internal apps.
4
+ *
5
+ * A separate file from `index.test.ts` because it has to replace
6
+ * `better-auth/react` wholesale to drive `useSession`, and that would strip the
7
+ * real client the export tests next door assert against.
8
+ *
9
+ * The value here is entirely in WHICH signed-out renders it reacts to. An
10
+ * external app has a real logged-out state, and an app built before the
11
+ * platform sent an access mode tells us nothing — redirecting either turns a
12
+ * working public page into a forced sign-in.
13
+ */
14
+ const { useSessionMock } = vi.hoisted(() => ({ useSessionMock: vi.fn() }));
15
+ vi.mock('better-auth/react', () => ({
16
+ createAuthClient: () => ({
17
+ useSession: useSessionMock,
18
+ signIn: {},
19
+ signUp: {},
20
+ signOut: vi.fn(),
21
+ updateUser: vi.fn(),
22
+ }),
23
+ }));
24
+ vi.mock('better-auth/client/plugins', () => ({
25
+ magicLinkClient: () => ({}),
26
+ inferAdditionalFields: () => ({}),
27
+ }));
28
+ // `useAuth` takes only `useEffect` from React. Running it inline is the whole
29
+ // of what a render would do here, and avoids pulling in a renderer.
30
+ vi.mock('react', () => ({ useEffect: (fn) => fn() }));
31
+ import { useAuth } from './index.js';
32
+ const APP_URL = 'https://app.zite.so/dashboard';
33
+ function stubLocation(href) {
34
+ const location = { pathname: new URL(href).pathname, href };
35
+ vi.stubGlobal('window', { location });
36
+ return location;
37
+ }
38
+ const signedOut = { data: null, isPending: false };
39
+ beforeEach(() => {
40
+ delete process.env.ZITE_ACCESS_MODE;
41
+ });
42
+ afterEach(() => {
43
+ delete process.env.ZITE_ACCESS_MODE;
44
+ vi.unstubAllGlobals();
45
+ useSessionMock.mockReset();
46
+ });
47
+ const render = (session, { accessMode, href = APP_URL } = {}) => {
48
+ if (accessMode !== undefined)
49
+ process.env.ZITE_ACCESS_MODE = accessMode;
50
+ const location = stubLocation(href);
51
+ useSessionMock.mockReturnValue(session);
52
+ return { result: useAuth(), location };
53
+ };
54
+ describe('useAuth signed-out handling', () => {
55
+ it('sends a signed-out visitor on an internal app to sign-in', () => {
56
+ const { result, location } = render(signedOut, { accessMode: 'internal' });
57
+ expect(location.href.startsWith('/auth/login')).toBe(true);
58
+ // Reported as loading rather than signed out: the signed-out branch of an
59
+ // internal app is the empty screen this exists to prevent.
60
+ expect(result.isLoading).toBe(true);
61
+ });
62
+ it('leaves a signed-out visitor on an external app alone', () => {
63
+ const { result, location } = render(signedOut, { accessMode: 'external' });
64
+ expect(location.href).toBe(APP_URL);
65
+ expect(result.isLoading).toBe(false);
66
+ expect(result.user).toBe(null);
67
+ });
68
+ // Every app published before the platform started sending an access mode.
69
+ it('leaves an app that never declared an access mode alone', () => {
70
+ const { result, location } = render(signedOut);
71
+ expect(location.href).toBe(APP_URL);
72
+ expect(result.isLoading).toBe(false);
73
+ });
74
+ it('does not redirect while the session is still resolving', () => {
75
+ const { result, location } = render({ data: null, isPending: true }, { accessMode: 'internal' });
76
+ expect(location.href).toBe(APP_URL);
77
+ expect(result.isLoading).toBe(true);
78
+ });
79
+ it('does not redirect a signed-in visitor', () => {
80
+ const { result, location } = render({ data: { user: { id: 'u1', email: 'a@b.com' } }, isPending: false }, { accessMode: 'internal' });
81
+ expect(location.href).toBe(APP_URL);
82
+ expect(result.isLoading).toBe(false);
83
+ expect(result.user).toMatchObject({ id: 'u1' });
84
+ });
85
+ // `loginWithRedirect` refuses to navigate away from an auth page, so claiming
86
+ // a redirect here would hang the caller on `isLoading` forever.
87
+ it('does not claim to be loading on an auth page it cannot leave', () => {
88
+ const { result } = render(signedOut, {
89
+ accessMode: 'internal',
90
+ href: 'https://app.zite.so/auth/login',
91
+ });
92
+ expect(result.isLoading).toBe(false);
93
+ });
94
+ });
@@ -2,7 +2,7 @@ import { watch } from "fs";
2
2
  import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, } from "fs";
3
3
  import { join } from "path";
4
4
  import { runSync } from "../sync/index.js";
5
- import { generateDbTs, generateApiTs, generateBackendWrapperTs, generateAirtableTs, generateEmailSdk, } from "../sync/lib.js";
5
+ import { generateDbTs, generateApiTs, generateBackendWrapperTs, generateAirtableTs, findEmailIntegrationId, generateEmailSdk, } from "../sync/lib.js";
6
6
  const debounceTimers = new Map();
7
7
  function debounce(key, fn, ms) {
8
8
  const existing = debounceTimers.get(key);
@@ -29,24 +29,18 @@ function getFlowId(appDir) {
29
29
  catch { }
30
30
  return undefined;
31
31
  }
32
- /**
33
- * Find the connected email integration's key in an app's zite.config.json,
34
- * if any. The key is the integrationId used by the runtime SDK bridge.
35
- */
36
- function getEmailIntegrationId(appDir) {
32
+ const readJsonFile = (path) => {
37
33
  try {
38
- const configPath = join("apps", appDir, "zite.config.json");
39
- if (!existsSync(configPath))
40
- return undefined;
41
- const config = JSON.parse(readFileSync(configPath, "utf-8"));
42
- const integrations = config.integrations ?? {};
43
- for (const [id, int] of Object.entries(integrations)) {
44
- if (int?.type === "email")
45
- return id;
46
- }
34
+ return existsSync(path)
35
+ ? JSON.parse(readFileSync(path, "utf-8"))
36
+ : undefined;
47
37
  }
48
- catch { }
49
- return undefined;
38
+ catch {
39
+ return undefined;
40
+ }
41
+ };
42
+ function getEmailIntegrationId(appDir) {
43
+ return findEmailIntegrationId(readJsonFile(join("apps", appDir, "zite.config.json")), readJsonFile("zite.config.json"));
50
44
  }
51
45
  function getDeclaredEnvVarNames(appDir) {
52
46
  try {
@@ -82,6 +82,12 @@ export declare function normalizeAirtableLockNames(lock: AirtableLock): {
82
82
  };
83
83
  export declare function generateAirtableTs(inputLock: AirtableLock): string | null;
84
84
  export declare function generateBackendWrapperTs(envVarNames?: string[]): string;
85
+ /**
86
+ * The key of the email integration an app sends through, which is the
87
+ * integrationId the runtime SDK bridge dispatches on. The app's own entry wins;
88
+ * otherwise it is the workspace email the app has `integrationSettings` for.
89
+ */
90
+ export declare function findEmailIntegrationId(appConfig: unknown, workspaceConfig: unknown): string | undefined;
85
91
  /**
86
92
  * Generate `.zite/integrations/email.ts` — the `Email` client for an app with
87
93
  * an email integration connected. Mirrors the airtable SDK generation: a thin
@@ -1333,6 +1333,18 @@ export function generateBackendWrapperTs(envVarNames = []) {
1333
1333
  "",
1334
1334
  ].join("\n");
1335
1335
  }
1336
+ /**
1337
+ * The key of the email integration an app sends through, which is the
1338
+ * integrationId the runtime SDK bridge dispatches on. The app's own entry wins;
1339
+ * otherwise it is the workspace email the app has `integrationSettings` for.
1340
+ */
1341
+ export function findEmailIntegrationId(appConfig, workspaceConfig) {
1342
+ const app = (appConfig ?? {});
1343
+ const workspace = (workspaceConfig ?? {});
1344
+ const isEmail = (config, id) => config.integrations?.[id]?.type === "email";
1345
+ return (Object.keys(app.integrations ?? {}).find((id) => isEmail(app, id)) ??
1346
+ Object.keys(app.integrationSettings ?? {}).find((id) => isEmail(workspace, id)));
1347
+ }
1336
1348
  /**
1337
1349
  * Generate `.zite/integrations/email.ts` — the `Email` client for an app with
1338
1350
  * an email integration connected. Mirrors the airtable SDK generation: a thin
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import ts from 'typescript';
3
- import { generateAirtableTs, generateApiTs, generateBackendWrapperTs, generateDbTs, generateEmailSdk, } from './lib.js';
3
+ import { findEmailIntegrationId, generateAirtableTs, generateApiTs, generateBackendWrapperTs, generateDbTs, generateEmailSdk, } from './lib.js';
4
4
  /**
5
5
  * Syntax errors in the emitted source. `.zite/db.ts` sits at the repo root and
6
6
  * every app imports it, so anything unparseable here fails typecheck for the
@@ -380,3 +380,31 @@ describe('generateBackendWrapperTs', () => {
380
380
  expect(output).toContain('ZiteError');
381
381
  });
382
382
  });
383
+ describe('findEmailIntegrationId', () => {
384
+ const workspace = {
385
+ integrations: {
386
+ slack: { type: 'slack' },
387
+ 'team-email': { type: 'email' },
388
+ },
389
+ };
390
+ it('finds the app\'s own email entry', () => {
391
+ expect(findEmailIntegrationId({ integrations: { mail: { type: 'email' } } }, workspace)).toBe('mail');
392
+ });
393
+ it('finds the workspace email the app has settings for', () => {
394
+ expect(findEmailIntegrationId({ integrationSettings: { slack: {}, 'team-email': { fromName: 'Ops' } } }, workspace)).toBe('team-email');
395
+ });
396
+ // An app that never opted in generates no client, so `zitejs/email` stays
397
+ // unresolved there instead of sending as a sender it never chose.
398
+ it('ignores a workspace email the app has no settings for', () => {
399
+ expect(findEmailIntegrationId({}, workspace)).toBeUndefined();
400
+ });
401
+ it('prefers the app\'s own entry over a workspace one', () => {
402
+ expect(findEmailIntegrationId({
403
+ integrations: { mail: { type: 'email' } },
404
+ integrationSettings: { 'team-email': {} },
405
+ }, workspace)).toBe('mail');
406
+ });
407
+ it('tolerates missing configs', () => {
408
+ expect(findEmailIntegrationId(undefined, undefined)).toBeUndefined();
409
+ });
410
+ });
@@ -2,6 +2,7 @@ export type UploadData = string | Blob | ArrayBuffer | File;
2
2
  export declare class FileUploadError extends Error {
3
3
  constructor(message: string);
4
4
  }
5
+ export declare function toUploadBlob(data: UploadData): Blob;
5
6
  export declare function uploadFile({ data, filename, }: {
6
7
  data: UploadData;
7
8
  filename: string;
@@ -18,33 +18,35 @@ function getZiteAppMode(hostname) {
18
18
  }
19
19
  return 'live';
20
20
  }
21
- function readBlobAsDataUrl(blob) {
22
- return new Promise((resolve, reject) => {
23
- const reader = new FileReader();
24
- reader.onload = () => resolve(reader.result);
25
- reader.onerror = () => reject(reader.error ?? new Error('Failed to read file'));
26
- reader.readAsDataURL(blob);
27
- });
21
+ function decodeDataUrl(dataUrl) {
22
+ const comma = dataUrl.indexOf(',');
23
+ if (comma === -1) {
24
+ throw new FileUploadError('Invalid data URL');
25
+ }
26
+ const header = dataUrl.slice(0, comma);
27
+ const payload = dataUrl.slice(comma + 1);
28
+ const mimeMatch = /^data:([^;,]*)/.exec(header);
29
+ const mimeType = mimeMatch?.[1] || 'application/octet-stream';
30
+ const bytes = Uint8Array.from(atob(payload), c => c.charCodeAt(0));
31
+ return new Blob([bytes], { type: mimeType });
28
32
  }
29
- async function toDataUrl(data) {
30
- if (data instanceof Blob) {
31
- return readBlobAsDataUrl(data);
33
+ export function toUploadBlob(data) {
34
+ if (typeof Blob !== 'undefined' && data instanceof Blob) {
35
+ return data;
32
36
  }
33
37
  if (data instanceof ArrayBuffer) {
34
- const bytes = new Uint8Array(data);
35
- let binary = '';
36
- for (let i = 0; i < bytes.byteLength; i++) {
37
- binary += String.fromCharCode(bytes[i]);
38
- }
39
- return 'data:application/octet-stream;base64,' + btoa(binary);
38
+ return new Blob([data], { type: 'application/octet-stream' });
40
39
  }
41
40
  if (typeof data === 'string') {
42
41
  if (data.startsWith('data:'))
43
- return data;
44
- if (/^[A-Za-z0-9+/]+=*$/.test(data)) {
45
- return 'data:application/octet-stream;base64,' + data;
42
+ return decodeDataUrl(data);
43
+ // Only treat as raw base64 when the string is padded/aligned — otherwise
44
+ // short text like "hello" matches the alphabet and atob throws.
45
+ if (data.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(data)) {
46
+ const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));
47
+ return new Blob([bytes], { type: 'application/octet-stream' });
46
48
  }
47
- return 'data:text/plain;base64,' + btoa(unescape(encodeURIComponent(data)));
49
+ return new Blob([data], { type: 'text/plain' });
48
50
  }
49
51
  throw new FileUploadError('Invalid data format. Expected string, Blob, ArrayBuffer, or File.');
50
52
  }
@@ -56,36 +58,55 @@ function resolveFilename(data, filename) {
56
58
  }
57
59
  throw new FileUploadError('A filename is required unless the uploaded data is a File.');
58
60
  }
59
- async function performUpload(data, filename) {
60
- const body = await toDataUrl(data);
61
- const mode = getZiteAppMode(window.location.hostname);
62
- const flowId = getFlowId();
61
+ async function readErrorMessage(res, fallback) {
62
+ try {
63
+ const err = (await res.json());
64
+ if (err.message)
65
+ return err.message;
66
+ }
67
+ catch { }
68
+ return fallback;
69
+ }
70
+ function authHeaders() {
63
71
  const token = typeof localStorage !== 'undefined'
64
72
  ? localStorage.getItem('zite.auth.token')
65
73
  : null;
66
- const res = await fetch(getApiUrl() + '/v1/zite/public/' + flowId + '/upload?mode=' + mode, {
74
+ return token ? { Authorization: 'Bearer ' + token } : {};
75
+ }
76
+ async function performUpload(data, filename) {
77
+ const blob = toUploadBlob(data);
78
+ const mode = getZiteAppMode(window.location.hostname);
79
+ const flowId = getFlowId();
80
+ const sessionRes = await fetch(getApiUrl() + '/v1/zite/public/' + flowId + '/upload-session?mode=' + mode, {
67
81
  method: 'POST',
68
82
  headers: {
69
83
  'Content-Type': 'application/json',
70
- ...(token ? { Authorization: 'Bearer ' + token } : {}),
84
+ ...authHeaders(),
71
85
  },
72
- body: JSON.stringify({ data: body, filename }),
86
+ body: JSON.stringify({
87
+ filename,
88
+ uploadLength: blob.size,
89
+ contentType: blob.type || 'application/octet-stream',
90
+ }),
73
91
  });
74
- if (!res.ok) {
75
- let message = 'Upload failed';
76
- try {
77
- const err = (await res.json());
78
- if (err.message)
79
- message = err.message;
80
- }
81
- catch { }
82
- throw new FileUploadError(message);
92
+ if (!sessionRes.ok) {
93
+ throw new FileUploadError(await readErrorMessage(sessionRes, 'Upload failed'));
83
94
  }
84
- const result = (await res.json());
85
- if (!result.success || !result.fileUrl) {
86
- throw new FileUploadError(result.message ?? 'Upload failed');
95
+ const session = (await sessionRes.json());
96
+ if (!session.success || !session.presignedUrl || !session.fileUrl) {
97
+ throw new FileUploadError(session.message ?? 'Upload failed');
98
+ }
99
+ const putRes = await fetch(session.presignedUrl, {
100
+ method: 'PUT',
101
+ headers: {
102
+ 'Content-Type': session.contentType || blob.type || 'application/octet-stream',
103
+ },
104
+ body: blob,
105
+ });
106
+ if (!putRes.ok) {
107
+ throw new FileUploadError('Upload failed');
87
108
  }
88
- return result.fileUrl;
109
+ return session.fileUrl;
89
110
  }
90
111
  export async function uploadFile({ data, filename, }) {
91
112
  return { fileUrl: await performUpload(data, filename) };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { FileUploadError, toUploadBlob, uploadFile } from './index.js';
3
+ vi.mock('../auth/config.js', () => ({
4
+ getApiUrl: () => 'https://api.example.com',
5
+ getFlowId: () => 'app_123',
6
+ }));
7
+ const fetchMock = vi.fn();
8
+ beforeEach(() => {
9
+ fetchMock.mockReset();
10
+ vi.stubGlobal('fetch', fetchMock);
11
+ vi.stubGlobal('window', { location: { hostname: 'portal.acme.com' } });
12
+ vi.stubGlobal('localStorage', { getItem: () => null });
13
+ });
14
+ afterEach(() => {
15
+ vi.unstubAllGlobals();
16
+ });
17
+ describe('toUploadBlob', () => {
18
+ it('wraps a string as text/plain and preserves File/Blob size', () => {
19
+ expect(toUploadBlob('hello').size).toBe(5);
20
+ expect(toUploadBlob('hello').type).toBe('text/plain');
21
+ const file = new File(['abc'], 'note.txt', { type: 'text/plain' });
22
+ expect(toUploadBlob(file).size).toBe(3);
23
+ });
24
+ });
25
+ describe('uploadFile', () => {
26
+ it('requests a session then PUTs the bytes to the presigned URL', async () => {
27
+ const file = new File(['hello-world'], 'hello.txt', { type: 'text/plain' });
28
+ fetchMock
29
+ .mockResolvedValueOnce({
30
+ ok: true,
31
+ json: async () => ({
32
+ success: true,
33
+ presignedUrl: 'https://s3.example.com/put',
34
+ fileUrl: 'https://uploads.zite.com/orgid-1/zite-uploads/app_123/hello.txt',
35
+ contentType: 'text/plain',
36
+ }),
37
+ })
38
+ .mockResolvedValueOnce({ ok: true });
39
+ const result = await uploadFile({ data: file, filename: 'hello.txt' });
40
+ expect(result.fileUrl).toContain('hello.txt');
41
+ expect(fetchMock).toHaveBeenCalledTimes(2);
42
+ expect(fetchMock.mock.calls[0][0]).toBe('https://api.example.com/v1/zite/public/app_123/upload-session?mode=live');
43
+ const sessionInit = fetchMock.mock.calls[0][1];
44
+ expect(JSON.parse(sessionInit.body)).toEqual({
45
+ filename: 'hello.txt',
46
+ uploadLength: 11,
47
+ contentType: 'text/plain',
48
+ });
49
+ expect(fetchMock.mock.calls[1][0]).toBe('https://s3.example.com/put');
50
+ expect(fetchMock.mock.calls[1][1].method).toBe('PUT');
51
+ });
52
+ it('surfaces the plan-limit message from the session endpoint', async () => {
53
+ fetchMock.mockResolvedValueOnce({
54
+ ok: false,
55
+ json: async () => ({
56
+ message: 'File size exceeds maximum of 20 MB',
57
+ }),
58
+ });
59
+ const upload = uploadFile({
60
+ data: new File(['x'], 'big.bin'),
61
+ filename: 'big.bin',
62
+ });
63
+ await expect(upload).rejects.toBeInstanceOf(FileUploadError);
64
+ await expect(upload).rejects.toThrow('File size exceeds maximum of 20 MB');
65
+ });
66
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zitejs",
3
- "version": "0.9.118",
3
+ "version": "0.9.120",
4
4
  "description": "The Zite framework — build apps on Zite Database",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",