@volter/twin-sentry 0.1.0
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/LICENSE +202 -0
- package/README.md +142 -0
- package/client/sentry-mirror.css +99 -0
- package/client/sentry-mirror.tsx +352 -0
- package/dist/client/sentry-mirror.bundle.js +321 -0
- package/dist/client/sentry-mirror.css +99 -0
- package/dist/client/sentry-mirror.d.ts +17 -0
- package/dist/client/sentry-mirror.js +156 -0
- package/dist/client/sentry-mirror.tsx +352 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +36 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +75 -0
- package/dist/src/sentry-budget.d.ts +50 -0
- package/dist/src/sentry-budget.js +145 -0
- package/dist/src/sentry-capabilities.d.ts +3 -0
- package/dist/src/sentry-capabilities.js +1180 -0
- package/dist/src/sentry-conformance.d.ts +22 -0
- package/dist/src/sentry-conformance.js +97 -0
- package/dist/src/sentry-connector.d.ts +77 -0
- package/dist/src/sentry-connector.js +226 -0
- package/dist/src/sentry-events.d.ts +54 -0
- package/dist/src/sentry-events.js +131 -0
- package/dist/src/sentry-ingest.d.ts +137 -0
- package/dist/src/sentry-ingest.js +387 -0
- package/dist/src/sentry-mirror-ui.d.ts +38 -0
- package/dist/src/sentry-mirror-ui.js +162 -0
- package/dist/src/sentry-perform-harness.d.ts +9 -0
- package/dist/src/sentry-perform-harness.js +20 -0
- package/dist/src/sentry-server.d.ts +14 -0
- package/dist/src/sentry-server.js +27 -0
- package/dist/src/sentry-twin.d.ts +17 -0
- package/dist/src/sentry-twin.js +1739 -0
- package/dist/test-fixtures/sentry-openapi-operations.SOURCE.md +24 -0
- package/dist/test-fixtures/sentry-openapi-operations.json +1837 -0
- package/package.json +75 -0
- package/src/cli.ts +34 -0
- package/src/index.ts +145 -0
- package/src/sentry-budget.ts +171 -0
- package/src/sentry-capabilities.ts +1222 -0
- package/src/sentry-conformance.ts +110 -0
- package/src/sentry-connector.ts +260 -0
- package/src/sentry-events.ts +171 -0
- package/src/sentry-ingest.ts +471 -0
- package/src/sentry-mirror-ui.ts +162 -0
- package/src/sentry-perform-harness.ts +19 -0
- package/src/sentry-server.ts +35 -0
- package/src/sentry-twin.ts +1615 -0
- package/test-fixtures/sentry-openapi-operations.SOURCE.md +24 -0
- package/test-fixtures/sentry-openapi-operations.json +1837 -0
|
@@ -0,0 +1,1739 @@
|
|
|
1
|
+
// Sentry twin REQUEST HANDLER — the canonical Sentry API surface for the twin. Two API
|
|
2
|
+
// families share one handler:
|
|
3
|
+
// 1. INGESTION (the SDK side): POST /api/:projectId/store/ and /api/:projectId/envelope/
|
|
4
|
+
// — `@sentry/node` POSTs captured events here; the twin groups them into issues.
|
|
5
|
+
// 2. WEB API (the dashboard side, /api/0/...): issues, events, projects, project keys,
|
|
6
|
+
// releases, deploys, organizations, teams, alert rules.
|
|
7
|
+
//
|
|
8
|
+
// Contract: handleSentryTwinRequest({ method, path, body, headers, readOnly }) -> {status,
|
|
9
|
+
// body}. State is the kernel event/action log (D1); reads are the projection. No real
|
|
10
|
+
// Sentry is ever called (D4). An unmodeled operation FAILS LIKE SENTRY (4xx JSON error),
|
|
11
|
+
// never a fabricated success (D2). readOnly rejects writes (D3).
|
|
12
|
+
import { applyTwinWrite, projectResources, subjectHistory } from '@volter/world-core';
|
|
13
|
+
import { ensureDefaultProject, envelopeAttachments, envelopeEvents, envelopeSessions, hashFingerprint, ingestEvent, parseEnvelope, persistEventAttachments, persistSessions, resolveProjectKey, resolvePublicKey, } from "./sentry-ingest.js";
|
|
14
|
+
import { emitIssueAlert } from "./sentry-events.js";
|
|
15
|
+
const SERVICE = 'sentry';
|
|
16
|
+
// Sentry's error envelope shape: { detail: "<message>" } with the appropriate HTTP status.
|
|
17
|
+
function err(detail, status = 404) {
|
|
18
|
+
return { status, body: { detail } };
|
|
19
|
+
}
|
|
20
|
+
function rows(type, root) {
|
|
21
|
+
return projectResources(SERVICE, root).filter((r) => r.type === type);
|
|
22
|
+
}
|
|
23
|
+
function getRow(type, id, root) {
|
|
24
|
+
return rows(type, root).find((r) => r.id === id);
|
|
25
|
+
}
|
|
26
|
+
// Project resource view: Sentry's project object (drop kernel meta + twin-internal fields).
|
|
27
|
+
function projectView(r) {
|
|
28
|
+
const { type: _t, updatedAt: _u, deleted, ...rest } = r;
|
|
29
|
+
return { ...rest, id: r.id };
|
|
30
|
+
}
|
|
31
|
+
// Issue view: Sentry's issue object. `count` is a string (Sentry serializes it as one).
|
|
32
|
+
function issueView(r) {
|
|
33
|
+
const { type: _t, updatedAt: _u, metadataType: _m, ...rest } = r;
|
|
34
|
+
return {
|
|
35
|
+
...rest,
|
|
36
|
+
id: r.id,
|
|
37
|
+
count: String(r.count ?? '0'),
|
|
38
|
+
permalink: `/organizations/${r.projectSlug ?? 'org'}/issues/${r.id}/`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function eventView(r) {
|
|
42
|
+
const { type: _t, updatedAt: _u, ...rest } = r;
|
|
43
|
+
const exception = rest['entries:exception'];
|
|
44
|
+
const { ['entries:exception']: _ex, ...flat } = rest;
|
|
45
|
+
return {
|
|
46
|
+
...flat,
|
|
47
|
+
id: r.id,
|
|
48
|
+
entries: exception ? [{ type: 'exception', data: { values: exception } }] : [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// ── List helpers ─────────────────────────────────────────────────────────────────
|
|
52
|
+
function parseQuery(path) {
|
|
53
|
+
const i = path.indexOf('?');
|
|
54
|
+
if (i === -1)
|
|
55
|
+
return { path, params: new URLSearchParams() };
|
|
56
|
+
return { path: path.slice(0, i), params: new URLSearchParams(path.slice(i + 1)) };
|
|
57
|
+
}
|
|
58
|
+
// Sentry's issue `query` mini-DSL (subset): `is:unresolved|resolved|ignored`,
|
|
59
|
+
// `level:error`, and free text matched against the issue title. Faithful-but-simple
|
|
60
|
+
// (the full search backend is not modeled — documented in the manifest).
|
|
61
|
+
function matchIssueQuery(issue, query) {
|
|
62
|
+
if (!query.trim())
|
|
63
|
+
return true;
|
|
64
|
+
for (const token of query.trim().split(/\s+/)) {
|
|
65
|
+
const colon = token.indexOf(':');
|
|
66
|
+
if (colon > 0) {
|
|
67
|
+
const facet = token.slice(0, colon);
|
|
68
|
+
const value = token.slice(colon + 1);
|
|
69
|
+
if (facet === 'is') {
|
|
70
|
+
if (value === 'unresolved' && issue.status !== 'unresolved')
|
|
71
|
+
return false;
|
|
72
|
+
else if (value === 'resolved' && issue.status !== 'resolved')
|
|
73
|
+
return false;
|
|
74
|
+
else if (value === 'ignored' && issue.status !== 'ignored' && issue.status !== 'muted')
|
|
75
|
+
return false;
|
|
76
|
+
else if (value === 'assigned' && !issue.assignedTo)
|
|
77
|
+
return false;
|
|
78
|
+
else if (value === 'unassigned' && issue.assignedTo)
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
else if (facet === 'level') {
|
|
82
|
+
if (String(issue.level) !== value)
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
else if (facet === 'environment') {
|
|
86
|
+
// not modeled per-issue; treat as a no-op match (faithful: no environment dimension)
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// free `key:value` against tags is not modeled — ignore unknown facets (lenient).
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
// free text → title substring match (case-insensitive)
|
|
94
|
+
if (!String(issue.title ?? '').toLowerCase().includes(token.toLowerCase()))
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
function sortIssues(issues, sort) {
|
|
101
|
+
const by = sort || 'date';
|
|
102
|
+
return [...issues].sort((a, b) => {
|
|
103
|
+
if (by === 'freq')
|
|
104
|
+
return Number(b.count ?? 0) - Number(a.count ?? 0);
|
|
105
|
+
if (by === 'new')
|
|
106
|
+
return Date.parse(String(b.firstSeen ?? 0)) - Date.parse(String(a.firstSeen ?? 0));
|
|
107
|
+
// default 'date' = last seen, newest first
|
|
108
|
+
return Date.parse(String(b.lastSeen ?? 0)) - Date.parse(String(a.lastSeen ?? 0));
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
function findProjectBySlug(org, project, root) {
|
|
112
|
+
return rows('project', root).find((p) => p.slug === project && p.organizationSlug === org && !p.deleted);
|
|
113
|
+
}
|
|
114
|
+
async function writeResource(type, id, fields, op, root, occurredAt) {
|
|
115
|
+
const { resource } = await applyTwinWrite(SERVICE, { operation: op, subjectType: type, subjectId: id, fields, ...(occurredAt ? { occurredAt } : {}), actor: { kind: 'agent' } }, root);
|
|
116
|
+
return resource;
|
|
117
|
+
}
|
|
118
|
+
// ── Main entry ─────────────────────────────────────────────────────────────────
|
|
119
|
+
export async function handleSentryTwinRequest(req) {
|
|
120
|
+
// Seed a default org/project/key so an out-of-the-box DSN ingests immediately.
|
|
121
|
+
await ensureDefaultProject(req.root, req.occurredAt);
|
|
122
|
+
const method = req.method.toUpperCase();
|
|
123
|
+
const { path } = parseQuery(req.path);
|
|
124
|
+
const clean = path.replace(/\/+$/, '') || '/';
|
|
125
|
+
// ── INGESTION (the SDK side) ──────────────────────────────────────────────────
|
|
126
|
+
// POST /api/:projectId/store/ — legacy JSON event
|
|
127
|
+
// POST /api/:projectId/envelope/ — modern envelope (the default @sentry/node transport)
|
|
128
|
+
const ingestMatch = /^\/api\/([^/]+)\/(store|envelope)$/.exec(clean);
|
|
129
|
+
if (ingestMatch) {
|
|
130
|
+
if (method !== 'POST')
|
|
131
|
+
return err('Method not allowed', 405);
|
|
132
|
+
if (req.readOnly)
|
|
133
|
+
return err('twin is read-only; omit readOnly to accept events', 405);
|
|
134
|
+
return ingest(ingestMatch[1], ingestMatch[2], req);
|
|
135
|
+
}
|
|
136
|
+
// ── WEB API (/api/0/...) ──────────────────────────────────────────────────────
|
|
137
|
+
if (clean.startsWith('/api/0/')) {
|
|
138
|
+
if (req.readOnly && method !== 'GET')
|
|
139
|
+
return err('twin is read-only; omit readOnly to accept writes', 405);
|
|
140
|
+
return webApi(method, clean.slice('/api/0'.length) || '/', req);
|
|
141
|
+
}
|
|
142
|
+
return err('Not found', 404);
|
|
143
|
+
}
|
|
144
|
+
// ── Ingestion path ───────────────────────────────────────────────────────────────
|
|
145
|
+
async function ingest(projectId, kind, req) {
|
|
146
|
+
const { params } = parseQuery(req.path);
|
|
147
|
+
let events = [];
|
|
148
|
+
let envelopeDsn;
|
|
149
|
+
let attachments = [];
|
|
150
|
+
let sessions = [];
|
|
151
|
+
if (kind === 'envelope') {
|
|
152
|
+
const env = parseEnvelope(req.body ?? '');
|
|
153
|
+
envelopeDsn = typeof env.header.dsn === 'string' ? env.header.dsn : undefined;
|
|
154
|
+
events = envelopeEvents(env);
|
|
155
|
+
attachments = envelopeAttachments(env);
|
|
156
|
+
sessions = envelopeSessions(env);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
try {
|
|
160
|
+
events = [JSON.parse(req.body ?? '{}')];
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return err('Invalid event payload', 400);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const publicKey = resolvePublicKey({
|
|
167
|
+
authHeader: req.headers?.['x-sentry-auth'] ?? req.headers?.['X-Sentry-Auth'],
|
|
168
|
+
query: req.path.includes('?') ? req.path.slice(req.path.indexOf('?') + 1) : '',
|
|
169
|
+
envelopeDsn,
|
|
170
|
+
});
|
|
171
|
+
const key = resolveProjectKey(publicKey, req.root);
|
|
172
|
+
// Sentry 401s an unknown/missing DSN public key.
|
|
173
|
+
if (!key)
|
|
174
|
+
return { status: 401, body: { detail: 'Invalid api key' } };
|
|
175
|
+
// The DSN's project must match the URL's projectId (Sentry rejects a mismatch).
|
|
176
|
+
if (key.projectId !== projectId)
|
|
177
|
+
return { status: 401, body: { detail: 'Invalid project for this key' } };
|
|
178
|
+
// Session items (release-health heartbeats) are persisted regardless of any event item.
|
|
179
|
+
if (sessions.length > 0)
|
|
180
|
+
await persistSessions(key.projectId, sessions, { root: req.root, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}) });
|
|
181
|
+
// Envelope with no event item (e.g. session-only) → accept + 200 with no id, like Sentry.
|
|
182
|
+
if (events.length === 0)
|
|
183
|
+
return { status: 200, body: {} };
|
|
184
|
+
let lastId = '';
|
|
185
|
+
for (const event of events) {
|
|
186
|
+
const outcome = await ingestEvent(key, event, { root: req.root, occurredAt: req.occurredAt });
|
|
187
|
+
lastId = outcome.eventId;
|
|
188
|
+
// Fire alert webhooks on new-issue / regression (injected deliverer — offline by default).
|
|
189
|
+
if (outcome.isNew)
|
|
190
|
+
await emitIssueAlert('new-issue', outcome.issue, { root: req.root, ...(req.deliver ? { deliver: req.deliver } : {}), ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}) });
|
|
191
|
+
else if (outcome.wasResolved)
|
|
192
|
+
await emitIssueAlert('regression', outcome.issue, { root: req.root, ...(req.deliver ? { deliver: req.deliver } : {}), ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}) });
|
|
193
|
+
}
|
|
194
|
+
// Envelope attachment items attach to the (last) event in the same envelope (Sentry's model).
|
|
195
|
+
if (attachments.length > 0 && lastId)
|
|
196
|
+
await persistEventAttachments(lastId, key.projectId, attachments, { root: req.root, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}) });
|
|
197
|
+
// Sentry's store/envelope success body is `{ id: "<event_id>" }`.
|
|
198
|
+
return { status: 200, body: { id: lastId } };
|
|
199
|
+
}
|
|
200
|
+
// ── Web API path ─────────────────────────────────────────────────────────────────
|
|
201
|
+
async function webApi(method, path, req) {
|
|
202
|
+
const { params } = parseQuery(req.path);
|
|
203
|
+
const seg = path.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean);
|
|
204
|
+
const root = req.root;
|
|
205
|
+
// ---- Organizations ----
|
|
206
|
+
// GET /organizations/ (list)
|
|
207
|
+
if (seg[0] === 'organizations' && seg.length === 1 && method === 'GET') {
|
|
208
|
+
const items = rows('organization', root).filter((o) => !o.deleted).map(orgView);
|
|
209
|
+
return { status: 200, body: items };
|
|
210
|
+
}
|
|
211
|
+
// POST /organizations/ (create)
|
|
212
|
+
if (seg[0] === 'organizations' && seg.length === 1 && method === 'POST') {
|
|
213
|
+
const b = body(req);
|
|
214
|
+
if (!b.slug && !b.name)
|
|
215
|
+
return err('slug or name is required', 400);
|
|
216
|
+
const slug = String(b.slug ?? slugify(String(b.name)));
|
|
217
|
+
if (rows('organization', root).some((o) => o.slug === slug && !o.deleted))
|
|
218
|
+
return err('An organization with this slug already exists', 409);
|
|
219
|
+
const id = `org_${rows('organization', root).length + 1}`;
|
|
220
|
+
const r = await writeResource('organization', id, { slug, name: b.name ?? slug, dateCreated: req.occurredAt ?? '1970-01-01T00:00:00.000Z' }, 'organization.create', root, req.occurredAt);
|
|
221
|
+
return { status: 201, body: orgView(r) };
|
|
222
|
+
}
|
|
223
|
+
// GET/PUT/DELETE /organizations/:org/
|
|
224
|
+
if (seg[0] === 'organizations' && seg.length === 2) {
|
|
225
|
+
const org = rows('organization', root).find((o) => o.slug === seg[1] && !o.deleted);
|
|
226
|
+
if (!org)
|
|
227
|
+
return err('The requested resource does not exist', 404);
|
|
228
|
+
if (method === 'GET')
|
|
229
|
+
return { status: 200, body: orgView(org) };
|
|
230
|
+
if (method === 'PUT') {
|
|
231
|
+
const b = body(req);
|
|
232
|
+
const r = await writeResource('organization', org.id, { ...(b.name !== undefined ? { name: b.name } : {}), ...(b.slug !== undefined ? { slug: b.slug } : {}) }, 'organization.update', root, req.occurredAt);
|
|
233
|
+
return { status: 200, body: orgView(r) };
|
|
234
|
+
}
|
|
235
|
+
if (method === 'DELETE') {
|
|
236
|
+
await writeResource('organization', org.id, { deleted: true }, 'organization.delete', root, req.occurredAt);
|
|
237
|
+
return { status: 204, body: null };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// ---- Org members: invite / list / role ----
|
|
241
|
+
// GET/POST /organizations/:org/members/
|
|
242
|
+
if (seg[0] === 'organizations' && seg[2] === 'members' && seg.length === 3) {
|
|
243
|
+
const orgSlug = seg[1];
|
|
244
|
+
if (!rows('organization', root).some((o) => o.slug === orgSlug && !o.deleted))
|
|
245
|
+
return err('The requested resource does not exist', 404);
|
|
246
|
+
if (method === 'GET') {
|
|
247
|
+
const items = rows('org_member', root).filter((m) => m.organizationSlug === orgSlug && !m.deleted).map(memberView);
|
|
248
|
+
return { status: 200, body: items };
|
|
249
|
+
}
|
|
250
|
+
if (method === 'POST') {
|
|
251
|
+
const b = body(req);
|
|
252
|
+
const email = typeof b.email === 'string' ? b.email.trim() : '';
|
|
253
|
+
if (!email)
|
|
254
|
+
return err('email is required', 400);
|
|
255
|
+
if (rows('org_member', root).some((m) => m.organizationSlug === orgSlug && m.email === email && !m.deleted)) {
|
|
256
|
+
return err(`The user ${email} is already a member`, 409);
|
|
257
|
+
}
|
|
258
|
+
const role = ['member', 'admin', 'manager', 'owner', 'billing'].includes(String(b.role)) ? String(b.role) : 'member';
|
|
259
|
+
const teams = Array.isArray(b.teams) ? b.teams.map(String) : [];
|
|
260
|
+
const id = `member_${rows('org_member', root).length + 1}`;
|
|
261
|
+
const r = await writeResource('org_member', id, {
|
|
262
|
+
organizationSlug: orgSlug, email, role, teams, pending: true, expired: false,
|
|
263
|
+
name: b.name ?? email, dateCreated: req.occurredAt ?? '1970-01-01T00:00:00.000Z',
|
|
264
|
+
}, 'org_member.create', root, req.occurredAt);
|
|
265
|
+
return { status: 201, body: memberView(r) };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// GET/PUT/DELETE /organizations/:org/members/:id/
|
|
269
|
+
if (seg[0] === 'organizations' && seg[2] === 'members' && seg.length === 4) {
|
|
270
|
+
const orgSlug = seg[1];
|
|
271
|
+
const m = rows('org_member', root).find((x) => x.id === seg[3] && x.organizationSlug === orgSlug && !x.deleted);
|
|
272
|
+
if (!m)
|
|
273
|
+
return err('The requested resource does not exist', 404);
|
|
274
|
+
if (method === 'GET')
|
|
275
|
+
return { status: 200, body: memberView(m) };
|
|
276
|
+
if (method === 'PUT') {
|
|
277
|
+
const b = body(req);
|
|
278
|
+
const patch = {};
|
|
279
|
+
if (b.role !== undefined) {
|
|
280
|
+
if (!['member', 'admin', 'manager', 'owner', 'billing'].includes(String(b.role)))
|
|
281
|
+
return err('Invalid role', 400);
|
|
282
|
+
patch.role = String(b.role);
|
|
283
|
+
}
|
|
284
|
+
if (Array.isArray(b.teams))
|
|
285
|
+
patch.teams = b.teams.map(String);
|
|
286
|
+
if (Object.keys(patch).length === 0)
|
|
287
|
+
return err('No updatable fields provided', 400);
|
|
288
|
+
const r = await writeResource('org_member', m.id, patch, 'org_member.update', root, req.occurredAt);
|
|
289
|
+
return { status: 200, body: memberView(r) };
|
|
290
|
+
}
|
|
291
|
+
if (method === 'DELETE') {
|
|
292
|
+
await writeResource('org_member', m.id, { deleted: true }, 'org_member.delete', root, req.occurredAt);
|
|
293
|
+
return { status: 204, body: null };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// GET /organizations/:org/stats_v2/ (event/error stats over the persisted events)
|
|
297
|
+
if (seg[0] === 'organizations' && (seg[2] === 'stats_v2' || seg[2] === 'stats') && seg.length === 3 && method === 'GET') {
|
|
298
|
+
const orgSlug = seg[1];
|
|
299
|
+
if (!rows('organization', root).some((o) => o.slug === orgSlug && !o.deleted))
|
|
300
|
+
return err('The requested resource does not exist', 404);
|
|
301
|
+
const projIds = new Set(rows('project', root).filter((p) => p.organizationSlug === orgSlug && !p.deleted).map((p) => p.id));
|
|
302
|
+
const events = rows('event', root).filter((e) => projIds.has(String(e.projectID)) && !e.deleted);
|
|
303
|
+
// Sentry's stats_v2 envelope: groups by category with a `totals` sum.
|
|
304
|
+
const total = events.length;
|
|
305
|
+
return {
|
|
306
|
+
status: 200,
|
|
307
|
+
body: {
|
|
308
|
+
start: params.get('start') ?? null, end: params.get('end') ?? null,
|
|
309
|
+
intervals: [], // time-bucketed series needs the tracing/metrics engine (a filed todo); totals are real
|
|
310
|
+
groups: [{ by: { category: 'error', outcome: 'accepted' }, totals: { 'sum(quantity)': total, 'sum(times_seen)': total } }],
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
// ---- Org integrations (GitHub/Slack/etc.) config ----
|
|
315
|
+
// GET/POST /organizations/:org/integrations/ (list installed integrations / install a new one)
|
|
316
|
+
if (seg[0] === 'organizations' && seg[2] === 'integrations' && seg.length === 3) {
|
|
317
|
+
const orgSlug = seg[1];
|
|
318
|
+
if (!rows('organization', root).some((o) => o.slug === orgSlug && !o.deleted))
|
|
319
|
+
return err('The requested resource does not exist', 404);
|
|
320
|
+
if (method === 'GET') {
|
|
321
|
+
const provider = params.get('provider_key') ?? params.get('providerKey');
|
|
322
|
+
let items = rows('org_integration', root).filter((i) => i.organizationSlug === orgSlug && !i.deleted);
|
|
323
|
+
if (provider)
|
|
324
|
+
items = items.filter((i) => i.provider === provider);
|
|
325
|
+
return { status: 200, body: items.map(integrationView) };
|
|
326
|
+
}
|
|
327
|
+
if (method === 'POST') {
|
|
328
|
+
const b = body(req);
|
|
329
|
+
const provider = typeof b.provider === 'string' ? b.provider : '';
|
|
330
|
+
if (!provider)
|
|
331
|
+
return err('provider is required', 400);
|
|
332
|
+
if (!['github', 'gitlab', 'slack', 'pagerduty', 'jira', 'msteams', 'vsts', 'bitbucket'].includes(provider)) {
|
|
333
|
+
return err(`Unknown integration provider: ${provider}`, 400);
|
|
334
|
+
}
|
|
335
|
+
const id = `int_${rows('org_integration', root).length + 1}`;
|
|
336
|
+
const r = await writeResource('org_integration', id, {
|
|
337
|
+
organizationSlug: orgSlug, provider, name: b.name ?? provider,
|
|
338
|
+
externalId: b.externalId ?? `ext_${id}`, status: 'active',
|
|
339
|
+
domainName: b.domainName ?? null, configData: (b.configData && typeof b.configData === 'object') ? b.configData : {},
|
|
340
|
+
dateCreated: req.occurredAt ?? '1970-01-01T00:00:00.000Z',
|
|
341
|
+
}, 'org_integration.create', root, req.occurredAt);
|
|
342
|
+
return { status: 201, body: integrationView(r) };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// GET/PUT/DELETE /organizations/:org/integrations/:id/
|
|
346
|
+
if (seg[0] === 'organizations' && seg[2] === 'integrations' && seg.length === 4) {
|
|
347
|
+
const orgSlug = seg[1];
|
|
348
|
+
const i = rows('org_integration', root).find((x) => x.id === seg[3] && x.organizationSlug === orgSlug && !x.deleted);
|
|
349
|
+
if (!i)
|
|
350
|
+
return err('The requested resource does not exist', 404);
|
|
351
|
+
if (method === 'GET')
|
|
352
|
+
return { status: 200, body: integrationView(i) };
|
|
353
|
+
if (method === 'PUT') {
|
|
354
|
+
const b = body(req);
|
|
355
|
+
const patch = {};
|
|
356
|
+
if (b.name !== undefined)
|
|
357
|
+
patch.name = b.name;
|
|
358
|
+
if (b.domainName !== undefined)
|
|
359
|
+
patch.domainName = b.domainName;
|
|
360
|
+
if (b.configData && typeof b.configData === 'object')
|
|
361
|
+
patch.configData = { ...(i.configData ?? {}), ...b.configData };
|
|
362
|
+
if (Object.keys(patch).length === 0)
|
|
363
|
+
return err('No updatable fields provided', 400);
|
|
364
|
+
const r = await writeResource('org_integration', i.id, patch, 'org_integration.update', root, req.occurredAt);
|
|
365
|
+
return { status: 200, body: integrationView(r) };
|
|
366
|
+
}
|
|
367
|
+
if (method === 'DELETE') {
|
|
368
|
+
await writeResource('org_integration', i.id, { deleted: true }, 'org_integration.delete', root, req.occurredAt);
|
|
369
|
+
return { status: 204, body: null };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
// ---- Custom dashboards + widgets (config CRUD — fully reproducible offline) ----
|
|
373
|
+
// GET/POST /organizations/:org/dashboards/
|
|
374
|
+
if (seg[0] === 'organizations' && seg[2] === 'dashboards' && seg.length === 3) {
|
|
375
|
+
const orgSlug = seg[1];
|
|
376
|
+
if (!rows('organization', root).some((o) => o.slug === orgSlug && !o.deleted))
|
|
377
|
+
return err('The requested resource does not exist', 404);
|
|
378
|
+
if (method === 'GET') {
|
|
379
|
+
const items = rows('dashboard', root).filter((d) => d.organizationSlug === orgSlug && !d.deleted).map(dashboardView);
|
|
380
|
+
return { status: 200, body: items };
|
|
381
|
+
}
|
|
382
|
+
if (method === 'POST') {
|
|
383
|
+
const b = body(req);
|
|
384
|
+
if (!b.title || typeof b.title !== 'string')
|
|
385
|
+
return err('title is required', 400);
|
|
386
|
+
const widgets = normalizeWidgets(b.widgets);
|
|
387
|
+
if (widgets.error)
|
|
388
|
+
return err(widgets.error, 400);
|
|
389
|
+
const id = `dash_${rows('dashboard', root).length + 1}`;
|
|
390
|
+
const r = await writeResource('dashboard', id, {
|
|
391
|
+
organizationSlug: orgSlug, title: b.title, widgets: widgets.widgets,
|
|
392
|
+
dateCreated: req.occurredAt ?? '1970-01-01T00:00:00.000Z',
|
|
393
|
+
}, 'dashboard.create', root, req.occurredAt);
|
|
394
|
+
return { status: 201, body: dashboardView(r) };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// GET/PUT/DELETE /organizations/:org/dashboards/:id/
|
|
398
|
+
if (seg[0] === 'organizations' && seg[2] === 'dashboards' && seg.length === 4) {
|
|
399
|
+
const orgSlug = seg[1];
|
|
400
|
+
const d = rows('dashboard', root).find((x) => x.id === seg[3] && x.organizationSlug === orgSlug && !x.deleted);
|
|
401
|
+
if (!d)
|
|
402
|
+
return err('The requested resource does not exist', 404);
|
|
403
|
+
if (method === 'GET')
|
|
404
|
+
return { status: 200, body: dashboardView(d) };
|
|
405
|
+
if (method === 'PUT') {
|
|
406
|
+
const b = body(req);
|
|
407
|
+
const patch = {};
|
|
408
|
+
if (b.title !== undefined)
|
|
409
|
+
patch.title = String(b.title);
|
|
410
|
+
if (b.widgets !== undefined) {
|
|
411
|
+
const widgets = normalizeWidgets(b.widgets);
|
|
412
|
+
if (widgets.error)
|
|
413
|
+
return err(widgets.error, 400);
|
|
414
|
+
patch.widgets = widgets.widgets;
|
|
415
|
+
}
|
|
416
|
+
if (Object.keys(patch).length === 0)
|
|
417
|
+
return err('No updatable fields provided', 400);
|
|
418
|
+
const r = await writeResource('dashboard', d.id, patch, 'dashboard.update', root, req.occurredAt);
|
|
419
|
+
return { status: 200, body: dashboardView(r) };
|
|
420
|
+
}
|
|
421
|
+
if (method === 'DELETE') {
|
|
422
|
+
await writeResource('dashboard', d.id, { deleted: true }, 'dashboard.delete', root, req.occurredAt);
|
|
423
|
+
return { status: 204, body: null };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// POST /organizations/:org/discover/saved/ + GET list (Discover saved queries — the query
|
|
427
|
+
// BUILDER config persists faithfully; live EXECUTION of an arbitrary Discover query against a
|
|
428
|
+
// columnar event store is a filed todo: it needs the data-plane query engine. Config is real.)
|
|
429
|
+
if (seg[0] === 'organizations' && seg[2] === 'discover' && seg[3] === 'saved' && seg.length === 4) {
|
|
430
|
+
const orgSlug = seg[1];
|
|
431
|
+
if (!rows('organization', root).some((o) => o.slug === orgSlug && !o.deleted))
|
|
432
|
+
return err('The requested resource does not exist', 404);
|
|
433
|
+
if (method === 'GET') {
|
|
434
|
+
const items = rows('discover_query', root).filter((q) => q.organizationSlug === orgSlug && !q.deleted).map(discoverView);
|
|
435
|
+
return { status: 200, body: items };
|
|
436
|
+
}
|
|
437
|
+
if (method === 'POST') {
|
|
438
|
+
const b = body(req);
|
|
439
|
+
if (!b.name || typeof b.name !== 'string')
|
|
440
|
+
return err('name is required', 400);
|
|
441
|
+
if (!Array.isArray(b.fields) || b.fields.length === 0)
|
|
442
|
+
return err('fields (a non-empty array of columns) is required', 400);
|
|
443
|
+
const id = `dq_${rows('discover_query', root).length + 1}`;
|
|
444
|
+
const r = await writeResource('discover_query', id, {
|
|
445
|
+
organizationSlug: orgSlug, name: b.name, fields: b.fields.map(String),
|
|
446
|
+
query: typeof b.query === 'string' ? b.query : '',
|
|
447
|
+
orderby: typeof b.orderby === 'string' ? b.orderby : null,
|
|
448
|
+
range: typeof b.range === 'string' ? b.range : '14d',
|
|
449
|
+
dateCreated: req.occurredAt ?? '1970-01-01T00:00:00.000Z',
|
|
450
|
+
}, 'discover_query.create', root, req.occurredAt);
|
|
451
|
+
return { status: 201, body: discoverView(r) };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// ---- Metric alert rules (thresholds over event-count / percentage) ----
|
|
455
|
+
// GET/POST /organizations/:org/alert-rules/ (Sentry's metric alert rules: a metric/dataset +
|
|
456
|
+
// thresholds with critical/warning triggers + actions). The twin models the RULE CONFIG +
|
|
457
|
+
// response shape faithfully; the live threshold EVALUATION against a time-series metrics
|
|
458
|
+
// backend is a filed todo (it needs the metrics engine) — the config CRUD is real and failable.
|
|
459
|
+
if (seg[0] === 'organizations' && seg[2] === 'alert-rules' && seg.length === 3) {
|
|
460
|
+
const orgSlug = seg[1];
|
|
461
|
+
if (!rows('organization', root).some((o) => o.slug === orgSlug && !o.deleted))
|
|
462
|
+
return err('The requested resource does not exist', 404);
|
|
463
|
+
if (method === 'GET') {
|
|
464
|
+
const items = rows('metric_alert', root).filter((r) => r.organizationSlug === orgSlug && !r.deleted).map(metricAlertView);
|
|
465
|
+
return { status: 200, body: items };
|
|
466
|
+
}
|
|
467
|
+
if (method === 'POST') {
|
|
468
|
+
const b = body(req);
|
|
469
|
+
const v = validateMetricAlert(b);
|
|
470
|
+
if (v.error || !v.fields)
|
|
471
|
+
return err(v.error ?? 'invalid metric alert', 400);
|
|
472
|
+
const id = `mar_${rows('metric_alert', root).length + 1}`;
|
|
473
|
+
const r = await writeResource('metric_alert', id, { organizationSlug: orgSlug, ...v.fields, dateCreated: req.occurredAt ?? '1970-01-01T00:00:00.000Z' }, 'metric_alert.create', root, req.occurredAt);
|
|
474
|
+
return { status: 201, body: metricAlertView(r) };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// GET/PUT/DELETE /organizations/:org/alert-rules/:id/
|
|
478
|
+
if (seg[0] === 'organizations' && seg[2] === 'alert-rules' && seg.length === 4) {
|
|
479
|
+
const orgSlug = seg[1];
|
|
480
|
+
const rule = rows('metric_alert', root).find((r) => r.id === seg[3] && r.organizationSlug === orgSlug && !r.deleted);
|
|
481
|
+
if (!rule)
|
|
482
|
+
return err('The requested resource does not exist', 404);
|
|
483
|
+
if (method === 'GET')
|
|
484
|
+
return { status: 200, body: metricAlertView(rule) };
|
|
485
|
+
if (method === 'PUT') {
|
|
486
|
+
const b = body(req);
|
|
487
|
+
const v = validateMetricAlert({ ...rule, ...b }, true);
|
|
488
|
+
if (v.error || !v.fields)
|
|
489
|
+
return err(v.error ?? 'invalid metric alert', 400);
|
|
490
|
+
const r = await writeResource('metric_alert', rule.id, v.fields, 'metric_alert.update', root, req.occurredAt);
|
|
491
|
+
return { status: 200, body: metricAlertView(r) };
|
|
492
|
+
}
|
|
493
|
+
if (method === 'DELETE') {
|
|
494
|
+
await writeResource('metric_alert', rule.id, { deleted: true }, 'metric_alert.delete', root, req.occurredAt);
|
|
495
|
+
return { status: 204, body: null };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
// GET /organizations/:org/sessions/ (release-health session metrics — crash-free counts)
|
|
499
|
+
// Aggregated EXACTLY over the persisted session resources (each session's final status).
|
|
500
|
+
// This is a real tally, not a simulated metrics engine: sum(session), crash-free rate, and
|
|
501
|
+
// the per-release/per-status breakdown are all derived from stored data. The time-bucketed
|
|
502
|
+
// SERIES (intervals) is left empty (a time-series rollup engine is a filed todo).
|
|
503
|
+
if (seg[0] === 'organizations' && seg[2] === 'sessions' && seg.length === 3 && method === 'GET') {
|
|
504
|
+
const orgSlug = seg[1];
|
|
505
|
+
if (!rows('organization', root).some((o) => o.slug === orgSlug && !o.deleted))
|
|
506
|
+
return err('The requested resource does not exist', 404);
|
|
507
|
+
const projIds = new Set(rows('project', root).filter((p) => p.organizationSlug === orgSlug && !p.deleted).map((p) => p.id));
|
|
508
|
+
const sessions = rows('session', root).filter((s) => projIds.has(String(s.projectID)) && !s.deleted);
|
|
509
|
+
return { status: 200, body: sessionMetrics(sessions, params) };
|
|
510
|
+
}
|
|
511
|
+
// ---- Teams: GET/POST /organizations/:org/teams/ ----
|
|
512
|
+
if (seg[0] === 'organizations' && seg[2] === 'teams' && seg.length === 3) {
|
|
513
|
+
const orgSlug = seg[1];
|
|
514
|
+
if (method === 'GET') {
|
|
515
|
+
const items = rows('team', root).filter((t) => t.organizationSlug === orgSlug && !t.deleted).map(teamView);
|
|
516
|
+
return { status: 200, body: items };
|
|
517
|
+
}
|
|
518
|
+
if (method === 'POST') {
|
|
519
|
+
const b = body(req);
|
|
520
|
+
const slug = String(b.slug ?? slugify(String(b.name ?? '')));
|
|
521
|
+
if (!slug)
|
|
522
|
+
return err('slug or name is required', 400);
|
|
523
|
+
const id = `team_${rows('team', root).length + 1}`;
|
|
524
|
+
const r = await writeResource('team', id, { slug, name: b.name ?? slug, organizationSlug: orgSlug, dateCreated: req.occurredAt ?? '1970-01-01T00:00:00.000Z' }, 'team.create', root, req.occurredAt);
|
|
525
|
+
return { status: 201, body: teamView(r) };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
// ---- SCIM 2.0 provisioning: /organizations/:org/scim/v2/{Users,Groups} ----
|
|
529
|
+
if (seg[0] === 'organizations' && seg[2] === 'scim' && seg[3] === 'v2') {
|
|
530
|
+
return scimApi(method, seg, req);
|
|
531
|
+
}
|
|
532
|
+
// ---- Releases: /organizations/:org/releases/ ----
|
|
533
|
+
if (seg[0] === 'organizations' && seg[2] === 'releases') {
|
|
534
|
+
return releasesApi(method, seg, req);
|
|
535
|
+
}
|
|
536
|
+
// ---- Projects ----
|
|
537
|
+
// GET /projects/ (list all)
|
|
538
|
+
if (seg[0] === 'projects' && seg.length === 1 && method === 'GET') {
|
|
539
|
+
const items = rows('project', root).filter((p) => !p.deleted).map(projectView);
|
|
540
|
+
return { status: 200, body: items };
|
|
541
|
+
}
|
|
542
|
+
// GET /projects/:org/:project/ (retrieve)
|
|
543
|
+
if (seg[0] === 'projects' && seg.length === 3 && method === 'GET') {
|
|
544
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
545
|
+
return p ? { status: 200, body: projectView(p) } : err('The requested resource does not exist', 404);
|
|
546
|
+
}
|
|
547
|
+
// PUT /projects/:org/:project/ (update)
|
|
548
|
+
if (seg[0] === 'projects' && seg.length === 3 && method === 'PUT') {
|
|
549
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
550
|
+
if (!p)
|
|
551
|
+
return err('The requested resource does not exist', 404);
|
|
552
|
+
const b = body(req);
|
|
553
|
+
const r = await writeResource('project', p.id, { ...(b.name !== undefined ? { name: b.name } : {}), ...(b.slug !== undefined ? { slug: b.slug } : {}), ...(b.platform !== undefined ? { platform: b.platform } : {}) }, 'project.update', root, req.occurredAt);
|
|
554
|
+
return { status: 200, body: projectView(r) };
|
|
555
|
+
}
|
|
556
|
+
// DELETE /projects/:org/:project/
|
|
557
|
+
if (seg[0] === 'projects' && seg.length === 3 && method === 'DELETE') {
|
|
558
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
559
|
+
if (!p)
|
|
560
|
+
return err('The requested resource does not exist', 404);
|
|
561
|
+
await writeResource('project', p.id, { deleted: true }, 'project.delete', root, req.occurredAt);
|
|
562
|
+
return { status: 204, body: null };
|
|
563
|
+
}
|
|
564
|
+
// POST /teams/:org/:team/projects/ (create project under a team) — Sentry's create path
|
|
565
|
+
if (seg[0] === 'teams' && seg[3] === 'projects' && seg.length === 4 && method === 'POST') {
|
|
566
|
+
const orgSlug = seg[1];
|
|
567
|
+
const b = body(req);
|
|
568
|
+
const slug = String(b.slug ?? slugify(String(b.name ?? '')));
|
|
569
|
+
if (!slug)
|
|
570
|
+
return err('name is required', 400);
|
|
571
|
+
if (findProjectBySlug(orgSlug, slug, root))
|
|
572
|
+
return err('A project with this slug already exists', 409);
|
|
573
|
+
const id = String(rows('project', root).length + 1);
|
|
574
|
+
const r = await writeResource('project', id, { slug, name: b.name ?? slug, platform: b.platform ?? null, organizationSlug: orgSlug, team: seg[2], dateCreated: req.occurredAt ?? '1970-01-01T00:00:00.000Z' }, 'project.create', root, req.occurredAt);
|
|
575
|
+
return { status: 201, body: projectView(r) };
|
|
576
|
+
}
|
|
577
|
+
// ---- Team membership: GET/POST /teams/:org/:team/members/ + DELETE .../:memberId/ ----
|
|
578
|
+
if (seg[0] === 'teams' && seg[3] === 'members') {
|
|
579
|
+
const orgSlug = seg[1];
|
|
580
|
+
const teamSlug = seg[2];
|
|
581
|
+
const team = rows('team', root).find((t) => t.slug === teamSlug && t.organizationSlug === orgSlug && !t.deleted);
|
|
582
|
+
if (!team)
|
|
583
|
+
return err('The requested resource does not exist', 404);
|
|
584
|
+
if (seg.length === 4 && method === 'GET') {
|
|
585
|
+
const items = rows('org_member', root)
|
|
586
|
+
.filter((m) => m.organizationSlug === orgSlug && !m.deleted && Array.isArray(m.teams) && m.teams.includes(teamSlug))
|
|
587
|
+
.map(memberView);
|
|
588
|
+
return { status: 200, body: items };
|
|
589
|
+
}
|
|
590
|
+
if (seg.length === 5 && (method === 'POST' || method === 'DELETE')) {
|
|
591
|
+
const m = rows('org_member', root).find((x) => x.id === seg[4] && x.organizationSlug === orgSlug && !x.deleted);
|
|
592
|
+
if (!m)
|
|
593
|
+
return err('The requested resource does not exist', 404);
|
|
594
|
+
const teams = new Set(Array.isArray(m.teams) ? m.teams : []);
|
|
595
|
+
if (method === 'POST')
|
|
596
|
+
teams.add(teamSlug);
|
|
597
|
+
else
|
|
598
|
+
teams.delete(teamSlug);
|
|
599
|
+
const r = await writeResource('org_member', m.id, { teams: [...teams] }, 'org_member.update', root, req.occurredAt);
|
|
600
|
+
return method === 'POST' ? { status: 201, body: memberView(r) } : { status: 204, body: null };
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
// ---- Project keys (DSN) ----
|
|
604
|
+
// GET/POST /projects/:org/:project/keys/
|
|
605
|
+
if (seg[0] === 'projects' && seg[3] === 'keys' && seg.length === 4) {
|
|
606
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
607
|
+
if (!p)
|
|
608
|
+
return err('The requested resource does not exist', 404);
|
|
609
|
+
if (method === 'GET') {
|
|
610
|
+
const items = rows('project_key', root).filter((k) => k.projectId === p.id && !k.deleted).map((k) => keyView(k, req));
|
|
611
|
+
return { status: 200, body: items };
|
|
612
|
+
}
|
|
613
|
+
if (method === 'POST') {
|
|
614
|
+
const b = body(req);
|
|
615
|
+
const idx = rows('project_key', root).length + 1;
|
|
616
|
+
const publicKey = `twinkey${String(idx).padStart(25, '0')}`;
|
|
617
|
+
const r = await writeResource('project_key', `key_${idx}`, {
|
|
618
|
+
public: publicKey, secret: `twinsecret${String(idx).padStart(22, '0')}`,
|
|
619
|
+
projectId: p.id, projectSlug: p.slug, organizationSlug: p.organizationSlug,
|
|
620
|
+
name: b.name ?? 'Default', isActive: true, dateCreated: req.occurredAt ?? '1970-01-01T00:00:00.000Z',
|
|
621
|
+
}, 'project_key.create', root, req.occurredAt);
|
|
622
|
+
return { status: 201, body: keyView(r, req) };
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// PUT/DELETE /projects/:org/:project/keys/:keyId/ (toggle active / revoke DSN)
|
|
626
|
+
if (seg[0] === 'projects' && seg[3] === 'keys' && seg.length === 5) {
|
|
627
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
628
|
+
if (!p)
|
|
629
|
+
return err('The requested resource does not exist', 404);
|
|
630
|
+
const key = rows('project_key', root).find((k) => k.id === seg[4] && k.projectId === p.id && !k.deleted)
|
|
631
|
+
?? rows('project_key', root).find((k) => k.public === seg[4] && k.projectId === p.id && !k.deleted);
|
|
632
|
+
if (!key)
|
|
633
|
+
return err('The requested resource does not exist', 404);
|
|
634
|
+
if (method === 'PUT') {
|
|
635
|
+
const b = body(req);
|
|
636
|
+
const patch = {};
|
|
637
|
+
if (b.isActive !== undefined)
|
|
638
|
+
patch.isActive = !!b.isActive;
|
|
639
|
+
if (b.name !== undefined)
|
|
640
|
+
patch.name = b.name;
|
|
641
|
+
// Per-key rate limit: { window: <seconds>, count: <events> } or null to clear.
|
|
642
|
+
if (b.rateLimit !== undefined) {
|
|
643
|
+
if (b.rateLimit === null)
|
|
644
|
+
patch.rateLimit = null;
|
|
645
|
+
else {
|
|
646
|
+
const rl = b.rateLimit;
|
|
647
|
+
const window = Number(rl.window);
|
|
648
|
+
const count = Number(rl.count);
|
|
649
|
+
if (!Number.isFinite(window) || !Number.isFinite(count) || window <= 0 || count < 0) {
|
|
650
|
+
return err('rateLimit requires positive window (seconds) and non-negative count', 400);
|
|
651
|
+
}
|
|
652
|
+
patch.rateLimit = { window, count };
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const r = await writeResource('project_key', key.id, patch, 'project_key.update', root, req.occurredAt);
|
|
656
|
+
return { status: 200, body: keyView(r, req) };
|
|
657
|
+
}
|
|
658
|
+
if (method === 'DELETE') {
|
|
659
|
+
await writeResource('project_key', key.id, { deleted: true }, 'project_key.delete', root, req.occurredAt);
|
|
660
|
+
return { status: 204, body: null };
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// GET /projects/:org/:project/environments/ (distinct environments seen across events)
|
|
664
|
+
if (seg[0] === 'projects' && seg[3] === 'environments' && seg.length === 4 && method === 'GET') {
|
|
665
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
666
|
+
if (!p)
|
|
667
|
+
return err('The requested resource does not exist', 404);
|
|
668
|
+
const seen = new Set();
|
|
669
|
+
for (const ev of rows('event', root)) {
|
|
670
|
+
if (ev.projectID === p.id && typeof ev.environment === 'string' && ev.environment)
|
|
671
|
+
seen.add(ev.environment);
|
|
672
|
+
}
|
|
673
|
+
for (const d of rows('deploy', root)) {
|
|
674
|
+
if (typeof d.environment === 'string' && d.environment)
|
|
675
|
+
seen.add(d.environment);
|
|
676
|
+
}
|
|
677
|
+
const items = [...seen].sort().map((name, i) => ({ id: String(i + 1), name, displayName: name, isHidden: false }));
|
|
678
|
+
return { status: 200, body: items };
|
|
679
|
+
}
|
|
680
|
+
// ---- Issues ----
|
|
681
|
+
// GET /projects/:org/:project/issues/ (list with query/sort)
|
|
682
|
+
if (seg[0] === 'projects' && seg[3] === 'issues' && seg.length === 4 && method === 'GET') {
|
|
683
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
684
|
+
if (!p)
|
|
685
|
+
return err('The requested resource does not exist', 404);
|
|
686
|
+
const query = params.get('query') ?? 'is:unresolved';
|
|
687
|
+
const sort = params.get('sort') ?? 'date';
|
|
688
|
+
let issues = rows('issue', root).filter((i) => i.projectId === p.id && !i.deleted && matchIssueQuery(i, query));
|
|
689
|
+
issues = sortIssues(issues, sort);
|
|
690
|
+
return { status: 200, body: issues.map(issueView) };
|
|
691
|
+
}
|
|
692
|
+
// PUT/DELETE /projects/:org/:project/issues/ (BULK mutate) — Sentry selects targets via
|
|
693
|
+
// repeated ?id=<n> params, or ?query=<dsl> (all matching). PUT sets status/assignedTo;
|
|
694
|
+
// DELETE removes. Returns the applied patch on PUT (Sentry's shape), 204 on DELETE.
|
|
695
|
+
if (seg[0] === 'projects' && seg[3] === 'issues' && seg.length === 4 && (method === 'PUT' || method === 'DELETE')) {
|
|
696
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
697
|
+
if (!p)
|
|
698
|
+
return err('The requested resource does not exist', 404);
|
|
699
|
+
const ids = params.getAll('id');
|
|
700
|
+
const all = rows('issue', root).filter((i) => i.projectId === p.id && !i.deleted);
|
|
701
|
+
let targets;
|
|
702
|
+
if (ids.length > 0) {
|
|
703
|
+
targets = all.filter((i) => ids.includes(String(i.id)));
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
const query = params.get('query');
|
|
707
|
+
if (query === null)
|
|
708
|
+
return err('Cannot apply a bulk mutation without a query or id filter', 400);
|
|
709
|
+
targets = all.filter((i) => matchIssueQuery(i, query));
|
|
710
|
+
}
|
|
711
|
+
if (method === 'DELETE') {
|
|
712
|
+
for (const t of targets)
|
|
713
|
+
await writeResource('issue', t.id, { deleted: true }, 'issue.delete', root, req.occurredAt);
|
|
714
|
+
return { status: 204, body: null };
|
|
715
|
+
}
|
|
716
|
+
const b = body(req);
|
|
717
|
+
// MERGE: ?id=A&id=B&… + {merge:1} folds the selected groups into one primary issue.
|
|
718
|
+
// The primary is the highest-count target (Sentry merges into the most-seen group);
|
|
719
|
+
// others are tombstoned and their events are re-pointed + counts summed into the primary.
|
|
720
|
+
if (b.merge) {
|
|
721
|
+
if (targets.length < 2)
|
|
722
|
+
return err('Merging requires at least two issues', 400);
|
|
723
|
+
const ordered = [...targets].sort((a, b2) => Number(b2.count ?? 0) - Number(a.count ?? 0));
|
|
724
|
+
const primary = ordered[0];
|
|
725
|
+
const others = ordered.slice(1);
|
|
726
|
+
let mergedCount = Number(primary.count ?? 0);
|
|
727
|
+
let firstSeen = String(primary.firstSeen ?? '');
|
|
728
|
+
let lastSeen = String(primary.lastSeen ?? '');
|
|
729
|
+
for (const o of others) {
|
|
730
|
+
mergedCount += Number(o.count ?? 0);
|
|
731
|
+
if (o.firstSeen && (!firstSeen || String(o.firstSeen) < firstSeen))
|
|
732
|
+
firstSeen = String(o.firstSeen);
|
|
733
|
+
if (o.lastSeen && (!lastSeen || String(o.lastSeen) > lastSeen))
|
|
734
|
+
lastSeen = String(o.lastSeen);
|
|
735
|
+
// re-point the merged group's events at the primary
|
|
736
|
+
for (const ev of rows('event', root).filter((e) => e.groupID === o.id && !e.deleted)) {
|
|
737
|
+
await writeResource('event', ev.id, { groupID: primary.id }, 'event.merge', root, req.occurredAt);
|
|
738
|
+
}
|
|
739
|
+
await writeResource('issue', o.id, { deleted: true, mergedInto: primary.id }, 'issue.merge', root, req.occurredAt);
|
|
740
|
+
}
|
|
741
|
+
await writeResource('issue', primary.id, { count: String(mergedCount), firstSeen, lastSeen }, 'issue.update', root, req.occurredAt);
|
|
742
|
+
return { status: 200, body: { merge: { parent: primary.id, children: others.map((o) => o.id) } } };
|
|
743
|
+
}
|
|
744
|
+
const patch = {};
|
|
745
|
+
if (b.status !== undefined) {
|
|
746
|
+
const status = String(b.status);
|
|
747
|
+
if (!['resolved', 'unresolved', 'ignored', 'muted'].includes(status))
|
|
748
|
+
return err('Invalid status', 400);
|
|
749
|
+
patch.status = status === 'muted' ? 'ignored' : status;
|
|
750
|
+
if (b.statusDetails !== undefined)
|
|
751
|
+
patch.statusDetails = b.statusDetails;
|
|
752
|
+
}
|
|
753
|
+
if (b.assignedTo !== undefined)
|
|
754
|
+
patch.assignedTo = b.assignedTo;
|
|
755
|
+
if (Object.keys(patch).length === 0)
|
|
756
|
+
return err('No updatable fields provided', 400);
|
|
757
|
+
for (const t of targets)
|
|
758
|
+
await writeResource('issue', t.id, patch, 'issue.update', root, req.occurredAt);
|
|
759
|
+
return { status: 200, body: patch };
|
|
760
|
+
}
|
|
761
|
+
// GET/PUT/DELETE /issues/:id/
|
|
762
|
+
if (seg[0] === 'issues' && seg.length === 2) {
|
|
763
|
+
const issue = getRow('issue', seg[1], root);
|
|
764
|
+
if (!issue || issue.deleted)
|
|
765
|
+
return err('The requested resource does not exist', 404);
|
|
766
|
+
if (method === 'GET')
|
|
767
|
+
return { status: 200, body: issueView(issue) };
|
|
768
|
+
if (method === 'PUT') {
|
|
769
|
+
const b = body(req);
|
|
770
|
+
const patch = {};
|
|
771
|
+
if (b.status !== undefined) {
|
|
772
|
+
const status = String(b.status);
|
|
773
|
+
if (!['resolved', 'unresolved', 'ignored', 'muted'].includes(status))
|
|
774
|
+
return err('Invalid status', 400);
|
|
775
|
+
patch.status = status === 'muted' ? 'ignored' : status;
|
|
776
|
+
if (b.statusDetails !== undefined)
|
|
777
|
+
patch.statusDetails = b.statusDetails;
|
|
778
|
+
}
|
|
779
|
+
if (b.assignedTo !== undefined)
|
|
780
|
+
patch.assignedTo = b.assignedTo;
|
|
781
|
+
if (Object.keys(patch).length === 0)
|
|
782
|
+
return err('No updatable fields provided', 400);
|
|
783
|
+
const r = await writeResource('issue', issue.id, patch, 'issue.update', root, req.occurredAt);
|
|
784
|
+
return { status: 200, body: issueView(r) };
|
|
785
|
+
}
|
|
786
|
+
if (method === 'DELETE') {
|
|
787
|
+
await writeResource('issue', issue.id, { deleted: true }, 'issue.delete', root, req.occurredAt);
|
|
788
|
+
return { status: 204, body: null };
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
// GET /issues/:id/events/
|
|
792
|
+
if (seg[0] === 'issues' && seg[2] === 'events' && seg.length === 3 && method === 'GET') {
|
|
793
|
+
const issue = getRow('issue', seg[1], root);
|
|
794
|
+
if (!issue || issue.deleted)
|
|
795
|
+
return err('The requested resource does not exist', 404);
|
|
796
|
+
const evs = rows('event', root).filter((e) => e.groupID === issue.id).map(eventView)
|
|
797
|
+
.sort((a, b) => Date.parse(String(b.dateCreated)) - Date.parse(String(a.dateCreated)));
|
|
798
|
+
return { status: 200, body: evs };
|
|
799
|
+
}
|
|
800
|
+
// GET /issues/:id/events/latest/
|
|
801
|
+
if (seg[0] === 'issues' && seg[2] === 'events' && seg[3] === 'latest' && seg.length === 4 && method === 'GET') {
|
|
802
|
+
const issue = getRow('issue', seg[1], root);
|
|
803
|
+
if (!issue || issue.deleted)
|
|
804
|
+
return err('The requested resource does not exist', 404);
|
|
805
|
+
const evs = rows('event', root).filter((e) => e.groupID === issue.id)
|
|
806
|
+
.sort((a, b) => Date.parse(String(b.dateCreated)) - Date.parse(String(a.dateCreated)));
|
|
807
|
+
return evs[0] ? { status: 200, body: eventView(evs[0]) } : err('The requested resource does not exist', 404);
|
|
808
|
+
}
|
|
809
|
+
// GET /issues/:id/tags/ (the issue's tag keys + per-key value distribution)
|
|
810
|
+
// GET /issues/:id/tags/:key/ (one tag key's full value distribution)
|
|
811
|
+
if (seg[0] === 'issues' && seg[2] === 'tags' && method === 'GET' && (seg.length === 3 || seg.length === 4)) {
|
|
812
|
+
const issue = getRow('issue', seg[1], root);
|
|
813
|
+
if (!issue || issue.deleted)
|
|
814
|
+
return err('The requested resource does not exist', 404);
|
|
815
|
+
const dist = issueTagDistribution(issue.id, root);
|
|
816
|
+
if (seg.length === 4) {
|
|
817
|
+
const key = seg[3];
|
|
818
|
+
const found = dist.find((d) => d.key === key);
|
|
819
|
+
return found ? { status: 200, body: found } : err('The requested resource does not exist', 404);
|
|
820
|
+
}
|
|
821
|
+
return { status: 200, body: dist };
|
|
822
|
+
}
|
|
823
|
+
// GET /issues/:id/hashes/ (the grouping hashes for the issue — the twin's fingerprint IS
|
|
824
|
+
// the issue id, so the issue has exactly one grouping hash: its own deterministic group id).
|
|
825
|
+
if (seg[0] === 'issues' && seg[2] === 'hashes' && seg.length === 3 && method === 'GET') {
|
|
826
|
+
const issue = getRow('issue', seg[1], root);
|
|
827
|
+
if (!issue || issue.deleted)
|
|
828
|
+
return err('The requested resource does not exist', 404);
|
|
829
|
+
const latest = rows('event', root).filter((e) => e.groupID === issue.id)
|
|
830
|
+
.sort((a, b) => Date.parse(String(b.dateCreated)) - Date.parse(String(a.dateCreated)))[0];
|
|
831
|
+
return { status: 200, body: [{ id: issue.id, latestEvent: latest ? eventView(latest) : null }] };
|
|
832
|
+
}
|
|
833
|
+
// GET /issues/:id/activity/ (the issue's activity feed: creation + triage from the log)
|
|
834
|
+
if (seg[0] === 'issues' && seg[2] === 'activity' && seg.length === 3 && method === 'GET') {
|
|
835
|
+
const issue = getRow('issue', seg[1], root);
|
|
836
|
+
if (!issue || issue.deleted)
|
|
837
|
+
return err('The requested resource does not exist', 404);
|
|
838
|
+
return { status: 200, body: { activity: issueActivity(issue.id, root) } };
|
|
839
|
+
}
|
|
840
|
+
// ---- Events ----
|
|
841
|
+
// GET /projects/:org/:project/events/ (list)
|
|
842
|
+
if (seg[0] === 'projects' && seg[3] === 'events' && seg.length === 4 && method === 'GET') {
|
|
843
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
844
|
+
if (!p)
|
|
845
|
+
return err('The requested resource does not exist', 404);
|
|
846
|
+
const evs = rows('event', root).filter((e) => e.projectID === p.id).map(eventView)
|
|
847
|
+
.sort((a, b) => Date.parse(String(b.dateCreated)) - Date.parse(String(a.dateCreated)));
|
|
848
|
+
return { status: 200, body: evs };
|
|
849
|
+
}
|
|
850
|
+
// GET /projects/:org/:project/events/:id/ (retrieve)
|
|
851
|
+
if (seg[0] === 'projects' && seg[3] === 'events' && seg.length === 5 && method === 'GET') {
|
|
852
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
853
|
+
if (!p)
|
|
854
|
+
return err('The requested resource does not exist', 404);
|
|
855
|
+
const ev = rows('event', root).find((e) => e.id === seg[4] && e.projectID === p.id);
|
|
856
|
+
return ev ? { status: 200, body: eventView(ev) } : err('The requested resource does not exist', 404);
|
|
857
|
+
}
|
|
858
|
+
// GET /projects/:org/:project/events/:id/attachments/ (event attachments listing)
|
|
859
|
+
// Attachments arrive on the ingest envelope as `attachment` items; the twin persists their
|
|
860
|
+
// metadata and lists them here in Sentry's attachment shape. (Binary blob download — the
|
|
861
|
+
// file body over /attachments/:attId/?download — is a filed todo: it needs a blob store; the
|
|
862
|
+
// metadata listing is real.)
|
|
863
|
+
if (seg[0] === 'projects' && seg[3] === 'events' && seg[5] === 'attachments' && seg.length === 6 && method === 'GET') {
|
|
864
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
865
|
+
if (!p)
|
|
866
|
+
return err('The requested resource does not exist', 404);
|
|
867
|
+
const ev = rows('event', root).find((e) => e.id === seg[4] && e.projectID === p.id);
|
|
868
|
+
if (!ev)
|
|
869
|
+
return err('The requested resource does not exist', 404);
|
|
870
|
+
const items = rows('event_attachment', root)
|
|
871
|
+
.filter((a) => a.eventId === ev.id && !a.deleted)
|
|
872
|
+
.map(attachmentView);
|
|
873
|
+
return { status: 200, body: items };
|
|
874
|
+
}
|
|
875
|
+
// ---- User feedback (crash-report dialog submissions) ----
|
|
876
|
+
// GET/POST /projects/:org/:project/user-feedback/ — submit/list user feedback for a project.
|
|
877
|
+
// (Deprecated by Sentry in favor of the Feedback Widget, but still a real, modellable endpoint.)
|
|
878
|
+
if (seg[0] === 'projects' && seg[3] === 'user-feedback' && seg.length === 4) {
|
|
879
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
880
|
+
if (!p)
|
|
881
|
+
return err('The requested resource does not exist', 404);
|
|
882
|
+
if (method === 'GET') {
|
|
883
|
+
const items = rows('user_feedback', root).filter((f) => f.projectId === p.id && !f.deleted)
|
|
884
|
+
.sort((a, b) => Date.parse(String(b.dateCreated)) - Date.parse(String(a.dateCreated)))
|
|
885
|
+
.map((f) => userFeedbackView(f, root));
|
|
886
|
+
return { status: 200, body: items };
|
|
887
|
+
}
|
|
888
|
+
if (method === 'POST') {
|
|
889
|
+
const b = body(req);
|
|
890
|
+
const eventId = typeof b.event_id === 'string' ? b.event_id : '';
|
|
891
|
+
const email = typeof b.email === 'string' ? b.email.trim() : '';
|
|
892
|
+
const name = typeof b.name === 'string' ? b.name : '';
|
|
893
|
+
const comments = typeof b.comments === 'string' ? b.comments : '';
|
|
894
|
+
// Sentry requires event_id, name, email and comments.
|
|
895
|
+
if (!eventId || !email || !name || !comments)
|
|
896
|
+
return err('event_id, name, email and comments are required', 400);
|
|
897
|
+
const id = `feedback_${rows('user_feedback', root).length + 1}`;
|
|
898
|
+
// Link to the issue the event grouped into, if the event is known to the twin.
|
|
899
|
+
const ev = rows('event', root).find((e) => e.id === eventId && e.projectID === p.id);
|
|
900
|
+
const r = await writeResource('user_feedback', id, {
|
|
901
|
+
projectId: p.id, eventID: eventId, name, email, comments,
|
|
902
|
+
issueId: ev ? String(ev.groupID) : null,
|
|
903
|
+
dateCreated: req.occurredAt ?? '1970-01-01T00:00:00.000Z',
|
|
904
|
+
}, 'user_feedback.create', root, req.occurredAt);
|
|
905
|
+
return { status: 200, body: userFeedbackView(r, root) };
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// ---- Discover org-wide event facets (tag-value distributions) ----
|
|
909
|
+
// GET /organizations/:org/events-facets/ — tag key → topValues distribution across the org's
|
|
910
|
+
// events. Sentry's Discover facets endpoint, folded from the persisted event tags.
|
|
911
|
+
if (seg[0] === 'organizations' && seg[2] === 'events-facets' && seg.length === 3 && method === 'GET') {
|
|
912
|
+
const orgSlug = seg[1];
|
|
913
|
+
if (!rows('organization', root).some((o) => o.slug === orgSlug && !o.deleted))
|
|
914
|
+
return err('The requested resource does not exist', 404);
|
|
915
|
+
return { status: 200, body: orgEventFacets(orgSlug, root) };
|
|
916
|
+
}
|
|
917
|
+
// ---- Spike protection (org-level enable/disable per project) ----
|
|
918
|
+
// POST /organizations/:org/spike-protections/ { projects: ["slug"] | "$all" } — enable
|
|
919
|
+
// DELETE /organizations/:org/spike-protections/ { projects: ["slug"] | "$all" } — disable
|
|
920
|
+
if (seg[0] === 'organizations' && seg[2] === 'spike-protections' && seg.length === 3) {
|
|
921
|
+
const orgSlug = seg[1];
|
|
922
|
+
if (!rows('organization', root).some((o) => o.slug === orgSlug && !o.deleted))
|
|
923
|
+
return err('The requested resource does not exist', 404);
|
|
924
|
+
if (method === 'GET') {
|
|
925
|
+
const items = rows('project', root).filter((p) => p.organizationSlug === orgSlug && !p.deleted)
|
|
926
|
+
.map((p) => ({ project_slug: p.slug, enabled: !!getRow('spike_protection', `sp_${p.id}`, root)?.enabled }));
|
|
927
|
+
return { status: 200, body: items };
|
|
928
|
+
}
|
|
929
|
+
if (method === 'POST' || method === 'DELETE') {
|
|
930
|
+
const b = body(req);
|
|
931
|
+
const orgProjects = rows('project', root).filter((p) => p.organizationSlug === orgSlug && !p.deleted);
|
|
932
|
+
let targets = orgProjects;
|
|
933
|
+
if (b.projects !== '$all') {
|
|
934
|
+
const want = Array.isArray(b.projects) ? b.projects.map(String) : [];
|
|
935
|
+
if (want.length === 0)
|
|
936
|
+
return err('projects is required', 400);
|
|
937
|
+
targets = orgProjects.filter((p) => want.includes(String(p.slug)));
|
|
938
|
+
if (targets.length === 0)
|
|
939
|
+
return err('The requested resource does not exist', 404);
|
|
940
|
+
}
|
|
941
|
+
const enabled = method === 'POST';
|
|
942
|
+
for (const p of targets) {
|
|
943
|
+
await writeResource('spike_protection', `sp_${p.id}`, { projectId: p.id, enabled }, 'spike_protection.set', root, req.occurredAt);
|
|
944
|
+
}
|
|
945
|
+
return { status: enabled ? 201 : 200, body: null };
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
// ---- Project ownership rules + code owners ----
|
|
949
|
+
// GET/PUT /projects/:org/:project/ownership/ (issue-owner rules: path/url/module → owners)
|
|
950
|
+
if (seg[0] === 'projects' && seg[3] === 'ownership' && seg.length === 4) {
|
|
951
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
952
|
+
if (!p)
|
|
953
|
+
return err('The requested resource does not exist', 404);
|
|
954
|
+
const existing = getRow('project_ownership', `own_${p.id}`, root);
|
|
955
|
+
if (method === 'GET')
|
|
956
|
+
return { status: 200, body: ownershipView(existing, p.id) };
|
|
957
|
+
if (method === 'PUT') {
|
|
958
|
+
const b = body(req);
|
|
959
|
+
const patch = { projectId: p.id };
|
|
960
|
+
if (b.raw !== undefined) {
|
|
961
|
+
if (typeof b.raw !== 'string')
|
|
962
|
+
return err('raw must be a string', 400);
|
|
963
|
+
const parsed = parseOwnershipRaw(String(b.raw));
|
|
964
|
+
if (parsed.error)
|
|
965
|
+
return err(parsed.error, 400);
|
|
966
|
+
patch.raw = String(b.raw);
|
|
967
|
+
patch.schema = parsed.schema;
|
|
968
|
+
}
|
|
969
|
+
if (b.fallthrough !== undefined)
|
|
970
|
+
patch.fallthrough = !!b.fallthrough;
|
|
971
|
+
if (b.autoAssignment !== undefined)
|
|
972
|
+
patch.autoAssignment = String(b.autoAssignment);
|
|
973
|
+
if (b.codeownersAutoSync !== undefined)
|
|
974
|
+
patch.codeownersAutoSync = !!b.codeownersAutoSync;
|
|
975
|
+
const r = await writeResource('project_ownership', `own_${p.id}`, patch, 'project_ownership.update', root, req.occurredAt);
|
|
976
|
+
return { status: 200, body: ownershipView(r, p.id) };
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
// GET/POST /projects/:org/:project/codeowners/ (CODEOWNERS file import)
|
|
980
|
+
if (seg[0] === 'projects' && seg[3] === 'codeowners' && seg.length === 4) {
|
|
981
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
982
|
+
if (!p)
|
|
983
|
+
return err('The requested resource does not exist', 404);
|
|
984
|
+
if (method === 'GET') {
|
|
985
|
+
const items = rows('codeowners', root).filter((c) => c.projectId === p.id && !c.deleted).map(codeownersView);
|
|
986
|
+
return { status: 200, body: items };
|
|
987
|
+
}
|
|
988
|
+
if (method === 'POST') {
|
|
989
|
+
const b = body(req);
|
|
990
|
+
if (typeof b.raw !== 'string' || !b.raw.trim())
|
|
991
|
+
return err('raw is required', 400);
|
|
992
|
+
const parsed = parseOwnershipRaw(String(b.raw));
|
|
993
|
+
if (parsed.error)
|
|
994
|
+
return err(parsed.error, 400);
|
|
995
|
+
const id = `co_${rows('codeowners', root).length + 1}`;
|
|
996
|
+
const r = await writeResource('codeowners', id, {
|
|
997
|
+
projectId: p.id, raw: String(b.raw), schema: parsed.schema,
|
|
998
|
+
codeMappingId: b.codeMappingId ?? null, dateCreated: req.occurredAt ?? '1970-01-01T00:00:00.000Z',
|
|
999
|
+
}, 'codeowners.create', root, req.occurredAt);
|
|
1000
|
+
return { status: 201, body: codeownersView(r) };
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
// ---- Inbound data filters config ----
|
|
1004
|
+
// GET /projects/:org/:project/filters/ (list inbound filters + active state)
|
|
1005
|
+
if (seg[0] === 'projects' && seg[3] === 'filters' && seg.length === 4 && method === 'GET') {
|
|
1006
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
1007
|
+
if (!p)
|
|
1008
|
+
return err('The requested resource does not exist', 404);
|
|
1009
|
+
return { status: 200, body: projectFilters(p.id, root) };
|
|
1010
|
+
}
|
|
1011
|
+
// PUT /projects/:org/:project/filters/:filterId/ (toggle/configure a filter)
|
|
1012
|
+
if (seg[0] === 'projects' && seg[3] === 'filters' && seg.length === 5 && method === 'PUT') {
|
|
1013
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
1014
|
+
if (!p)
|
|
1015
|
+
return err('The requested resource does not exist', 404);
|
|
1016
|
+
const filterId = seg[4];
|
|
1017
|
+
if (!INBOUND_FILTERS.some((f) => f.id === filterId))
|
|
1018
|
+
return err('The requested resource does not exist', 404);
|
|
1019
|
+
const b = body(req);
|
|
1020
|
+
const patch = { projectId: p.id, filterId };
|
|
1021
|
+
if (b.active !== undefined)
|
|
1022
|
+
patch.active = Array.isArray(b.active) ? b.active.map(String) : !!b.active;
|
|
1023
|
+
if (Array.isArray(b.subfilters))
|
|
1024
|
+
patch.active = b.subfilters.map(String);
|
|
1025
|
+
await writeResource('project_filter', `filt_${p.id}_${filterId}`, patch, 'project_filter.update', root, req.occurredAt);
|
|
1026
|
+
return { status: 204, body: null };
|
|
1027
|
+
}
|
|
1028
|
+
// ---- Per-key rate-limit config ----
|
|
1029
|
+
// The key's rate-limit (window+count) is set/cleared via PUT on the key with a `rateLimit`
|
|
1030
|
+
// object; the GET on the keys list already returns it (keyView). This handles the dedicated
|
|
1031
|
+
// sub-resource form some clients use: PUT /projects/:org/:project/keys/:keyId/ is already
|
|
1032
|
+
// routed above; here we surface the standalone GET for a single key's rate-limit.
|
|
1033
|
+
if (seg[0] === 'projects' && seg[3] === 'keys' && seg[5] === 'rate-limit' && seg.length === 6 && method === 'GET') {
|
|
1034
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
1035
|
+
if (!p)
|
|
1036
|
+
return err('The requested resource does not exist', 404);
|
|
1037
|
+
const key = rows('project_key', root).find((k) => k.id === seg[4] && k.projectId === p.id && !k.deleted);
|
|
1038
|
+
if (!key)
|
|
1039
|
+
return err('The requested resource does not exist', 404);
|
|
1040
|
+
return { status: 200, body: { rateLimit: key.rateLimit ?? null } };
|
|
1041
|
+
}
|
|
1042
|
+
// ---- Alert rules: GET/POST /projects/:org/:project/rules/ ----
|
|
1043
|
+
if (seg[0] === 'projects' && seg[3] === 'rules' && seg.length === 4) {
|
|
1044
|
+
const p = findProjectBySlug(seg[1], seg[2], root);
|
|
1045
|
+
if (!p)
|
|
1046
|
+
return err('The requested resource does not exist', 404);
|
|
1047
|
+
if (method === 'GET') {
|
|
1048
|
+
const items = rows('alert_rule', root).filter((r) => r.projectId === p.id && !r.deleted).map(alertView);
|
|
1049
|
+
return { status: 200, body: items };
|
|
1050
|
+
}
|
|
1051
|
+
if (method === 'POST') {
|
|
1052
|
+
const b = body(req);
|
|
1053
|
+
const id = `rule_${rows('alert_rule', root).length + 1}`;
|
|
1054
|
+
const triggers = Array.isArray(b.triggers) ? b.triggers : ['new-issue'];
|
|
1055
|
+
const endpoints = Array.isArray(b.endpoints) ? b.endpoints : [];
|
|
1056
|
+
// Alert ACTIONS beyond the webhook: email / Slack / PagerDuty / MS Teams. Each action is
|
|
1057
|
+
// normalized + validated by type; actual DELIVERY of email/Slack notifications is not
|
|
1058
|
+
// out (needs SMTP / a Slack app) — but the action CONFIG persists + round-trips faithfully.
|
|
1059
|
+
const actions = normalizeAlertActions(b.actions);
|
|
1060
|
+
if (actions.error)
|
|
1061
|
+
return err(actions.error, 400);
|
|
1062
|
+
const r = await writeResource('alert_rule', id, {
|
|
1063
|
+
projectId: p.id, name: b.name ?? id, triggers, endpoints, actions: actions.actions,
|
|
1064
|
+
conditions: Array.isArray(b.conditions) ? b.conditions : [],
|
|
1065
|
+
actionMatch: ['all', 'any', 'none'].includes(String(b.actionMatch)) ? String(b.actionMatch) : 'all',
|
|
1066
|
+
frequency: typeof b.frequency === 'number' ? b.frequency : 30,
|
|
1067
|
+
secret: b.secret ?? 'twinhooksecret', installationUuid: b.installationUuid ?? `inst_${id}`,
|
|
1068
|
+
dateCreated: req.occurredAt ?? '1970-01-01T00:00:00.000Z',
|
|
1069
|
+
}, 'alert_rule.create', root, req.occurredAt);
|
|
1070
|
+
return { status: 201, body: alertView(r) };
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
return err('Not found', 404);
|
|
1074
|
+
}
|
|
1075
|
+
// ── Releases sub-API ──────────────────────────────────────────────────────────────
|
|
1076
|
+
async function releasesApi(method, seg, req) {
|
|
1077
|
+
const root = req.root;
|
|
1078
|
+
const orgSlug = seg[1];
|
|
1079
|
+
// GET/POST /organizations/:org/releases/
|
|
1080
|
+
if (seg.length === 3) {
|
|
1081
|
+
if (method === 'GET') {
|
|
1082
|
+
const items = rows('release', root).filter((r) => r.organizationSlug === orgSlug && !r.deleted).map(releaseView);
|
|
1083
|
+
return { status: 200, body: items };
|
|
1084
|
+
}
|
|
1085
|
+
if (method === 'POST') {
|
|
1086
|
+
const b = body(req);
|
|
1087
|
+
if (!b.version)
|
|
1088
|
+
return err('version is required', 400);
|
|
1089
|
+
const version = String(b.version);
|
|
1090
|
+
const id = `${orgSlug}:${version}`;
|
|
1091
|
+
if (getRow('release', id, root))
|
|
1092
|
+
return err('A release with this version already exists', 409);
|
|
1093
|
+
const projects = Array.isArray(b.projects) ? b.projects : [];
|
|
1094
|
+
const r = await writeResource('release', id, {
|
|
1095
|
+
version, organizationSlug: orgSlug, projects, ref: b.ref ?? null, url: b.url ?? null,
|
|
1096
|
+
dateCreated: req.occurredAt ?? '1970-01-01T00:00:00.000Z', dateReleased: b.dateReleased ?? null,
|
|
1097
|
+
commitCount: Array.isArray(b.commits) ? b.commits.length : 0,
|
|
1098
|
+
...(Array.isArray(b.commits) ? { commits: b.commits } : {}),
|
|
1099
|
+
}, 'release.create', root, req.occurredAt);
|
|
1100
|
+
return { status: 201, body: releaseView(r) };
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
// GET/PUT/DELETE /organizations/:org/releases/:version/
|
|
1104
|
+
if (seg.length === 4) {
|
|
1105
|
+
const version = decodeURIComponent(seg[3]);
|
|
1106
|
+
const id = `${orgSlug}:${version}`;
|
|
1107
|
+
const rel = getRow('release', id, root);
|
|
1108
|
+
if (!rel || rel.deleted)
|
|
1109
|
+
return err('The requested resource does not exist', 404);
|
|
1110
|
+
if (method === 'GET')
|
|
1111
|
+
return { status: 200, body: releaseView(rel) };
|
|
1112
|
+
if (method === 'PUT') {
|
|
1113
|
+
const b = body(req);
|
|
1114
|
+
const patch = {};
|
|
1115
|
+
if (b.ref !== undefined)
|
|
1116
|
+
patch.ref = b.ref;
|
|
1117
|
+
if (b.url !== undefined)
|
|
1118
|
+
patch.url = b.url;
|
|
1119
|
+
if (b.dateReleased !== undefined)
|
|
1120
|
+
patch.dateReleased = b.dateReleased;
|
|
1121
|
+
if (Array.isArray(b.commits)) {
|
|
1122
|
+
patch.commits = b.commits;
|
|
1123
|
+
patch.commitCount = b.commits.length;
|
|
1124
|
+
}
|
|
1125
|
+
const r = await writeResource('release', id, patch, 'release.update', root, req.occurredAt);
|
|
1126
|
+
return { status: 200, body: releaseView(r) };
|
|
1127
|
+
}
|
|
1128
|
+
if (method === 'DELETE') {
|
|
1129
|
+
await writeResource('release', id, { deleted: true }, 'release.delete', root, req.occurredAt);
|
|
1130
|
+
return { status: 204, body: null };
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
// GET/POST /organizations/:org/releases/:version/files/ (release artifacts: source maps etc.)
|
|
1134
|
+
if (seg.length === 5 && seg[4] === 'files') {
|
|
1135
|
+
const version = decodeURIComponent(seg[3]);
|
|
1136
|
+
const id = `${orgSlug}:${version}`;
|
|
1137
|
+
const rel = getRow('release', id, root);
|
|
1138
|
+
if (!rel || rel.deleted)
|
|
1139
|
+
return err('The requested resource does not exist', 404);
|
|
1140
|
+
if (method === 'GET') {
|
|
1141
|
+
const items = rows('release_file', root).filter((f) => f.release === id && !f.deleted).map(releaseFileView);
|
|
1142
|
+
return { status: 200, body: items };
|
|
1143
|
+
}
|
|
1144
|
+
if (method === 'POST') {
|
|
1145
|
+
// Sentry takes a multipart upload (the file + a `name`). The twin records the artifact
|
|
1146
|
+
// METADATA (name, size, sha1, headers/dist) faithfully; the raw bytes / source-map
|
|
1147
|
+
// de-minification depth is a filed todo (sentry.events.symbolication) but the artifact registry is real.
|
|
1148
|
+
const b = body(req);
|
|
1149
|
+
const name = typeof b.name === 'string' ? b.name : '';
|
|
1150
|
+
if (!name)
|
|
1151
|
+
return err('name is required', 400);
|
|
1152
|
+
const fileId = `relfile_${rows('release_file', root).length + 1}`;
|
|
1153
|
+
const size = typeof b.size === 'number' ? b.size : (typeof b.contents === 'string' ? b.contents.length : 0);
|
|
1154
|
+
const r = await writeResource('release_file', fileId, {
|
|
1155
|
+
release: id, name, size,
|
|
1156
|
+
sha1: typeof b.sha1 === 'string' ? b.sha1 : hashFingerprint(`${id}|${name}|${size}`),
|
|
1157
|
+
dist: b.dist ?? null,
|
|
1158
|
+
headers: (b.headers && typeof b.headers === 'object') ? b.headers : {},
|
|
1159
|
+
dateCreated: req.occurredAt ?? '1970-01-01T00:00:00.000Z',
|
|
1160
|
+
}, 'release_file.create', root, req.occurredAt);
|
|
1161
|
+
return { status: 201, body: releaseFileView(r) };
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
// DELETE /organizations/:org/releases/:version/files/:fileId/
|
|
1165
|
+
if (seg.length === 6 && seg[4] === 'files' && method === 'DELETE') {
|
|
1166
|
+
const version = decodeURIComponent(seg[3]);
|
|
1167
|
+
const id = `${orgSlug}:${version}`;
|
|
1168
|
+
const file = rows('release_file', root).find((f) => f.id === seg[5] && f.release === id && !f.deleted);
|
|
1169
|
+
if (!file)
|
|
1170
|
+
return err('The requested resource does not exist', 404);
|
|
1171
|
+
await writeResource('release_file', file.id, { deleted: true }, 'release_file.delete', root, req.occurredAt);
|
|
1172
|
+
return { status: 204, body: null };
|
|
1173
|
+
}
|
|
1174
|
+
// POST /organizations/:org/releases/:version/deploys/ (+ GET list)
|
|
1175
|
+
if (seg.length === 5 && seg[4] === 'deploys') {
|
|
1176
|
+
const version = decodeURIComponent(seg[3]);
|
|
1177
|
+
const id = `${orgSlug}:${version}`;
|
|
1178
|
+
const rel = getRow('release', id, root);
|
|
1179
|
+
if (!rel || rel.deleted)
|
|
1180
|
+
return err('The requested resource does not exist', 404);
|
|
1181
|
+
if (method === 'GET') {
|
|
1182
|
+
const items = rows('deploy', root).filter((d) => d.release === id && !d.deleted).map(deployView);
|
|
1183
|
+
return { status: 200, body: items };
|
|
1184
|
+
}
|
|
1185
|
+
if (method === 'POST') {
|
|
1186
|
+
const b = body(req);
|
|
1187
|
+
if (!b.environment)
|
|
1188
|
+
return err('environment is required', 400);
|
|
1189
|
+
const deployId = `deploy_${rows('deploy', root).length + 1}`;
|
|
1190
|
+
const r = await writeResource('deploy', deployId, {
|
|
1191
|
+
release: id, environment: b.environment, name: b.name ?? null, url: b.url ?? null,
|
|
1192
|
+
dateStarted: b.dateStarted ?? null, dateFinished: b.dateFinished ?? (req.occurredAt ?? '1970-01-01T00:00:00.000Z'),
|
|
1193
|
+
}, 'deploy.create', root, req.occurredAt);
|
|
1194
|
+
return { status: 201, body: deployView(r) };
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
// GET /organizations/:org/releases/:version/commitfiles/ — files changed across the release's
|
|
1198
|
+
// commits (derived from each commit's patch_set: [{ path, type }]). Sentry's suspect-commit /
|
|
1199
|
+
// file-change view reads from exactly this. A release with no commits returns [].
|
|
1200
|
+
if (seg.length === 5 && seg[4] === 'commitfiles' && method === 'GET') {
|
|
1201
|
+
const version = decodeURIComponent(seg[3]);
|
|
1202
|
+
const id = `${orgSlug}:${version}`;
|
|
1203
|
+
const rel = getRow('release', id, root);
|
|
1204
|
+
if (!rel || rel.deleted)
|
|
1205
|
+
return err('The requested resource does not exist', 404);
|
|
1206
|
+
return { status: 200, body: releaseCommitFiles(rel, root) };
|
|
1207
|
+
}
|
|
1208
|
+
return err('Not found', 404);
|
|
1209
|
+
}
|
|
1210
|
+
// ── SCIM 2.0 sub-API ────────────────────────────────────────────────────────────────
|
|
1211
|
+
// Sentry maps SCIM Users → org members and SCIM Groups → teams. Faithful SCIM envelopes
|
|
1212
|
+
// (schemas/meta/ListResponse), 404 on unknown id, 409 on duplicate userName.
|
|
1213
|
+
const SCIM_USER_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User';
|
|
1214
|
+
const SCIM_GROUP_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:Group';
|
|
1215
|
+
const SCIM_LIST_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:ListResponse';
|
|
1216
|
+
function scimError(status, detail) {
|
|
1217
|
+
return { status, body: { schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'], detail, status } };
|
|
1218
|
+
}
|
|
1219
|
+
function scimUserView(r) {
|
|
1220
|
+
return {
|
|
1221
|
+
schemas: [SCIM_USER_SCHEMA], id: r.id, userName: r.userName,
|
|
1222
|
+
emails: [{ primary: true, value: r.userName, type: 'work' }],
|
|
1223
|
+
active: r.active !== false,
|
|
1224
|
+
name: { givenName: r.name?.givenName ?? 'N/A', familyName: r.name?.familyName ?? 'N/A' },
|
|
1225
|
+
meta: { resourceType: 'User' },
|
|
1226
|
+
sentryOrgRole: r.sentryOrgRole ?? 'member',
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
function scimGroupView(r) {
|
|
1230
|
+
return {
|
|
1231
|
+
schemas: [SCIM_GROUP_SCHEMA], id: r.id, displayName: r.displayName,
|
|
1232
|
+
members: Array.isArray(r.members) ? r.members : [],
|
|
1233
|
+
meta: { resourceType: 'Group' },
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
function scimList(schemaItems) {
|
|
1237
|
+
return { schemas: [SCIM_LIST_SCHEMA], totalResults: schemaItems.length, startIndex: 1, itemsPerPage: schemaItems.length, Resources: schemaItems };
|
|
1238
|
+
}
|
|
1239
|
+
async function scimApi(method, seg, req) {
|
|
1240
|
+
const root = req.root;
|
|
1241
|
+
const orgSlug = seg[1];
|
|
1242
|
+
if (!rows('organization', root).some((o) => o.slug === orgSlug && !o.deleted))
|
|
1243
|
+
return scimError(404, 'Organization not found');
|
|
1244
|
+
const resource = seg[4]; // 'Users' | 'Groups'
|
|
1245
|
+
// ---- Users (org members) ----
|
|
1246
|
+
if (resource === 'Users') {
|
|
1247
|
+
if (seg.length === 5 && method === 'GET') {
|
|
1248
|
+
const items = rows('scim_user', root).filter((u) => u.organizationSlug === orgSlug && !u.deleted).map(scimUserView);
|
|
1249
|
+
return { status: 200, body: scimList(items) };
|
|
1250
|
+
}
|
|
1251
|
+
if (seg.length === 5 && method === 'POST') {
|
|
1252
|
+
const b = body(req);
|
|
1253
|
+
const userName = typeof b.userName === 'string' ? b.userName.trim() : '';
|
|
1254
|
+
if (!userName)
|
|
1255
|
+
return scimError(400, 'userName is required');
|
|
1256
|
+
if (rows('scim_user', root).some((u) => u.organizationSlug === orgSlug && u.userName === userName && !u.deleted)) {
|
|
1257
|
+
return scimError(409, 'User already exists in the database.');
|
|
1258
|
+
}
|
|
1259
|
+
const id = `scimuser_${rows('scim_user', root).length + 1}`;
|
|
1260
|
+
const r = await writeResource('scim_user', id, {
|
|
1261
|
+
organizationSlug: orgSlug, userName,
|
|
1262
|
+
name: (b.name && typeof b.name === 'object') ? b.name : { givenName: 'N/A', familyName: 'N/A' },
|
|
1263
|
+
active: b.active !== false, sentryOrgRole: b.sentryOrgRole ?? 'member',
|
|
1264
|
+
}, 'scim_user.create', root, req.occurredAt);
|
|
1265
|
+
return { status: 201, body: scimUserView(r) };
|
|
1266
|
+
}
|
|
1267
|
+
if (seg.length === 6) {
|
|
1268
|
+
const u = rows('scim_user', root).find((x) => x.id === seg[5] && x.organizationSlug === orgSlug && !x.deleted);
|
|
1269
|
+
if (!u)
|
|
1270
|
+
return scimError(404, 'User not found');
|
|
1271
|
+
if (method === 'GET')
|
|
1272
|
+
return { status: 200, body: scimUserView(u) };
|
|
1273
|
+
if (method === 'PATCH') {
|
|
1274
|
+
// Okta deactivation: Operations: [{ op:'replace', value:{ active:false } }]
|
|
1275
|
+
const b = body(req);
|
|
1276
|
+
const ops = Array.isArray(b.Operations) ? b.Operations : [];
|
|
1277
|
+
let active = u.active !== false;
|
|
1278
|
+
for (const op of ops) {
|
|
1279
|
+
const val = (op && typeof op === 'object') ? op.value : undefined;
|
|
1280
|
+
if (val && typeof val === 'object' && 'active' in val)
|
|
1281
|
+
active = !!val.active;
|
|
1282
|
+
else if (typeof val === 'boolean')
|
|
1283
|
+
active = val;
|
|
1284
|
+
}
|
|
1285
|
+
const r = await writeResource('scim_user', u.id, { active }, 'scim_user.update', root, req.occurredAt);
|
|
1286
|
+
return { status: 200, body: scimUserView(r) };
|
|
1287
|
+
}
|
|
1288
|
+
if (method === 'DELETE') {
|
|
1289
|
+
await writeResource('scim_user', u.id, { deleted: true }, 'scim_user.delete', root, req.occurredAt);
|
|
1290
|
+
return { status: 204, body: null };
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
// ---- Groups (teams) ----
|
|
1295
|
+
if (resource === 'Groups') {
|
|
1296
|
+
if (seg.length === 5 && method === 'GET') {
|
|
1297
|
+
const items = rows('scim_group', root).filter((g) => g.organizationSlug === orgSlug && !g.deleted).map(scimGroupView);
|
|
1298
|
+
return { status: 200, body: scimList(items) };
|
|
1299
|
+
}
|
|
1300
|
+
if (seg.length === 5 && method === 'POST') {
|
|
1301
|
+
const b = body(req);
|
|
1302
|
+
const displayName = typeof b.displayName === 'string' ? b.displayName.trim() : '';
|
|
1303
|
+
if (!displayName)
|
|
1304
|
+
return scimError(400, 'displayName is required');
|
|
1305
|
+
const id = `scimgroup_${rows('scim_group', root).length + 1}`;
|
|
1306
|
+
const r = await writeResource('scim_group', id, {
|
|
1307
|
+
organizationSlug: orgSlug, displayName, members: Array.isArray(b.members) ? b.members : [],
|
|
1308
|
+
}, 'scim_group.create', root, req.occurredAt);
|
|
1309
|
+
return { status: 201, body: scimGroupView(r) };
|
|
1310
|
+
}
|
|
1311
|
+
if (seg.length === 6) {
|
|
1312
|
+
const g = rows('scim_group', root).find((x) => x.id === seg[5] && x.organizationSlug === orgSlug && !x.deleted);
|
|
1313
|
+
if (!g)
|
|
1314
|
+
return scimError(404, 'Group not found');
|
|
1315
|
+
if (method === 'GET')
|
|
1316
|
+
return { status: 200, body: scimGroupView(g) };
|
|
1317
|
+
if (method === 'DELETE') {
|
|
1318
|
+
await writeResource('scim_group', g.id, { deleted: true }, 'scim_group.delete', root, req.occurredAt);
|
|
1319
|
+
return { status: 204, body: null };
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
return scimError(404, 'Not found');
|
|
1324
|
+
}
|
|
1325
|
+
// ── Views + helpers ────────────────────────────────────────────────────────────────
|
|
1326
|
+
function orgView(r) {
|
|
1327
|
+
const { type: _t, updatedAt: _u, deleted, ...rest } = r;
|
|
1328
|
+
return { ...rest, id: r.id };
|
|
1329
|
+
}
|
|
1330
|
+
function teamView(r) {
|
|
1331
|
+
const { type: _t, updatedAt: _u, deleted, ...rest } = r;
|
|
1332
|
+
return { ...rest, id: r.id };
|
|
1333
|
+
}
|
|
1334
|
+
function memberView(r) {
|
|
1335
|
+
const { type: _t, updatedAt: _u, deleted, organizationSlug: _o, ...rest } = r;
|
|
1336
|
+
return { ...rest, id: r.id, user: r.pending ? null : { email: r.email, username: r.email } };
|
|
1337
|
+
}
|
|
1338
|
+
function keyView(r, req) {
|
|
1339
|
+
const { type: _t, updatedAt: _u, deleted, public: pub, secret, projectId, ...rest } = r;
|
|
1340
|
+
// Sentry returns the DSN strings alongside the key.
|
|
1341
|
+
const host = req.headers?.host ?? '127.0.0.1';
|
|
1342
|
+
const dsnHost = String(host);
|
|
1343
|
+
return {
|
|
1344
|
+
...rest, id: r.id, public: pub, secret, projectId, isActive: r.isActive ?? true,
|
|
1345
|
+
rateLimit: r.rateLimit ?? null,
|
|
1346
|
+
dsn: {
|
|
1347
|
+
public: `http://${pub}@${dsnHost}/${projectId}`,
|
|
1348
|
+
secret: `http://${pub}:${secret}@${dsnHost}/${projectId}`,
|
|
1349
|
+
csp: `http://${dsnHost}/api/${projectId}/csp-report/?sentry_key=${pub}`,
|
|
1350
|
+
security: `http://${dsnHost}/api/${projectId}/security/?sentry_key=${pub}`,
|
|
1351
|
+
minidump: `http://${dsnHost}/api/${projectId}/minidump/?sentry_key=${pub}`,
|
|
1352
|
+
},
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
function releaseView(r) {
|
|
1356
|
+
const { type: _t, updatedAt: _u, deleted, ...rest } = r;
|
|
1357
|
+
return { ...rest, id: r.version, shortVersion: String(r.version).slice(0, 12) };
|
|
1358
|
+
}
|
|
1359
|
+
function deployView(r) {
|
|
1360
|
+
const { type: _t, updatedAt: _u, deleted, ...rest } = r;
|
|
1361
|
+
return { ...rest, id: r.id };
|
|
1362
|
+
}
|
|
1363
|
+
function releaseFileView(r) {
|
|
1364
|
+
const { type: _t, updatedAt: _u, deleted, ...rest } = r;
|
|
1365
|
+
return { ...rest, id: r.id };
|
|
1366
|
+
}
|
|
1367
|
+
// Aggregate persisted sessions into Sentry's session-stats envelope. Crash-free rate and the
|
|
1368
|
+
// status breakdown are EXACT tallies (status ∈ ok|errored|crashed|abnormal), grouped by
|
|
1369
|
+
// release. The series buckets (`intervals`/`series`) are left empty — a time-series rollup
|
|
1370
|
+
// engine is a filed todo; the totals are real.
|
|
1371
|
+
function sessionMetrics(sessions, params) {
|
|
1372
|
+
const groupBy = params.getAll('groupBy').concat(params.getAll('groupBy[]'));
|
|
1373
|
+
const tally = (subset) => {
|
|
1374
|
+
const total = subset.length;
|
|
1375
|
+
const crashed = subset.filter((s) => s.status === 'crashed').length;
|
|
1376
|
+
const errored = subset.filter((s) => s.status === 'errored').length;
|
|
1377
|
+
const abnormal = subset.filter((s) => s.status === 'abnormal').length;
|
|
1378
|
+
const healthy = total - crashed - errored - abnormal;
|
|
1379
|
+
return {
|
|
1380
|
+
'sum(session)': total,
|
|
1381
|
+
'count_unique(user)': new Set(subset.map((s) => String(s.id))).size,
|
|
1382
|
+
crashed, errored, abnormal, healthy,
|
|
1383
|
+
crashFreeRate: total === 0 ? 1 : 1 - crashed / total,
|
|
1384
|
+
};
|
|
1385
|
+
};
|
|
1386
|
+
let groups;
|
|
1387
|
+
if (groupBy.includes('release') || groupBy.includes('session.status')) {
|
|
1388
|
+
const byRelease = new Map();
|
|
1389
|
+
for (const s of sessions) {
|
|
1390
|
+
const rel = String(s.release ?? '');
|
|
1391
|
+
const arr = byRelease.get(rel) ?? [];
|
|
1392
|
+
arr.push(s);
|
|
1393
|
+
byRelease.set(rel, arr);
|
|
1394
|
+
}
|
|
1395
|
+
groups = [...byRelease.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([release, subset]) => {
|
|
1396
|
+
const t = tally(subset);
|
|
1397
|
+
return { by: { release }, totals: t, series: {} };
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
else {
|
|
1401
|
+
groups = [{ by: {}, totals: tally(sessions), series: {} }];
|
|
1402
|
+
}
|
|
1403
|
+
return {
|
|
1404
|
+
start: params.get('start') ?? null, end: params.get('end') ?? null,
|
|
1405
|
+
intervals: [], // time-series rollup engine is a filed todo; totals above are real tallies
|
|
1406
|
+
groups,
|
|
1407
|
+
};
|
|
1408
|
+
}
|
|
1409
|
+
function alertView(r) {
|
|
1410
|
+
const { type: _t, updatedAt: _u, deleted, secret, ...rest } = r;
|
|
1411
|
+
return { ...rest, id: r.id }; // secret omitted from the API response (Sentry hides it)
|
|
1412
|
+
}
|
|
1413
|
+
function integrationView(r) {
|
|
1414
|
+
const { type: _t, updatedAt: _u, deleted, organizationSlug: _o, ...rest } = r;
|
|
1415
|
+
return { ...rest, id: r.id };
|
|
1416
|
+
}
|
|
1417
|
+
function metricAlertView(r) {
|
|
1418
|
+
const { type: _t, updatedAt: _u, deleted, organizationSlug: _o, ...rest } = r;
|
|
1419
|
+
return { ...rest, id: r.id };
|
|
1420
|
+
}
|
|
1421
|
+
function dashboardView(r) {
|
|
1422
|
+
const { type: _t, updatedAt: _u, deleted, organizationSlug: _o, ...rest } = r;
|
|
1423
|
+
return { ...rest, id: r.id };
|
|
1424
|
+
}
|
|
1425
|
+
function discoverView(r) {
|
|
1426
|
+
const { type: _t, updatedAt: _u, deleted, organizationSlug: _o, ...rest } = r;
|
|
1427
|
+
return { ...rest, id: r.id };
|
|
1428
|
+
}
|
|
1429
|
+
// Normalize a dashboard's widgets into Sentry's widget shape ({ title, displayType, queries }).
|
|
1430
|
+
const WIDGET_DISPLAY_TYPES = ['line', 'area', 'bar', 'table', 'big_number', 'top_n'];
|
|
1431
|
+
function normalizeWidgets(raw) {
|
|
1432
|
+
if (raw === undefined)
|
|
1433
|
+
return { widgets: [] };
|
|
1434
|
+
if (!Array.isArray(raw))
|
|
1435
|
+
return { error: 'widgets must be an array' };
|
|
1436
|
+
const out = [];
|
|
1437
|
+
for (const w of raw) {
|
|
1438
|
+
if (!w || typeof w !== 'object')
|
|
1439
|
+
return { error: 'each widget must be an object' };
|
|
1440
|
+
const widget = w;
|
|
1441
|
+
if (!widget.title || typeof widget.title !== 'string')
|
|
1442
|
+
return { error: 'widget.title is required' };
|
|
1443
|
+
const displayType = String(widget.displayType ?? 'line');
|
|
1444
|
+
if (!WIDGET_DISPLAY_TYPES.includes(displayType))
|
|
1445
|
+
return { error: `widget.displayType must be one of ${WIDGET_DISPLAY_TYPES.join(', ')}` };
|
|
1446
|
+
out.push({
|
|
1447
|
+
title: widget.title, displayType,
|
|
1448
|
+
queries: Array.isArray(widget.queries) ? widget.queries : [{ name: '', fields: ['count()'], conditions: '', orderby: '' }],
|
|
1449
|
+
interval: widget.interval ?? '5m',
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
return { widgets: out };
|
|
1453
|
+
}
|
|
1454
|
+
// Sentry's metric-alert action targets. DELIVERY (email/Slack/Pagerduty notification sending)
|
|
1455
|
+
// is a filed todo — but the action TYPE catalog + per-type required fields are validated faithfully.
|
|
1456
|
+
const ALERT_ACTION_TYPES = {
|
|
1457
|
+
'sentry.mail.actions.NotifyEmailAction': { requires: ['targetType'] },
|
|
1458
|
+
'sentry.integrations.slack.notify_action.SlackNotifyServiceAction': { requires: ['channel'] },
|
|
1459
|
+
'sentry.integrations.pagerduty.notify_action.PagerDutyNotifyServiceAction': { requires: ['service'] },
|
|
1460
|
+
'sentry.integrations.msteams.notify_action.MsTeamsNotifyServiceAction': { requires: ['channel'] },
|
|
1461
|
+
'sentry.rules.actions.notify_event.NotifyEventAction': {},
|
|
1462
|
+
'sentry.rules.actions.notify_event_service.NotifyEventServiceAction': {},
|
|
1463
|
+
};
|
|
1464
|
+
function normalizeAlertActions(raw) {
|
|
1465
|
+
if (raw === undefined)
|
|
1466
|
+
return { actions: [] };
|
|
1467
|
+
if (!Array.isArray(raw))
|
|
1468
|
+
return { error: 'actions must be an array' };
|
|
1469
|
+
const out = [];
|
|
1470
|
+
for (const a of raw) {
|
|
1471
|
+
if (!a || typeof a !== 'object')
|
|
1472
|
+
return { error: 'each action must be an object' };
|
|
1473
|
+
const act = a;
|
|
1474
|
+
const id = String(act.id ?? '');
|
|
1475
|
+
const spec = ALERT_ACTION_TYPES[id];
|
|
1476
|
+
if (!spec)
|
|
1477
|
+
return { error: `Unknown alert action: ${id || '(missing id)'}` };
|
|
1478
|
+
for (const req of spec.requires ?? []) {
|
|
1479
|
+
if (act[req] === undefined || act[req] === null || act[req] === '')
|
|
1480
|
+
return { error: `action ${id} requires ${req}` };
|
|
1481
|
+
}
|
|
1482
|
+
out.push(act);
|
|
1483
|
+
}
|
|
1484
|
+
return { actions: out };
|
|
1485
|
+
}
|
|
1486
|
+
// Validate + normalize a metric-alert rule body into the stored fields. Faithful to Sentry's
|
|
1487
|
+
// shape: aggregate + dataset + query + triggers[{ label, alertThreshold, actions }] +
|
|
1488
|
+
// thresholdType (0=above, 1=below) + a time window. Required fields are failable.
|
|
1489
|
+
function validateMetricAlert(b, isUpdate = false) {
|
|
1490
|
+
const f = {};
|
|
1491
|
+
if (!isUpdate || b.name !== undefined) {
|
|
1492
|
+
if (!b.name || typeof b.name !== 'string')
|
|
1493
|
+
return { error: 'name is required' };
|
|
1494
|
+
f.name = b.name;
|
|
1495
|
+
}
|
|
1496
|
+
else
|
|
1497
|
+
f.name = b.name;
|
|
1498
|
+
const aggregate = b.aggregate ?? 'count()';
|
|
1499
|
+
f.aggregate = String(aggregate);
|
|
1500
|
+
f.dataset = ['events', 'transactions', 'metrics', 'sessions', 'generic_metrics'].includes(String(b.dataset)) ? String(b.dataset) : 'events';
|
|
1501
|
+
f.query = typeof b.query === 'string' ? b.query : '';
|
|
1502
|
+
const window = b.timeWindow ?? b.time_window;
|
|
1503
|
+
if (window !== undefined && (!Number.isFinite(Number(window)) || Number(window) <= 0))
|
|
1504
|
+
return { error: 'timeWindow must be a positive number of minutes' };
|
|
1505
|
+
f.timeWindow = window !== undefined ? Number(window) : 60;
|
|
1506
|
+
const thresholdType = b.thresholdType ?? b.threshold_type ?? 0;
|
|
1507
|
+
if (![0, 1].includes(Number(thresholdType)))
|
|
1508
|
+
return { error: 'thresholdType must be 0 (above) or 1 (below)' };
|
|
1509
|
+
f.thresholdType = Number(thresholdType);
|
|
1510
|
+
const triggers = b.triggers;
|
|
1511
|
+
if (!Array.isArray(triggers) || triggers.length === 0) {
|
|
1512
|
+
if (!isUpdate)
|
|
1513
|
+
return { error: 'at least one trigger is required' };
|
|
1514
|
+
f.triggers = Array.isArray(b.triggers) ? b.triggers : [];
|
|
1515
|
+
}
|
|
1516
|
+
else {
|
|
1517
|
+
const norm = [];
|
|
1518
|
+
for (const t of triggers) {
|
|
1519
|
+
if (!t || typeof t !== 'object')
|
|
1520
|
+
return { error: 'each trigger must be an object' };
|
|
1521
|
+
const tr = t;
|
|
1522
|
+
const label = String(tr.label ?? '');
|
|
1523
|
+
if (!['critical', 'warning'].includes(label))
|
|
1524
|
+
return { error: 'trigger.label must be "critical" or "warning"' };
|
|
1525
|
+
if (tr.alertThreshold === undefined || !Number.isFinite(Number(tr.alertThreshold)))
|
|
1526
|
+
return { error: 'trigger.alertThreshold (a number) is required' };
|
|
1527
|
+
const acts = normalizeAlertActions(tr.actions);
|
|
1528
|
+
if (acts.error)
|
|
1529
|
+
return { error: acts.error };
|
|
1530
|
+
norm.push({ label, alertThreshold: Number(tr.alertThreshold), actions: acts.actions });
|
|
1531
|
+
}
|
|
1532
|
+
if (!norm.some((t) => t.label === 'critical'))
|
|
1533
|
+
return { error: 'a "critical" trigger is required' };
|
|
1534
|
+
f.triggers = norm;
|
|
1535
|
+
}
|
|
1536
|
+
f.projects = Array.isArray(b.projects) ? b.projects.map(String) : [];
|
|
1537
|
+
f.status = 0; // 0 = unresolved/active; live evaluation (firing) needs the metrics engine (a filed todo)
|
|
1538
|
+
return { fields: f };
|
|
1539
|
+
}
|
|
1540
|
+
// The issue's activity feed, derived from the ENTRIES that touched that issue subject. Protocol 2: a
|
|
1541
|
+
// serve path takes history through `subjectHistory` — the entries the head holds for one subject, parent
|
|
1542
|
+
// and branch together — never by reading the v1 action log.
|
|
1543
|
+
function issueActivity(issueId, root) {
|
|
1544
|
+
const out = [];
|
|
1545
|
+
const actions = subjectHistory(SERVICE, { type: 'issue', id: issueId }, root);
|
|
1546
|
+
for (const a of actions) {
|
|
1547
|
+
const f = a.fields ?? {};
|
|
1548
|
+
let type;
|
|
1549
|
+
const data = {};
|
|
1550
|
+
if (a.operation === 'issue.create')
|
|
1551
|
+
type = 'first_seen';
|
|
1552
|
+
else if (a.operation === 'issue.merge') {
|
|
1553
|
+
type = 'merge';
|
|
1554
|
+
if (f.mergedInto)
|
|
1555
|
+
data.mergedInto = f.mergedInto;
|
|
1556
|
+
}
|
|
1557
|
+
else if (f.status !== undefined) {
|
|
1558
|
+
type = f.status === 'resolved' ? 'set_resolved' : f.status === 'ignored' ? 'set_ignored' : 'set_unresolved';
|
|
1559
|
+
}
|
|
1560
|
+
else if (f.assignedTo !== undefined) {
|
|
1561
|
+
type = 'assigned';
|
|
1562
|
+
data.assignee = f.assignedTo;
|
|
1563
|
+
}
|
|
1564
|
+
if (!type)
|
|
1565
|
+
continue;
|
|
1566
|
+
out.push({ id: a.id, type, dateCreated: a.occurredAt, user: a.actor?.kind === 'system' ? null : { id: a.actor?.id ?? null }, data });
|
|
1567
|
+
}
|
|
1568
|
+
// newest first (Sentry's activity feed order)
|
|
1569
|
+
return out.reverse();
|
|
1570
|
+
}
|
|
1571
|
+
// Aggregate the issue's events into Sentry's tag-distribution shape:
|
|
1572
|
+
// [{ key, name, uniqueValues, totalValues, topValues: [{ value, count, name }] }]
|
|
1573
|
+
// Computed live from the persisted events for the issue (the same events the API serves).
|
|
1574
|
+
function issueTagDistribution(issueId, root) {
|
|
1575
|
+
const evs = rows('event', root).filter((e) => e.groupID === issueId && !e.deleted);
|
|
1576
|
+
// key → (value → count)
|
|
1577
|
+
const byKey = new Map();
|
|
1578
|
+
for (const ev of evs) {
|
|
1579
|
+
const tags = Array.isArray(ev.tags) ? ev.tags : [];
|
|
1580
|
+
// an event always carries an implicit `level` tag (Sentry does this)
|
|
1581
|
+
const pairs = tags
|
|
1582
|
+
.filter((t) => t && t.key != null)
|
|
1583
|
+
.map((t) => [String(t.key), String(t.value ?? '')]);
|
|
1584
|
+
if (ev.level != null)
|
|
1585
|
+
pairs.push(['level', String(ev.level)]);
|
|
1586
|
+
for (const [k, v] of pairs) {
|
|
1587
|
+
const m = byKey.get(k) ?? new Map();
|
|
1588
|
+
m.set(v, (m.get(v) ?? 0) + 1);
|
|
1589
|
+
byKey.set(k, m);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
return [...byKey.entries()]
|
|
1593
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
1594
|
+
.map(([key, values]) => {
|
|
1595
|
+
const total = [...values.values()].reduce((s, n) => s + n, 0);
|
|
1596
|
+
const topValues = [...values.entries()]
|
|
1597
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
1598
|
+
.map(([value, count]) => ({ value, name: value, count, key }));
|
|
1599
|
+
return { key, name: key, uniqueValues: values.size, totalValues: total, topValues };
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
function attachmentView(r) {
|
|
1603
|
+
const { type: _t, updatedAt: _u, deleted, ...rest } = r;
|
|
1604
|
+
return { ...rest, id: r.id };
|
|
1605
|
+
}
|
|
1606
|
+
// Sentry's user-feedback object (the crash-report dialog submission shape).
|
|
1607
|
+
function userFeedbackView(r, root) {
|
|
1608
|
+
const ev = rows('event', root).find((e) => e.id === r.eventID);
|
|
1609
|
+
return {
|
|
1610
|
+
id: r.id, eventID: r.eventID, name: r.name, email: r.email, comments: r.comments,
|
|
1611
|
+
dateCreated: r.dateCreated,
|
|
1612
|
+
event: { eventID: r.eventID, id: ev ? ev.id : null },
|
|
1613
|
+
issue: r.issueId ?? null, user: null,
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
// Org-wide Discover event facets: each tag key → its top tag values across the org's events,
|
|
1617
|
+
// in Sentry's `events-facets` shape (`{ key, name, topValues: [{ name, value, count, ... }] }`).
|
|
1618
|
+
function orgEventFacets(orgSlug, root) {
|
|
1619
|
+
const projIds = new Set(rows('project', root).filter((p) => p.organizationSlug === orgSlug && !p.deleted).map((p) => p.id));
|
|
1620
|
+
const evs = rows('event', root).filter((e) => projIds.has(String(e.projectID)) && !e.deleted);
|
|
1621
|
+
const byKey = new Map();
|
|
1622
|
+
for (const ev of evs) {
|
|
1623
|
+
const tags = Array.isArray(ev.tags) ? ev.tags : [];
|
|
1624
|
+
const pairs = tags.filter((t) => t && t.key != null).map((t) => [String(t.key), String(t.value ?? '')]);
|
|
1625
|
+
if (ev.level != null)
|
|
1626
|
+
pairs.push(['level', String(ev.level)]);
|
|
1627
|
+
for (const [k, v] of pairs) {
|
|
1628
|
+
const m = byKey.get(k) ?? new Map();
|
|
1629
|
+
m.set(v, (m.get(v) ?? 0) + 1);
|
|
1630
|
+
byKey.set(k, m);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
return [...byKey.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([key, values]) => {
|
|
1634
|
+
const total = [...values.values()].reduce((s, n) => s + n, 0);
|
|
1635
|
+
const topValues = [...values.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
1636
|
+
.map(([value, count]) => ({ name: value, value, count, key }));
|
|
1637
|
+
return { key, name: key, totalValues: total, topValues };
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
// Files changed across a release's commits, in Sentry's commitfiles shape. Each modeled commit
|
|
1641
|
+
// may carry `patch_set: [{ path, type }]` (type ∈ A/M/D); commitfiles flattens those.
|
|
1642
|
+
function releaseCommitFiles(rel, _root) {
|
|
1643
|
+
const commits = Array.isArray(rel.commits) ? rel.commits : [];
|
|
1644
|
+
const out = [];
|
|
1645
|
+
let n = 0;
|
|
1646
|
+
for (const c of commits) {
|
|
1647
|
+
const author = (c.author && typeof c.author === 'object') ? c.author : {};
|
|
1648
|
+
const repoName = String(c.repository ?? c.repo ?? '');
|
|
1649
|
+
const patchSet = Array.isArray(c.patch_set) ? c.patch_set : [];
|
|
1650
|
+
for (const ps of patchSet) {
|
|
1651
|
+
out.push({
|
|
1652
|
+
id: `cf_${++n}`, filename: String(ps.path ?? ''), type: String(ps.type ?? 'M'),
|
|
1653
|
+
repoName, author: { name: author.name ?? null, email: author.email ?? null },
|
|
1654
|
+
commitMessage: c.message ?? null,
|
|
1655
|
+
});
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
return out;
|
|
1659
|
+
}
|
|
1660
|
+
// Sentry's inbound data filters — the fixed set of built-in filters every project exposes.
|
|
1661
|
+
const INBOUND_FILTERS = [
|
|
1662
|
+
{ id: 'browser-extensions', name: 'Filter out errors known to be caused by browser extensions', description: 'browser-extensions' },
|
|
1663
|
+
{ id: 'legacy-browsers', name: 'Filter out events from legacy browsers', description: 'legacy-browsers' },
|
|
1664
|
+
{ id: 'localhost', name: 'Filter out events coming from localhost', description: 'localhost' },
|
|
1665
|
+
{ id: 'web-crawlers', name: 'Filter out known web crawlers', description: 'web-crawlers' },
|
|
1666
|
+
{ id: 'filtered-transaction', name: 'Filter out health check transactions', description: 'filtered-transaction' },
|
|
1667
|
+
];
|
|
1668
|
+
/** The project's inbound filters: the fixed catalog merged with persisted active state. */
|
|
1669
|
+
function projectFilters(projectId, root) {
|
|
1670
|
+
const state = rows('project_filter', root).filter((f) => f.projectId === projectId && !f.deleted);
|
|
1671
|
+
return INBOUND_FILTERS.map((f) => {
|
|
1672
|
+
const s = state.find((x) => x.filterId === f.id);
|
|
1673
|
+
return { id: f.id, name: f.name, description: f.description, active: s ? s.active ?? false : false };
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
// Project ownership rule view (and its empty default). Mirrors Sentry's ownership object.
|
|
1677
|
+
function ownershipView(r, projectId) {
|
|
1678
|
+
return {
|
|
1679
|
+
raw: r?.raw ?? null,
|
|
1680
|
+
fallthrough: r?.fallthrough ?? true,
|
|
1681
|
+
autoAssignment: r?.autoAssignment ?? 'Auto Assign to Suspect Commits',
|
|
1682
|
+
codeownersAutoSync: r?.codeownersAutoSync ?? true,
|
|
1683
|
+
schema: r?.schema ?? { $version: 1, rules: [] },
|
|
1684
|
+
dateCreated: r?.dateCreated ?? null,
|
|
1685
|
+
lastUpdated: r?.updatedAt ?? null,
|
|
1686
|
+
isActive: !!(r?.raw),
|
|
1687
|
+
projectId,
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
function codeownersView(r) {
|
|
1691
|
+
const { type: _t, updatedAt: _u, deleted, ...rest } = r;
|
|
1692
|
+
return { ...rest, id: r.id };
|
|
1693
|
+
}
|
|
1694
|
+
// Parse a Sentry ownership/CODEOWNERS `raw` block into the {$version,rules:[{matcher,owners}]}
|
|
1695
|
+
// schema. Each non-comment line is `<matcher-type>:<pattern> <owner> [owner...]`. Matcher types:
|
|
1696
|
+
// `path:`, `url:`, `module:`, `tags.<key>:`, or a bare CODEOWNERS glob (treated as codeowners).
|
|
1697
|
+
function parseOwnershipRaw(raw) {
|
|
1698
|
+
const rules = [];
|
|
1699
|
+
for (const rawLine of raw.split('\n')) {
|
|
1700
|
+
const line = rawLine.trim();
|
|
1701
|
+
if (!line || line.startsWith('#'))
|
|
1702
|
+
continue;
|
|
1703
|
+
const parts = line.split(/\s+/);
|
|
1704
|
+
const matcherToken = parts[0];
|
|
1705
|
+
const owners = parts.slice(1).filter(Boolean);
|
|
1706
|
+
if (owners.length === 0)
|
|
1707
|
+
return { error: `Invalid rule (no owners): ${line}` };
|
|
1708
|
+
let type = 'codeowners';
|
|
1709
|
+
let pattern = matcherToken;
|
|
1710
|
+
const colon = matcherToken.indexOf(':');
|
|
1711
|
+
if (colon > 0) {
|
|
1712
|
+
const t = matcherToken.slice(0, colon);
|
|
1713
|
+
if (t === 'path' || t === 'url' || t === 'module' || t.startsWith('tags.')) {
|
|
1714
|
+
type = t;
|
|
1715
|
+
pattern = matcherToken.slice(colon + 1);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
rules.push({
|
|
1719
|
+
matcher: { type, pattern },
|
|
1720
|
+
owners: owners.map((o) => ({ type: o.startsWith('#') ? 'team' : 'user', identifier: o.replace(/^[#@]/, '') })),
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
return { schema: { $version: 1, rules } };
|
|
1724
|
+
}
|
|
1725
|
+
function slugify(s) {
|
|
1726
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'untitled';
|
|
1727
|
+
}
|
|
1728
|
+
/** Parse a JSON request body into an object (Sentry's Web API is JSON). */
|
|
1729
|
+
function body(req) {
|
|
1730
|
+
if (!req.body)
|
|
1731
|
+
return {};
|
|
1732
|
+
try {
|
|
1733
|
+
const v = JSON.parse(req.body);
|
|
1734
|
+
return v && typeof v === 'object' ? v : {};
|
|
1735
|
+
}
|
|
1736
|
+
catch {
|
|
1737
|
+
return {};
|
|
1738
|
+
}
|
|
1739
|
+
}
|