@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,110 @@
|
|
|
1
|
+
// Sentry twin conformance — offline shape conformance against Sentry's REST response
|
|
2
|
+
// shapes. Sentry publishes no single machine-readable OpenAPI we can vendor cleanly, so
|
|
3
|
+
// (like the rest of the suite's offline checks) we drive REAL requests through the twin and
|
|
4
|
+
// validate each served object against an authored per-resource REQUIRED-FIELD spec drawn
|
|
5
|
+
// from Sentry's API docs. A served object missing a required Sentry field — or the ingest→
|
|
6
|
+
// grouping invariant breaking — fails the report. This is offline + deterministic (no token).
|
|
7
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { handleSentryTwinRequest, type SentryResponse } from './sentry-twin.ts';
|
|
11
|
+
import { DEFAULT_ORG, DEFAULT_PROJECT, DEFAULT_PROJECT_ID, DEFAULT_PUBLIC_KEY } from './sentry-ingest.ts';
|
|
12
|
+
|
|
13
|
+
// The Sentry fields each served object MUST carry (from Sentry's API reference). A missing
|
|
14
|
+
// field is a conformance violation; extra twin-internal `_`-prefixed fields are exempt.
|
|
15
|
+
const REQUIRED_FIELDS: Record<string, string[]> = {
|
|
16
|
+
issue: ['id', 'shortId', 'title', 'culprit', 'level', 'status', 'count', 'firstSeen', 'lastSeen', 'permalink', 'metadata'],
|
|
17
|
+
event: ['id', 'eventID', 'groupID', 'projectID', 'title', 'message', 'dateCreated', 'tags', 'entries'],
|
|
18
|
+
project: ['id', 'slug', 'name', 'platform'],
|
|
19
|
+
project_key: ['id', 'public', 'secret', 'projectId', 'dsn', 'isActive'],
|
|
20
|
+
organization: ['id', 'slug', 'name'],
|
|
21
|
+
team: ['id', 'slug', 'name'],
|
|
22
|
+
release: ['id', 'version', 'shortVersion', 'dateCreated'],
|
|
23
|
+
deploy: ['id', 'environment', 'release'],
|
|
24
|
+
alert_rule: ['id', 'name', 'triggers'],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type SentryViolation = { object: string; id: string; field: string; reason: string };
|
|
28
|
+
export type SentryConformanceReport = {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
resourcesChecked: number;
|
|
31
|
+
fieldsChecked: number;
|
|
32
|
+
violations: SentryViolation[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function checkObject(object: string, body: Record<string, unknown>): { fieldsChecked: number; violations: SentryViolation[] } {
|
|
36
|
+
const required = REQUIRED_FIELDS[object] ?? [];
|
|
37
|
+
const violations: SentryViolation[] = [];
|
|
38
|
+
const id = String(body.id ?? '?');
|
|
39
|
+
for (const f of required) {
|
|
40
|
+
if (!(f in body) || body[f] === undefined) {
|
|
41
|
+
violations.push({ object, id, field: f, reason: 'required Sentry field missing' });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { fieldsChecked: required.length, violations };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Drive a representative request flow through the twin and validate every served object's
|
|
49
|
+
* shape against the required-field spec, plus assert the ingest→grouping invariant. Offline,
|
|
50
|
+
* deterministic (a fresh temp root, fixed occurredAt).
|
|
51
|
+
*/
|
|
52
|
+
export async function checkSentryConformance(opts: { root?: string } = {}): Promise<SentryConformanceReport> {
|
|
53
|
+
const root = opts.root ?? mkdtempSync(join(tmpdir(), 'sentry-conf-'));
|
|
54
|
+
const own = !opts.root;
|
|
55
|
+
const occurredAt = '2026-01-01T00:00:00.000Z';
|
|
56
|
+
const h = (method: string, path: string, body?: unknown): Promise<SentryResponse> =>
|
|
57
|
+
handleSentryTwinRequest({ method, path, body: body === undefined ? undefined : JSON.stringify(body), root, occurredAt, headers: {} });
|
|
58
|
+
const ingest = (event: unknown): Promise<SentryResponse> =>
|
|
59
|
+
handleSentryTwinRequest({ method: 'POST', path: `/api/${DEFAULT_PROJECT_ID}/store/`, body: JSON.stringify(event), root, occurredAt, headers: { 'x-sentry-auth': `Sentry sentry_key=${DEFAULT_PUBLIC_KEY}` } });
|
|
60
|
+
|
|
61
|
+
const violations: SentryViolation[] = [];
|
|
62
|
+
let resourcesChecked = 0;
|
|
63
|
+
let fieldsChecked = 0;
|
|
64
|
+
const check = (object: string, body: unknown) => {
|
|
65
|
+
if (!body || typeof body !== 'object') return;
|
|
66
|
+
resourcesChecked++;
|
|
67
|
+
const r = checkObject(object, body as Record<string, unknown>);
|
|
68
|
+
fieldsChecked += r.fieldsChecked;
|
|
69
|
+
violations.push(...r.violations);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
// Ingest the same error twice → must group into ONE issue with count 2.
|
|
74
|
+
const err1 = { exception: { values: [{ type: 'TypeError', value: 'x is undefined' }] } };
|
|
75
|
+
const e1 = await ingest(err1);
|
|
76
|
+
const e2 = await ingest(err1);
|
|
77
|
+
const id1 = (e1.body as { id?: string }).id;
|
|
78
|
+
const id2 = (e2.body as { id?: string }).id;
|
|
79
|
+
|
|
80
|
+
const issuesRes = await h('GET', `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/issues/?query=`);
|
|
81
|
+
const issues = issuesRes.body as Array<Record<string, unknown>>;
|
|
82
|
+
if (!Array.isArray(issues) || issues.length !== 1) {
|
|
83
|
+
violations.push({ object: 'issue', id: '?', field: 'grouping', reason: `ingest→grouping invariant broken: expected 1 issue, got ${Array.isArray(issues) ? issues.length : 'non-array'}` });
|
|
84
|
+
} else {
|
|
85
|
+
const issue = issues[0]!;
|
|
86
|
+
if (String(issue.count) !== '2') violations.push({ object: 'issue', id: String(issue.id), field: 'count', reason: `grouping count expected 2, got ${issue.count}` });
|
|
87
|
+
if (!id1 || !id2 || id1 === id2) violations.push({ object: 'event', id: '?', field: 'eventID', reason: 'distinct event ids expected for two captures' });
|
|
88
|
+
check('issue', issue);
|
|
89
|
+
const evs = await h('GET', `/api/0/issues/${issue.id}/events/`);
|
|
90
|
+
check('event', (evs.body as unknown[])[0]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
check('project', (await h('GET', `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/`)).body);
|
|
94
|
+
check('project_key', ((await h('GET', `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/keys/`)).body as unknown[])[0]);
|
|
95
|
+
check('organization', (await h('GET', `/api/0/organizations/${DEFAULT_ORG}/`)).body);
|
|
96
|
+
check('team', (await h('POST', `/api/0/organizations/${DEFAULT_ORG}/teams/`, { name: 'Backend' })).body);
|
|
97
|
+
check('release', (await h('POST', `/api/0/organizations/${DEFAULT_ORG}/releases/`, { version: '1.0.0' })).body);
|
|
98
|
+
check('deploy', (await h('POST', `/api/0/organizations/${DEFAULT_ORG}/releases/1.0.0/deploys/`, { environment: 'production' })).body);
|
|
99
|
+
check('alert_rule', (await h('POST', `/api/0/projects/${DEFAULT_ORG}/${DEFAULT_PROJECT}/rules/`, { name: 'New issues', triggers: ['new-issue'], endpoints: ['https://x.test/hook'] })).body);
|
|
100
|
+
} finally {
|
|
101
|
+
if (own) rmSync(root, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { ok: violations.length === 0, resourcesChecked, fieldsChecked, violations };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Per-object coverage: required-field count covered vs. declared (the offline coverage view). */
|
|
108
|
+
export function sentryConformanceFields(): Record<string, number> {
|
|
109
|
+
return Object.fromEntries(Object.entries(REQUIRED_FIELDS).map(([k, v]) => [k, v.length]));
|
|
110
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// Sentry CONNECTOR — the live-vendor pull/push path that gives the Sentry twin the full
|
|
2
|
+
// "git for SaaS" lifecycle.
|
|
3
|
+
//
|
|
4
|
+
// PULL (real → twin): fetch real Sentry issues/projects/releases over the injected
|
|
5
|
+
// client, map → SyncResource[], fold into the event log via syncPull
|
|
6
|
+
// (shadow-diff dedup, so a re-pull of identical state appends nothing).
|
|
7
|
+
// PUSH (twin → real): for every PENDING local issue.update action (resolve/ignore/assign),
|
|
8
|
+
// PUT the real Sentry issue and confirmAction on success — which records
|
|
9
|
+
// the confirmed fields as an observed event and suppresses the local
|
|
10
|
+
// projection (the change is counted exactly once).
|
|
11
|
+
//
|
|
12
|
+
// The vendor I/O is an INJECTED executor (the auth-boundary): the kernel and this pack hold
|
|
13
|
+
// NO Sentry token and import NO network client. Offline/tests pass a fake executor; live runs
|
|
14
|
+
// pass `liveSentryExecute(token)`. Same code path either way.
|
|
15
|
+
import { assertBudgetGuardIntact, observeResources } from '@volter/world-core';
|
|
16
|
+
import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, TwinAction } from '@volter/world-core';
|
|
17
|
+
import { SentryBudget, SentryBudgetError, sentryCallWeight, type SentryBudgetOptions } from './sentry-budget.ts';
|
|
18
|
+
|
|
19
|
+
const SERVICE = 'sentry';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The injected real-Sentry boundary. `request` issues ONE Sentry Web API call:
|
|
23
|
+
* method — 'GET' | 'PUT' | 'POST' | 'DELETE'
|
|
24
|
+
* path — e.g. '/api/0/projects/org/proj/issues/' or '/api/0/issues/123/'
|
|
25
|
+
* body — JSON body for writes; omitted for GETs
|
|
26
|
+
* Returns the parsed JSON body (an object or array). A real client (a fetch wrapper around
|
|
27
|
+
* sentry.io with a bearer token) is structurally assignable; tests pass a fake.
|
|
28
|
+
*/
|
|
29
|
+
export type SentryExecute = (
|
|
30
|
+
method: 'GET' | 'PUT' | 'POST' | 'DELETE',
|
|
31
|
+
path: string,
|
|
32
|
+
body?: Record<string, unknown>,
|
|
33
|
+
) => Promise<any>;
|
|
34
|
+
|
|
35
|
+
/** Construction options for the live executor. `budget` cannot be null and cannot be loosened. */
|
|
36
|
+
export type LiveSentryOptions = {
|
|
37
|
+
/** Injected `fetch`, so a test can COUNT the requests the guard did or did not let through. */
|
|
38
|
+
fetchImpl?: typeof fetch;
|
|
39
|
+
/** An existing budget to share across executors. Omit and one is constructed. Cannot be null. */
|
|
40
|
+
budget?: SentryBudget;
|
|
41
|
+
/** Construction options for the default budget (ledger path, clock). Cannot loosen it. */
|
|
42
|
+
budgetOptions?: SentryBudgetOptions;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A live executor against real Sentry (token = the user's own auth token). Never imported
|
|
47
|
+
* by the pack's own runtime path — only constructed by a caller that opts into real I/O.
|
|
48
|
+
*
|
|
49
|
+
* THIS IS THE ONE PLACE this pack issues a live `sentry.io` request, and therefore the one place
|
|
50
|
+
* the rate budget has to be enforced. EVERY call is guarded: the budget is charged BEFORE the
|
|
51
|
+
* request goes out (`checkBudget`, which THROWS `SentryBudgetError` instead of returning when the
|
|
52
|
+
* ceiling or a cooldown says stop) and the response is fed back (`recordCall`) so a `Retry-After` /
|
|
53
|
+
* 429 / `X-Sentry-Rate-Limit-Remaining: 0` signal becomes a persisted cooldown that makes every
|
|
54
|
+
* later call fail fast WITHOUT touching Sentry. Sentry publishes no scalar limit, so that cooldown
|
|
55
|
+
* is the part that matters most — see `sentry-budget.ts`. There is deliberately no OPTION to disable the guard, and no value a caller can pass for `budget`
|
|
56
|
+
* that yields an unguarded client — though not immunity from a caller who wants one (a fresh
|
|
57
|
+
* `budgetOptions.path` or an injected clock restores the allowance; the kernel header says so).
|
|
58
|
+
*/
|
|
59
|
+
export function liveSentryExecute(
|
|
60
|
+
token: string,
|
|
61
|
+
base = 'https://sentry.io',
|
|
62
|
+
opts: LiveSentryOptions = {},
|
|
63
|
+
): SentryExecute {
|
|
64
|
+
// `null`/`undefined` (or omitting it) build the default budget. Anything else must be an
|
|
65
|
+
// UNMODIFIED SentryBudget: a duck-typed stand-in, a SUBCLASS that overrides `checkBudget`, and a
|
|
66
|
+
// Proxy that traps it are all refused, because all three are one-liners that would otherwise
|
|
67
|
+
// hand back a client with no ceiling at all (§9 finding, 2026-07-26 — `instanceof` alone was
|
|
68
|
+
// not a check). What this cannot stop is deliberate sabotage from inside the process (an
|
|
69
|
+
// injected clock, a throwaway ledger path); the kernel's header says so rather than pretending
|
|
70
|
+
// otherwise, and this guards the accident and the one-liner, which are the shapes that happen.
|
|
71
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
72
|
+
// The default ledger is keyed by a hash of THIS token — Sentry's limits attach to the token's
|
|
73
|
+
// organization, so a cwd-scoped ledger would hand it a fresh allowance per checkout/worktree/CI leg.
|
|
74
|
+
// ONE expression decides which budget is used, so there is no second, weaker test that could
|
|
75
|
+
// disagree with the first. `null`/`undefined` (or omitting it) build the default; anything else
|
|
76
|
+
// must be an UNMODIFIED SentryBudget — a duck-typed stand-in, a SUBCLASS overriding
|
|
77
|
+
// `checkBudget`, and a Proxy trapping it are ALL refused, because each is a one-liner that
|
|
78
|
+
// would otherwise hand back a client with no ceiling (§9 finding, 2026-07-26: `instanceof`
|
|
79
|
+
// alone was not a check — a subclass satisfied it). What this cannot stop is deliberate
|
|
80
|
+
// sabotage from inside the process (an injected clock, a throwaway ledger path); the kernel
|
|
81
|
+
// header states that limit rather than pretending otherwise. This closes the accident and the
|
|
82
|
+
// one-liner, which are the shapes that actually happen.
|
|
83
|
+
const budget = opts.budget !== undefined && opts.budget !== null
|
|
84
|
+
? assertBudgetGuardIntact(opts.budget, SentryBudget, 'liveSentryExecute')
|
|
85
|
+
: new SentryBudget({ token, ...(opts.budgetOptions ?? {}) });
|
|
86
|
+
return async (method, path, reqBody) => {
|
|
87
|
+
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
|
|
88
|
+
const init: { method: string; headers: Record<string, string>; body?: string } = { method, headers };
|
|
89
|
+
if (method !== 'GET' && reqBody) {
|
|
90
|
+
headers['Content-Type'] = 'application/json';
|
|
91
|
+
init.body = JSON.stringify(reqBody);
|
|
92
|
+
}
|
|
93
|
+
const weight = sentryCallWeight(method, path);
|
|
94
|
+
// THROWS instead of calling. Nothing below this line runs when the budget refuses.
|
|
95
|
+
const reservation = budget.checkBudget(weight);
|
|
96
|
+
const res = await doFetch(`${base}${path}`, init);
|
|
97
|
+
const resHeaders: Record<string, string> = {};
|
|
98
|
+
res.headers.forEach((v: string, k: string) => { resHeaders[k.toLowerCase()] = v; });
|
|
99
|
+
const text = await res.text();
|
|
100
|
+
// Settles the reservation and, on a back-off signal, arms the cooldown. May itself throw (a
|
|
101
|
+
// `Retry-After` beyond the cap is not something to sleep off) — the cooldown is persisted
|
|
102
|
+
// first either way, so the refusal survives the throw.
|
|
103
|
+
// recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused
|
|
104
|
+
// call that louder refusal wins; an answer Sentry ACCEPTED is kept, so a write that landed is
|
|
105
|
+
// never recorded as failed and performed again on retry.
|
|
106
|
+
try {
|
|
107
|
+
budget.recordCall(weight, resHeaders, { status: res.status, reservation });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (!(error instanceof SentryBudgetError) || !res.ok) throw error;
|
|
110
|
+
}
|
|
111
|
+
return text ? JSON.parse(text) : {};
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Mapping (real Sentry snake/camel → twin SyncResource) ────────────────────────
|
|
116
|
+
export function mapIssue(i: Record<string, unknown>): SyncResource {
|
|
117
|
+
return {
|
|
118
|
+
type: 'issue', id: String(i.id),
|
|
119
|
+
fields: {
|
|
120
|
+
projectId: i.project && typeof i.project === 'object' ? String((i.project as Record<string, unknown>).id ?? '') : String(i.projectId ?? ''),
|
|
121
|
+
projectSlug: i.project && typeof i.project === 'object' ? String((i.project as Record<string, unknown>).slug ?? '') : String(i.projectSlug ?? ''),
|
|
122
|
+
shortId: i.shortId ?? null,
|
|
123
|
+
title: i.title ?? (i.metadata && (i.metadata as Record<string, unknown>).title) ?? '',
|
|
124
|
+
culprit: i.culprit ?? '',
|
|
125
|
+
level: i.level ?? 'error',
|
|
126
|
+
status: i.status ?? 'unresolved',
|
|
127
|
+
count: String(i.count ?? '0'),
|
|
128
|
+
userCount: i.userCount ?? 0,
|
|
129
|
+
firstSeen: i.firstSeen ?? null,
|
|
130
|
+
lastSeen: i.lastSeen ?? null,
|
|
131
|
+
assignedTo: i.assignedTo ?? null,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
export function mapProject(p: Record<string, unknown>): SyncResource {
|
|
136
|
+
return {
|
|
137
|
+
type: 'project', id: String(p.id),
|
|
138
|
+
fields: {
|
|
139
|
+
slug: p.slug ?? '', name: p.name ?? '', platform: p.platform ?? null,
|
|
140
|
+
organizationSlug: p.organization && typeof p.organization === 'object' ? String((p.organization as Record<string, unknown>).slug ?? '') : String(p.organizationSlug ?? ''),
|
|
141
|
+
dateCreated: p.dateCreated ?? null,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
export function mapRelease(r: Record<string, unknown>, orgSlug: string): SyncResource {
|
|
146
|
+
const version = String(r.version);
|
|
147
|
+
return {
|
|
148
|
+
type: 'release', id: `${orgSlug}:${version}`,
|
|
149
|
+
fields: {
|
|
150
|
+
version, organizationSlug: orgSlug,
|
|
151
|
+
ref: r.ref ?? null, url: r.url ?? null,
|
|
152
|
+
dateCreated: r.dateCreated ?? null, dateReleased: r.dateReleased ?? null,
|
|
153
|
+
commitCount: r.commitCount ?? 0,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── PULL ─────────────────────────────────────────────────────────────────────────
|
|
159
|
+
export async function pullSentryIssues(execute: SentryExecute, org: string, project: string, opts: { query?: string } = {}): Promise<SyncResource[]> {
|
|
160
|
+
const q = opts.query ? `?query=${encodeURIComponent(opts.query)}` : '';
|
|
161
|
+
const res = await execute('GET', `/api/0/projects/${org}/${project}/issues/${q}`);
|
|
162
|
+
return (Array.isArray(res) ? res : []).map((i) => mapIssue(i as Record<string, unknown>));
|
|
163
|
+
}
|
|
164
|
+
export async function pullSentryProjects(execute: SentryExecute): Promise<SyncResource[]> {
|
|
165
|
+
const res = await execute('GET', `/api/0/projects/`);
|
|
166
|
+
return (Array.isArray(res) ? res : []).map((p) => mapProject(p as Record<string, unknown>));
|
|
167
|
+
}
|
|
168
|
+
export async function pullSentryReleases(execute: SentryExecute, org: string): Promise<SyncResource[]> {
|
|
169
|
+
const res = await execute('GET', `/api/0/organizations/${org}/releases/`);
|
|
170
|
+
return (Array.isArray(res) ? res : []).map((r) => mapRelease(r as Record<string, unknown>, org));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Pull issues + projects + releases from real Sentry and fold them into the event log. */
|
|
174
|
+
export async function syncSentryFromReal(
|
|
175
|
+
execute: SentryExecute,
|
|
176
|
+
opts: { org: string; project: string; root?: string; occurredAt: string },
|
|
177
|
+
): Promise<{ pulled: number }> {
|
|
178
|
+
const [issues, projects, releases] = await Promise.all([
|
|
179
|
+
pullSentryIssues(execute, opts.org, opts.project),
|
|
180
|
+
pullSentryProjects(execute),
|
|
181
|
+
pullSentryReleases(execute, opts.org),
|
|
182
|
+
]);
|
|
183
|
+
const resources = [...projects, ...issues, ...releases];
|
|
184
|
+
// protocol 2: the observation lands on the head through the kernel's fold — one batch, one instant
|
|
185
|
+
const result = observeResources(SERVICE, resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields })), { ...(opts.root !== undefined ? { root: opts.root } : {}), at: opts.occurredAt, batch: `obs:${SERVICE}:${opts.occurredAt}` });
|
|
186
|
+
return { pulled: result.appended };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── PUSH ─────────────────────────────────────────────────────────────────────────
|
|
190
|
+
// The twin write operations this connector can faithfully push back to real Sentry. The
|
|
191
|
+
// connector pushes ISSUE STATUS changes (resolve / ignore / unresolve / assign) — the
|
|
192
|
+
// human "triage" actions an operator drives the twin with. Anything else FAILS LOUDLY
|
|
193
|
+
// rather than silently hitting the wrong real endpoint.
|
|
194
|
+
const PUSHABLE_OPS = new Set(['issue.update']);
|
|
195
|
+
|
|
196
|
+
function assertPushable(op: string): void {
|
|
197
|
+
if (!PUSHABLE_OPS.has(op)) {
|
|
198
|
+
throw new Error(`sentry push: unsupported operation '${op}' — refusing to silently drop a local write`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The real-Sentry request for a pending action (PUT /api/0/issues/:id/ with the patch). */
|
|
203
|
+
export function sentryRequestForAction(action: Pick<TwinAction, 'operation' | 'subject' | 'fields'>): { method: 'PUT'; path: string; body: Record<string, unknown> } {
|
|
204
|
+
const fields = action.fields ?? {};
|
|
205
|
+
const patch: Record<string, unknown> = {};
|
|
206
|
+
if (fields.status !== undefined) patch.status = fields.status;
|
|
207
|
+
if (fields.assignedTo !== undefined) patch.assignedTo = fields.assignedTo;
|
|
208
|
+
if (fields.statusDetails !== undefined) patch.statusDetails = fields.statusDetails;
|
|
209
|
+
return { method: 'PUT', path: `/api/0/issues/${action.subject.id}/`, body: patch };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Push ONE pending issue.update action to REAL Sentry via the injected executor. */
|
|
213
|
+
export async function pushSentryAction(
|
|
214
|
+
execute: SentryExecute,
|
|
215
|
+
action: Pick<TwinAction, 'operation' | 'subject' | 'fields'>,
|
|
216
|
+
): Promise<{ externalId: string }> {
|
|
217
|
+
assertPushable(action.operation ?? '');
|
|
218
|
+
const { method, path, body } = sentryRequestForAction(action);
|
|
219
|
+
const res = await execute(method, path, body);
|
|
220
|
+
if (res && typeof res === 'object' && 'detail' in res && !('id' in res)) {
|
|
221
|
+
throw new Error(`sentry push issue ${action.subject.id} failed: ${(res as { detail?: string }).detail ?? 'unknown error'}`);
|
|
222
|
+
}
|
|
223
|
+
const id = (res as { id?: unknown })?.id;
|
|
224
|
+
return { externalId: typeof id === 'string' && id ? id : action.subject.id };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
|
|
228
|
+
/** The pack's executor over the kernel's: the same Sentry REST call, carried by the head. */
|
|
229
|
+
export function sentryExecuteOver(execute: RemoteExecute): SentryExecute {
|
|
230
|
+
return async (method, path, body) => {
|
|
231
|
+
const res = await execute({
|
|
232
|
+
method, path,
|
|
233
|
+
// Sentry authenticates with a bearer token; at a real boundary the kernel's executor sets the sealed
|
|
234
|
+
// credential over this header, and at the twin's own wire any bearer is a bearer.
|
|
235
|
+
headers: { accept: 'application/json', authorization: 'Bearer twin', ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
236
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
237
|
+
});
|
|
238
|
+
if (res.body === '') return {};
|
|
239
|
+
try { return JSON.parse(res.body) as Record<string, unknown>; } catch { return { detail: res.body.slice(0, 200) }; }
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/** The refresh adapter: pull the organization's projects and issues through the executor. */
|
|
243
|
+
export async function syncSentryFromRemote(execute: RemoteExecute, opts: { root?: string; origin?: string; occurredAt?: string; org?: string; project?: string } = {}): Promise<{ pulled: number }> {
|
|
244
|
+
// Sentry's reads are scoped to an org and a project. A world names them on its root (`scope`); the twin's
|
|
245
|
+
// own defaults stand in when a refresh runs against the twin itself.
|
|
246
|
+
return syncSentryFromReal(sentryExecuteOver(execute), {
|
|
247
|
+
org: opts.org ?? 'twin-org',
|
|
248
|
+
project: opts.project ?? 'twin-project',
|
|
249
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
250
|
+
occurredAt: opts.occurredAt ?? new Date().toISOString(),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
/** The perform adapter: TRIAGE is what crosses — an issue's resolution, assignment, mute. Everything else
|
|
254
|
+
* this twin holds is something Sentry told it (an event, a release, a project), not a write it owes back. */
|
|
255
|
+
export async function performSentryAction(execute: RemoteExecute, action: TwinAction, _ctx: PerformContext): Promise<PushOutcome> {
|
|
256
|
+
if (action.operation !== 'issue.update') {
|
|
257
|
+
return { externalId: action.subject.id, data: { performed: false, reason: `${action.operation ?? action.subject.type} is the twin's own record — only issue triage crosses to Sentry` } };
|
|
258
|
+
}
|
|
259
|
+
return pushSentryAction(sentryExecuteOver(execute), { operation: action.operation, subject: action.subject, fields: action.fields ?? {} });
|
|
260
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Sentry ALERTS + WEBHOOKS — Sentry fires webhooks (to "internal integrations" /
|
|
2
|
+
// alert-rule actions) on new-issue and regression (issue-reopened) events so an app's
|
|
3
|
+
// handler runs. A faithful twin must too. On ingest, the twin builds the Sentry webhook
|
|
4
|
+
// envelope and delivers it, SIGNED, to registered endpoints.
|
|
5
|
+
//
|
|
6
|
+
// Signature: Sentry signs each webhook delivery with HMAC-SHA256 over the raw JSON body
|
|
7
|
+
// keyed by the integration's client secret, in the `Sentry-Hook-Signature` header. We
|
|
8
|
+
// reproduce that byte-for-byte (pure node:crypto, deterministic, offline) so a consumer
|
|
9
|
+
// can verify twin-delivered webhooks with the same code it uses in prod.
|
|
10
|
+
//
|
|
11
|
+
// Delivery is an INJECTED seam (the auth-boundary): tests pass a fake deliverer, live runs
|
|
12
|
+
// pass the HTTP deliverer. verify() in the manifest stays fully offline.
|
|
13
|
+
import { applyTwinWrite, projectResources, nodeBuiltin } from '@volter/world-core';
|
|
14
|
+
import type { TwinResource } from '@volter/world-core';
|
|
15
|
+
import { worldEgressRefusal } from '@volter/world-core/network-policy';
|
|
16
|
+
|
|
17
|
+
const SERVICE = 'sentry';
|
|
18
|
+
|
|
19
|
+
export type SentryWebhookResource = 'issue' | 'error' | 'event_alert' | 'metric_alert';
|
|
20
|
+
export type SentryWebhookAction = 'created' | 'resolved' | 'assigned' | 'ignored' | 'triggered';
|
|
21
|
+
|
|
22
|
+
export type SentryWebhook = {
|
|
23
|
+
action: SentryWebhookAction;
|
|
24
|
+
installation: { uuid: string };
|
|
25
|
+
data: Record<string, unknown>;
|
|
26
|
+
actor: { type: 'application' | 'user'; id: string; name: string };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type SentryWebhookDelivery = (
|
|
30
|
+
url: string,
|
|
31
|
+
payload: string,
|
|
32
|
+
headers: Record<string, string>,
|
|
33
|
+
) => Promise<void> | void;
|
|
34
|
+
|
|
35
|
+
// ── Signature (Sentry's scheme) ──────────────────────────────────────────────────
|
|
36
|
+
type NodeCrypto = typeof import('node:crypto');
|
|
37
|
+
function nodeCrypto(): NodeCrypto {
|
|
38
|
+
// Lazy require so this module can be pulled into the browser UI bundle without
|
|
39
|
+
// Bun's browser target choking on node:crypto (the signature helpers are server-only).
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
|
+
return nodeBuiltin('node:crypto') as NodeCrypto;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** HMAC-SHA256(secret, rawBody) as lowercase hex — Sentry's Sentry-Hook-Signature. */
|
|
45
|
+
export function computeSentrySignature(rawBody: string, secret: string): string {
|
|
46
|
+
return nodeCrypto().createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class SentrySignatureVerificationError extends Error {
|
|
50
|
+
constructor(message: string) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'SentrySignatureVerificationError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Verify a delivered webhook body against the header signature + secret (constant-time). */
|
|
57
|
+
export function verifySentrySignature(rawBody: string, signatureHeader: string | undefined, secret: string): boolean {
|
|
58
|
+
if (!signatureHeader) return false;
|
|
59
|
+
const expected = computeSentrySignature(rawBody, secret);
|
|
60
|
+
const a = Buffer.from(expected, 'utf8');
|
|
61
|
+
const b = Buffer.from(signatureHeader, 'utf8');
|
|
62
|
+
return a.length === b.length && nodeCrypto().timingSafeEqual(a, b);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Verify or throw (mirrors a consumer's "reject bad signature" branch). */
|
|
66
|
+
export function constructSentryWebhook(rawBody: string, signatureHeader: string | undefined, secret: string): SentryWebhook {
|
|
67
|
+
if (!verifySentrySignature(rawBody, signatureHeader, secret)) {
|
|
68
|
+
throw new SentrySignatureVerificationError('Sentry-Hook-Signature does not match the expected signature for payload.');
|
|
69
|
+
}
|
|
70
|
+
return JSON.parse(rawBody) as SentryWebhook;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Alert-rule registry (stored as twin state) ───────────────────────────────────
|
|
74
|
+
// An alert rule fires its action(s) when its trigger condition is met. We model the two
|
|
75
|
+
// triggers an issue's lifecycle produces: 'new-issue' (first time a group is seen) and
|
|
76
|
+
// 'regression' (a resolved/ignored issue reopened). Each rule has webhook target URLs +
|
|
77
|
+
// a signing secret. Rules live in the action log (D1) like every other resource.
|
|
78
|
+
export type AlertTrigger = 'new-issue' | 'regression';
|
|
79
|
+
|
|
80
|
+
function rows(type: string, root?: string): TwinResource[] {
|
|
81
|
+
return projectResources(SERVICE, root).filter((r) => r.type === type);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function registerAlertRule(
|
|
85
|
+
rule: { id: string; projectId: string; name?: string; triggers: AlertTrigger[]; endpoints: string[]; secret: string; installationUuid?: string },
|
|
86
|
+
opts: { root?: string; occurredAt?: string } = {},
|
|
87
|
+
): Promise<void> {
|
|
88
|
+
await applyTwinWrite(SERVICE, {
|
|
89
|
+
operation: 'alert_rule.create', subjectType: 'alert_rule', subjectId: rule.id,
|
|
90
|
+
fields: {
|
|
91
|
+
projectId: rule.projectId,
|
|
92
|
+
name: rule.name ?? rule.id,
|
|
93
|
+
triggers: rule.triggers,
|
|
94
|
+
endpoints: rule.endpoints,
|
|
95
|
+
secret: rule.secret,
|
|
96
|
+
installationUuid: rule.installationUuid ?? `inst_${rule.id}`,
|
|
97
|
+
dateCreated: opts.occurredAt ?? '1970-01-01T00:00:00.000Z',
|
|
98
|
+
},
|
|
99
|
+
occurredAt: opts.occurredAt ?? '1970-01-01T00:00:00.000Z', actor: { kind: 'system' },
|
|
100
|
+
}, opts.root);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function listAlertRules(projectId: string | undefined, root?: string): TwinResource[] {
|
|
104
|
+
return rows('alert_rule', root).filter((r) => !r.deleted && (projectId === undefined || r.projectId === projectId));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Webhook emission on issue lifecycle ──────────────────────────────────────────
|
|
108
|
+
const httpDelivery: SentryWebhookDelivery = async (url, payload, headers) => {
|
|
109
|
+
if (worldEgressRefusal(url) !== null) return; // the World's egress rule: refused like an unreachable endpoint
|
|
110
|
+
try {
|
|
111
|
+
await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: payload });
|
|
112
|
+
} catch {
|
|
113
|
+
/* fire-and-forget, like Sentry */
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** A delivery's request id, DERIVED from the delivery itself — never a process counter, so two identical
|
|
118
|
+
* worlds send the same hook with the same id (protocol 2, R9). */
|
|
119
|
+
function hookRequestId(seed: string): string {
|
|
120
|
+
let h = 0x811c9dc5;
|
|
121
|
+
for (let i = 0; i < seed.length; i += 1) { h ^= seed.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
122
|
+
return `whk_twin_${h.toString(16).padStart(8, '0')}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Fire alert webhooks for an issue lifecycle event. `trigger` is 'new-issue' or
|
|
127
|
+
* 'regression'; `issue` is the issue resource (Web-API shape). Delivers a SIGNED webhook
|
|
128
|
+
* to every endpoint of every matching rule for the issue's project. Returns the delivered
|
|
129
|
+
* payloads (for assertions). No-op when no rule matches. `deliver` is injected.
|
|
130
|
+
*/
|
|
131
|
+
export async function emitIssueAlert(
|
|
132
|
+
trigger: AlertTrigger,
|
|
133
|
+
issue: Record<string, unknown>,
|
|
134
|
+
opts: { root?: string; deliver?: SentryWebhookDelivery; occurredAt?: string } = {},
|
|
135
|
+
): Promise<Array<{ url: string; payload: SentryWebhook; signature: string }>> {
|
|
136
|
+
const projectId = String(issue.projectId ?? '');
|
|
137
|
+
const rules = listAlertRules(projectId, opts.root).filter((r) =>
|
|
138
|
+
Array.isArray(r.triggers) && (r.triggers as string[]).includes(trigger),
|
|
139
|
+
);
|
|
140
|
+
if (rules.length === 0) return [];
|
|
141
|
+
const deliver = opts.deliver ?? httpDelivery;
|
|
142
|
+
const action: SentryWebhookAction = trigger === 'new-issue' ? 'created' : 'triggered';
|
|
143
|
+
const out: Array<{ url: string; payload: SentryWebhook; signature: string }> = [];
|
|
144
|
+
for (const rule of rules) {
|
|
145
|
+
const installationUuid = String(rule.installationUuid ?? `inst_${rule.id}`);
|
|
146
|
+
const webhook: SentryWebhook = {
|
|
147
|
+
action,
|
|
148
|
+
installation: { uuid: installationUuid },
|
|
149
|
+
actor: { type: 'application', id: 'sentry', name: 'Sentry' },
|
|
150
|
+
data: {
|
|
151
|
+
issue,
|
|
152
|
+
trigger,
|
|
153
|
+
triggered_rule: rule.name ?? rule.id,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
const rawBody = JSON.stringify(webhook);
|
|
157
|
+
const signature = computeSentrySignature(rawBody, String(rule.secret));
|
|
158
|
+
const resource = trigger === 'regression' ? 'error' : 'issue';
|
|
159
|
+
const headers = {
|
|
160
|
+
'sentry-hook-resource': resource,
|
|
161
|
+
'sentry-hook-signature': signature,
|
|
162
|
+
'sentry-hook-timestamp': String(Math.floor(Date.parse(opts.occurredAt ?? '1970-01-01T00:00:00.000Z') / 1000) || 0),
|
|
163
|
+
'request-id': hookRequestId(`${rawBody}|${signature}`),
|
|
164
|
+
};
|
|
165
|
+
for (const url of rule.endpoints as string[]) {
|
|
166
|
+
await deliver(url, rawBody, headers);
|
|
167
|
+
out.push({ url, payload: webhook, signature });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|