@volter/twin-sentry 0.1.37 → 1.0.1
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/README.md +28 -140
- package/api/spec/SOURCE.md +32 -0
- package/api/spec/doc-examples.json +12 -0
- package/api/spec/openapi.json.gz +0 -0
- package/api/spec/patches.json +5350 -0
- package/api/spec/sources.json +122 -0
- package/api/src/fetch.ts +18 -0
- package/api/src/generated/surface.gen.json +1 -0
- package/api/src/generated/ui.gen.json +1 -0
- package/api/src/index.ts +3 -0
- package/api/src/manifest.ts +137 -0
- package/api/src/semantics/api.ts +247 -0
- package/api/src/semantics/around.ts +36 -0
- package/api/src/semantics/doors.ts +76 -0
- package/api/src/semantics/index.ts +19 -0
- package/api/src/semantics/shared.ts +31 -0
- package/api/src/semantics/states.ts +17 -0
- package/dist/api/spec/SOURCE.md +32 -0
- package/dist/api/spec/doc-examples.json +12 -0
- package/dist/api/spec/openapi.json.gz +0 -0
- package/dist/api/spec/patches.json +5350 -0
- package/dist/api/spec/sources.json +122 -0
- package/dist/api/src/fetch.d.ts +2 -0
- package/dist/api/src/fetch.js +17 -0
- package/dist/api/src/fetch.ts +18 -0
- package/dist/api/src/generated/surface.gen.json +1 -0
- package/dist/api/src/generated/ui.gen.json +1 -0
- package/dist/api/src/index.d.ts +2 -0
- package/dist/api/src/index.js +3 -0
- package/dist/api/src/index.ts +3 -0
- package/dist/api/src/manifest.d.ts +2 -0
- package/dist/api/src/manifest.js +136 -0
- package/dist/api/src/manifest.ts +137 -0
- package/dist/api/src/semantics/api.d.ts +13 -0
- package/dist/api/src/semantics/api.js +269 -0
- package/dist/api/src/semantics/api.ts +247 -0
- package/dist/api/src/semantics/around.d.ts +2 -0
- package/dist/api/src/semantics/around.js +41 -0
- package/dist/api/src/semantics/around.ts +36 -0
- package/dist/api/src/semantics/doors.d.ts +5 -0
- package/dist/api/src/semantics/doors.js +71 -0
- package/dist/api/src/semantics/doors.ts +76 -0
- package/dist/api/src/semantics/index.d.ts +2 -0
- package/dist/api/src/semantics/index.js +15 -0
- package/dist/api/src/semantics/index.ts +19 -0
- package/dist/api/src/semantics/shared.d.ts +14 -0
- package/dist/api/src/semantics/shared.js +26 -0
- package/dist/api/src/semantics/shared.ts +31 -0
- package/dist/api/src/semantics/states.d.ts +24 -0
- package/dist/api/src/semantics/states.js +17 -0
- package/dist/api/src/semantics/states.ts +17 -0
- package/dist/ingest/spec/SOURCE.md +30 -0
- package/dist/ingest/spec/authentication.mdx.gz +0 -0
- package/dist/ingest/spec/compression.mdx.gz +0 -0
- package/dist/ingest/spec/doc-examples.json +216 -0
- package/dist/ingest/spec/envelope-items.mdx.gz +0 -0
- package/dist/ingest/spec/envelopes.mdx.gz +0 -0
- package/dist/ingest/spec/openapi.json +178 -0
- package/dist/ingest/spec/rate-limiting.mdx.gz +0 -0
- package/dist/ingest/spec/sources.json +25 -0
- package/dist/ingest/spec/transport.mdx.gz +0 -0
- package/dist/ingest/spec/write-spec.ts +192 -0
- package/dist/ingest/src/fetch.d.ts +2 -0
- package/dist/ingest/src/fetch.js +15 -0
- package/dist/ingest/src/fetch.ts +16 -0
- package/dist/ingest/src/generated/surface.gen.json +1 -0
- package/dist/ingest/src/generated/ui.gen.json +1 -0
- package/dist/ingest/src/index.d.ts +2 -0
- package/dist/ingest/src/index.js +3 -0
- package/dist/ingest/src/index.ts +3 -0
- package/dist/ingest/src/manifest.d.ts +2 -0
- package/dist/ingest/src/manifest.js +37 -0
- package/dist/ingest/src/manifest.ts +42 -0
- package/dist/ingest/src/semantics/api.d.ts +2 -0
- package/dist/ingest/src/semantics/api.js +240 -0
- package/dist/ingest/src/semantics/api.ts +188 -0
- package/dist/ingest/src/semantics/around.d.ts +2 -0
- package/dist/ingest/src/semantics/around.js +6 -0
- package/dist/ingest/src/semantics/around.ts +7 -0
- package/dist/ingest/src/semantics/index.d.ts +2 -0
- package/dist/ingest/src/semantics/index.js +4 -0
- package/dist/ingest/src/semantics/index.ts +8 -0
- package/dist/ingest/src/semantics/shared.d.ts +4 -0
- package/dist/ingest/src/semantics/shared.js +2 -0
- package/dist/ingest/src/semantics/shared.ts +7 -0
- package/dist/ingest/src/semantics/states.d.ts +1 -0
- package/dist/ingest/src/semantics/states.js +2 -0
- package/dist/ingest/src/semantics/states.ts +2 -0
- package/dist/journeys/coverage.json +205 -0
- package/dist/journeys/customer-life.json +921 -0
- package/dist/journeys/customer-life.outline.md +51 -0
- package/dist/journeys/decisions.json +170 -0
- package/dist/journeys/demand.json +92 -0
- package/dist/journeys/first-use.json +22 -0
- package/dist/journeys/score.json +231 -0
- package/dist/journeys/unreachable.json +29 -0
- package/dist/journeys/vendor-examples.json +474 -0
- package/dist/spec/sources.json +76 -0
- package/dist/src/cli.js +12 -27
- package/dist/src/fetch.d.ts +2 -0
- package/dist/src/fetch.js +9 -0
- package/dist/src/index.d.ts +5 -15
- package/dist/src/index.js +13 -75
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +35 -0
- package/dist/src/semantics/shared.d.ts +88 -0
- package/dist/src/semantics/shared.js +407 -0
- package/dist/src/server.d.ts +7 -0
- package/dist/src/server.js +7 -0
- package/generated/pack-facts.json +101 -0
- package/ingest/spec/SOURCE.md +30 -0
- package/ingest/spec/authentication.mdx.gz +0 -0
- package/ingest/spec/compression.mdx.gz +0 -0
- package/ingest/spec/doc-examples.json +216 -0
- package/ingest/spec/envelope-items.mdx.gz +0 -0
- package/ingest/spec/envelopes.mdx.gz +0 -0
- package/ingest/spec/openapi.json +178 -0
- package/ingest/spec/rate-limiting.mdx.gz +0 -0
- package/ingest/spec/sources.json +25 -0
- package/ingest/spec/transport.mdx.gz +0 -0
- package/ingest/spec/write-spec.ts +192 -0
- package/ingest/src/fetch.ts +16 -0
- package/ingest/src/generated/surface.gen.json +1 -0
- package/ingest/src/generated/ui.gen.json +1 -0
- package/ingest/src/index.ts +3 -0
- package/ingest/src/manifest.ts +42 -0
- package/ingest/src/semantics/api.ts +188 -0
- package/ingest/src/semantics/around.ts +7 -0
- package/ingest/src/semantics/index.ts +8 -0
- package/ingest/src/semantics/shared.ts +7 -0
- package/ingest/src/semantics/states.ts +2 -0
- package/journeys/coverage.json +205 -0
- package/journeys/customer-life.json +921 -0
- package/journeys/customer-life.outline.md +51 -0
- package/journeys/decisions.json +170 -0
- package/journeys/demand.json +92 -0
- package/journeys/first-use.json +22 -0
- package/journeys/score.json +231 -0
- package/journeys/unreachable.json +29 -0
- package/journeys/vendor-examples.json +474 -0
- package/package.json +22 -47
- package/spec/sources.json +76 -0
- package/src/cli.ts +12 -24
- package/src/fetch.ts +10 -0
- package/src/index.ts +13 -143
- package/src/manifest.ts +41 -0
- package/src/semantics/shared.ts +439 -0
- package/src/server.ts +8 -0
- package/client/sentry-mirror.css +0 -99
- package/client/sentry-mirror.tsx +0 -352
- package/dist/client/sentry-mirror.bundle.js +0 -388
- package/dist/client/sentry-mirror.css +0 -99
- package/dist/client/sentry-mirror.d.ts +0 -17
- package/dist/client/sentry-mirror.js +0 -156
- package/dist/client/sentry-mirror.tsx +0 -352
- package/dist/src/sentry-budget.d.ts +0 -50
- package/dist/src/sentry-budget.js +0 -145
- package/dist/src/sentry-capabilities.d.ts +0 -3
- package/dist/src/sentry-capabilities.js +0 -1180
- package/dist/src/sentry-conformance.d.ts +0 -22
- package/dist/src/sentry-conformance.js +0 -97
- package/dist/src/sentry-connector.d.ts +0 -77
- package/dist/src/sentry-connector.js +0 -226
- package/dist/src/sentry-events.d.ts +0 -54
- package/dist/src/sentry-events.js +0 -132
- package/dist/src/sentry-ingest.d.ts +0 -137
- package/dist/src/sentry-ingest.js +0 -387
- package/dist/src/sentry-mirror-ui.d.ts +0 -38
- package/dist/src/sentry-mirror-ui.js +0 -162
- package/dist/src/sentry-perform-harness.d.ts +0 -9
- package/dist/src/sentry-perform-harness.js +0 -20
- package/dist/src/sentry-server.d.ts +0 -14
- package/dist/src/sentry-server.js +0 -27
- package/dist/src/sentry-twin.d.ts +0 -17
- package/dist/src/sentry-twin.js +0 -1739
- package/dist/test-fixtures/sentry-openapi-operations.SOURCE.md +0 -24
- package/dist/test-fixtures/sentry-openapi-operations.json +0 -1837
- package/src/sentry-budget.ts +0 -171
- package/src/sentry-capabilities.ts +0 -1222
- package/src/sentry-conformance.ts +0 -110
- package/src/sentry-connector.ts +0 -260
- package/src/sentry-events.ts +0 -172
- package/src/sentry-ingest.ts +0 -471
- package/src/sentry-mirror-ui.ts +0 -162
- package/src/sentry-perform-harness.ts +0 -19
- package/src/sentry-server.ts +0 -35
- package/src/sentry-twin.ts +0 -1615
- package/test-fixtures/sentry-openapi-operations.SOURCE.md +0 -24
- package/test-fixtures/sentry-openapi-operations.json +0 -1837
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"generatedBy":"scripts/derive-pack.ts","resources":{"organization":{"actions":[],"states":{},"embeds":{}},"project":{"list":"/api/0/organizations/{organization_id_or_slug}/projects/","actions":[],"states":{},"embeds":{}},"project_key":{"actions":[],"states":{},"embeds":{}},"event":{"retrieve":"/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/events/{event_id}/","actions":[],"states":{},"embeds":{}},"issue":{"retrieve":"/api/0/organizations/{organization_id_or_slug}/issues/{issue_id}/","actions":[],"states":{},"embeds":{}},"release":{"actions":[],"states":{},"embeds":{}},"monitor":{"list":"/api/0/organizations/{organization_id_or_slug}/monitors/","actions":[],"states":{},"embeds":{}},"check_in":{"list":"/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/monitors/{monitor_id_or_slug}/checkins/","actions":[],"states":{"status":["error","in_progress","ok"]},"embeds":{}},"_seq_token":{"actions":[],"states":{},"embeds":{}},"_seq_event":{"actions":[],"states":{},"embeds":{}},"_seq_envelope":{"actions":[],"states":{},"embeds":{}},"_seq_project_key":{"actions":[],"states":{},"embeds":{}}}}
|
package/api/src/index.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Sentry web API: application release/source-map calls, account setup, and core resource refresh reads.
|
|
2
|
+
// Every API path needs a scoped account token (./semantics/around.ts).
|
|
3
|
+
import { states } from './semantics/states.ts';
|
|
4
|
+
import type { DerivedManifest } from '@volter/world-core';
|
|
5
|
+
|
|
6
|
+
export const manifest: DerivedManifest = {
|
|
7
|
+
vendor: 'sentry',
|
|
8
|
+
// both lanes are one service, so one store: the ingest lane writes events the API lane reads, and the doors' accounts
|
|
9
|
+
// are the ingest lane's too (aws's lanes likewise)
|
|
10
|
+
service: 'sentry',
|
|
11
|
+
hosts: [{ host: 'sentry.io' }, { host: 'us.sentry.io' }, { host: 'de.sentry.io' }],
|
|
12
|
+
body: { json: 'always' },
|
|
13
|
+
ids: { template: '{uuid}' },
|
|
14
|
+
time: 'iso',
|
|
15
|
+
// Django REST framework's error body, which Sentry's API answers with: `{"detail": ...}`
|
|
16
|
+
error: { detail: '{message}' },
|
|
17
|
+
readOnly: { status: 403, message: 'This twin was started read-only; writes are refused.' },
|
|
18
|
+
malformedBody: { status: 400, message: 'Malformed request.' },
|
|
19
|
+
notFound: { status: 404, message: 'The requested resource does not exist' },
|
|
20
|
+
gap: { status: 404, message: 'The requested resource does not exist' },
|
|
21
|
+
// source: https://docs.sentry.io/api/pagination/ "row offset"
|
|
22
|
+
list: { style: 'envelope', envelope: '{data}', limit: { param: 'per_page', default: 100, max: 100 }, cursor: { param: 'cursor', encoding: 'offset-template', template: '0:{offset}:{reverse}', link: { previous: 'previous', next: 'next', results: 'results', cursor: 'cursor' } } },
|
|
23
|
+
deleted: {},
|
|
24
|
+
// the sequences the vendor numbers by (ctx.issue over each, src/semantics/shared.ts next)
|
|
25
|
+
// source: spec:listOrganizations "List Your Organizations"
|
|
26
|
+
// source: https://docs.sentry.io/api/pagination/ "Pagination in the API is handled via the Link header standard:"
|
|
27
|
+
resources: {
|
|
28
|
+
// source: https://docs.sentry.io/api/projects/create-a-new-project/ "6758470122493650"
|
|
29
|
+
organization: { idPrefix: '', unique: [{ fields: ['slug'], refusal: { status: 409, message: 'An organization with this slug already exists.' } }], alternateKeys: ['slug'], ids: '{digits:16}', refresh: { list: 'listOrganizations', items: '$body', nextLink: { header: 'link', rel: 'next', query: 'cursor', when: { results: 'true' } } } },
|
|
30
|
+
// source: spec:listOrganizationProjects "Return a list of projects bound to a organization"
|
|
31
|
+
// source: https://docs.sentry.io/api/projects/create-a-new-project/ "6758470122493650"
|
|
32
|
+
project: { idPrefix: '', unique: [{ fields: ['_organization', 'slug'], refusal: { status: 409, message: 'A project with this slug already exists.' } }], alternateKeys: ['slug'], ids: '{digits:16}', parent: { param: 'organization_id_or_slug', field: '_organization', resource: 'organization' }, refresh: { list: 'listOrganizationProjects', items: '$body', nextLink: { header: 'link', rel: 'next', query: 'cursor', when: { results: 'true' } } } },
|
|
33
|
+
project_key: { idPrefix: '', ids: '{hex:32}', refresh: { none: 'Client keys contain credential secrets. Credential custody keeps their secret-bearing reads out of refresh.' } },
|
|
34
|
+
// source: spec:listProjectEvents "Return a list of events bound to a project."
|
|
35
|
+
event: { idPrefix: '', ids: '{hex:32}', key: '{project_id_or_slug}:{event_id}', vendorUnderscored: ['_meta'], ...states.event,
|
|
36
|
+
parent: { params: ['organization_id_or_slug', 'project_id_or_slug'], value: '{project_id_or_slug}', field: '_project', resource: 'project', where: { _organization: '{organization_id_or_slug}', id: '{project_id_or_slug}' }, keep: { _organization: '{organization_id_or_slug}' } },
|
|
37
|
+
refresh: { get: 'getProjectEvent', known: true, params: { organization_id_or_slug: '{_organization}', project_id_or_slug: '{projectID}', event_id: '{eventID}' }, idAs: '{projectID}:{eventID}' } },
|
|
38
|
+
// source: spec:getOrganizationIssue "Return details on an individual issue"
|
|
39
|
+
issue: { idPrefix: '', ids: '{n}', ...states.issue, parent: { param: 'organization_id_or_slug', field: '_organization', resource: 'organization' }, refresh: { get: 'getOrganizationIssue', known: true, params: { organization_id_or_slug: '{_organization}', issue_id: '{id}' } } },
|
|
40
|
+
// source: spec:listOrganizationReleases "List an Organization's Releases"
|
|
41
|
+
release: { idPrefix: '', ids: '{n}', parent: { param: 'organization_id_or_slug', field: '_organization', resource: 'organization' }, refresh: { list: 'listOrganizationReleases', items: '$body', nextLink: { header: 'link', rel: 'next', query: 'cursor', when: { results: 'true' } } } },
|
|
42
|
+
// source: spec:listOrganizationMonitors "Retrieve Monitors for an Organization"
|
|
43
|
+
monitor: { ...states.monitor, idPrefix: '', ids: '{uuid}', parent: { param: 'organization_id_or_slug', field: '_organization', resource: 'organization' }, refresh: { list: 'listOrganizationMonitors', items: '$body', nextLink: { header: 'link', rel: 'next', query: 'cursor', when: { results: 'true' } } } },
|
|
44
|
+
// source: spec:listProjectMonitorCheckins "Retrieve Check-Ins for a Monitor by Project"
|
|
45
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/monitors/serializers.py "str(obj.guid)"
|
|
46
|
+
check_in: { ...states.check_in, idPrefix: '', ids: '{uuid}', parent: { params: ['organization_id_or_slug', 'project_id_or_slug', 'monitor_id_or_slug'], value: '{monitor_id_or_slug}', field: '_monitor', resource: 'monitor', where: { _organization: '{organization_id_or_slug}', 'project.id': '{project_id_or_slug}', id: '{monitor_id_or_slug}' } }, refresh: { list: 'listProjectMonitorCheckins', items: '$body', nextLink: { header: 'link', rel: 'next', query: 'cursor', when: { results: 'true' } } } },
|
|
47
|
+
_seq_token: { idPrefix: '', ids: '{n}' }, _seq_event: { idPrefix: '', ids: '{n}' }, _seq_envelope: { idPrefix: '', ids: '{n}' }, _seq_project_key: { idPrefix: '', ids: '{n}' },
|
|
48
|
+
},
|
|
49
|
+
unmodeled: [
|
|
50
|
+
'getOrganization', 'listProjectEvents', 'getProjectRelease', 'getProjectMonitor',
|
|
51
|
+
// served by no handler: called by no application here (../../journeys/demand.json)
|
|
52
|
+
'updateOrganization', 'listOrganizationAIConversations',
|
|
53
|
+
'retrieveOrganizationAIConversation', 'getOrganizationConfigIntegrations', 'listOrganizationDashboards',
|
|
54
|
+
'createOrganizationDashboard', 'getOrganizationDashboard', 'updateOrganizationDashboard',
|
|
55
|
+
'deleteOrganizationDashboard', 'listOrganizationDetectors', 'updateOrganizationDetectors',
|
|
56
|
+
'deleteOrganizationDetectors', 'getOrganizationDetector', 'updateOrganizationDetector',
|
|
57
|
+
'deleteOrganizationDetector', 'listOrganizationDiscoverSavedQueries', 'createOrganizationDiscoverSavedQuery',
|
|
58
|
+
'getOrganizationDiscoverSavedQuery', 'updateOrganizationDiscoverSavedQuery',
|
|
59
|
+
'deleteOrganizationDiscoverSavedQuery', 'listOrganizationEnvironments', 'resolveOrganizationEventId',
|
|
60
|
+
'listOrganizationEventsTimeseries', 'listOrganizationEvents', 'createOrganizationExternalUser',
|
|
61
|
+
'updateOrganizationExternalUser', 'deleteOrganizationExternalUser', 'listOrganizationForwarding',
|
|
62
|
+
'createOrganizationForwarding', 'updateOrganizationForwarding', 'deleteOrganizationForwarding',
|
|
63
|
+
'listOrganizationIssueViews', 'createOrganizationIssueView', 'listOrganizationIntegrations',
|
|
64
|
+
'getOrganizationIntegration', 'deleteOrganizationIntegration', 'listOrganizationIntegrationRepositories',
|
|
65
|
+
'listOrganizationIssues', 'updateOrganizationIssues', 'deleteOrganizationIssues',
|
|
66
|
+
'updateOrganizationIssue', 'deleteOrganizationIssue', 'getOrganizationIssueAutofixState',
|
|
67
|
+
'startOrganizationIssueAutofix', 'listOrganizationIssueEvents', 'getOrganizationIssueEvent',
|
|
68
|
+
'listOrganizationIssueExternalIssues', 'deleteOrganizationIssueExternalIssue', 'listOrganizationIssueHashes',
|
|
69
|
+
'listOrganizationIssueIntegrations', "Retrieve an Integration's Issue Config for an Issue",
|
|
70
|
+
'Create an External Issue and Link It to an Issue', 'Link an Existing External Issue to an Issue',
|
|
71
|
+
'Unlink an External Issue from an Issue', 'getOrganizationIssueTag', 'listOrganizationIssueTagValues',
|
|
72
|
+
'listOrganizationMembers', 'addOrganizationMember', 'getOrganizationMember', 'updateOrganizationMember',
|
|
73
|
+
'deleteOrganizationMember', 'addOrganizationMemberTeam', 'updateOrganizationMemberTeam',
|
|
74
|
+
'deleteOrganizationMemberTeam', 'createOrganizationMonitor', 'getOrganizationMonitor',
|
|
75
|
+
'updateOrganizationMonitor', 'deleteOrganizationMonitor', 'listOrganizationMonitorCheckins',
|
|
76
|
+
'listOrganizationNotificationsActions', 'createOrganizationNotificationsAction',
|
|
77
|
+
'getOrganizationNotificationsAction', 'updateOrganizationNotificationsAction',
|
|
78
|
+
'deleteOrganizationNotificationsAction', 'getOrganizationPreprodArtifactInstallDetails',
|
|
79
|
+
'getOrganizationPreprodArtifactSizeAnalysis', 'getOrganizationPreprodArtifactSnapshot',
|
|
80
|
+
'deleteOrganizationPreprodArtifactSnapshot', 'getOrganizationPreprodArtifactSnapshotImage',
|
|
81
|
+
'getOrganizationPreprodArtifactSnapshotLatestBase', 'listOrganizationProfilingChunks',
|
|
82
|
+
'getOrganizationProfilingFlamegraph', 'listOrganizationProjectKeys',
|
|
83
|
+
'createOrganizationProjectDetector', 'listOrganizationRelayUsage', 'listOrganizationReleaseThresholdStatuses',
|
|
84
|
+
'createOrganizationRelease', 'getOrganizationRelease', 'deleteOrganizationRelease',
|
|
85
|
+
"Retrieve Files Changed in a Release's Commits", 'listOrganizationReleaseCommits',
|
|
86
|
+
'listOrganizationReleaseDeploys', 'createOrganizationReleaseDeploy', 'listOrganizationReleaseFiles',
|
|
87
|
+
'uploadOrganizationReleaseFile', 'getOrganizationReleaseFile', 'updateOrganizationReleaseFile',
|
|
88
|
+
'deleteOrganizationReleaseFile', 'getOrganizationReplayCount', 'listOrganizationReplaySelectors',
|
|
89
|
+
'listOrganizationReplays', 'getOrganizationReplay', 'listOrganizationRepoCommits', 'listOrganizationScimV2Groups',
|
|
90
|
+
'provisionOrganizationScimV2Group', 'getOrganizationScimV2Group', 'updateOrganizationScimV2Group',
|
|
91
|
+
'deleteOrganizationScimV2Group', 'listOrganizationScimV2Users', 'provisionOrganizationScimV2User',
|
|
92
|
+
'getOrganizationScimV2User', 'updateOrganizationScimV2User', 'deleteOrganizationScimV2User',
|
|
93
|
+
'listOrganizationSentryAppComponents', "List an Organization's Integration Platform Installations",
|
|
94
|
+
'listOrganizationSentryApps', 'getOrganizationSessions', 'resolveOrganizationShortId', 'Enable Spike Protection',
|
|
95
|
+
'Disable Spike Protection', 'listOrganizationStatsV2', 'getOrganizationStatsSummary', 'listOrganizationTags',
|
|
96
|
+
'listOrganizationTeams', 'createOrganizationTeam', 'listOrganizationTraceItemAttributes',
|
|
97
|
+
'listOrganizationTraceMetrics', 'Retrieve Trace Item Statistics', 'getOrganizationTraceMeta',
|
|
98
|
+
'getOrganizationTrace', 'listOrganizationUserTeams', 'listOrganizationWorkflows', 'createOrganizationWorkflow',
|
|
99
|
+
'updateOrganizationWorkflows', 'deleteOrganizationWorkflows', 'getOrganizationWorkflow',
|
|
100
|
+
'updateOrganizationWorkflow', 'deleteOrganizationWorkflow', 'getProject', 'updateProject', 'deleteProject',
|
|
101
|
+
'listProjectCodeOwners', 'createProjectCodeOwners', 'retrieveProjectCodeOwners', 'updateProjectCodeOwners',
|
|
102
|
+
'deleteProjectCodeOwners', "List a Project's Custom Inbound Filters", 'Create a Custom Inbound Filter',
|
|
103
|
+
'Retrieve a Custom Inbound Filter', 'Update a Custom Inbound Filter', 'Delete a Custom Inbound Filter',
|
|
104
|
+
'listProjectEnvironments', 'Bulk Update Project Environments', 'getProjectEnvironment',
|
|
105
|
+
'updateProjectEnvironment', 'listProjectEventAttachments', 'getProjectEventAttachment',
|
|
106
|
+
'getProjectEventSourceMapDebug', 'listProjectDebugFiles', 'listProjectFilters', 'updateProjectFilter',
|
|
107
|
+
"List a Project's Service Hooks", 'Register a New Service Hook', 'Retrieve a Service Hook',
|
|
108
|
+
'Update a Service Hook', 'Remove a Service Hook', "List a Project's Issues", 'Bulk Mutate a List of Issues',
|
|
109
|
+
'Bulk Remove a List of Issues', 'createProjectKey', 'getProjectKey', 'updateProjectKey',
|
|
110
|
+
'deleteProjectKey', 'listProjectMembers', 'updateProjectMonitor', 'deleteProjectMonitor',
|
|
111
|
+
'getProjectOwnership', 'updateProjectOwnership',
|
|
112
|
+
'getProjectPreprodSizeAnalysisStatusCheckRules', 'createProjectPreprodSizeAnalysisSkippedStatusCheck',
|
|
113
|
+
'getProjectPreprodSnapshotStatusCheckRules', 'createProjectPreprodSnapshotSkippedStatusCheck',
|
|
114
|
+
'getProjectInstallableBuildLatest', 'uploadProjectPreprodArtifactSnapshot', 'getProjectProfilingProfile',
|
|
115
|
+
'listProjectReleases', 'listProjectReleaseCommits', 'listProjectReleaseFiles', 'uploadProjectReleaseFile',
|
|
116
|
+
'getProjectReleaseFile', 'updateProjectReleaseFile', 'deleteProjectReleaseFile', 'deleteProjectReplay',
|
|
117
|
+
'listProjectReplayClicks', 'listProjectReplayRecordingSegments', 'getProjectReplayRecordingSegment',
|
|
118
|
+
'listProjectReplayViewedBy', 'listProjectReplayDeletionJobs', 'createProjectReplayDeletionJob',
|
|
119
|
+
'getProjectReplayDeletionJob', 'listProjectRepositories', 'linkProjectRepository', 'listProjectStats',
|
|
120
|
+
'listProjectSymbolSources', 'addProjectSymbolSource', 'updateProjectSymbolSource', 'deleteProjectSymbolSource',
|
|
121
|
+
"List a Tag's Values", 'listProjectTeams', 'addProjectTeam', 'deleteProjectTeam',
|
|
122
|
+
"List a Project's User Feedback", 'Submit User Feedback', 'listProjectUsers', 'listSeerModels',
|
|
123
|
+
'executeSentryAppInstallationExternalIssueAction', 'Create or update an External Issue',
|
|
124
|
+
'Delete an External Issue', 'getSentryAppInstallationExternalRequestOptions', 'getSentryApp', 'updateSentryApp',
|
|
125
|
+
'deleteSentryApp', 'getTeam', 'updateTeam', 'deleteTeam', 'createTeamExternalTeam', 'updateTeamExternalTeam',
|
|
126
|
+
'deleteTeamExternalTeam', 'listTeamMembers', 'listTeamProjects', 'createTeamProject'],
|
|
127
|
+
// the World's doors (./semantics/doors.ts): what sentry.io's pages do that its API does not
|
|
128
|
+
doors: [
|
|
129
|
+
{ id: 'appCredentials', method: 'POST', path: '/_twin/app-credentials', note: "the World application's DSN, auth token, organization and project, as the runtime issues them" },
|
|
130
|
+
{ id: 'organizations', method: 'POST', path: '/_twin/organizations', note: "an organization (sentry.io's sign-up)" },
|
|
131
|
+
{ id: 'authTokens', method: 'POST', path: '/_twin/auth-tokens', note: 'an organization CI token or scoped personal auth token, shown once (Settings > Auth Tokens)' },
|
|
132
|
+
],
|
|
133
|
+
// source: https://docs.sentry.io/api/ratelimits/ "Each endpoint has its own maximum number of requests and window size."
|
|
134
|
+
// The page supplies response headers rather than fixed numerical limits; no global allowance is inferred.
|
|
135
|
+
accountSetup: [{ operation: 'createOrganizationProject', why: 'The operator creates additional account projects; application calls do not deploy account setup.' }],
|
|
136
|
+
discovery: { twinOf: "Sentry's web API (sentry.io/api/0): release and source-map operations, account project/key operations, and declared resource read-back", stores: 'organizations, projects and their client keys, auth tokens (by their SHA-256), releases, uploaded chunks and artifact bundles' },
|
|
137
|
+
};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { gunzip, type HandlerContext } from '@volter/world-core';
|
|
2
|
+
import { bearerToken, detail, EVENT, forbidden, grants, keyOf, makeProject, notFound, orgFor, PROJECT, projectFor, projectOf, type Row, wire } from './shared.ts';
|
|
3
|
+
|
|
4
|
+
// Releases, as sentry-cli makes, finalizes and associates commits with them (`releases new`, `releases finalize`,
|
|
5
|
+
// `releases set-commits`), for one project at its path or for the organization; and the organization's repositories the
|
|
6
|
+
// commits are read against. A release is the organization's, by its version, linked to the projects it was made for.
|
|
7
|
+
|
|
8
|
+
const RELEASE = 'release';
|
|
9
|
+
const releaseBody = (ctx: HandlerContext): Row => (ctx.body && typeof ctx.body === 'object' ? ctx.body as Row : {});
|
|
10
|
+
const releaseOf = (ctx: HandlerContext, org: Row, version: string): Row | undefined =>
|
|
11
|
+
ctx.rowsRaw(RELEASE).find((r) => r.deleted !== true && r._organization === org.id && r.version === version);
|
|
12
|
+
|
|
13
|
+
/** A release as the API answers it (the spec's release schema). */
|
|
14
|
+
function view(ctx: HandlerContext, r: Row): Row {
|
|
15
|
+
const projects = (r._projects as string[]).map((id) => ctx.row(PROJECT, id)).filter((p): p is Row => !!p);
|
|
16
|
+
const events = ctx.rowsRaw(EVENT).filter((e) => e.release === r.version && (r._projects as string[]).includes(String(e.project))).map((e) => String(e.dateCreated)).sort();
|
|
17
|
+
return {
|
|
18
|
+
id: Number(r.id), version: r.version, shortVersion: String(r.version).length > 12 && /^[0-9a-f]{40}$/.test(String(r.version)) ? String(r.version).slice(0, 12) : r.version,
|
|
19
|
+
status: 'open', ref: r.ref ?? null, url: r.url ?? null, dateReleased: r.dateReleased ?? null, dateCreated: r.dateCreated, data: {}, newGroups: 0, owner: null,
|
|
20
|
+
commitCount: Number(r.commitCount ?? 0), lastCommit: r.lastCommit ?? null, deployCount: 0, lastDeploy: null, authors: [], userAgent: r.userAgent ?? null, currentProjectMeta: {},
|
|
21
|
+
firstEvent: events[0] ?? null, lastEvent: events.at(-1) ?? null, versionInfo: { package: null, version: { raw: r.version }, description: r.version, buildHash: null },
|
|
22
|
+
projects: projects.map((p) => ({ id: Number(p.id), slug: p.slug, name: p.name, newGroups: 0, platform: p.platform ?? null, platforms: p.platform ? [p.platform] : [], hasHealthData: false })),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A release made for projects of the organization, or the projects linked to one that exists: 201 when anything was
|
|
27
|
+
* made or linked, else 208 (the release as it was). */
|
|
28
|
+
async function makeRelease(ctx: HandlerContext, org: Row, projectIds: string[]): Promise<Response> {
|
|
29
|
+
const b = releaseBody(ctx);
|
|
30
|
+
const version = typeof b.version === 'string' ? b.version : '';
|
|
31
|
+
// the release's version: not empty, no "/", not "." or ".." or "latest" (the spec's version field)
|
|
32
|
+
if (!version || version.includes('/') || ['.', '..', 'latest'].includes(version) || version.length > 200) return Response.json({ version: ['Release with name "' + version + '" is not allowed.'] }, { status: 400 });
|
|
33
|
+
const held = releaseOf(ctx, org, version);
|
|
34
|
+
const fields = { ...(typeof b.ref === 'string' ? { ref: b.ref } : {}), ...(typeof b.url === 'string' ? { url: b.url } : {}), ...(typeof b.dateReleased === 'string' ? { dateReleased: b.dateReleased } : {}) };
|
|
35
|
+
if (held) {
|
|
36
|
+
const linked = [...new Set([...(held._projects as string[]), ...projectIds])];
|
|
37
|
+
if (linked.length === (held._projects as string[]).length) return Response.json(view(ctx, held), { status: 208 });
|
|
38
|
+
const kept = await ctx.write(RELEASE, String(held.id), { _projects: linked, ...fields }, 'release.update');
|
|
39
|
+
const answer = view(ctx, ctx.row(RELEASE, String(kept.id))!);
|
|
40
|
+
await ctx.write(RELEASE, String(kept.id), answer, 'release.update');
|
|
41
|
+
return Response.json(answer, { status: 201 });
|
|
42
|
+
}
|
|
43
|
+
const id = ctx.mint(RELEASE);
|
|
44
|
+
const made = await ctx.write(RELEASE, id, { id: Number(id), version, _organization: org.id, _projects: projectIds, projects: projectIds, dateCreated: ctx.occurredAt, userAgent: ctx.call.request.headers.get('user-agent'), ...fields }, 'release.create');
|
|
45
|
+
const answer = view(ctx, ctx.row(RELEASE, id)!);
|
|
46
|
+
await ctx.write(RELEASE, id, answer, 'release.update');
|
|
47
|
+
return Response.json(answer, { status: 201 });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A release's changes: when it was released, its ref and URL, and the commits named by `refs` (each a repository the
|
|
51
|
+
* organization has). */
|
|
52
|
+
async function changeRelease(ctx: HandlerContext, org: Row, r: Row): Promise<Response> {
|
|
53
|
+
const b = releaseBody(ctx);
|
|
54
|
+
const refs = Array.isArray(b.refs) ? b.refs as Row[] : [];
|
|
55
|
+
const repos = ctx.rowsRaw('repository').filter((x) => x.deleted !== true && x.organization === org.id).map((x) => String(x.name));
|
|
56
|
+
const unknown = refs.map((x) => String(x.repository ?? '')).filter((n) => !repos.includes(n));
|
|
57
|
+
if (unknown.length) return Response.json({ refs: [`Invalid repository names: ${unknown.join(',')}`] }, { status: 400 });
|
|
58
|
+
const kept = await ctx.write(RELEASE, String(r.id), {
|
|
59
|
+
...(typeof b.dateReleased === 'string' || b.dateReleased === null ? { dateReleased: b.dateReleased } : {}),
|
|
60
|
+
...(typeof b.ref === 'string' ? { ref: b.ref } : {}), ...(typeof b.url === 'string' ? { url: b.url } : {}), ...(refs.length ? { _refs: refs } : {}), ...(Array.isArray(b.commits) ? { _commits: b.commits, commitCount: b.commits.length, lastCommit: (b.commits as Row[]).at(-1) ?? null } : {}),
|
|
61
|
+
}, 'release.update');
|
|
62
|
+
const answer = view(ctx, ctx.row(RELEASE, String(kept.id))!);
|
|
63
|
+
await ctx.write(RELEASE, String(kept.id), answer, 'release.update');
|
|
64
|
+
return Response.json(answer);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/releases/endpoints/project_releases.py "Create a new release and/or associate a project with a release."
|
|
68
|
+
export async function createProjectRelease(ctx: HandlerContext): Promise<Response> {
|
|
69
|
+
const at = projectFor(ctx, 'project:releases');
|
|
70
|
+
if (at instanceof Response) return at;
|
|
71
|
+
return makeRelease(ctx, at.org, [String(at.project.id)]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/releases/endpoints/project_release_details.py "def put("
|
|
77
|
+
export async function updateProjectRelease(ctx: HandlerContext): Promise<Response> {
|
|
78
|
+
const at = projectFor(ctx, 'project:releases');
|
|
79
|
+
if (at instanceof Response) return at;
|
|
80
|
+
const r = releaseOf(ctx, at.org, String(ctx.call.params.version ?? ''));
|
|
81
|
+
return r && (r._projects as string[]).includes(String(at.project.id)) ? changeRelease(ctx, at.org, r) : detail(404, 'The requested resource does not exist');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// source: spec:updateOrganizationRelease "Update a release. This can change some metadata associated with"
|
|
85
|
+
export async function updateOrganizationRelease(ctx: HandlerContext): Promise<Response> {
|
|
86
|
+
const org = orgFor(ctx, 'project:releases');
|
|
87
|
+
if (org instanceof Response) return org;
|
|
88
|
+
const r = releaseOf(ctx, org, String(ctx.call.params.version ?? ''));
|
|
89
|
+
return r ? changeRelease(ctx, org, r) : detail(404, 'The requested resource does not exist');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// source: spec:listOrganizationRepos "Return a list of version control repositories for a given organization."
|
|
93
|
+
export async function listOrganizationRepos(ctx: HandlerContext): Promise<Response> {
|
|
94
|
+
const org = orgFor(ctx, 'org:read');
|
|
95
|
+
if (org instanceof Response) return org;
|
|
96
|
+
// Where the evidence stops: a repository comes from a source-code integration (GitHub's app, installed on
|
|
97
|
+
// Settings > Integrations), which the World does not run yet; an organization has none
|
|
98
|
+
return Response.json(ctx.rowsRaw('repository').filter((x) => x.deleted !== true && x.organization === org.id).map((x) => wire(ctx, x)));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Source maps as sentry-cli uploads them (`sourcemaps upload`, and the bundler plugins through it): the server's chunk
|
|
102
|
+
// options, chunks of the artifact bundle posted by their SHA-1, and the bundle assembled from them for its projects and
|
|
103
|
+
// release (getsentry/sentry: api/endpoints/chunk.py and organization_artifactbundle_assemble.py).
|
|
104
|
+
|
|
105
|
+
const CHUNK = '_chunk';
|
|
106
|
+
const BUNDLE = '_artifact_bundle';
|
|
107
|
+
const error = (message: string): Response => Response.json({ error: message }, { status: 400 });
|
|
108
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/api/endpoints/chunk.py "MAX_CHUNKS_PER_REQUEST = 64"
|
|
109
|
+
const MAX_CHUNKS_PER_REQUEST = 64;
|
|
110
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/api/endpoints/chunk.py "MAX_REQUEST_SIZE = 32 * 1024 * 1024"
|
|
111
|
+
const MAX_REQUEST_SIZE = 32 * 1024 * 1024;
|
|
112
|
+
// Where the documentation stops: the chunk size is the server's setting (SENTRY_CHUNK_UPLOAD_BLOB_SIZE), a power of two
|
|
113
|
+
// for sentry-cli 2.39 and older; the twin's is 8 MiB. The largest file is the file store's limit, 2 GiB here.
|
|
114
|
+
const CHUNK_SIZE = 8 * 1024 * 1024;
|
|
115
|
+
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024;
|
|
116
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/api/endpoints/chunk.py "# The `assemble` endpoint will check for missing chunks"
|
|
117
|
+
const ACCEPT = ['debug_files', 'release_files', 'pdbs', 'sources', 'bcsymbolmaps', 'il2cpp', 'portablepdbs', 'artifact_bundles', 'artifact_bundles_v2', 'proguard', 'preprod_artifacts', 'dartsymbolmap'];
|
|
118
|
+
const SHA1 = /^[0-9a-f]{40}$/;
|
|
119
|
+
|
|
120
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/api/endpoints/chunk.py "Return chunk upload parameters"
|
|
121
|
+
export async function getChunkUploadOptions(ctx: HandlerContext): Promise<Response> {
|
|
122
|
+
const org = orgFor(ctx, 'project:releases');
|
|
123
|
+
if (org instanceof Response) return org;
|
|
124
|
+
// sentry-cli 2.30 and later are sent an absolute URL at the region's host (chunk.py `requires_region_url`); the
|
|
125
|
+
// compression offered is gzip alone, as the server may answer a narrower list than its own (an organization with
|
|
126
|
+
// compression turned off is answered none)
|
|
127
|
+
return Response.json({
|
|
128
|
+
url: `https://us.sentry.io/api/0/organizations/${String(org.slug)}/chunk-upload/`, chunkSize: CHUNK_SIZE, chunksPerRequest: MAX_CHUNKS_PER_REQUEST,
|
|
129
|
+
maxFileSize: MAX_FILE_SIZE, maxRequestSize: MAX_REQUEST_SIZE, concurrency: 8, hashAlgorithm: 'sha1', compression: ['gzip'], accept: ACCEPT,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/api/endpoints/chunk.py "files_gzip_legacy = request.FILES.getlist("
|
|
134
|
+
export async function uploadChunks(ctx: HandlerContext): Promise<Response> {
|
|
135
|
+
const org = orgFor(ctx, 'project:releases');
|
|
136
|
+
if (org instanceof Response) return org;
|
|
137
|
+
const encoding = (ctx.call.request.headers.get('content-encoding') ?? '').toLowerCase();
|
|
138
|
+
const parts = (await ctx.parts()).filter((p) => p.name === 'file' || p.name === 'file_gzip');
|
|
139
|
+
if (encoding && parts.some((p) => p.name === 'file_gzip')) return error('Cannot combine Content-Encoding with file_gzip field');
|
|
140
|
+
if (encoding && encoding !== 'gzip') return error('Unsupported Content-Encoding');
|
|
141
|
+
if (parts.length > MAX_CHUNKS_PER_REQUEST) return error('Too many chunks');
|
|
142
|
+
let total = 0;
|
|
143
|
+
for (const part of parts) {
|
|
144
|
+
let data: Uint8Array;
|
|
145
|
+
try { data = part.name === 'file_gzip' || encoding === 'gzip' ? await gunzip(part.body) : part.body; } catch { return error('Invalid gzip payload'); }
|
|
146
|
+
if (data.length > CHUNK_SIZE) return error('Chunk size too large');
|
|
147
|
+
total += data.length;
|
|
148
|
+
if (total > MAX_REQUEST_SIZE) return error('Request too large');
|
|
149
|
+
const checksum = ctx.crypto.digest('sha1', data, 'hex');
|
|
150
|
+
// Where the documentation stops: a part is named by its checksum; one whose content has another is refused
|
|
151
|
+
if (String(part.filename ?? '') !== checksum) return error(`Checksum mismatch: ${String(part.filename)}`);
|
|
152
|
+
await ctx.blobs.put(`chunk/${checksum}`, data);
|
|
153
|
+
await ctx.record(CHUNK, { organization: org.id, size: data.length }, `${String(org.id)}:${checksum}`);
|
|
154
|
+
}
|
|
155
|
+
return new Response(null, { status: 200 });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/api/endpoints/organization_artifactbundle_assemble.py "AssembleTask.ARTIFACT_BUNDLE, organization.id, checksum, ChunkFileState.CREATED"
|
|
159
|
+
export async function assembleArtifactBundle(ctx: HandlerContext): Promise<Response> {
|
|
160
|
+
const org = orgFor(ctx, 'project:releases');
|
|
161
|
+
if (org instanceof Response) return org;
|
|
162
|
+
const b = (ctx.body && typeof ctx.body === 'object' ? ctx.body : {}) as Row;
|
|
163
|
+
if (typeof b.checksum !== 'string' || !SHA1.test(b.checksum)) return error('The checksum field is required and must be a 40-character hexadecimal string.');
|
|
164
|
+
if (!Array.isArray(b.chunks) || b.chunks.some((c) => typeof c !== 'string' || !SHA1.test(c))) return error('The chunks field is required and must be provided as an array of 40-character hexadecimal strings.');
|
|
165
|
+
if (!Array.isArray(b.projects)) return error('The projects field is required and must be provided as an array of strings.');
|
|
166
|
+
if (b.version !== undefined && (typeof b.version !== 'string' || !b.version || b.version.includes('/'))) return error('The version field cannot be empty and cannot contain any "/" characters.');
|
|
167
|
+
if (b.projects.length === 0) return error('You need to specify at least one project');
|
|
168
|
+
const projects = (b.projects as unknown[]).map((p) => projectOf(ctx, org, String(p)));
|
|
169
|
+
if (projects.some((p) => !p)) return error('One or more projects are invalid');
|
|
170
|
+
const held = ctx.row(BUNDLE, `${String(org.id)}:${b.checksum}`);
|
|
171
|
+
if (held && held.deleted !== true) return Response.json({ state: held.state, detail: held.detail ?? null, missingChunks: [] });
|
|
172
|
+
const chunks = b.chunks as string[];
|
|
173
|
+
const missing = chunks.filter((c) => !ctx.row(CHUNK, `${String(org.id)}:${c}`));
|
|
174
|
+
if (missing.length) return Response.json({ state: 'not_found', missingChunks: missing });
|
|
175
|
+
// the bundle is assembled from its chunks in order; its checksum must be theirs together
|
|
176
|
+
const pieces = await Promise.all(chunks.map((c) => ctx.blobs.get(`chunk/${c}`)));
|
|
177
|
+
const whole = new Uint8Array(pieces.reduce((n, p) => n + (p?.length ?? 0), 0));
|
|
178
|
+
let at = 0;
|
|
179
|
+
for (const p of pieces) { if (p) { whole.set(p, at); at += p.length; } }
|
|
180
|
+
const ok = ctx.crypto.digest('sha1', whole, 'hex') === b.checksum;
|
|
181
|
+
if (ok) await ctx.blobs.put(`bundle/${String(org.id)}/${b.checksum}`, whole);
|
|
182
|
+
// the assembly is scheduled on the first request that names every chunk (answered `created`); later requests read
|
|
183
|
+
// its outcome: `ok`, or `error` with why
|
|
184
|
+
await ctx.record(BUNDLE, {
|
|
185
|
+
organization: org.id, checksum: b.checksum, projects: projects.map((p) => p!.id), release: b.version ?? null, dist: b.dist ?? null, size: whole.length,
|
|
186
|
+
state: ok ? 'ok' : 'error', detail: ok ? null : 'Reported checksum mismatch', dateCreated: ctx.occurredAt,
|
|
187
|
+
}, `${String(org.id)}:${b.checksum}`);
|
|
188
|
+
return Response.json({ state: 'created', missingChunks: [] });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// source: spec:createOrganizationProject "Create a new project for an organization."
|
|
192
|
+
export async function createOrganizationProject(ctx: HandlerContext): Promise<Response> {
|
|
193
|
+
const org = orgFor(ctx, 'project:write');
|
|
194
|
+
if (org instanceof Response) return org;
|
|
195
|
+
const b = (ctx.body ?? {}) as Row;
|
|
196
|
+
if (typeof b.name !== 'string' || !b.name.trim()) return Response.json({ name: ['This field is required.'] }, { status: 400 });
|
|
197
|
+
const slug = typeof b.slug === 'string' ? b.slug : b.name.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
|
|
198
|
+
// source: spec:/paths/~1api~10~1organizations~1{organization_id_or_slug}~1projects~1/post/requestBody "Uniquely identifies a project and is used for the interface."
|
|
199
|
+
if (!/^(?![0-9]+$)[a-z0-9_-]+$/.test(slug)) return Response.json({ slug: ['Enter a valid slug.'] }, { status: 400 });
|
|
200
|
+
if (projectOf(ctx, org, slug)) return Response.json({ slug: ['A project with this slug already exists.'] }, { status: 409 });
|
|
201
|
+
const project = await makeProject(ctx, org, slug, typeof b.platform === 'string' ? b.platform : null);
|
|
202
|
+
if (project instanceof Response) return project;
|
|
203
|
+
await ctx.write(PROJECT, String(project.id), { name: b.name }, 'project.update');
|
|
204
|
+
return Response.json(wire(ctx, ctx.row(PROJECT, String(project.id))!), { status: 201 });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// source: spec:listProjectKeys "Return a list of client keys bound to a project."
|
|
208
|
+
export async function listProjectKeys(ctx: HandlerContext): Promise<Response> {
|
|
209
|
+
const at = projectFor(ctx, 'project:read');
|
|
210
|
+
if (at instanceof Response) return at;
|
|
211
|
+
const key = keyOf(ctx, at.project);
|
|
212
|
+
return Response.json(key ? [wire(ctx, key)] : []);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// source: spec:listOrganizations "List Your Organizations"
|
|
216
|
+
export async function listOrganizations(ctx: HandlerContext): Promise<Response> {
|
|
217
|
+
const token = bearerToken(ctx)!;
|
|
218
|
+
if (!grants(token, 'org:read')) return forbidden();
|
|
219
|
+
return ctx.list('organization', ctx.rowsRaw('organization').filter((o) => o.deleted !== true && o.id === token.organization).map((o) => wire(ctx, o)));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// source: spec:listOrganizationReleases "List an Organization's Releases"
|
|
223
|
+
export async function listOrganizationReleases(ctx: HandlerContext): Promise<Response> {
|
|
224
|
+
const org = orgFor(ctx, 'project:releases');
|
|
225
|
+
if (org instanceof Response) return org;
|
|
226
|
+
return ctx.list('release', ctx.rowsRaw('release').filter((r) => r.deleted !== true && r._organization === org.id).map((r) => wire(ctx, r)));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/issues/endpoints/organization_release_previous_commits.py "raise ResourceDoesNotExist"
|
|
231
|
+
export async function getPreviousReleaseWithCommits(ctx: HandlerContext): Promise<Response> {
|
|
232
|
+
const org = orgFor(ctx, 'project:releases');
|
|
233
|
+
if (org instanceof Response) return org;
|
|
234
|
+
const current = releaseOf(ctx, org, String(ctx.call.params.version));
|
|
235
|
+
if (!current) return notFound();
|
|
236
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/issues/endpoints/organization_release_previous_commits.py "start_date = release.date_released or release.date_added"
|
|
237
|
+
const start = String(current.dateReleased ?? current.dateCreated);
|
|
238
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/issues/endpoints/organization_release_previous_commits.py "projects__id__in=project_ids"
|
|
239
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/issues/endpoints/organization_release_previous_commits.py "last_commit_id__isnull=False"
|
|
240
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/issues/endpoints/organization_release_previous_commits.py "COALESCE(date_released, date_added) <= %s"
|
|
241
|
+
const candidates = ctx.rowsRaw('release').filter((r) => r.deleted !== true && r._organization === org.id && r.lastCommit != null
|
|
242
|
+
&& (r._projects as string[]).some((id) => (current._projects as string[]).includes(id)) && String(r.dateReleased ?? r.dateCreated) <= start);
|
|
243
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/issues/endpoints/organization_release_previous_commits.py "order_by"
|
|
244
|
+
const previous = candidates.sort((a, b) => String(b.dateReleased ?? b.dateCreated).localeCompare(String(a.dateReleased ?? a.dateCreated)))[0];
|
|
245
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/issues/endpoints/organization_release_previous_commits.py "return Response({})"
|
|
246
|
+
return Response.json(previous ? wire(ctx, previous) : {});
|
|
247
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Scoped API authentication and the vendor's organization/project/monitor slug resolution.
|
|
2
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
3
|
+
import { bearerToken, detail, forbidden, grants, orgFor, projectFor, MONITOR } from './shared.ts';
|
|
4
|
+
|
|
5
|
+
export async function around(ctx: HandlerContext, next: (request?: Request) => Promise<Response>): Promise<Response> {
|
|
6
|
+
const request = ctx.call.request;
|
|
7
|
+
if (!new URL(request.url).pathname.startsWith('/api/0/')) return next();
|
|
8
|
+
if (!request.headers.get('authorization')) return detail(401, 'Authentication credentials were not provided.');
|
|
9
|
+
const token = bearerToken(ctx);
|
|
10
|
+
if (!token) return detail(401, 'Invalid token');
|
|
11
|
+
// Core refresh reads use the spec's scopes, with Sentry's inherited write/admin grants.
|
|
12
|
+
if (!ctx.call.operation.resource || !['list', 'retrieve'].includes(ctx.call.operation.class)) return next();
|
|
13
|
+
// source: https://docs.sentry.io/api/permissions/ "Scopes"
|
|
14
|
+
const scope = (ctx.call.operation.scopes ?? []).find((s) => grants(token, s));
|
|
15
|
+
if (!scope) return forbidden();
|
|
16
|
+
// source: spec:listOrganizationProjects "The ID or slug of the organization"
|
|
17
|
+
const org = orgFor(ctx, scope);
|
|
18
|
+
if (org instanceof Response) return org;
|
|
19
|
+
const params: Record<string, string> = { ...ctx.call.params, organization_id_or_slug: String(org.id) };
|
|
20
|
+
// source: spec:getProjectEvent "The ID or slug of the project"
|
|
21
|
+
if (ctx.call.params.project_id_or_slug !== undefined) {
|
|
22
|
+
const at = projectFor(ctx, scope);
|
|
23
|
+
if (at instanceof Response) return at;
|
|
24
|
+
params.project_id_or_slug = String(at.project.id);
|
|
25
|
+
// source: spec:listProjectMonitorCheckins "The ID or slug of the monitor"
|
|
26
|
+
if (ctx.call.params.monitor_id_or_slug !== undefined) {
|
|
27
|
+
const ref = ctx.call.params.monitor_id_or_slug;
|
|
28
|
+
const monitor = ctx.rowsRaw(MONITOR).find((m) => m.deleted !== true && m._project === at.project.id && (m.id === ref || m.slug === ref));
|
|
29
|
+
if (!monitor) return detail(404, 'The requested resource does not exist');
|
|
30
|
+
params.monitor_id_or_slug = String(monitor.id);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const url = new URL(request.url);
|
|
34
|
+
url.pathname = ctx.call.operation.path.replace(/\{([^}]+)\}/g, (_, name: string) => encodeURIComponent(params[name] ?? ''));
|
|
35
|
+
return next(new Request(url, request));
|
|
36
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The World's doors (architecture, "Doors, screens and the gap"): what sentry.io's pages do that its API does not, each
|
|
2
|
+
// declared in the manifest's `doors` and answered by the export its id names.
|
|
3
|
+
//
|
|
4
|
+
// POST /_twin/app-credentials the application's DSN, auth token, organization and project (the
|
|
5
|
+
// descriptor's credentialDoor), the same at every boot
|
|
6
|
+
// POST /_twin/organizations { slug, name? } an organization (sentry.io's sign-up)
|
|
7
|
+
// POST /_twin/auth-tokens { organization, name, kind?, scopes? }
|
|
8
|
+
// an organization auth token (Settings > Auth Tokens), or with kind
|
|
9
|
+
// personal a personal token with its scopes, shown once
|
|
10
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
11
|
+
import { CLIENT_KEY, dsnOf, keyOf, makeClientKey, makeOrg, makeOrgToken, makePersonalToken, makeProject, orgBySlug, ORG, PROJECT, readToken, TOKEN } from './shared.ts';
|
|
12
|
+
|
|
13
|
+
const bad = (error: string): Response => Response.json({ error }, { status: 400 });
|
|
14
|
+
const bodyOf = (ctx: HandlerContext): Record<string, unknown> => (ctx.body && typeof ctx.body === 'object' ? ctx.body : {}) as Record<string, unknown>;
|
|
15
|
+
const SLUG = /^[a-z0-9][a-z0-9_-]{0,49}$/;
|
|
16
|
+
|
|
17
|
+
/** The deployment's project client key and an independently issued auth token in its organization. */
|
|
18
|
+
export async function appCredentials(ctx: HandlerContext): Promise<Response> {
|
|
19
|
+
try {
|
|
20
|
+
const b = bodyOf(ctx);
|
|
21
|
+
const organizationId = await ctx.account();
|
|
22
|
+
const org = await ctx.tenant(ORG, async (organization) => {
|
|
23
|
+
const label = typeof b.organization === 'string' ? b.organization : organization.id === 'default' ? 'world' : organization.id;
|
|
24
|
+
const rule = { max: 50, lower: true, allowed: /[a-z0-9_-]/, first: /[a-z0-9]/ };
|
|
25
|
+
const name = typeof b.name === 'string' ? b.name : organization.label;
|
|
26
|
+
let made = await makeOrg(ctx, ctx.vendorName(label, rule, { identity: organization.id }), name, organization.members[0]!);
|
|
27
|
+
if (made instanceof Response) made = await makeOrg(ctx, ctx.vendorName(label, rule, { suffixed: true, identity: organization.id }), name, organization.members[0]!);
|
|
28
|
+
if (made instanceof Response) throw await duplicate(made, organization.id);
|
|
29
|
+
return String(made.id);
|
|
30
|
+
});
|
|
31
|
+
const project = await ctx.deploymentObject(`project:${String(org.id)}`, PROJECT, async (deployment) => {
|
|
32
|
+
const label = typeof b.project === 'string' ? b.project : deployment || 'app';
|
|
33
|
+
const identity = deployment === '' ? organizationId : `${organizationId}/${deployment}`;
|
|
34
|
+
const rule = { max: Number.MAX_SAFE_INTEGER, lower: true, allowed: /[a-z0-9_-]/, first: /[a-z_-]/ };
|
|
35
|
+
const platform = typeof b.platform === 'string' ? b.platform : 'javascript';
|
|
36
|
+
let made = await makeProject(ctx, org, ctx.vendorName(label, rule, { identity }), platform);
|
|
37
|
+
if (made instanceof Response) made = await makeProject(ctx, org, ctx.vendorName(label, rule, { suffixed: true, identity }), platform);
|
|
38
|
+
if (made instanceof Response) throw await duplicate(made, identity);
|
|
39
|
+
return String(made.id);
|
|
40
|
+
});
|
|
41
|
+
const key = await ctx.accountKey('client-key', CLIENT_KEY, {
|
|
42
|
+
make: async () => String((keyOf(ctx, project) ?? await makeClientKey(ctx, org, project)).id),
|
|
43
|
+
read: async (row) => row._project === project.id && row.isActive !== false && typeof row.public === 'string' ? row.public : undefined,
|
|
44
|
+
});
|
|
45
|
+
const token = await ctx.accountKey('auth-token', TOKEN, {
|
|
46
|
+
make: async () => ctx.crypto.sha256(await makePersonalToken(ctx, org, ctx.deployment() || 'world-app', ['org:read', 'project:read', 'project:write', 'project:releases', 'event:read', 'alerts:read'])),
|
|
47
|
+
read: (row) => readToken(ctx, org, row),
|
|
48
|
+
});
|
|
49
|
+
return Response.json({ dsn: dsnOf(org, project, { public: key.secret }), auth_token: token.secret, authorization: `Bearer ${token.secret}`, url: 'https://sentry.io', org: org.slug, org_id: org.id, project: project.slug }, { status: 201 });
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (error instanceof Response) return error;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function duplicate(response: Response, identity: string): Promise<Response> {
|
|
57
|
+
const body = await response.json() as { slug: string[] };
|
|
58
|
+
return Response.json({ slug: body.slug.map((message) => `${message} (${identity})`) }, { status: response.status });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function organizations(ctx: HandlerContext): Promise<Response> {
|
|
62
|
+
const b = bodyOf(ctx);
|
|
63
|
+
if (typeof b.slug !== 'string' || !SLUG.test(b.slug)) return bad('a slug (lowercase letters, digits, - and _) is required');
|
|
64
|
+
const org = await makeOrg(ctx, b.slug, typeof b.name === 'string' ? b.name : b.slug, (await ctx.organization()).members[0]!);
|
|
65
|
+
if (org instanceof Response) return org;
|
|
66
|
+
return Response.json({ id: org.id, slug: org.slug }, { status: 201 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function authTokens(ctx: HandlerContext): Promise<Response> {
|
|
70
|
+
const b = bodyOf(ctx);
|
|
71
|
+
const org = typeof b.organization === 'string' ? orgBySlug(ctx, b.organization) : undefined;
|
|
72
|
+
if (!org) return bad('organization must name an organization the World holds');
|
|
73
|
+
if (typeof b.name !== 'string' || !b.name) return bad('a name is required');
|
|
74
|
+
const scopes = Array.isArray(b.scopes) ? b.scopes.map(String) : ['org:read', 'project:read', 'event:read'];
|
|
75
|
+
return b.kind === 'personal' ? Response.json({ token: await makePersonalToken(ctx, org, String(b.name), scopes) }, { status: 201 }) : Response.json({ token: await makeOrgToken(ctx, org, String(b.name)) }, { status: 201 });
|
|
76
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// GENERATED by scripts/create-pack.ts --index: the handler map, by operationId, from the families' files (each
|
|
2
|
+
// handler named as its operation). Do not edit; add a handler to its family's file and generate again.
|
|
3
|
+
import type { Handler } from '@volter/world-core';
|
|
4
|
+
import * as api from './api.ts';
|
|
5
|
+
|
|
6
|
+
export const apiSemantics: Record<string, Handler> = {
|
|
7
|
+
assembleArtifactBundle: api.assembleArtifactBundle,
|
|
8
|
+
createOrganizationProject: api.createOrganizationProject,
|
|
9
|
+
createProjectRelease: api.createProjectRelease,
|
|
10
|
+
getChunkUploadOptions: api.getChunkUploadOptions,
|
|
11
|
+
getPreviousReleaseWithCommits: api.getPreviousReleaseWithCommits,
|
|
12
|
+
listOrganizationReleases: api.listOrganizationReleases,
|
|
13
|
+
listOrganizationRepos: api.listOrganizationRepos,
|
|
14
|
+
listOrganizations: api.listOrganizations,
|
|
15
|
+
listProjectKeys: api.listProjectKeys,
|
|
16
|
+
updateOrganizationRelease: api.updateOrganizationRelease,
|
|
17
|
+
updateProjectRelease: api.updateProjectRelease,
|
|
18
|
+
uploadChunks: api.uploadChunks,
|
|
19
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// What every family of Sentry's API shares: who is calling (an auth token the World issued), the organization a request
|
|
2
|
+
// names and whether the token may act on it, and the API's answers (Django REST framework's `{"detail": ...}`).
|
|
3
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
4
|
+
import { bearerToken, grants, orgBySlug, projectOf, type Row } from '../../../src/semantics/shared.ts';
|
|
5
|
+
|
|
6
|
+
export * from '../../../src/semantics/shared.ts';
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
export const detail = (status: number, message: string): Response => Response.json({ detail: message }, { status });
|
|
10
|
+
// Where the documentation stops: the spec names these answers' statuses (401, 403, 404) without their bodies; the
|
|
11
|
+
// messages are Django REST framework's, which Sentry's API answers with
|
|
12
|
+
export const notFound = (): Response => detail(404, 'The requested resource does not exist');
|
|
13
|
+
export const forbidden = (): Response => detail(403, 'You do not have permission to perform this action.');
|
|
14
|
+
|
|
15
|
+
/** The organization a request's path names, if the request's token is the organization's and its scopes grant the
|
|
16
|
+
* operation's (the spec's security scopes for it); else the API's refusal. */
|
|
17
|
+
export function orgFor(ctx: HandlerContext, scope: string): Row | Response {
|
|
18
|
+
const token = bearerToken(ctx);
|
|
19
|
+
if (!token) return detail(401, 'Invalid token');
|
|
20
|
+
const org = orgBySlug(ctx, String(ctx.call.params.organization_id_or_slug ?? ''));
|
|
21
|
+
if (!org) return notFound();
|
|
22
|
+
return token.organization === org.id && grants(token, scope) ? org : forbidden();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The organization and project a request's path names, or the API's refusal. */
|
|
26
|
+
export function projectFor(ctx: HandlerContext, scope: string): { org: Row; project: Row } | Response {
|
|
27
|
+
const org = orgFor(ctx, scope);
|
|
28
|
+
if (org instanceof Response) return org;
|
|
29
|
+
const project = projectOf(ctx, org, String(ctx.call.params.project_id_or_slug ?? ''));
|
|
30
|
+
return project ? { org, project } : notFound();
|
|
31
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// A check-in records progress and its final outcome; classification fields are not lifecycle machines.
|
|
2
|
+
export const states = {
|
|
3
|
+
// source: spec:listProjectEvents "Return a list of events bound to a project."
|
|
4
|
+
// Event type classifies the received payload; the reads do not move it between types.
|
|
5
|
+
event: { notState: ['type'] },
|
|
6
|
+
// source: spec:getOrganizationIssue "Return details on an individual issue"
|
|
7
|
+
// The issue read exposes classifications and account-relative flags; no served operation changes them.
|
|
8
|
+
issue: { notState: ['level', 'type', 'status', 'substatus', 'priority', 'isPublic', 'hasSeen', 'isBookmarked', 'isSubscribed', 'isUnhandled'] },
|
|
9
|
+
// source: spec:listOrganizationMonitors "Retrieve Monitors for an Organization"
|
|
10
|
+
// Monitor enablement is account configuration; received check-ins do not move that setting.
|
|
11
|
+
monitor: { notState: ['status'] },
|
|
12
|
+
check_in: { state: { status: { initial: 'in_progress', transitions: [
|
|
13
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry-docs/3ec4e139711af82b38a3c2c17261f5f5cdb66dfc/develop-docs/sdk/foundations/envelopes/envelope-items.mdx "check-in payload encoded as JSON"
|
|
14
|
+
{ actor: 'vendor' as const, from: ['in_progress'], to: 'ok', source: 'https://docs.sentry.io/platforms/javascript/guides/node/crons/ "ok"' },
|
|
15
|
+
{ actor: 'vendor' as const, from: ['in_progress'], to: 'error', source: 'https://docs.sentry.io/platforms/javascript/guides/node/crons/ "error"' },
|
|
16
|
+
] } } },
|
|
17
|
+
};
|