@volter/twin-sentry 0.1.36 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -134
- package/api/spec/SOURCE.md +15 -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 +136 -0
- package/api/src/semantics/api.ts +246 -0
- package/api/src/semantics/around.ts +36 -0
- package/api/src/semantics/doors.ts +50 -0
- package/api/src/semantics/index.ts +19 -0
- package/api/src/semantics/shared.ts +35 -0
- package/api/src/semantics/states.ts +17 -0
- package/dist/api/spec/SOURCE.md +15 -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 +135 -0
- package/dist/api/src/manifest.ts +136 -0
- package/dist/api/src/semantics/api.d.ts +13 -0
- package/dist/api/src/semantics/api.js +267 -0
- package/dist/api/src/semantics/api.ts +246 -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 +8 -0
- package/dist/api/src/semantics/doors.js +39 -0
- package/dist/api/src/semantics/doors.ts +50 -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 +15 -0
- package/dist/api/src/semantics/shared.js +27 -0
- package/dist/api/src/semantics/shared.ts +35 -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/generated/pack-facts.json +87 -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 +34 -0
- package/dist/ingest/src/manifest.ts +39 -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 +923 -0
- package/dist/journeys/customer-life.outline.md +51 -0
- package/dist/journeys/decisions.json +170 -0
- package/dist/journeys/demand.json +136 -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 +12 -75
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +34 -0
- package/dist/src/semantics/shared.d.ts +87 -0
- package/dist/src/semantics/shared.js +411 -0
- package/dist/src/server.d.ts +7 -0
- package/dist/src/server.js +7 -0
- package/generated/pack-facts.json +87 -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 +39 -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 +923 -0
- package/journeys/customer-life.outline.md +51 -0
- package/journeys/decisions.json +170 -0
- package/journeys/demand.json +136 -0
- package/journeys/score.json +231 -0
- package/journeys/unreachable.json +29 -0
- package/journeys/vendor-examples.json +474 -0
- package/package.json +16 -42
- package/spec/sources.json +76 -0
- package/src/cli.ts +12 -24
- package/src/fetch.ts +10 -0
- package/src/index.ts +12 -143
- package/src/manifest.ts +40 -0
- package/src/semantics/shared.ts +443 -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,246 @@
|
|
|
1
|
+
import { gunzip, type HandlerContext } from '@volter/world-core';
|
|
2
|
+
import { bearerToken, detail, EVENT, forbidden, grants, keyOf, makeProject, notFound, orgFor, PROJECT, projectFor, projectOf, projectView, 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
|
+
await ctx.write(PROJECT, String(project.id), { name: b.name }, 'project.update');
|
|
203
|
+
return Response.json(projectView(ctx, ctx.row(PROJECT, String(project.id))!, org), { status: 201 });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// source: spec:listProjectKeys "Return a list of client keys bound to a project."
|
|
207
|
+
export async function listProjectKeys(ctx: HandlerContext): Promise<Response> {
|
|
208
|
+
const at = projectFor(ctx, 'project:read');
|
|
209
|
+
if (at instanceof Response) return at;
|
|
210
|
+
const key = keyOf(ctx, at.project);
|
|
211
|
+
return Response.json(key ? [wire(ctx, key)] : []);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// source: spec:listOrganizations "List Your Organizations"
|
|
215
|
+
export async function listOrganizations(ctx: HandlerContext): Promise<Response> {
|
|
216
|
+
const token = bearerToken(ctx)!;
|
|
217
|
+
if (!grants(token, 'org:read')) return forbidden();
|
|
218
|
+
return ctx.list('organization', ctx.rowsRaw('organization').filter((o) => o.deleted !== true && o.id === token.organization).map((o) => wire(ctx, o)));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// source: spec:listOrganizationReleases "List an Organization's Releases"
|
|
222
|
+
export async function listOrganizationReleases(ctx: HandlerContext): Promise<Response> {
|
|
223
|
+
const org = orgFor(ctx, 'project:releases');
|
|
224
|
+
if (org instanceof Response) return org;
|
|
225
|
+
return ctx.list('release', ctx.rowsRaw('release').filter((r) => r.deleted !== true && r._organization === org.id).map((r) => wire(ctx, r)));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/issues/endpoints/organization_release_previous_commits.py "raise ResourceDoesNotExist"
|
|
230
|
+
export async function getPreviousReleaseWithCommits(ctx: HandlerContext): Promise<Response> {
|
|
231
|
+
const org = orgFor(ctx, 'project:releases');
|
|
232
|
+
if (org instanceof Response) return org;
|
|
233
|
+
const current = releaseOf(ctx, org, String(ctx.call.params.version));
|
|
234
|
+
if (!current) return notFound();
|
|
235
|
+
// 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"
|
|
236
|
+
const start = String(current.dateReleased ?? current.dateCreated);
|
|
237
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/issues/endpoints/organization_release_previous_commits.py "projects__id__in=project_ids"
|
|
238
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/issues/endpoints/organization_release_previous_commits.py "last_commit_id__isnull=False"
|
|
239
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/issues/endpoints/organization_release_previous_commits.py "COALESCE(date_released, date_added) <= %s"
|
|
240
|
+
const candidates = ctx.rowsRaw('release').filter((r) => r.deleted !== true && r._organization === org.id && r.lastCommit != null
|
|
241
|
+
&& (r._projects as string[]).some((id) => (current._projects as string[]).includes(id)) && String(r.dateReleased ?? r.dateCreated) <= start);
|
|
242
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/issues/endpoints/organization_release_previous_commits.py "order_by"
|
|
243
|
+
const previous = candidates.sort((a, b) => String(b.dateReleased ?? b.dateCreated).localeCompare(String(a.dateReleased ?? a.dateCreated)))[0];
|
|
244
|
+
// source: https://raw.githubusercontent.com/getsentry/sentry/2efdd4760a75a3d395e3534b7f66a7fbb0c3f22e/src/sentry/issues/endpoints/organization_release_previous_commits.py "return Response({})"
|
|
245
|
+
return Response.json(previous ? wire(ctx, previous) : {});
|
|
246
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { bearerToken, detail, forbidden, grants, orgFor, projectFor, MONITOR } from "./shared.js";
|
|
2
|
+
export async function around(ctx, next) {
|
|
3
|
+
const request = ctx.call.request;
|
|
4
|
+
if (!new URL(request.url).pathname.startsWith('/api/0/'))
|
|
5
|
+
return next();
|
|
6
|
+
if (!request.headers.get('authorization'))
|
|
7
|
+
return detail(401, 'Authentication credentials were not provided.');
|
|
8
|
+
const token = bearerToken(ctx);
|
|
9
|
+
if (!token)
|
|
10
|
+
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))
|
|
13
|
+
return next();
|
|
14
|
+
// source: https://docs.sentry.io/api/permissions/ "Scopes"
|
|
15
|
+
const scope = (ctx.call.operation.scopes ?? []).find((s) => grants(token, s));
|
|
16
|
+
if (!scope)
|
|
17
|
+
return forbidden();
|
|
18
|
+
// source: spec:listOrganizationProjects "The ID or slug of the organization"
|
|
19
|
+
const org = orgFor(ctx, scope);
|
|
20
|
+
if (org instanceof Response)
|
|
21
|
+
return org;
|
|
22
|
+
const params = { ...ctx.call.params, organization_id_or_slug: String(org.id) };
|
|
23
|
+
// source: spec:getProjectEvent "The ID or slug of the project"
|
|
24
|
+
if (ctx.call.params.project_id_or_slug !== undefined) {
|
|
25
|
+
const at = projectFor(ctx, scope);
|
|
26
|
+
if (at instanceof Response)
|
|
27
|
+
return at;
|
|
28
|
+
params.project_id_or_slug = String(at.project.id);
|
|
29
|
+
// source: spec:listProjectMonitorCheckins "The ID or slug of the monitor"
|
|
30
|
+
if (ctx.call.params.monitor_id_or_slug !== undefined) {
|
|
31
|
+
const ref = ctx.call.params.monitor_id_or_slug;
|
|
32
|
+
const monitor = ctx.rowsRaw(MONITOR).find((m) => m.deleted !== true && m._project === at.project.id && (m.id === ref || m.slug === ref));
|
|
33
|
+
if (!monitor)
|
|
34
|
+
return detail(404, 'The requested resource does not exist');
|
|
35
|
+
params.monitor_id_or_slug = String(monitor.id);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const url = new URL(request.url);
|
|
39
|
+
url.pathname = ctx.call.operation.path.replace(/\{([^}]+)\}/g, (_, name) => encodeURIComponent(params[name] ?? ''));
|
|
40
|
+
return next(new Request(url, request));
|
|
41
|
+
}
|
|
@@ -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,8 @@
|
|
|
1
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
2
|
+
/** Bootstrap (RECIPE, State): the account's sign-up creates its first credential and env-named project/key.
|
|
3
|
+
* The World's application: the organization `world`, its project `app`, the project's DSN and a token of the
|
|
4
|
+
* organization's, made the first time and answered the same at every boot: the token is re-derived from its secret and
|
|
5
|
+
* answered while it matches its hash; one revoked is not made again. */
|
|
6
|
+
export declare function appCredentials(ctx: HandlerContext): Promise<Response>;
|
|
7
|
+
export declare function organizations(ctx: HandlerContext): Promise<Response>;
|
|
8
|
+
export declare function authTokens(ctx: HandlerContext): Promise<Response>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { dsnOf, heldOrgToken, keyOf, makeOrg, makeOrgToken, makePersonalToken, makeProject, orgBySlug, TOKEN } from "./shared.js";
|
|
2
|
+
const bad = (error) => Response.json({ error }, { status: 400 });
|
|
3
|
+
const bodyOf = (ctx) => (ctx.body && typeof ctx.body === 'object' ? ctx.body : {});
|
|
4
|
+
const SLUG = /^[a-z0-9][a-z0-9_-]{0,49}$/;
|
|
5
|
+
/** Bootstrap (RECIPE, State): the account's sign-up creates its first credential and env-named project/key.
|
|
6
|
+
* The World's application: the organization `world`, its project `app`, the project's DSN and a token of the
|
|
7
|
+
* organization's, made the first time and answered the same at every boot: the token is re-derived from its secret and
|
|
8
|
+
* answered while it matches its hash; one revoked is not made again. */
|
|
9
|
+
export async function appCredentials(ctx) {
|
|
10
|
+
const b = bodyOf(ctx);
|
|
11
|
+
const org = await makeOrg(ctx, String(b.organization ?? 'world'), String(b.name ?? 'World'));
|
|
12
|
+
const project = await makeProject(ctx, org, String(b.project ?? 'app'), String(b.platform ?? 'javascript'));
|
|
13
|
+
const key = keyOf(ctx, project);
|
|
14
|
+
const revoked = ctx.rowsRaw(TOKEN, { withDeleted: true }).some((t) => t.deleted === true && t.organization === org.id && t.name === 'world-app');
|
|
15
|
+
if (revoked)
|
|
16
|
+
return Response.json({ error: 'the application token was revoked' }, { status: 409 });
|
|
17
|
+
const token = (await heldOrgToken(ctx, org, 'world-app')) ?? await makePersonalToken(ctx, org, 'world-app', ['org:read', 'project:read', 'project:write', 'project:releases', 'event:read', 'alerts:read']);
|
|
18
|
+
return Response.json({ dsn: dsnOf(org, project, key), auth_token: token, org: org.slug, org_id: org.id, project: project.slug }, { status: 201 });
|
|
19
|
+
}
|
|
20
|
+
export async function organizations(ctx) {
|
|
21
|
+
const b = bodyOf(ctx);
|
|
22
|
+
if (typeof b.slug !== 'string' || !SLUG.test(b.slug))
|
|
23
|
+
return bad('a slug (lowercase letters, digits, - and _) is required');
|
|
24
|
+
const org = await makeOrg(ctx, b.slug, typeof b.name === 'string' ? b.name : b.slug);
|
|
25
|
+
return Response.json({ id: org.id, slug: org.slug }, { status: 201 });
|
|
26
|
+
}
|
|
27
|
+
export async function authTokens(ctx) {
|
|
28
|
+
const b = bodyOf(ctx);
|
|
29
|
+
const org = typeof b.organization === 'string' ? orgBySlug(ctx, b.organization) : undefined;
|
|
30
|
+
if (!org)
|
|
31
|
+
return bad('organization must name an organization the World holds');
|
|
32
|
+
if (typeof b.name !== 'string' || !b.name)
|
|
33
|
+
return bad('a name is required');
|
|
34
|
+
const scopes = Array.isArray(b.scopes) ? b.scopes.map(String) : ['org:read', 'project:read', 'event:read'];
|
|
35
|
+
return b.kind === 'personal' ? Response.json({ token: await makePersonalToken(ctx, org, String(b.name), scopes) }, { status: 201 }) : issueOrganizationToken(ctx, org, String(b.name));
|
|
36
|
+
}
|
|
37
|
+
async function issueOrganizationToken(ctx, org, name) {
|
|
38
|
+
return Response.json({ token: await makeOrgToken(ctx, org, name) }, { status: 201 });
|
|
39
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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 { dsnOf, heldOrgToken, keyOf, makeOrg, makeOrgToken, makePersonalToken, makeProject, orgBySlug, 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
|
+
/** Bootstrap (RECIPE, State): the account's sign-up creates its first credential and env-named project/key.
|
|
18
|
+
* The World's application: the organization `world`, its project `app`, the project's DSN and a token of the
|
|
19
|
+
* organization's, made the first time and answered the same at every boot: the token is re-derived from its secret and
|
|
20
|
+
* answered while it matches its hash; one revoked is not made again. */
|
|
21
|
+
export async function appCredentials(ctx: HandlerContext): Promise<Response> {
|
|
22
|
+
const b = bodyOf(ctx);
|
|
23
|
+
const org = await makeOrg(ctx, String(b.organization ?? 'world'), String(b.name ?? 'World'));
|
|
24
|
+
const project = await makeProject(ctx, org, String(b.project ?? 'app'), String(b.platform ?? 'javascript'));
|
|
25
|
+
const key = keyOf(ctx, project)!;
|
|
26
|
+
const revoked = ctx.rowsRaw(TOKEN, { withDeleted: true }).some((t) => t.deleted === true && t.organization === org.id && t.name === 'world-app');
|
|
27
|
+
if (revoked) return Response.json({ error: 'the application token was revoked' }, { status: 409 });
|
|
28
|
+
const token = (await heldOrgToken(ctx, org, 'world-app')) ?? await makePersonalToken(ctx, org, 'world-app', ['org:read', 'project:read', 'project:write', 'project:releases', 'event:read', 'alerts:read']);
|
|
29
|
+
return Response.json({ dsn: dsnOf(org, project, key), auth_token: token, org: org.slug, org_id: org.id, project: project.slug }, { status: 201 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function organizations(ctx: HandlerContext): Promise<Response> {
|
|
33
|
+
const b = bodyOf(ctx);
|
|
34
|
+
if (typeof b.slug !== 'string' || !SLUG.test(b.slug)) return bad('a slug (lowercase letters, digits, - and _) is required');
|
|
35
|
+
const org = await makeOrg(ctx, b.slug, typeof b.name === 'string' ? b.name : b.slug);
|
|
36
|
+
return Response.json({ id: org.id, slug: org.slug }, { status: 201 });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function authTokens(ctx: HandlerContext): Promise<Response> {
|
|
40
|
+
const b = bodyOf(ctx);
|
|
41
|
+
const org = typeof b.organization === 'string' ? orgBySlug(ctx, b.organization) : undefined;
|
|
42
|
+
if (!org) return bad('organization must name an organization the World holds');
|
|
43
|
+
if (typeof b.name !== 'string' || !b.name) return bad('a name is required');
|
|
44
|
+
const scopes = Array.isArray(b.scopes) ? b.scopes.map(String) : ['org:read', 'project:read', 'event:read'];
|
|
45
|
+
return b.kind === 'personal' ? Response.json({ token: await makePersonalToken(ctx, org, String(b.name), scopes) }, { status: 201 }) : issueOrganizationToken(ctx, org, String(b.name));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function issueOrganizationToken(ctx: HandlerContext, org: Record<string, unknown>, name: string): Promise<Response> {
|
|
49
|
+
return Response.json({ token: await makeOrgToken(ctx, org, name) }, { status: 201 });
|
|
50
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as api from "./api.js";
|
|
2
|
+
export const apiSemantics = {
|
|
3
|
+
assembleArtifactBundle: api.assembleArtifactBundle,
|
|
4
|
+
createOrganizationProject: api.createOrganizationProject,
|
|
5
|
+
createProjectRelease: api.createProjectRelease,
|
|
6
|
+
getChunkUploadOptions: api.getChunkUploadOptions,
|
|
7
|
+
getPreviousReleaseWithCommits: api.getPreviousReleaseWithCommits,
|
|
8
|
+
listOrganizationReleases: api.listOrganizationReleases,
|
|
9
|
+
listOrganizationRepos: api.listOrganizationRepos,
|
|
10
|
+
listOrganizations: api.listOrganizations,
|
|
11
|
+
listProjectKeys: api.listProjectKeys,
|
|
12
|
+
updateOrganizationRelease: api.updateOrganizationRelease,
|
|
13
|
+
updateProjectRelease: api.updateProjectRelease,
|
|
14
|
+
uploadChunks: api.uploadChunks,
|
|
15
|
+
};
|
|
@@ -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,15 @@
|
|
|
1
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
2
|
+
import { type Row } from '../../../src/semantics/shared.js';
|
|
3
|
+
export * from '../../../src/semantics/shared.js';
|
|
4
|
+
export declare const detail: (status: number, message: string) => Response;
|
|
5
|
+
export declare const notFound: () => Response;
|
|
6
|
+
export declare const forbidden: () => Response;
|
|
7
|
+
/** The organization a request's path names, if the request's token is the organization's and its scopes grant the
|
|
8
|
+
* operation's (the spec's security scopes for it); else the API's refusal. */
|
|
9
|
+
export declare function orgFor(ctx: HandlerContext, scope: string): Row | Response;
|
|
10
|
+
/** The organization and project a request's path names, or the API's refusal. */
|
|
11
|
+
export declare function projectFor(ctx: HandlerContext, scope: string): {
|
|
12
|
+
org: Row;
|
|
13
|
+
project: Row;
|
|
14
|
+
} | Response;
|
|
15
|
+
export declare function projectView(ctx: HandlerContext, p: Row, _org: Row): Row;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { bearerToken, grants, orgBySlug, projectOf, wire } from "../../../src/semantics/shared.js";
|
|
2
|
+
export * from "../../../src/semantics/shared.js";
|
|
3
|
+
export const detail = (status, message) => Response.json({ detail: message }, { status });
|
|
4
|
+
// Where the documentation stops: the spec names these answers' statuses (401, 403, 404) without their bodies; the
|
|
5
|
+
// messages are Django REST framework's, which Sentry's API answers with
|
|
6
|
+
export const notFound = () => detail(404, 'The requested resource does not exist');
|
|
7
|
+
export const forbidden = () => detail(403, 'You do not have permission to perform this action.');
|
|
8
|
+
/** The organization a request's path names, if the request's token is the organization's and its scopes grant the
|
|
9
|
+
* operation's (the spec's security scopes for it); else the API's refusal. */
|
|
10
|
+
export function orgFor(ctx, scope) {
|
|
11
|
+
const token = bearerToken(ctx);
|
|
12
|
+
if (!token)
|
|
13
|
+
return detail(401, 'Invalid token');
|
|
14
|
+
const org = orgBySlug(ctx, String(ctx.call.params.organization_id_or_slug ?? ''));
|
|
15
|
+
if (!org)
|
|
16
|
+
return notFound();
|
|
17
|
+
return token.organization === org.id && grants(token, scope) ? org : forbidden();
|
|
18
|
+
}
|
|
19
|
+
/** The organization and project a request's path names, or the API's refusal. */
|
|
20
|
+
export function projectFor(ctx, scope) {
|
|
21
|
+
const org = orgFor(ctx, scope);
|
|
22
|
+
if (org instanceof Response)
|
|
23
|
+
return org;
|
|
24
|
+
const project = projectOf(ctx, org, String(ctx.call.params.project_id_or_slug ?? ''));
|
|
25
|
+
return project ? { org, project } : notFound();
|
|
26
|
+
}
|
|
27
|
+
export function projectView(ctx, p, _org) { return wire(ctx, p); }
|
|
@@ -0,0 +1,35 @@
|
|
|
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, wire } 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
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
export function projectView(ctx: HandlerContext, p: Row, _org: Row): Row { return wire(ctx, p); }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const states: {
|
|
2
|
+
event: {
|
|
3
|
+
notState: string[];
|
|
4
|
+
};
|
|
5
|
+
issue: {
|
|
6
|
+
notState: string[];
|
|
7
|
+
};
|
|
8
|
+
monitor: {
|
|
9
|
+
notState: string[];
|
|
10
|
+
};
|
|
11
|
+
check_in: {
|
|
12
|
+
state: {
|
|
13
|
+
status: {
|
|
14
|
+
initial: string;
|
|
15
|
+
transitions: {
|
|
16
|
+
actor: "vendor";
|
|
17
|
+
from: string[];
|
|
18
|
+
to: string;
|
|
19
|
+
source: string;
|
|
20
|
+
}[];
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
};
|
|
@@ -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', from: ['in_progress'], to: 'ok', source: 'https://docs.sentry.io/platforms/javascript/guides/node/crons/ "ok"' },
|
|
15
|
+
{ actor: 'vendor', from: ['in_progress'], to: 'error', source: 'https://docs.sentry.io/platforms/javascript/guides/node/crons/ "error"' },
|
|
16
|
+
] } } },
|
|
17
|
+
};
|
|
@@ -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
|
+
};
|