@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/CHANGELOG.md +373 -0
- package/dist/index.js +6 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +6 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +12 -6
- package/.turbo/turbo-build.log +0 -22
- package/src/index.ts +0 -34
- package/src/report-export-axis.test.ts +0 -220
- package/src/report-service.test.ts +0 -475
- package/src/report-service.ts +0 -707
- package/src/reports-plugin.ts +0 -161
- package/tsconfig.json +0 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/plugin-reports",
|
|
3
|
-
"version": "17.0.0-rc.
|
|
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.
|
|
18
|
-
"@objectstack/platform-objects": "17.0.0-rc.
|
|
19
|
-
"@objectstack/spec": "17.0.0-rc.
|
|
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.
|
|
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
|
}
|
package/.turbo/turbo-build.log
DELETED
|
@@ -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
|
-
[34mCLI[39m Building entry: src/index.ts
|
|
6
|
-
[34mCLI[39m Using tsconfig: tsconfig.json
|
|
7
|
-
[34mCLI[39m tsup v8.5.1
|
|
8
|
-
[34mCLI[39m Using tsup config: /home/runner/work/objectstack/objectstack/tsup.config.ts
|
|
9
|
-
[34mCLI[39m Target: es2020
|
|
10
|
-
[34mCLI[39m Cleaning output folder
|
|
11
|
-
[34mESM[39m Build start
|
|
12
|
-
[34mCJS[39m Build start
|
|
13
|
-
[32mESM[39m [1mdist/index.mjs [22m[32m24.38 KB[39m
|
|
14
|
-
[32mESM[39m [1mdist/index.mjs.map [22m[32m51.63 KB[39m
|
|
15
|
-
[32mESM[39m ⚡️ Build success in 92ms
|
|
16
|
-
[32mCJS[39m [1mdist/index.js [22m[32m25.50 KB[39m
|
|
17
|
-
[32mCJS[39m [1mdist/index.js.map [22m[32m51.63 KB[39m
|
|
18
|
-
[32mCJS[39m ⚡️ Build success in 95ms
|
|
19
|
-
[34mDTS[39m Build start
|
|
20
|
-
[32mDTS[39m ⚡️ Build success in 13639ms
|
|
21
|
-
[32mDTS[39m [1mdist/index.d.mts [22m[32m9.09 KB[39m
|
|
22
|
-
[32mDTS[39m [1mdist/index.d.ts [22m[32m9.09 KB[39m
|
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
|
-
});
|