@superblocksteam/shared 0.9614.3 → 0.9615.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/dist/database-lifecycle/appDatabaseCompatibility.d.ts +5 -3
  2. package/dist/database-lifecycle/appDatabaseCompatibility.d.ts.map +1 -1
  3. package/dist/database-lifecycle/appDatabaseCompatibility.js.map +1 -1
  4. package/dist/database-lifecycle/appDatabaseImport.d.ts +70 -0
  5. package/dist/database-lifecycle/appDatabaseImport.d.ts.map +1 -0
  6. package/dist/database-lifecycle/appDatabaseImport.js +111 -0
  7. package/dist/database-lifecycle/appDatabaseImport.js.map +1 -0
  8. package/dist/database-lifecycle/appDatabaseImport.test.d.ts +2 -0
  9. package/dist/database-lifecycle/appDatabaseImport.test.d.ts.map +1 -0
  10. package/dist/database-lifecycle/appDatabaseImport.test.js +145 -0
  11. package/dist/database-lifecycle/appDatabaseImport.test.js.map +1 -0
  12. package/dist/database-lifecycle/appDatabaseStatus.d.ts +79 -0
  13. package/dist/database-lifecycle/appDatabaseStatus.d.ts.map +1 -0
  14. package/dist/database-lifecycle/appDatabaseStatus.js +28 -0
  15. package/dist/database-lifecycle/appDatabaseStatus.js.map +1 -0
  16. package/dist/database-lifecycle/appDatabaseStatus.test.d.ts +2 -0
  17. package/dist/database-lifecycle/appDatabaseStatus.test.d.ts.map +1 -0
  18. package/dist/database-lifecycle/appDatabaseStatus.test.js +88 -0
  19. package/dist/database-lifecycle/appDatabaseStatus.test.js.map +1 -0
  20. package/dist/database-lifecycle/dataImport.d.ts +53 -0
  21. package/dist/database-lifecycle/dataImport.d.ts.map +1 -0
  22. package/dist/database-lifecycle/dataImport.js +173 -0
  23. package/dist/database-lifecycle/dataImport.js.map +1 -0
  24. package/dist/database-lifecycle/dataImport.test.d.ts +2 -0
  25. package/dist/database-lifecycle/dataImport.test.d.ts.map +1 -0
  26. package/dist/database-lifecycle/dataImport.test.js +84 -0
  27. package/dist/database-lifecycle/dataImport.test.js.map +1 -0
  28. package/dist/database-lifecycle/index.d.ts +101 -5
  29. package/dist/database-lifecycle/index.d.ts.map +1 -1
  30. package/dist/database-lifecycle/index.js +55 -6
  31. package/dist/database-lifecycle/index.js.map +1 -1
  32. package/dist/database-lifecycle/index.test.js +18 -6
  33. package/dist/database-lifecycle/index.test.js.map +1 -1
  34. package/dist/index.d.ts +1 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -0
  37. package/dist/index.js.map +1 -1
  38. package/dist/plugins/templates/workflow.d.ts.map +1 -1
  39. package/dist/plugins/templates/workflow.js +1 -3
  40. package/dist/plugins/templates/workflow.js.map +1 -1
  41. package/dist/socket/protocol.d.ts +19 -2
  42. package/dist/socket/protocol.d.ts.map +1 -1
  43. package/dist/socket/protocol.js.map +1 -1
  44. package/dist/types/ai/index.d.ts +39 -0
  45. package/dist/types/ai/index.d.ts.map +1 -1
  46. package/dist/types/ai/index.js +8 -1
  47. package/dist/types/ai/index.js.map +1 -1
  48. package/dist/types/billing/usageSource.d.ts +1 -0
  49. package/dist/types/billing/usageSource.d.ts.map +1 -1
  50. package/dist/types/billing/usageSource.js +19 -1
  51. package/dist/types/billing/usageSource.js.map +1 -1
  52. package/dist/types/event/index.d.ts +6 -1
  53. package/dist/types/event/index.d.ts.map +1 -1
  54. package/dist/types/event/index.js +5 -0
  55. package/dist/types/event/index.js.map +1 -1
  56. package/dist/types/policyGate/index.d.ts +11 -0
  57. package/dist/types/policyGate/index.d.ts.map +1 -1
  58. package/dist/types/policyGate/index.js +9 -0
  59. package/dist/types/policyGate/index.js.map +1 -1
  60. package/dist/utils/attachment-delivery.js +1 -1
  61. package/dist/utils/attachment-delivery.js.map +1 -1
  62. package/dist/utils/attachment-delivery.test.js +11 -0
  63. package/dist/utils/attachment-delivery.test.js.map +1 -1
  64. package/dist/utils/attachment-upload.d.ts +15 -0
  65. package/dist/utils/attachment-upload.d.ts.map +1 -1
  66. package/dist/utils/attachment-upload.js +44 -2
  67. package/dist/utils/attachment-upload.js.map +1 -1
  68. package/dist/utils/attachment-upload.test.js +37 -1
  69. package/dist/utils/attachment-upload.test.js.map +1 -1
  70. package/dist/utils/attachment-validation.d.ts +18 -1
  71. package/dist/utils/attachment-validation.d.ts.map +1 -1
  72. package/dist/utils/attachment-validation.js +38 -5
  73. package/dist/utils/attachment-validation.js.map +1 -1
  74. package/dist/utils/attachment-validation.test.js +22 -3
  75. package/dist/utils/attachment-validation.test.js.map +1 -1
  76. package/dist-esm/database-lifecycle/appDatabaseCompatibility.d.ts +5 -3
  77. package/dist-esm/database-lifecycle/appDatabaseCompatibility.d.ts.map +1 -1
  78. package/dist-esm/database-lifecycle/appDatabaseCompatibility.js.map +1 -1
  79. package/dist-esm/database-lifecycle/appDatabaseImport.d.ts +70 -0
  80. package/dist-esm/database-lifecycle/appDatabaseImport.d.ts.map +1 -0
  81. package/dist-esm/database-lifecycle/appDatabaseImport.js +104 -0
  82. package/dist-esm/database-lifecycle/appDatabaseImport.js.map +1 -0
  83. package/dist-esm/database-lifecycle/appDatabaseImport.test.d.ts +2 -0
  84. package/dist-esm/database-lifecycle/appDatabaseImport.test.d.ts.map +1 -0
  85. package/dist-esm/database-lifecycle/appDatabaseImport.test.js +143 -0
  86. package/dist-esm/database-lifecycle/appDatabaseImport.test.js.map +1 -0
  87. package/dist-esm/database-lifecycle/appDatabaseStatus.d.ts +79 -0
  88. package/dist-esm/database-lifecycle/appDatabaseStatus.d.ts.map +1 -0
  89. package/dist-esm/database-lifecycle/appDatabaseStatus.js +23 -0
  90. package/dist-esm/database-lifecycle/appDatabaseStatus.js.map +1 -0
  91. package/dist-esm/database-lifecycle/appDatabaseStatus.test.d.ts +2 -0
  92. package/dist-esm/database-lifecycle/appDatabaseStatus.test.d.ts.map +1 -0
  93. package/dist-esm/database-lifecycle/appDatabaseStatus.test.js +86 -0
  94. package/dist-esm/database-lifecycle/appDatabaseStatus.test.js.map +1 -0
  95. package/dist-esm/database-lifecycle/dataImport.d.ts +53 -0
  96. package/dist-esm/database-lifecycle/dataImport.d.ts.map +1 -0
  97. package/dist-esm/database-lifecycle/dataImport.js +165 -0
  98. package/dist-esm/database-lifecycle/dataImport.js.map +1 -0
  99. package/dist-esm/database-lifecycle/dataImport.test.d.ts +2 -0
  100. package/dist-esm/database-lifecycle/dataImport.test.d.ts.map +1 -0
  101. package/dist-esm/database-lifecycle/dataImport.test.js +82 -0
  102. package/dist-esm/database-lifecycle/dataImport.test.js.map +1 -0
  103. package/dist-esm/database-lifecycle/index.d.ts +101 -5
  104. package/dist-esm/database-lifecycle/index.d.ts.map +1 -1
  105. package/dist-esm/database-lifecycle/index.js +36 -5
  106. package/dist-esm/database-lifecycle/index.js.map +1 -1
  107. package/dist-esm/database-lifecycle/index.test.js +19 -7
  108. package/dist-esm/database-lifecycle/index.test.js.map +1 -1
  109. package/dist-esm/index.d.ts +1 -0
  110. package/dist-esm/index.d.ts.map +1 -1
  111. package/dist-esm/index.js +1 -0
  112. package/dist-esm/index.js.map +1 -1
  113. package/dist-esm/plugins/templates/workflow.d.ts.map +1 -1
  114. package/dist-esm/plugins/templates/workflow.js +1 -3
  115. package/dist-esm/plugins/templates/workflow.js.map +1 -1
  116. package/dist-esm/socket/protocol.d.ts +19 -2
  117. package/dist-esm/socket/protocol.d.ts.map +1 -1
  118. package/dist-esm/socket/protocol.js.map +1 -1
  119. package/dist-esm/types/ai/index.d.ts +39 -0
  120. package/dist-esm/types/ai/index.d.ts.map +1 -1
  121. package/dist-esm/types/ai/index.js +6 -0
  122. package/dist-esm/types/ai/index.js.map +1 -1
  123. package/dist-esm/types/billing/usageSource.d.ts +1 -0
  124. package/dist-esm/types/billing/usageSource.d.ts.map +1 -1
  125. package/dist-esm/types/billing/usageSource.js +18 -0
  126. package/dist-esm/types/billing/usageSource.js.map +1 -1
  127. package/dist-esm/types/event/index.d.ts +6 -1
  128. package/dist-esm/types/event/index.d.ts.map +1 -1
  129. package/dist-esm/types/event/index.js +5 -0
  130. package/dist-esm/types/event/index.js.map +1 -1
  131. package/dist-esm/types/policyGate/index.d.ts +11 -0
  132. package/dist-esm/types/policyGate/index.d.ts.map +1 -1
  133. package/dist-esm/types/policyGate/index.js +8 -0
  134. package/dist-esm/types/policyGate/index.js.map +1 -1
  135. package/dist-esm/utils/attachment-delivery.js +2 -2
  136. package/dist-esm/utils/attachment-delivery.js.map +1 -1
  137. package/dist-esm/utils/attachment-delivery.test.js +11 -0
  138. package/dist-esm/utils/attachment-delivery.test.js.map +1 -1
  139. package/dist-esm/utils/attachment-upload.d.ts +15 -0
  140. package/dist-esm/utils/attachment-upload.d.ts.map +1 -1
  141. package/dist-esm/utils/attachment-upload.js +41 -1
  142. package/dist-esm/utils/attachment-upload.js.map +1 -1
  143. package/dist-esm/utils/attachment-upload.test.js +38 -2
  144. package/dist-esm/utils/attachment-upload.test.js.map +1 -1
  145. package/dist-esm/utils/attachment-validation.d.ts +18 -1
  146. package/dist-esm/utils/attachment-validation.d.ts.map +1 -1
  147. package/dist-esm/utils/attachment-validation.js +38 -5
  148. package/dist-esm/utils/attachment-validation.js.map +1 -1
  149. package/dist-esm/utils/attachment-validation.test.js +23 -4
  150. package/dist-esm/utils/attachment-validation.test.js.map +1 -1
  151. package/package.json +1 -1
  152. package/src/database-lifecycle/appDatabaseCompatibility.ts +6 -3
  153. package/src/database-lifecycle/appDatabaseImport.test.ts +185 -0
  154. package/src/database-lifecycle/appDatabaseImport.ts +205 -0
  155. package/src/database-lifecycle/appDatabaseStatus.test.ts +105 -0
  156. package/src/database-lifecycle/appDatabaseStatus.ts +95 -0
  157. package/src/database-lifecycle/dataImport.test.ts +95 -0
  158. package/src/database-lifecycle/dataImport.ts +244 -0
  159. package/src/database-lifecycle/index.test.ts +20 -6
  160. package/src/database-lifecycle/index.ts +203 -7
  161. package/src/index.ts +1 -0
  162. package/src/plugins/templates/workflow.ts +1 -3
  163. package/src/socket/protocol.ts +14 -2
  164. package/src/types/ai/index.ts +39 -0
  165. package/src/types/billing/usageSource.ts +20 -0
  166. package/src/types/event/index.ts +6 -1
  167. package/src/types/policyGate/index.ts +14 -0
  168. package/src/utils/attachment-delivery.test.ts +14 -0
  169. package/src/utils/attachment-delivery.ts +2 -2
  170. package/src/utils/attachment-upload.test.ts +48 -1
  171. package/src/utils/attachment-upload.ts +48 -1
  172. package/src/utils/attachment-validation.test.ts +35 -3
  173. package/src/utils/attachment-validation.ts +52 -6
@@ -112,3 +112,23 @@ const POOL_LIKE_EXHAUSTIVE: Record<UsageSource, boolean> = {
112
112
  export const POOL_LIKE_USAGE_SOURCES: readonly UsageSource[] = Object.entries(POOL_LIKE_EXHAUSTIVE)
113
113
  .filter(([, isPoolLike]) => isPoolLike)
114
114
  .map(([source]) => source as UsageSource);
115
+
116
+ // Sources the live dollar_commit path actually writes ledger AI debits for.
117
+ // Replacement repricing must use this allowlist: a denylist (`is_byo IS NOT TRUE`)
118
+ // billed `overage_unbilled` (spend-cap usage the live path declined), plus `trial`
119
+ // and `topup`, as net-new money under the replacement contract.
120
+ const DOLLAR_COMMIT_LIVE_BILLED_EXHAUSTIVE: Record<UsageSource, boolean> = {
121
+ byo: false,
122
+ enterprise: false,
123
+ legacy: false,
124
+ on_demand: true,
125
+ overage_unbilled: false,
126
+ pool: true,
127
+ seat: false,
128
+ topup: false,
129
+ trial: false
130
+ };
131
+
132
+ export const DOLLAR_COMMIT_LIVE_BILLED_USAGE_SOURCES: readonly UsageSource[] = Object.entries(DOLLAR_COMMIT_LIVE_BILLED_EXHAUSTIVE)
133
+ .filter(([, billed]) => billed)
134
+ .map(([source]) => source as UsageSource);
@@ -71,6 +71,7 @@ export const CombinedViewMode = { ...ViewMode, ...ExportViewMode };
71
71
  export type CombinedViewMode = ViewMode | ExportViewMode;
72
72
 
73
73
  export enum EventAction {
74
+ ABANDON_TURN = 'abandon_turn',
74
75
  CREATED = 'created',
75
76
  CLONED = 'cloned',
76
77
  DELETED = 'deleted',
@@ -103,6 +104,8 @@ export enum EventAction {
103
104
  TRIAL_EXTENDED = 'trial_extended',
104
105
  PLAN_UPGRADED = 'plan_upgraded',
105
106
  PLAN_DOWNGRADED = 'plan_downgraded',
107
+ /** Single plan transition for analytics: carries currentPlan and newPlan. */
108
+ PLAN_CHANGED = 'plan_changed',
106
109
  MEMBER_ADDED = 'member_added',
107
110
  MEMBER_REMOVED = 'member_removed',
108
111
  SENT = 'sent',
@@ -114,6 +117,7 @@ export enum EventAction {
114
117
  PULLED = 'pulled',
115
118
  PUSHED = 'pushed',
116
119
  LOGGED_IN = 'logged_in',
120
+ SESSION_REVERTED = 'session_reverted',
117
121
  SESSION_STARTED = 'session_started',
118
122
  GRANTED = 'granted',
119
123
  DEACTIVATED = 'deactivated',
@@ -123,7 +127,8 @@ export enum EventAction {
123
127
  DETECTED = 'detected',
124
128
  DISABLED = 'disabled',
125
129
  ENABLED = 'enabled',
126
- PUBLISH_ANYWAY = 'publish_anyway'
130
+ PUBLISH_ANYWAY = 'publish_anyway',
131
+ EDIT_AFTER_CLARK = 'edit_after_clark'
127
132
  }
128
133
 
129
134
  export interface UserEvent {
@@ -74,6 +74,11 @@ export type PolicyGateReadinessItem = {
74
74
  mode: 'advisory' | 'approval' | 'blocking' | 'blocking_with_exceptions' | 'report_only';
75
75
  policyId: string;
76
76
  policyVersionId: string;
77
+ /**
78
+ * Latest completed run of this policy on the deployed commit. It is the
79
+ * baseline a diff-scoped scan reviews changes against.
80
+ */
81
+ priorReviewRunId?: string;
77
82
  progress?: PolicyGateScanProgress;
78
83
  reviewRunId?: string;
79
84
  runnerType?: ReviewPolicyRunnerType;
@@ -127,6 +132,15 @@ export function areReadinessItemsAckEligible(items: Pick<PolicyGateReadinessItem
127
132
  );
128
133
  }
129
134
 
135
+ /**
136
+ * Whether a readiness item has reported an outcome. `running` is still
137
+ * deciding, and `missing` is what a poll between two runs returns, not an
138
+ * answer.
139
+ */
140
+ export function hasPolicyItemReported(item: Pick<PolicyGateReadinessItem, 'status'>): boolean {
141
+ return item.status !== 'missing' && item.status !== 'running';
142
+ }
143
+
130
144
  export type PolicyGateReadinessRun = {
131
145
  completedAt?: string;
132
146
  createdAt?: string;
@@ -50,3 +50,17 @@ describe('dump attachments', () => {
50
50
  ).toBe(true);
51
51
  });
52
52
  });
53
+
54
+ describe('spreadsheet attachments', () => {
55
+ test('classifies .xlsx as a reference-only supported upload and leaves legacy Excel unsupported', () => {
56
+ expect(
57
+ classifyAttachmentDelivery({
58
+ fileName: 'budget.xlsx',
59
+ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
60
+ })
61
+ ).toBe('reference_only');
62
+ expect(isSupportedAttachmentUploadType({ fileName: 'budget.xlsx' })).toBe(true);
63
+ expect(isSupportedAttachmentUploadType({ fileName: 'inventory.xls' })).toBe(false);
64
+ expect(isSupportedAttachmentUploadType({ fileName: 'macros.xlsm' })).toBe(false);
65
+ });
66
+ });
@@ -1,4 +1,4 @@
1
- import { getTextAttachmentType } from './attachment-upload.js';
1
+ import { getTextAttachmentType, isXlsxAttachmentFile } from './attachment-upload.js';
2
2
 
3
3
  export type AttachmentDeliveryMode = 'inline_native' | 'reference_only';
4
4
  export type AttachmentArchiveType = 'gz' | 'tgz' | 'zip';
@@ -76,7 +76,7 @@ export function isDumpAttachment(input: AttachmentTypeInput): boolean {
76
76
  }
77
77
 
78
78
  export function isReferenceOnlySupportedAttachment(input: AttachmentTypeInput): boolean {
79
- return detectAttachmentArchiveType(input) !== null || isDumpAttachment(input);
79
+ return detectAttachmentArchiveType(input) !== null || isDumpAttachment(input) || isXlsxAttachmentFile(input);
80
80
  }
81
81
 
82
82
  export function classifyAttachmentDelivery(input: AttachmentTypeInput): AttachmentDeliveryMode {
@@ -9,14 +9,60 @@ import {
9
9
  createUploadedAttachmentFromServer,
10
10
  getAttachmentMimeTypeFromFileName,
11
11
  getTextAttachmentType,
12
+ isLegacyExcelFile,
12
13
  isOfficeXml,
13
14
  isTextLikeMimeType,
15
+ isXlsxAttachmentFile,
14
16
  normalizeAttachmentMimeType,
15
17
  parseUserFacingUploadError,
16
18
  toAttachmentUploadDescriptor,
17
19
  uploadAttachmentToServerApi
18
20
  } from './attachment-upload.js';
19
21
 
22
+ describe('isXlsxAttachmentFile', () => {
23
+ test('is true for .xlsx only', () => {
24
+ expect(isXlsxAttachmentFile({ fileName: 'a.xlsx' })).toBe(true);
25
+ expect(isXlsxAttachmentFile({ fileName: 'b.xls' })).toBe(false);
26
+ expect(isXlsxAttachmentFile({ fileName: 'c.xlsm' })).toBe(false);
27
+ expect(isXlsxAttachmentFile({ fileName: 'd.xlsb' })).toBe(false);
28
+ expect(isXlsxAttachmentFile({ fileName: 'e.docx' })).toBe(false);
29
+ });
30
+
31
+ test('does not read a legacy .xls as .xlsx even when the MIME type says spreadsheet', () => {
32
+ expect(
33
+ isXlsxAttachmentFile({
34
+ fileName: 'inventory.xls',
35
+ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
36
+ })
37
+ ).toBe(false);
38
+ });
39
+ });
40
+
41
+ describe('isLegacyExcelFile', () => {
42
+ test('is true for the Excel formats that cannot be converted', () => {
43
+ expect(isLegacyExcelFile({ fileName: 'a.xls' })).toBe(true);
44
+ expect(isLegacyExcelFile({ fileName: 'b.XLSM' })).toBe(true);
45
+ expect(isLegacyExcelFile({ fileName: 'c.xlsb' })).toBe(true);
46
+ expect(isLegacyExcelFile({ fileName: 'sheet', mimeType: 'application/vnd.ms-excel' })).toBe(true);
47
+ });
48
+
49
+ test('is false for .xlsx and for files that are not Excel at all', () => {
50
+ expect(isLegacyExcelFile({ fileName: 'a.xlsx' })).toBe(false);
51
+ expect(isLegacyExcelFile({ fileName: 'b.csv' })).toBe(false);
52
+ expect(isLegacyExcelFile({ fileName: 'c.docx' })).toBe(false);
53
+ expect(isLegacyExcelFile({})).toBe(false);
54
+ });
55
+
56
+ test('reports a macro-enabled workbook even when the MIME type claims .xlsx', () => {
57
+ expect(
58
+ isLegacyExcelFile({
59
+ fileName: 'macro.xlsm',
60
+ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
61
+ })
62
+ ).toBe(true);
63
+ });
64
+ });
65
+
20
66
  describe('getAttachmentMimeTypeFromFileName', () => {
21
67
  test.each([
22
68
  ['logo.png', 'image/png'],
@@ -29,7 +75,8 @@ describe('getAttachmentMimeTypeFromFileName', () => {
29
75
  ['server.log', 'text/plain'],
30
76
  ['rows.csv', 'text/csv'],
31
77
  ['config.json', 'application/json'],
32
- ['orders_prod.dump', 'application/x-postgresql-dump']
78
+ ['orders_prod.dump', 'application/x-postgresql-dump'],
79
+ ['budget.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']
33
80
  ])('reads %s as %s', (fileName, expected) => {
34
81
  expect(getAttachmentMimeTypeFromFileName(fileName)).toBe(expected);
35
82
  });
@@ -33,6 +33,8 @@ const TEXT_ATTACHMENT_MIME_TYPES = {
33
33
 
34
34
  const POSTGRES_DUMP_MIME_TYPES = new Set(['application/vnd.postgresql.dump', 'application/x-postgresql-dump']);
35
35
 
36
+ export const XLSX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
37
+
36
38
  export const TEXT_LIKE_FILE_EXTENSIONS = [
37
39
  '.txt',
38
40
  '.text',
@@ -86,7 +88,8 @@ const MIME_TYPE_FILE_EXTENSIONS: Record<string, string> = {
86
88
  'text/css': '.css',
87
89
  'text/csv': '.csv',
88
90
  'text/plain': '.txt',
89
- 'text/yaml': '.yaml'
91
+ 'text/yaml': '.yaml',
92
+ [XLSX_MIME_TYPE]: '.xlsx'
90
93
  };
91
94
 
92
95
  type UploadAttachmentDataLike = {
@@ -238,6 +241,47 @@ export function isTextLikeMimeType(mimeType: string): boolean {
238
241
  );
239
242
  }
240
243
 
244
+ const LEGACY_EXCEL_EXTENSIONS = ['.xls', '.xlsb', '.xlsm'] as const;
245
+ const LEGACY_EXCEL_MIME_TYPES = ['application/vnd.ms-excel'] as const;
246
+
247
+ /**
248
+ * Excel workbooks that Clark converts to CSV before import. Only the modern
249
+ * `.xlsx` format is read; legacy `.xls` and macro-enabled `.xlsm` files stay
250
+ * rejected with the rest of Office.
251
+ */
252
+ export function isXlsxAttachmentFile(params: { fileName?: string; mimeType?: string | null }): boolean {
253
+ const normalizedFileName = normalizeNonEmptyString(params.fileName)?.toLowerCase();
254
+ if (normalizedFileName?.endsWith('.xlsx') === true) {
255
+ return true;
256
+ }
257
+ // Any other extension wins over the MIME type. These bytes are handed to a
258
+ // reader that only understands .xlsx, so a declared MIME type must never let
259
+ // another format through - not just the legacy Excel ones.
260
+ if (normalizedFileName !== undefined && /\.[^.]+$/.test(normalizedFileName)) {
261
+ return false;
262
+ }
263
+
264
+ return normalizeNonEmptyString(params.mimeType)?.toLowerCase() === XLSX_MIME_TYPE;
265
+ }
266
+
267
+ /** Legacy binary (`.xls`/`.xlsb`) and macro-enabled (`.xlsm`) Excel, which stay rejected. */
268
+ export function isLegacyExcelFile(params: { fileName?: string; mimeType?: string | null }): boolean {
269
+ if (isXlsxAttachmentFile(params)) {
270
+ return false;
271
+ }
272
+
273
+ const normalizedFileName = normalizeNonEmptyString(params.fileName)?.toLowerCase();
274
+ if (normalizedFileName && LEGACY_EXCEL_EXTENSIONS.some((extension) => normalizedFileName.endsWith(extension))) {
275
+ return true;
276
+ }
277
+ if (normalizedFileName !== undefined && /\.[^.]+$/.test(normalizedFileName)) {
278
+ return false;
279
+ }
280
+
281
+ const normalizedMimeType = normalizeNonEmptyString(params.mimeType)?.toLowerCase();
282
+ return normalizedMimeType !== undefined && LEGACY_EXCEL_MIME_TYPES.includes(normalizedMimeType as never);
283
+ }
284
+
241
285
  export function isOfficeXml(params: { fileName?: string; mimeType?: string | null }): boolean {
242
286
  const normalizedMimeType = normalizeNonEmptyString(params.mimeType)?.toLowerCase();
243
287
  if (normalizedMimeType && OFFICE_MIME_SUBSTRINGS.some((mimeType) => normalizedMimeType.includes(mimeType))) {
@@ -321,6 +365,9 @@ export function getAttachmentMimeTypeFromFileName(fileName?: string): string | u
321
365
  if (normalizedFileName.endsWith('.jpeg')) {
322
366
  return 'image/jpeg';
323
367
  }
368
+ if (normalizedFileName.endsWith('.xlsx')) {
369
+ return XLSX_MIME_TYPE;
370
+ }
324
371
  if (normalizedFileName.endsWith('.dump')) {
325
372
  return 'application/x-postgresql-dump';
326
373
  }
@@ -4,6 +4,7 @@ import { describe, expect, test } from 'vitest';
4
4
  import {
5
5
  AttachmentValidationError,
6
6
  MAX_ARCHIVE_ATTACHMENT_BYTES,
7
+ MAX_SPREADSHEET_ATTACHMENT_BYTES,
7
8
  MAX_IMAGE_ATTACHMENT_BYTES,
8
9
  MAX_PDF_ATTACHMENT_BYTES,
9
10
  MAX_TEXT_ATTACHMENT_BYTES,
@@ -52,7 +53,8 @@ describe('attachment size policy', () => {
52
53
  ['notes.md', 'text/markdown', MAX_TEXT_ATTACHMENT_BYTES],
53
54
  ['bundle.zip', 'application/zip', MAX_ARCHIVE_ATTACHMENT_BYTES],
54
55
  ['bundle.tgz', 'application/octet-stream', MAX_ARCHIVE_ATTACHMENT_BYTES],
55
- ['orders_prod.dump', 'application/octet-stream', MAX_ARCHIVE_ATTACHMENT_BYTES]
56
+ ['orders_prod.dump', 'application/octet-stream', MAX_ARCHIVE_ATTACHMENT_BYTES],
57
+ ['budget.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', MAX_SPREADSHEET_ATTACHMENT_BYTES]
56
58
  ])('allows the exact limit and rejects one byte more for %s', (fileName, mimeType, limit) => {
57
59
  expect(getAttachmentUploadLimit({ fileName, mimeType })).toBe(limit);
58
60
  expect(() => assertAttachmentSize({ fileName, mimeType, sizeBytes: limit })).not.toThrow();
@@ -82,15 +84,26 @@ describe('attachment size policy', () => {
82
84
  test('uses the same policy for MIME types and extension fallback without trusting a conflicting extension', () => {
83
85
  expect(getAttachmentUploadPolicy({ fileName: 'SPEC.PDF' })).toEqual(getAttachmentUploadPolicy({ mimeType: 'application/pdf' }));
84
86
  expect(getAttachmentUploadPolicy({ fileName: 'spec.pdf', mimeType: 'image/png' }).maxBytes).toBe(3_750_000);
85
- expect(getAttachmentLimitsHelp(false)).toBe('PDFs: 4.5 MB each, 100 pages total. Images: 3.75 MB after conversion. Text/code: 5 MiB.');
86
- expect(getAttachmentLimitsHelp(true)).toBe(
87
+ });
88
+
89
+ test('lists limits only for the file families a surface accepts', () => {
90
+ expect(getAttachmentLimitsHelp({})).toBe('PDFs: 4.5 MB each, 100 pages total. Images: 3.75 MB after conversion. Text/code: 5 MiB.');
91
+ expect(getAttachmentLimitsHelp({ spreadsheetAttachmentsEnabled: true })).toBe(
92
+ 'PDFs: 4.5 MB each, 100 pages total. Images: 3.75 MB after conversion. Text/code: 5 MiB. Excel workbooks (.xlsx): 5 MiB.'
93
+ );
94
+ expect(getAttachmentLimitsHelp({ archiveAttachmentsEnabled: true })).toBe(
87
95
  'PDFs: 4.5 MB each, 100 pages total. Images: 3.75 MB after conversion. Text/code: 5 MiB. Supported archives and .dump files: 50 MiB.'
88
96
  );
97
+ expect(getAttachmentLimitsHelp({ archiveAttachmentsEnabled: true, spreadsheetAttachmentsEnabled: true })).toBe(
98
+ 'PDFs: 4.5 MB each, 100 pages total. Images: 3.75 MB after conversion. Text/code: 5 MiB. Supported archives and .dump files: 50 MiB. Excel workbooks (.xlsx): 5 MiB.'
99
+ );
89
100
  });
90
101
 
91
102
  test('rejects unsupported and Office types', () => {
92
103
  expectReason(() => getAttachmentUploadLimit({ fileName: 'logo.svg', mimeType: 'image/svg+xml' }), 'unsupported_type');
93
104
  expectReason(() => getAttachmentUploadLimit({ fileName: 'report.docx', mimeType: 'text/plain' }), 'unsupported_type');
105
+ expectReason(() => getAttachmentUploadLimit({ fileName: 'macros.xlsm' }), 'unsupported_type');
106
+ expectReason(() => getAttachmentUploadLimit({ fileName: 'inventory.xls', mimeType: 'application/vnd.ms-excel' }), 'unsupported_type');
94
107
  expectReason(() => getAttachmentUploadLimit({ fileName: 'notes.txt', mimeType: 'application/octet-stream' }), 'unsupported_type');
95
108
  });
96
109
  });
@@ -133,6 +146,25 @@ describe('attachment content validation', () => {
133
146
  );
134
147
  });
135
148
 
149
+ test('accepts Excel workbook magic for .xlsx', async () => {
150
+ await expect(
151
+ validateAttachmentBytes({
152
+ fileName: 'budget.xlsx',
153
+ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
154
+ bytes: new Uint8Array([0x50, 0x4b, 0x03, 0x04])
155
+ })
156
+ ).resolves.toEqual({ pdfPages: 0 });
157
+ await expectAsyncReason(
158
+ () =>
159
+ validateAttachmentBytes({
160
+ fileName: 'budget.xlsx',
161
+ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
162
+ bytes: encoder.encode('not a workbook')
163
+ }),
164
+ 'invalid_content'
165
+ );
166
+ });
167
+
136
168
  test('accepts dump mime type without a .dump filename', async () => {
137
169
  const bytes = encoder.encode('PGDMP\x01\x0e\x00');
138
170
  await expect(validateAttachmentBytes({ fileName: 'orders', mimeType: 'application/x-postgresql-dump', bytes })).resolves.toEqual({
@@ -1,7 +1,7 @@
1
1
  import { PDFDocument, PDFPageLeaf, PDFPageTree } from 'pdf-lib';
2
2
 
3
3
  import { detectAttachmentArchiveType, isDumpAttachment } from './attachment-delivery.js';
4
- import { getTextAttachmentType, isOfficeXml } from './attachment-upload.js';
4
+ import { getTextAttachmentType, isOfficeXml, isXlsxAttachmentFile } from './attachment-upload.js';
5
5
 
6
6
  const ATTACHMENT_UPLOAD_POLICIES = {
7
7
  archive: {
@@ -19,6 +19,16 @@ const ATTACHMENT_UPLOAD_POLICIES = {
19
19
  invalidContentMessage:
20
20
  'This database dump could not be read. Re-create it with pg_dump custom format (-Fc) so the file starts with PGDMP, then try again.'
21
21
  },
22
+ spreadsheet: {
23
+ // Reading a workbook builds the whole grid in memory: a 6 MB file already
24
+ // costs ~800 MB and a 32 MB one costs ~3 GB, which the editor process
25
+ // cannot absorb. Cap the upload where the conversion stays affordable.
26
+ maxBytes: 5 * 1024 * 1024,
27
+ sizeLabel: '5 MiB',
28
+ label: 'Excel workbook',
29
+ advice: 'Remove unused sheets or split the workbook before attaching it.',
30
+ invalidContentMessage: 'This Excel workbook could not be read. Re-save it as .xlsx without a password and try again.'
31
+ },
22
32
  image: {
23
33
  maxBytes: 3_750_000,
24
34
  sizeLabel: '3.75 MB after conversion',
@@ -46,11 +56,35 @@ export const MAX_PDF_ATTACHMENT_BYTES = ATTACHMENT_UPLOAD_POLICIES.pdf.maxBytes;
46
56
  export const MAX_IMAGE_ATTACHMENT_BYTES = ATTACHMENT_UPLOAD_POLICIES.image.maxBytes;
47
57
  export const MAX_TEXT_ATTACHMENT_BYTES = ATTACHMENT_UPLOAD_POLICIES.text.maxBytes;
48
58
  export const MAX_ARCHIVE_ATTACHMENT_BYTES = ATTACHMENT_UPLOAD_POLICIES.archive.maxBytes;
59
+ export const MAX_SPREADSHEET_ATTACHMENT_BYTES = ATTACHMENT_UPLOAD_POLICIES.spreadsheet.maxBytes;
49
60
  export const MAX_PDF_ATTACHMENT_PAGES = 100;
50
61
 
51
- export function getAttachmentLimitsHelp(archiveAttachmentsEnabled: boolean): string {
62
+ /**
63
+ * Which families of upload-and-reference files a surface accepts. Excel
64
+ * workbooks roll out separately from archives and dumps, so each family has
65
+ * its own switch.
66
+ */
67
+ export type ReferenceOnlyAttachmentFlags = {
68
+ archiveAttachmentsEnabled?: boolean;
69
+ spreadsheetAttachmentsEnabled?: boolean;
70
+ };
71
+
72
+ export function getAttachmentLimitsHelp(flags: ReferenceOnlyAttachmentFlags): string {
52
73
  const { archive, image, pdf, text } = ATTACHMENT_UPLOAD_POLICIES;
53
- return `PDFs: ${pdf.sizeLabel} each, ${MAX_PDF_ATTACHMENT_PAGES} pages total. Images: ${image.sizeLabel}. Text/code: ${text.sizeLabel}.${archiveAttachmentsEnabled ? ` Supported archives and .dump files: ${archive.sizeLabel}.` : ''}`;
74
+ const base = `PDFs: ${pdf.sizeLabel} each, ${MAX_PDF_ATTACHMENT_PAGES} pages total. Images: ${image.sizeLabel}. Text/code: ${text.sizeLabel}.`;
75
+ const archivesEnabled = flags.archiveAttachmentsEnabled === true;
76
+ const workbooksEnabled = flags.spreadsheetAttachmentsEnabled === true;
77
+
78
+ if (archivesEnabled && workbooksEnabled) {
79
+ return `${base} Supported archives and .dump files: ${archive.sizeLabel}. Excel workbooks (.xlsx): ${ATTACHMENT_UPLOAD_POLICIES.spreadsheet.sizeLabel}.`;
80
+ }
81
+ if (archivesEnabled) {
82
+ return `${base} Supported archives and .dump files: ${archive.sizeLabel}.`;
83
+ }
84
+ if (workbooksEnabled) {
85
+ return `${base} Excel workbooks (.xlsx): ${ATTACHMENT_UPLOAD_POLICIES.spreadsheet.sizeLabel}.`;
86
+ }
87
+ return base;
54
88
  }
55
89
 
56
90
  export type AttachmentValidationReason = 'invalid_content' | 'too_large' | 'too_many_pdf_pages' | 'unsupported_type';
@@ -60,7 +94,7 @@ const VALIDATION_MESSAGES: Record<AttachmentValidationReason, string> = {
60
94
  too_large: 'This upload is too large. Reduce its size and try again.',
61
95
  too_many_pdf_pages: `Clark supports up to ${MAX_PDF_ATTACHMENT_PAGES} PDF pages per request. Remove another PDF or attach fewer pages.`,
62
96
  unsupported_type:
63
- 'This file type is not supported. Use an image, PDF, text/code file, a supported ZIP, GZIP, or TGZ archive, or a Postgres .dump file.'
97
+ 'This file type is not supported. Use an image, PDF, text/code file, an Excel workbook (.xlsx), a supported ZIP, GZIP, or TGZ archive, or a Postgres .dump file.'
64
98
  };
65
99
 
66
100
  export class AttachmentValidationError extends Error {
@@ -81,7 +115,7 @@ export type AttachmentTypeInput = {
81
115
  mimeType?: string | null;
82
116
  };
83
117
 
84
- type AttachmentKind = 'archive' | 'dump' | 'gif' | 'jpeg' | 'pdf' | 'png' | 'text' | 'webp';
118
+ type AttachmentKind = 'archive' | 'dump' | 'gif' | 'jpeg' | 'pdf' | 'png' | 'spreadsheet' | 'text' | 'webp';
85
119
  const MAX_PDF_PAGE_TREE_NODES = 1_000;
86
120
  const POSTGRES_DUMP_MAGIC = [0x50, 0x47, 0x44, 0x4d, 0x50] as const;
87
121
 
@@ -94,6 +128,9 @@ function normalizedFileName(fileName?: string): string {
94
128
  }
95
129
 
96
130
  function getAttachmentKind(input: AttachmentTypeInput): AttachmentKind {
131
+ if (isXlsxAttachmentFile(input)) {
132
+ return 'spreadsheet';
133
+ }
97
134
  if (isOfficeXml(input)) {
98
135
  throw new AttachmentValidationError('unsupported_type');
99
136
  }
@@ -128,7 +165,10 @@ function getAttachmentKind(input: AttachmentTypeInput): AttachmentKind {
128
165
  /** Resolve the validated upload policy by MIME, falling back to the extension only when MIME is absent or generic. */
129
166
  export function getAttachmentUploadPolicy(input: AttachmentTypeInput) {
130
167
  const kind = getAttachmentKind(input);
131
- const policy = ATTACHMENT_UPLOAD_POLICIES[kind === 'archive' || kind === 'dump' || kind === 'pdf' || kind === 'text' ? kind : 'image'];
168
+ const policy =
169
+ ATTACHMENT_UPLOAD_POLICIES[
170
+ kind === 'archive' || kind === 'dump' || kind === 'pdf' || kind === 'spreadsheet' || kind === 'text' ? kind : 'image'
171
+ ];
132
172
  return {
133
173
  ...policy,
134
174
  tooLargeMessage: `This ${policy.label} is too large. The limit is ${policy.sizeLabel}. ${policy.advice}`
@@ -188,6 +228,11 @@ function validateDump(bytes: Uint8Array): void {
188
228
  }
189
229
  }
190
230
 
231
+ /** An .xlsx workbook is an OOXML zip, so the zip magic is what has to match. */
232
+ function validateSpreadsheet(bytes: Uint8Array): void {
233
+ if (!isZip(bytes)) throw new AttachmentValidationError('invalid_content');
234
+ }
235
+
191
236
  function validateText(bytes: Uint8Array): void {
192
237
  if (
193
238
  bytes.includes(0) ||
@@ -260,6 +305,7 @@ export async function validateAttachmentBytes(input: AttachmentTypeInput & { byt
260
305
  if (kind === 'text') validateText(input.bytes);
261
306
  else if (kind === 'archive') validateArchive(input, input.bytes);
262
307
  else if (kind === 'dump') validateDump(input.bytes);
308
+ else if (kind === 'spreadsheet') validateSpreadsheet(input.bytes);
263
309
  else validateImage(kind, input.bytes);
264
310
  return { pdfPages: 0 };
265
311
  } catch (error) {