@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,36 @@
1
+ #!/usr/bin/env node
2
+ import { keepProcessAlive } from '@volter/world-core/lifecycle';
3
+ // world-sentry CLI: serve the Sentry API twin (ingestion + Web API), the dashboard mirror
4
+ // UI, or run conformance. (Conformance lives in dev-tooling; cli.ts must not import it, so
5
+ // it's loaded lazily only when the `conformance` command is invoked.)
6
+ import { hasFlag, optionValue } from '@volter/world-core/args';
7
+ import { createSentryTwinServer } from "./sentry-server.js";
8
+ import { createSentryMirrorServer } from "./sentry-mirror-ui.js";
9
+ import { defaultDsn } from "./sentry-ingest.js";
10
+ const [cmd, ...rest] = process.argv.slice(2);
11
+ const port = Number(optionValue(rest, '--port', '0')) || undefined;
12
+ const root = optionValue(rest, '--root') || undefined;
13
+ const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes/events unless read-only
14
+ if (cmd === 'serve') {
15
+ const s = await createSentryTwinServer({ readOnly, ...(root ? { root } : {}), ...(port ? { port } : {}) });
16
+ const origin = `http://127.0.0.1:${s.port}`;
17
+ process.stdout.write(`sentry twin (ingest + Web API)${readOnly ? ' [read-only]' : ''} at ${origin}\n`);
18
+ process.stdout.write(` point @sentry/node at DSN: ${defaultDsn(origin)}\n`);
19
+ await keepProcessAlive();
20
+ }
21
+ else if (cmd === 'mirror') {
22
+ const s = await createSentryMirrorServer({ ...(root ? { root } : {}), ...(port ? { port } : {}) });
23
+ process.stdout.write(`sentry mirror UI (dashboard) at http://127.0.0.1:${s.port}\n`);
24
+ await keepProcessAlive();
25
+ }
26
+ else if (cmd === 'conformance') {
27
+ // dev-only: dynamic import so the conformance module never enters the runtime entrypoints.
28
+ const { checkSentryConformance } = await import("./sentry-conformance.js");
29
+ const report = await checkSentryConformance({ ...(root ? { root } : {}) });
30
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
31
+ if (!report.ok)
32
+ process.exitCode = 1;
33
+ }
34
+ else {
35
+ process.stdout.write('Usage: world-sentry serve|mirror|conformance [--port N] [--root DIR] [--read-only]\n');
36
+ }
@@ -0,0 +1,15 @@
1
+ export { handleSentryTwinRequest } from './sentry-twin.js';
2
+ export type { SentryRequest, SentryResponse } from './sentry-twin.js';
3
+ export { createSentryTwinFetch, createSentryTwinServer, type SentryTwinFetchOptions } from './sentry-server.js';
4
+ export { defaultDsn, DEFAULT_ORG, DEFAULT_PROJECT, DEFAULT_PROJECT_ID, DEFAULT_PUBLIC_KEY, ensureDefaultProject, envelopeEvents, eventCulprit, eventTitle, fingerprintText, hashFingerprint, ingestEvent, issueIdFor, normalizeFingerprintText, parseEnvelope, publicKeyFromAuthHeader, publicKeyFromDsn, resolveProjectKey, resolvePublicKey, } from './sentry-ingest.js';
5
+ export type { IngestOutcome, ParsedEnvelope, ResolvedKey, SentryEventPayload, SentryExceptionValue, SentryFrame } from './sentry-ingest.js';
6
+ export { computeSentrySignature, constructSentryWebhook, emitIssueAlert, listAlertRules, registerAlertRule, SentrySignatureVerificationError, verifySentrySignature, } from './sentry-events.js';
7
+ export type { AlertTrigger, SentryWebhook, SentryWebhookAction, SentryWebhookDelivery } from './sentry-events.js';
8
+ export { liveSentryExecute, mapIssue, mapProject, mapRelease, pullSentryIssues, pullSentryProjects, pullSentryReleases, pushSentryAction, sentryRequestForAction, syncSentryFromReal, } from './sentry-connector.js';
9
+ export type { SentryExecute, LiveSentryOptions } from './sentry-connector.js';
10
+ export { SENTRY_BUDGET_CEILING, SENTRY_BUDGET_MAX_RETRY_AFTER_S, SENTRY_BUDGET_WINDOW_MS, SENTRY_CALL_WEIGHTS, SENTRY_RATE_BUDGET, SentryBudget, SentryBudgetError, sentryBudgetPath, sentryCallWeight, } from './sentry-budget.js';
11
+ export type { SentryBudgetErrorKind, SentryBudgetOptions, SentryBudgetReservation, SentryBudgetSnapshot } from './sentry-budget.js';
12
+ export { buildSentryMirrorClient, createSentryMirrorServer, eventTags, formatCount, latestStacktrace, levelTone, sentryMirrorHtml, statusTone, timeAgo, } from './sentry-mirror-ui.js';
13
+ export type { IssueRow, StackFrame } from './sentry-mirror-ui.js';
14
+ import type { TwinPack } from '@volter/world-core';
15
+ export declare const pack: TwinPack;
@@ -0,0 +1,75 @@
1
+ // @volter/twin-sentry — the Sentry twin (one vendor, one package), built on the shared
2
+ // @volter/world-core kernel. Two API families over local event-sourced state: INGESTION (the
3
+ // `@sentry/node` store/envelope endpoints → grouped issues) and the WEB API (/api/0/...:
4
+ // issues, events, projects, keys/DSN, releases, deploys, orgs, teams, alert rules), plus
5
+ // signed alert webhooks and a React dashboard mirror. No real Sentry is ever contacted.
6
+ // (Conformance tooling lives in @volter/world-tooling, a dev dependency — not shipped here.)
7
+ export { handleSentryTwinRequest } from "./sentry-twin.js";
8
+ export { createSentryTwinFetch, createSentryTwinServer } from "./sentry-server.js";
9
+ export { defaultDsn, DEFAULT_ORG, DEFAULT_PROJECT, DEFAULT_PROJECT_ID, DEFAULT_PUBLIC_KEY, ensureDefaultProject, envelopeEvents, eventCulprit, eventTitle, fingerprintText, hashFingerprint, ingestEvent, issueIdFor, normalizeFingerprintText, parseEnvelope, publicKeyFromAuthHeader, publicKeyFromDsn, resolveProjectKey, resolvePublicKey, } from "./sentry-ingest.js";
10
+ export { computeSentrySignature, constructSentryWebhook, emitIssueAlert, listAlertRules, registerAlertRule, SentrySignatureVerificationError, verifySentrySignature, } from "./sentry-events.js";
11
+ export { liveSentryExecute, mapIssue, mapProject, mapRelease, pullSentryIssues, pullSentryProjects, pullSentryReleases, pushSentryAction, sentryRequestForAction, syncSentryFromReal, } from "./sentry-connector.js";
12
+ // The client-side rate budget — the fail-closed backstop `liveSentryExecute` routes every live
13
+ // request through. The MECHANISM is the kernel's shared, vendor-agnostic `RateBudget`; what lives
14
+ // here is Sentry's DECLARATION (window/ceiling/per-endpoint weights) plus the vendor-bound
15
+ // bindings. Exported so an operator can inspect spend (`snapshot`) and so a caller can catch
16
+ // `SentryBudgetError` by type; there is deliberately no export that disables the guard.
17
+ export { SENTRY_BUDGET_CEILING, SENTRY_BUDGET_MAX_RETRY_AFTER_S, SENTRY_BUDGET_WINDOW_MS, SENTRY_CALL_WEIGHTS, SENTRY_RATE_BUDGET, SentryBudget, SentryBudgetError, sentryBudgetPath, sentryCallWeight, } from "./sentry-budget.js";
18
+ export { buildSentryMirrorClient, createSentryMirrorServer, eventTags, formatCount, latestStacktrace, levelTone, sentryMirrorHtml, statusTone, timeAgo, } from "./sentry-mirror-ui.js";
19
+ import { SENTRY_RATE_BUDGET as RATE_BUDGET } from "./sentry-budget.js";
20
+ import { performSentryAction, syncSentryFromRemote } from "./sentry-connector.js";
21
+ export const pack = {
22
+ // PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a plugin — its wire, its tree, and its half of the real
23
+ // state system. Moved 2026-09-08. TRIAGE is what crosses to Sentry — an issue resolved, assigned, muted;
24
+ // everything else this twin holds is something Sentry told it.
25
+ protocol: '2',
26
+ refresh: { every: '2m', webhook: true, onDemand: { atMost: '30s' } },
27
+ stateSystem: { perform: performSentryAction, refresh: syncSentryFromRemote },
28
+ // the round trip: an organization, then a project in it — the two things a Sentry account is made of
29
+ // the round trip: an organization, a team in it, then a project under that team — Sentry's own create
30
+ // path for a project is /teams/:org/:team/projects/, not the organization
31
+ roundTrip: [
32
+ { method: 'POST', path: '/api/0/organizations/', body: { name: 'Round Trip', slug: 'round-trip' }, headers: { authorization: 'Bearer round-trip' } },
33
+ { method: 'POST', path: '/api/0/organizations/round-trip/teams/', body: { name: 'Round Trip', slug: 'round-trip' }, headers: { authorization: 'Bearer round-trip' } },
34
+ { method: 'POST', path: '/api/0/teams/round-trip/round-trip/projects/', body: { name: 'Round Trip', platform: 'node' }, headers: { authorization: 'Bearer round-trip' } },
35
+ // a project's slug is unique inside its org, so the create above rightly refuses a second one on a
36
+ // branch that already has it — the vendor's own truth. The last write is therefore the one a world
37
+ // repeats freely: triage on the project's own issue stream.
38
+ { method: 'PUT', path: '/api/0/projects/round-trip/round-trip/', body: { platform: 'node', digestsMinDelay: 60 }, headers: { authorization: 'Bearer round-trip' } },
39
+ ],
40
+ parityOrigin: 'http://twin',
41
+ vendor: 'sentry',
42
+ // The SAME object sentry-budget.ts declares at module load — one source of truth, so registering
43
+ // the pack and importing the connector can never arm two different ceilings.
44
+ rateBudget: RATE_BUDGET,
45
+ transport: 'rest',
46
+ archetype: 'crud',
47
+ bin: 'world-sentry',
48
+ resources: ['organization', 'team', 'project', 'project_key', 'issue', 'event', 'event_attachment', 'session', 'project_ownership', 'codeowners', 'project_filter', 'release', 'release_file', 'deploy', 'alert_rule', 'metric_alert', 'org_integration'],
49
+ specSource: 'Sentry API reference (https://docs.sentry.io/api/) — ingest store/envelope + Web API /api/0/',
50
+ description: 'Sentry REST twin — SDK ingestion → grouped issues, Web API, signed alert webhooks, dashboard mirror.',
51
+ // Adoption + interception, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
52
+ // 2026-08-31). The four canonical clients plus the further first-party @sentry
53
+ // framework SDKs of the same ingest surface — battery-observed: sentry was the single
54
+ // most-missed vendor, 13 repos, largely via these — and the whole `@sentry/` scope so a
55
+ // satellite package like `@sentry/cli` still detects.
56
+ adoption: {
57
+ // Sentry's official Python SDK - the one distribution behind every Python integration.
58
+ pypi: ['sentry-sdk'],
59
+ sdks: [
60
+ '@sentry/node', '@sentry/browser', '@sentry/react', '@sentry/nextjs',
61
+ '@sentry/vue', '@sentry/sveltekit', '@sentry/astro', '@sentry/remix',
62
+ '@sentry/electron', '@sentry/react-native',
63
+ ],
64
+ scopes: ['@sentry/'],
65
+ // 'APPSMITHSENTRY' — appsmith names its DSN APPSMITH_SENTRY_DSN, an app-prefixed Sentry
66
+ // credential for the same ingest surface (ladder classification 2026-09-02).
67
+ envStems: ['SENTRY', 'APPSMITHSENTRY'],
68
+ },
69
+ // sentry.io (the web/REST API) plus the per-organization DSN ingest hosts
70
+ // (`o<org-id>.ingest.sentry.io`), which need the suffix form.
71
+ hosts: [{ host: 'sentry.io' }, { suffix: '.ingest.sentry.io' }],
72
+ // The @sentry/node SDK POSTs to <dsn-host>/api/:projectId/envelope/; the dashboard hits
73
+ // /api/0/. The dev proxy forwards both prefixes to the twin and strips the sentry.io host.
74
+ browserRouting: { apiPathPrefix: '/api/', loaderHost: 'https://sentry.io' },
75
+ };
@@ -0,0 +1,50 @@
1
+ import { RateBudget, type RateBudgetDeclaration, type RateBudgetOptions, type RateBudgetReservation, type RateBudgetSnapshot } from '@volter/world-core';
2
+ /** Rolling window, in ms. Spend older than this is pruned. */
3
+ export declare const SENTRY_BUDGET_WINDOW_MS = 60000;
4
+ /**
5
+ * Weighted units allowed inside one window. 60/60s at `defaultWeight` 2 = 30 calls a minute —
6
+ * EXACTLY the kernel's undeclared fallback, because Sentry publishes no scalar that would justify
7
+ * more. See the header.
8
+ */
9
+ export declare const SENTRY_BUDGET_CEILING = 60;
10
+ /** Seconds. A `Retry-After` above this means the token is throttled hard — fail loudly, don't sleep. */
11
+ export declare const SENTRY_BUDGET_MAX_RETRY_AFTER_S = 300;
12
+ /** Per-call cost, keyed by `"<METHOD> <path>"`. No figure here can claim to be published. */
13
+ export declare const SENTRY_CALL_WEIGHTS: {
14
+ /** `…/issues/` and `…/events/` list reads — the cursor-paginated firehose the docs say not to poll. */
15
+ readonly stream: 4;
16
+ /** Everything else: project/release/issue reads, issue updates. */
17
+ readonly other: 2;
18
+ };
19
+ /** THE PACK'S DECLARATION — pure data, the only Sentry-specific thing in the whole budget. */
20
+ export declare const SENTRY_RATE_BUDGET: RateBudgetDeclaration;
21
+ /**
22
+ * Price one call. The key is `"<METHOD> <path>"` with the query string split off, so a rule can
23
+ * price by method without the kernel knowing anything about Sentry. An unclassified endpoint still
24
+ * costs `defaultWeight` — nothing is ever free.
25
+ */
26
+ export declare function sentryCallWeight(method: string, path: string): number;
27
+ /** Where Sentry's ledger lives. Token-keyed and cwd-independent by default (limits attach to the
28
+ * auth token's organization, so a cwd-scoped ledger would hand the same token a fresh allowance in
29
+ * every checkout, worktree and CI matrix leg); pass `root` to opt into world-scoped accounting. */
30
+ export declare function sentryBudgetPath(opts?: {
31
+ root?: string;
32
+ token?: string;
33
+ } | string): string;
34
+ /** Construction options for Sentry's budget. The vendor is fixed; everything else may only TIGHTEN. */
35
+ export type SentryBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
36
+ /**
37
+ * Sentry's budget — the shared kernel guard bound to this vendor's declaration. A real subclass,
38
+ * not an alias, so `budget instanceof SentryBudget` in `liveSentryExecute` means "a budget that
39
+ * accounts against SENTRY's ledger under SENTRY's ceiling": another vendor's `RateBudget` (with its
40
+ * own, possibly larger, ceiling) is NOT assignable there.
41
+ */
42
+ export declare class SentryBudget extends RateBudget {
43
+ constructor(opts?: SentryBudgetOptions);
44
+ }
45
+ /** The typed refusal. One error class shared with every other vendor's budget; `err.vendor` says
46
+ * which one refused, and `err.kind` says why. */
47
+ export { RateBudgetError as SentryBudgetError } from '@volter/world-core';
48
+ export type { RateBudgetErrorKind as SentryBudgetErrorKind } from '@volter/world-core';
49
+ export type SentryBudgetReservation = RateBudgetReservation;
50
+ export type SentryBudgetSnapshot = RateBudgetSnapshot;
@@ -0,0 +1,145 @@
1
+ // Sentry's CLIENT-SIDE RATE BUDGET — the pack's DECLARATION (the numbers) plus the thin typed
2
+ // bindings `liveSentryExecute` uses. The MECHANISM — the durable token-keyed ledger, the rolling
3
+ // window, reserve-under-lock, the `Retry-After`/429 cooldown, fail-CLOSED on a corrupt ledger —
4
+ // lives ONCE in the vendor-agnostic kernel (`@volter/world-core` → `rateBudget.ts`). Read that module's
5
+ // header for the full rationale AND for the honest list of what the guard does not guarantee (an
6
+ // injected clock or ledger path still defeats it — it guards carelessness, not malice).
7
+ //
8
+ // ── WHY THIS EXISTS ─────────────────────────────────────────────────────────────────────────
9
+ // A real ~4.5-DAY vendor lockout (Figma, 2026-07-25) happened because raw API calls were made
10
+ // outside the pack's connector — no cache, no batching, no ceiling. Discipline only binds the code
11
+ // that follows it; a BUDGET binds the code that does not.
12
+ //
13
+ // ── HOW THE CEILING WAS CHOSEN: NO SCALAR IS PUBLISHED ──────────────────────────────────────
14
+ // Sentry does NOT publish a scalar limit. From https://docs.sentry.io/api/ratelimits/ (read
15
+ // 2026-07-26): every request is rate limited on TWO axes — a requests-per-second fixed window and a
16
+ // concurrency cap — and "each endpoint has its own maximum number of requests and window size". No
17
+ // figure is stated anywhere. (Plan-dependence is widely reported but is NOT on that page, so it is
18
+ // not quoted as if it were — §9 removed an earlier quotation mark around it, 2026-07-26.) Over the limit you get
19
+ // 429 + `Retry-After`, and every response carries `X-Sentry-Rate-Limit-Limit` / `-Remaining` /
20
+ // `-Reset` / `-ConcurrentLimit` / `-ConcurrentRemaining`. The docs' own advice is to prefer
21
+ // webhooks over polling.
22
+ //
23
+ // So this budget deliberately does NOT model the vendor's limit, and the numbers below are NOT
24
+ // derived from one. Because there is no documented figure to justify going higher, the ceiling is
25
+ // pinned at the kernel's own undeclared fallback in every dimension: 60 weighted units per 60s at
26
+ // `defaultWeight` 2 — 30 calls a minute, exactly `DEFAULT_RATE_BUDGET`, with no endpoint priced
27
+ // CHEAPER than the fallback would price it. What this declaration adds over the fallback is
28
+ // therefore not headroom, it is RESOLUTION: the paginated firehose gets priced up.
29
+ //
30
+ // The one thing the guard genuinely buys here is the COOLDOWN: `X-Sentry-Rate-Limit-Remaining: 0`
31
+ // and `Retry-After` are both read off the response and turn into a persisted refusal, so the
32
+ // vendor's first "back off" becomes a hard client-side stop instead of a retry storm — which is the
33
+ // only correct response to a limit whose real value you cannot know client-side.
34
+ //
35
+ // ── HOW THE WEIGHTS WERE CHOSEN (a judgement call, stated as one) ────────────────────────────
36
+ // Since no per-endpoint figure is published, no weight here can claim to be arithmetic. The single
37
+ // rule prices the ISSUE/EVENT list reads at 4 — double the default. They are the cursor-paginated
38
+ // firehose (an org's whole event stream, page after page), they are the thing the docs single out
39
+ // as "use webhooks instead of polling this", and they play exactly the role `/images` renders
40
+ // played in the Figma lockout: the endpoint a pagination bug turns into hundreds of calls. At
41
+ // weight 4 at most 15 land in a window.
42
+ import { declareRateBudget, rateBudgetPath, rateBudgetWeight, RateBudget, } from '@volter/world-core';
43
+ const VENDOR = 'sentry';
44
+ /** Rolling window, in ms. Spend older than this is pruned. */
45
+ export const SENTRY_BUDGET_WINDOW_MS = 60_000;
46
+ /**
47
+ * Weighted units allowed inside one window. 60/60s at `defaultWeight` 2 = 30 calls a minute —
48
+ * EXACTLY the kernel's undeclared fallback, because Sentry publishes no scalar that would justify
49
+ * more. See the header.
50
+ */
51
+ export const SENTRY_BUDGET_CEILING = 60;
52
+ /** Seconds. A `Retry-After` above this means the token is throttled hard — fail loudly, don't sleep. */
53
+ export const SENTRY_BUDGET_MAX_RETRY_AFTER_S = 300;
54
+ /** Per-call cost, keyed by `"<METHOD> <path>"`. No figure here can claim to be published. */
55
+ export const SENTRY_CALL_WEIGHTS = {
56
+ /** `…/issues/` and `…/events/` list reads — the cursor-paginated firehose the docs say not to poll. */
57
+ stream: 4,
58
+ /** Everything else: project/release/issue reads, issue updates. */
59
+ other: 2,
60
+ };
61
+ /** THE PACK'S DECLARATION — pure data, the only Sentry-specific thing in the whole budget. */
62
+ export const SENTRY_RATE_BUDGET = {
63
+ windowMs: SENTRY_BUDGET_WINDOW_MS,
64
+ ceiling: SENTRY_BUDGET_CEILING,
65
+ defaultWeight: SENTRY_CALL_WEIGHTS.other,
66
+ maxRetryAfterSeconds: SENTRY_BUDGET_MAX_RETRY_AFTER_S,
67
+ rules: [
68
+ // `(/|$)` rather than a bare trailing `/`: the pricer normalizes a trailing slash away (so
69
+ // `…/issues/` and `…/issues` are one endpoint), and a rule insisting on the slash would miss both.
70
+ // (§9 finding, 2026-07-26.)
71
+ { match: '^GET /api/0/(projects|organizations)/[^/]+/[^/]+/(issues|events)(/|$)', weight: SENTRY_CALL_WEIGHTS.stream },
72
+ ],
73
+ reason: 'Sentry publishes NO scalar limit (docs.sentry.io/api/ratelimits, read 2026-07-26): every request ' +
74
+ 'is limited on a requests-per-second fixed window AND a concurrency cap, "each endpoint has its ' +
75
+ 'own maximum number of requests and window size", and no figure is stated anywhere. ' +
76
+ 'Over it: 429 + Retry-After, with X-Sentry-Rate-Limit-Limit/-Remaining/-Reset/-ConcurrentLimit ' +
77
+ 'headers on every response, and the docs advise webhooks over polling. Because no published ' +
78
+ 'figure justifies going higher, the ceiling is pinned at the kernel fallback in EVERY dimension ' +
79
+ '— 60 units / 60s at defaultWeight 2 = 30 calls/min, and no endpoint is priced cheaper than the ' +
80
+ 'fallback would price it; the declaration buys resolution, not headroom. The issue/event LIST ' +
81
+ 'reads cost 4 (the cursor-paginated firehose the docs single out, and the shape a pagination bug ' +
82
+ 'turns into hundreds of calls) — a judgement call, not a published cost, since none exists. What ' +
83
+ "the guard genuinely buys here is the cooldown: the vendor's first back-off signal becomes a " +
84
+ 'persisted client-side stop instead of a retry storm.',
85
+ };
86
+ // Declared at module load, so merely importing this module (which `sentry-connector.ts` does) is
87
+ // enough to arm the real ceiling. `RateBudget` reads its policy live precisely so this declaration
88
+ // takes effect the moment it lands, and constructing through the subclass below (which imports this
89
+ // module) is what makes the ordering a non-issue in practice.
90
+ declareRateBudget(VENDOR, SENTRY_RATE_BUDGET);
91
+ /**
92
+ * Price one call. The key is `"<METHOD> <path>"` with the query string split off, so a rule can
93
+ * price by method without the kernel knowing anything about Sentry. An unclassified endpoint still
94
+ * costs `defaultWeight` — nothing is ever free.
95
+ */
96
+ export function sentryCallWeight(method, path) {
97
+ const { bare, query } = splitQuery(path);
98
+ // UPPER-CASE the method: `fetch` normalizes a known lowercase method before sending, so
99
+ // `execute('post', …)` really does issue a POST and must be priced as one.
100
+ return rateBudgetWeight(VENDOR, `${String(method).toUpperCase()} ${bare}`, query);
101
+ }
102
+ /**
103
+ * `/v1/x?a=1` -> `{ bare: '/v1/x', query: { a: '1' } }`. Rules match the path; `whenQuery*` the query.
104
+ *
105
+ * NORMALIZED, because the anchored rules are otherwise trivially evaded (§9 finding, 2026-07-26):
106
+ * `fetch` upper-cases a known method before sending, so `execute('post', …)` issues a real WRITE
107
+ * that a `^POST ` rule would price as a read; and a trailing slash makes a path miss a `$` anchor
108
+ * while most routers treat it as the same endpoint. Both are input variations, not attacks, and
109
+ * either one silently voids the "expensive endpoints are priced up" claim the ceiling rests on.
110
+ */
111
+ function splitQuery(path) {
112
+ const at = path.indexOf('?');
113
+ const query = {};
114
+ if (at !== -1)
115
+ for (const [k, v] of new URLSearchParams(path.slice(at + 1)))
116
+ query[k] = v;
117
+ const raw = at === -1 ? path : path.slice(0, at);
118
+ // Collapse a trailing slash, but never turn the root path into the empty string.
119
+ const bare = raw.length > 1 && raw.endsWith('/') ? raw.replace(/\/+$/, '') : raw;
120
+ return { bare, query };
121
+ }
122
+ /** Where Sentry's ledger lives. Token-keyed and cwd-independent by default (limits attach to the
123
+ * auth token's organization, so a cwd-scoped ledger would hand the same token a fresh allowance in
124
+ * every checkout, worktree and CI matrix leg); pass `root` to opt into world-scoped accounting. */
125
+ export function sentryBudgetPath(opts = {}) {
126
+ const o = typeof opts === 'string' ? { root: opts } : opts;
127
+ // VENDOR spread LAST: a loosely-typed `{ vendor: 'other', … }` slipping through (TypeScript's
128
+ // excess-property check only catches object literals) must not redirect this pack's ledger to
129
+ // another vendor's file.
130
+ return rateBudgetPath({ ...o, vendor: VENDOR });
131
+ }
132
+ /**
133
+ * Sentry's budget — the shared kernel guard bound to this vendor's declaration. A real subclass,
134
+ * not an alias, so `budget instanceof SentryBudget` in `liveSentryExecute` means "a budget that
135
+ * accounts against SENTRY's ledger under SENTRY's ceiling": another vendor's `RateBudget` (with its
136
+ * own, possibly larger, ceiling) is NOT assignable there.
137
+ */
138
+ export class SentryBudget extends RateBudget {
139
+ constructor(opts = {}) {
140
+ super({ ...opts, vendor: VENDOR });
141
+ }
142
+ }
143
+ /** The typed refusal. One error class shared with every other vendor's budget; `err.vendor` says
144
+ * which one refused, and `err.kind` says why. */
145
+ export { RateBudgetError as SentryBudgetError } from '@volter/world-core';
@@ -0,0 +1,3 @@
1
+ import { type CapabilityReport, type CapabilitySpec } from '@volter/world-tooling';
2
+ export declare const SENTRY_CAPABILITIES: CapabilitySpec[];
3
+ export declare function sentryCapabilities(): Promise<CapabilityReport>;