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