@objectstack/plugin-reports 17.0.0-rc.0 → 17.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-reports",
3
- "version": "17.0.0-rc.0",
3
+ "version": "17.0.0-rc.2",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Saved reports + scheduled email digests for ObjectStack — sys_saved_report + sys_report_schedule + IReportService.",
6
6
  "main": "dist/index.js",
@@ -14,12 +14,12 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "croner": "^10.0.1",
17
- "@objectstack/core": "17.0.0-rc.0",
18
- "@objectstack/platform-objects": "17.0.0-rc.0",
19
- "@objectstack/spec": "17.0.0-rc.0"
17
+ "@objectstack/core": "17.0.0-rc.2",
18
+ "@objectstack/platform-objects": "17.0.0-rc.2",
19
+ "@objectstack/spec": "17.0.0-rc.2"
20
20
  },
21
21
  "devDependencies": {
22
- "@types/node": "^26.1.1",
22
+ "@types/node": "^26.1.2",
23
23
  "typescript": "^6.0.3",
24
24
  "vitest": "^4.1.10"
25
25
  },
@@ -30,8 +30,14 @@
30
30
  "scheduling",
31
31
  "email"
32
32
  ],
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "CHANGELOG.md"
37
+ ],
33
38
  "scripts": {
34
39
  "build": "tsup --config ../../../tsup.config.ts",
35
- "test": "vitest run --passWithNoTests"
40
+ "test": "vitest run --passWithNoTests",
41
+ "typecheck": "tsc --noEmit"
36
42
  }
37
43
  }
@@ -1,22 +0,0 @@
1
-
2
- > @objectstack/plugin-reports@17.0.0-rc.0 build /home/runner/work/objectstack/objectstack/packages/plugins/plugin-reports
3
- > tsup --config ../../../tsup.config.ts
4
-
5
- CLI Building entry: src/index.ts
6
- CLI Using tsconfig: tsconfig.json
7
- CLI tsup v8.5.1
8
- CLI Using tsup config: /home/runner/work/objectstack/objectstack/tsup.config.ts
9
- CLI Target: es2020
10
- CLI Cleaning output folder
11
- ESM Build start
12
- CJS Build start
13
- ESM dist/index.mjs 24.38 KB
14
- ESM dist/index.mjs.map 51.63 KB
15
- ESM ⚡️ Build success in 92ms
16
- CJS dist/index.js 25.50 KB
17
- CJS dist/index.js.map 51.63 KB
18
- CJS ⚡️ Build success in 95ms
19
- DTS Build start
20
- DTS ⚡️ Build success in 13639ms
21
- DTS dist/index.d.mts 9.09 KB
22
- DTS dist/index.d.ts 9.09 KB
package/src/index.ts DELETED
@@ -1,34 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * @objectstack/plugin-reports
5
- *
6
- * Saved reports + scheduled email digests for ObjectStack.
7
- * Persists `sys_saved_report` definitions and `sys_report_schedule`
8
- * rows, then drives a dispatcher that runs due schedules and emails
9
- * the rendered output via the configured `email` service.
10
- */
11
-
12
- export { SysSavedReport, SysReportSchedule } from '@objectstack/platform-objects/audit';
13
- export {
14
- ReportService,
15
- renderReport,
16
- type ReportEngine,
17
- type ReportEmail,
18
- type ReportClock,
19
- type ReportServiceOptions,
20
- } from './report-service.js';
21
- export {
22
- ReportsServicePlugin,
23
- type ReportsPluginOptions,
24
- } from './reports-plugin.js';
25
- export type {
26
- IReportService,
27
- SavedReport,
28
- ReportSchedule,
29
- ReportQuery,
30
- ReportRunResult,
31
- ReportFormat,
32
- SaveReportInput,
33
- ScheduleReportInput,
34
- } from '@objectstack/spec/contracts';
@@ -1,220 +0,0 @@
1
- // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * #3710 — the reports side door around the user-level export axis (#3544).
5
- *
6
- * `GET /data/:object/export` answers 403 for a caller without the export grant.
7
- * A report over the SAME object rendered as CSV is the same bulk copy of the
8
- * same rows, so before this gate a refused caller could simply save a report,
9
- * run it as CSV — or schedule one to their own inbox — and get the data anyway.
10
- *
11
- * The gate lives in `executeReport`, which every path funnels through (run,
12
- * ad-hoc, and the scheduled dispatch), so these exercise all three rather than
13
- * trusting one call site.
14
- */
15
-
16
- import { describe, it, expect, beforeEach, vi } from 'vitest';
17
- import { ReportService, type ReportEmail } from './report-service.js';
18
-
19
- function makeFakeEngine() {
20
- const tables: Record<string, any[]> = {};
21
- const ensure = (n: string) => (tables[n] ??= []);
22
- const matches = (row: any, filter: any): boolean => {
23
- if (!filter || typeof filter !== 'object') return true;
24
- for (const [k, v] of Object.entries(filter)) if (row[k] !== v) return false;
25
- return true;
26
- };
27
- return {
28
- _tables: tables,
29
- async find(object: string, options?: any) {
30
- return ensure(object)
31
- .filter((r) => matches(r, options?.filter ?? options?.where))
32
- .slice(0, options?.limit ?? 1000);
33
- },
34
- async insert(object: string, data: any) { ensure(object).push({ ...data }); return { ...data }; },
35
- async update(object: string, idOrData: any, _opts?: any) {
36
- const data = typeof idOrData === 'object' ? idOrData : _opts;
37
- const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
38
- const table = ensure(object);
39
- const i = table.findIndex((r) => r.id === id);
40
- if (i >= 0) table[i] = { ...table[i], ...data };
41
- return table[i];
42
- },
43
- async delete(object: string, options?: any) {
44
- const table = ensure(object);
45
- const i = table.findIndex((r) => r.id === (options?.where?.id ?? options?.id));
46
- if (i >= 0) table.splice(i, 1);
47
- return {};
48
- },
49
- };
50
- }
51
-
52
- function makeFakeEmail() {
53
- const sent: any[] = [];
54
- const email: ReportEmail & { _sent: any[] } = {
55
- _sent: sent,
56
- async send(input) { sent.push(input); return { status: 'sent' }; },
57
- };
58
- return email;
59
- }
60
-
61
- const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] };
62
- const now = new Date('2026-01-15T10:00:00Z');
63
-
64
- describe('reports × the user-level export axis (#3710)', () => {
65
- let engine: ReturnType<typeof makeFakeEngine>;
66
- let email: ReturnType<typeof makeFakeEmail>;
67
- let canExport: ReturnType<typeof vi.fn>;
68
-
69
- const build = (opts: { canExport?: any } = {}) =>
70
- new ReportService({
71
- engine: engine as any,
72
- email,
73
- clock: { now: () => now },
74
- maxRows: 5000,
75
- resolveOwnerContext: async (ownerId: string) =>
76
- ownerId ? { userId: ownerId, tenantId: 't1', positions: [], permissions: [] } : null,
77
- ...('canExport' in opts ? { canExport: opts.canExport } : { canExport }),
78
- });
79
-
80
- beforeEach(() => {
81
- engine = makeFakeEngine();
82
- email = makeFakeEmail();
83
- canExport = vi.fn().mockResolvedValue(true);
84
- engine._tables['lead'] = [
85
- { id: 'l1', name: 'Acme', status: 'open' },
86
- { id: 'l2', name: 'Globex', status: 'won' },
87
- ];
88
- });
89
-
90
- const saveReport = async (svc: ReportService, format: 'csv' | 'json' | 'html_table') =>
91
- svc.saveReport(
92
- { name: 'leads', object: 'lead', query: { fields: ['id', 'name'] }, format },
93
- CTX,
94
- );
95
-
96
- describe('interactive run', () => {
97
- it('denies a CSV report run when the caller may not export the object', async () => {
98
- const svc = build({ canExport: vi.fn().mockResolvedValue(false) });
99
- const report = await saveReport(svc, 'csv');
100
- await expect(svc.run(report.id, CTX)).rejects.toThrow(/EXPORT_NOT_PERMITTED/);
101
- });
102
-
103
- it('denies a JSON report run too — json is a bulk machine-readable copy', async () => {
104
- const svc = build({ canExport: vi.fn().mockResolvedValue(false) });
105
- const report = await saveReport(svc, 'json');
106
- await expect(svc.run(report.id, CTX)).rejects.toThrow(/EXPORT_NOT_PERMITTED/);
107
- });
108
-
109
- it('ALLOWS html_table — a rendered view is reading, not exporting', async () => {
110
- // The axis must not become a second read permission: a caller holding
111
- // allowRead may already see these rows on screen.
112
- const svc = build({ canExport: vi.fn().mockResolvedValue(false) });
113
- const report = await saveReport(svc, 'html_table');
114
- const result = await svc.run(report.id, CTX);
115
- expect(result.rowCount).toBe(2);
116
- });
117
-
118
- it('allows CSV when the grant is held, and asks about the REPORT object', async () => {
119
- const svc = build();
120
- const report = await saveReport(svc, 'csv');
121
- const result = await svc.run(report.id, CTX);
122
- expect(result.rowCount).toBe(2);
123
- expect(canExport).toHaveBeenCalledWith('lead', expect.objectContaining({ userId: 'u1' }));
124
- });
125
-
126
- it('refuses BEFORE reading any row', async () => {
127
- const svc = build({ canExport: vi.fn().mockResolvedValue(false) });
128
- const report = await saveReport(svc, 'csv');
129
- const findSpy = vi.spyOn(engine, 'find');
130
- await expect(svc.run(report.id, CTX)).rejects.toThrow(/EXPORT_NOT_PERMITTED/);
131
- // `sys_saved_report` lookups are fine; the OBJECT must never be queried.
132
- expect(findSpy.mock.calls.some(([obj]) => obj === 'lead')).toBe(false);
133
- });
134
-
135
- it('a throwing canExport denies (fail closed, ADR-0049)', async () => {
136
- const svc = build({ canExport: vi.fn().mockRejectedValue(new Error('resolution failed')) });
137
- const report = await saveReport(svc, 'csv');
138
- await expect(svc.run(report.id, CTX)).rejects.toThrow(/EXPORT_NOT_PERMITTED/);
139
- });
140
-
141
- it('no canExport wired → axis does not apply (no plugin-security deployment)', async () => {
142
- const svc = build({ canExport: undefined });
143
- const report = await saveReport(svc, 'csv');
144
- const result = await svc.run(report.id, CTX);
145
- expect(result.rowCount).toBe(2);
146
- });
147
- });
148
-
149
- describe('scheduling', () => {
150
- it('refuses to CREATE a csv schedule the author could not run', async () => {
151
- const svc = build({ canExport: vi.fn().mockResolvedValue(false) });
152
- const report = await saveReport(svc, 'html_table');
153
- await expect(
154
- svc.scheduleReport({ reportId: report.id, recipients: ['a@b.c'], format: 'csv' }, CTX),
155
- ).rejects.toThrow(/EXPORT_NOT_PERMITTED/);
156
- });
157
-
158
- it('allows an html_table schedule for the same caller', async () => {
159
- const svc = build({ canExport: vi.fn().mockResolvedValue(false) });
160
- const report = await saveReport(svc, 'html_table');
161
- const sched = await svc.scheduleReport(
162
- { reportId: report.id, recipients: ['a@b.c'], format: 'html_table' },
163
- CTX,
164
- );
165
- expect(sched.id).toBeTruthy();
166
- });
167
- });
168
-
169
- describe('scheduled dispatch — the original side door', () => {
170
- it('a csv schedule created while granted STOPS delivering once the grant is revoked', async () => {
171
- // The reason the dispatch re-checks instead of trusting the create-time
172
- // check: permissions change after a schedule exists.
173
- const gate = vi.fn().mockResolvedValue(true);
174
- const svc = build({ canExport: gate });
175
- const report = await saveReport(svc, 'html_table');
176
- await svc.scheduleReport(
177
- { reportId: report.id, recipients: ['a@b.c'], format: 'csv', intervalMinutes: 1 },
178
- CTX,
179
- );
180
-
181
- gate.mockResolvedValue(false); // grant revoked
182
- // Force the schedule due (mirrors the existing dispatch suite).
183
- engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1000).toISOString();
184
- const out = await svc.dispatchDue();
185
-
186
- expect(out.failed).toBe(1);
187
- expect(email._sent).toHaveLength(0);
188
- const sched = engine._tables['sys_report_schedule'][0];
189
- expect(String(sched.last_error)).toMatch(/EXPORT_NOT_PERMITTED/);
190
- });
191
-
192
- it('an html_table schedule still delivers for a caller without the grant', async () => {
193
- const svc = build({ canExport: vi.fn().mockResolvedValue(false) });
194
- const report = await saveReport(svc, 'html_table');
195
- await svc.scheduleReport(
196
- { reportId: report.id, recipients: ['a@b.c'], format: 'html_table', intervalMinutes: 1 },
197
- CTX,
198
- );
199
- // Force the schedule due (mirrors the existing dispatch suite).
200
- engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1000).toISOString();
201
- const out = await svc.dispatchDue();
202
- expect(out.fired).toBe(1);
203
- expect(email._sent).toHaveLength(1);
204
- });
205
-
206
- it('a csv schedule delivers when the owner holds the grant', async () => {
207
- const svc = build();
208
- const report = await saveReport(svc, 'html_table');
209
- await svc.scheduleReport(
210
- { reportId: report.id, recipients: ['a@b.c'], format: 'csv', intervalMinutes: 1 },
211
- CTX,
212
- );
213
- // Force the schedule due (mirrors the existing dispatch suite).
214
- engine._tables['sys_report_schedule'][0].next_run_at = new Date(now.getTime() - 1000).toISOString();
215
- const out = await svc.dispatchDue();
216
- expect(out.fired).toBe(1);
217
- expect(email._sent[0].attachments?.[0]?.contentType).toBe('text/csv');
218
- });
219
- });
220
- });