@shipwithai/loopnote 0.0.0-stage → 2.0.0-bootstrap.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +202 -2
- package/THIRD_PARTY_NOTICES.md +580 -0
- package/dist/bin/loopnote.mjs +5 -0
- package/dist/cli/confirm.js +38 -0
- package/dist/cli/picker.js +342 -0
- package/dist/cli/request.js +120 -0
- package/dist/cli/run.js +970 -0
- package/dist/contracts/api.js +640 -0
- package/dist/contracts/comparison.js +178 -0
- package/dist/contracts/draft.js +606 -0
- package/dist/contracts/request.js +124 -0
- package/dist/domain/anchor.js +122 -0
- package/dist/domain/comparison-key.js +51 -0
- package/dist/domain/errors.js +43 -0
- package/dist/domain/path-bytes.js +82 -0
- package/dist/domain/product-identity.js +66 -0
- package/dist/domain/source.js +4 -0
- package/dist/draft/mutate-draft.js +87 -0
- package/dist/export/render-review-markdown.js +163 -0
- package/dist/export/review-export.js +273 -0
- package/dist/git/availability.js +92 -0
- package/dist/git/candidates.js +413 -0
- package/dist/git/comparison.js +263 -0
- package/dist/git/exact-patch.js +702 -0
- package/dist/git/ignore-status.js +23 -0
- package/dist/git/inventory.js +160 -0
- package/dist/git/numstat.js +111 -0
- package/dist/git/objects.js +95 -0
- package/dist/git/raw-diff.js +84 -0
- package/dist/git/repository.js +144 -0
- package/dist/git/runner.js +145 -0
- package/dist/git/selector-drift.js +169 -0
- package/dist/git/standalone-resolution.js +174 -0
- package/dist/git/worktree-content.js +97 -0
- package/dist/git/worktree-inventory.js +391 -0
- package/dist/native/directory_exchange.node +0 -0
- package/dist/server/app.js +171 -0
- package/dist/server/attached-completion.js +85 -0
- package/dist/server/capabilities.js +1572 -0
- package/dist/server/draft-loader.js +134 -0
- package/dist/server/draft-recovery.js +5 -0
- package/dist/server/draft-store.js +380 -0
- package/dist/server/export-store.js +291 -0
- package/dist/server/gitignore-capability.js +197 -0
- package/dist/server/image-content.js +101 -0
- package/dist/server/lifecycle.js +72 -0
- package/dist/server/native-exchange-capability.js +45 -0
- package/dist/server/patch-snapshot.js +500 -0
- package/dist/server/routes.js +418 -0
- package/dist/server/security.js +123 -0
- package/dist/server/snapshot-fs.js +58 -0
- package/dist/server/support-client.js +76 -0
- package/dist/server/support-store.js +194 -0
- package/dist/server/worktree-snapshot.js +553 -0
- package/dist/web/assets/abap-08VXUWAP.js +1 -0
- package/dist/web/assets/apex-BWPQTe0t.js +1 -0
- package/dist/web/assets/azcli-Bc_sGQ0U.js +1 -0
- package/dist/web/assets/bat-i0X4ZdIN.js +1 -0
- package/dist/web/assets/bicep-B5-_aFwp.js +2 -0
- package/dist/web/assets/cameligo-DMUM7wLl.js +1 -0
- package/dist/web/assets/clojure-Cm7r79vr.js +1 -0
- package/dist/web/assets/codicon-ngg6Pgfi.ttf +0 -0
- package/dist/web/assets/coffee-Ba7i2nA0.js +1 -0
- package/dist/web/assets/cpp-C7h46wYY.js +1 -0
- package/dist/web/assets/csharp-BKxtCVv1.js +1 -0
- package/dist/web/assets/csp-bTuwJoIa.js +1 -0
- package/dist/web/assets/css-DIMkf-bt.js +3 -0
- package/dist/web/assets/css.worker-yKcq13f2.js +89 -0
- package/dist/web/assets/cssMode-CpBTfhMg.js +1 -0
- package/dist/web/assets/cypher-CVaqCwHa.js +1 -0
- package/dist/web/assets/dart-onAF5SnQ.js +1 -0
- package/dist/web/assets/dockerfile-DZFCIeNp.js +1 -0
- package/dist/web/assets/ecl-D05T4iGw.js +1 -0
- package/dist/web/assets/editor.worker-tytyn86m.js +26 -0
- package/dist/web/assets/elixir-6RTg0lbw.js +1 -0
- package/dist/web/assets/flow9-C5_-GSwl.js +1 -0
- package/dist/web/assets/freemarker2-skQ63wb7.js +3 -0
- package/dist/web/assets/fsharp-C8Ef5oNN.js +1 -0
- package/dist/web/assets/go-C-y9NEjX.js +1 -0
- package/dist/web/assets/graphql-fmXr3nnJ.js +1 -0
- package/dist/web/assets/handlebars-CqvPAZY8.js +1 -0
- package/dist/web/assets/hcl-CpzslTdj.js +1 -0
- package/dist/web/assets/html-BDAqstpo.js +1 -0
- package/dist/web/assets/html.worker-DSJ9zmd7.js +502 -0
- package/dist/web/assets/htmlMode-BRYA3pnT.js +1 -0
- package/dist/web/assets/index-CCeYBqPo.js +1111 -0
- package/dist/web/assets/index-CRj9ojWJ.css +1 -0
- package/dist/web/assets/ini-sBoK_t0W.js +1 -0
- package/dist/web/assets/java-BEtHBSE6.js +1 -0
- package/dist/web/assets/javascript-1Ywytw67.js +1 -0
- package/dist/web/assets/json.worker-Oa9UrkKE.js +58 -0
- package/dist/web/assets/jsonMode-AO1C_0kX.js +7 -0
- package/dist/web/assets/julia-Bri6UV-V.js +1 -0
- package/dist/web/assets/kotlin-BOotOW0E.js +1 -0
- package/dist/web/assets/less-B9JPFI3C.js +2 -0
- package/dist/web/assets/lexon-CfSJPG6W.js +1 -0
- package/dist/web/assets/liquid-CkkqyyGc.js +1 -0
- package/dist/web/assets/lspLanguageFeatures-CYOFEMZp.js +4 -0
- package/dist/web/assets/lua-CsQS60Ue.js +1 -0
- package/dist/web/assets/m3-D-oSqn_W.js +1 -0
- package/dist/web/assets/markdown-Cimd5fb3.js +1 -0
- package/dist/web/assets/mdx-Bd3Wlfqw.js +1 -0
- package/dist/web/assets/mips-CIPQ_RoX.js +1 -0
- package/dist/web/assets/msdax-DauUninz.js +1 -0
- package/dist/web/assets/mysql-SOo6toE5.js +1 -0
- package/dist/web/assets/objective-c-FvmIjYaQ.js +1 -0
- package/dist/web/assets/pascal-DrH0SRf2.js +1 -0
- package/dist/web/assets/pascaligo-D-ptJ9y-.js +1 -0
- package/dist/web/assets/perl-oz_6vUea.js +1 -0
- package/dist/web/assets/pgsql-DTj74zXo.js +1 -0
- package/dist/web/assets/php-nr791fC2.js +1 -0
- package/dist/web/assets/pla-CopQ2nXW.js +1 -0
- package/dist/web/assets/postiats-43DmfD33.js +1 -0
- package/dist/web/assets/powerquery-D3hlyOfw.js +1 -0
- package/dist/web/assets/powershell-DmHpPYUd.js +1 -0
- package/dist/web/assets/protobuf-C531GsRP.js +2 -0
- package/dist/web/assets/pug-Z5eAx3Zn.js +1 -0
- package/dist/web/assets/python-CET4IumD.js +1 -0
- package/dist/web/assets/qsharp-DkqhCAOL.js +1 -0
- package/dist/web/assets/r-BwWrilGY.js +1 -0
- package/dist/web/assets/razor-d-reaHeT.js +1 -0
- package/dist/web/assets/redis-ClamHrr6.js +1 -0
- package/dist/web/assets/redshift-DT7zqm-g.js +1 -0
- package/dist/web/assets/restructuredtext-BYgofb2h.js +1 -0
- package/dist/web/assets/ruby-DezsRK8O.js +1 -0
- package/dist/web/assets/rust-DdL9SqIa.js +1 -0
- package/dist/web/assets/sb-CcwsVR0C.js +1 -0
- package/dist/web/assets/scala-DHpiXF5c.js +1 -0
- package/dist/web/assets/scheme-BeGwcela.js +1 -0
- package/dist/web/assets/scss-gp-XZpBa.js +3 -0
- package/dist/web/assets/shell-CC2rA5mh.js +1 -0
- package/dist/web/assets/solidity-BEEn4gHE.js +1 -0
- package/dist/web/assets/sophia-CRfGWb83.js +1 -0
- package/dist/web/assets/sparql-D_Lu-MrJ.js +1 -0
- package/dist/web/assets/sql-NEE52Syq.js +1 -0
- package/dist/web/assets/st-DbInun42.js +1 -0
- package/dist/web/assets/swift-Bxkupp3x.js +1 -0
- package/dist/web/assets/systemverilog-Bz4Y3fRF.js +1 -0
- package/dist/web/assets/tcl-DISqw1ZD.js +1 -0
- package/dist/web/assets/ts.worker-BtjBFNHX.js +67734 -0
- package/dist/web/assets/tsMode-DLxSAtkD.js +11 -0
- package/dist/web/assets/twig-De2hgUGE.js +1 -0
- package/dist/web/assets/typescript-DprEVA-S.js +1 -0
- package/dist/web/assets/typespec-B8J7ngcE.js +1 -0
- package/dist/web/assets/vb-DV3o63ZY.js +1 -0
- package/dist/web/assets/wgsl-DpFanUEy.js +298 -0
- package/dist/web/assets/xml-B7ZwKOhJ.js +1 -0
- package/dist/web/assets/yaml-OQ9Asg7B.js +1 -0
- package/dist/web/index.html +14 -0
- package/package.json +87 -4
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { AppendLoopnoteIgnoreResultSchema, LoopnoteIgnoreStatusSchema, DraftLoadResponseSchema, DraftMutationRequestSchema, DraftMutationResultSchema, DraftRecoveryRequestSchema, DraftRecoveryResultSchema, DraftRevealResultSchema, ExportDirectoryRevealResultSchema, ExportReviewRequestSchema, ExportReviewResultSchema, PatchStatusResponseSchema, SelectorDriftResponseSchema, OpaqueFileIdSchema, AttachedCompletionStatusSchema, FinishReviewRequestSchema, FinishReviewResultSchema, SupportStartRequestSchema, SupportStartResultSchema, SupportStatusSchema, UncommittedDraftRecoveryResultSchema, } from '../contracts/api.js';
|
|
3
|
+
import { DurableAnchorV1Schema } from '../contracts/draft.js';
|
|
4
|
+
import { buildDurableAnchor } from '../domain/anchor.js';
|
|
5
|
+
import { REQUEST_UNAVAILABLE_ERROR } from './security.js';
|
|
6
|
+
const FileImageQuerySchema = z.strictObject({
|
|
7
|
+
path: z.string().min(1),
|
|
8
|
+
side: z.enum(['base', 'head']),
|
|
9
|
+
// Carries the session token for same-origin <img src> requests (GAP-01, 16-UI-REVIEW);
|
|
10
|
+
// security.ts's auth gate validates it, this schema just tolerates its presence.
|
|
11
|
+
token: z.string().optional(),
|
|
12
|
+
});
|
|
13
|
+
const EMPTY_QUERY_SCHEMA = {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {},
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
};
|
|
18
|
+
const FILE_PARAMS_SCHEMA = {
|
|
19
|
+
type: 'object',
|
|
20
|
+
required: ['fileId'],
|
|
21
|
+
properties: {
|
|
22
|
+
fileId: { type: 'string' },
|
|
23
|
+
},
|
|
24
|
+
additionalProperties: false,
|
|
25
|
+
};
|
|
26
|
+
function unavailable(reply, statusCode) {
|
|
27
|
+
return reply.code(statusCode).send(REQUEST_UNAVAILABLE_ERROR);
|
|
28
|
+
}
|
|
29
|
+
function publicLoad(load, draft) {
|
|
30
|
+
switch (load.kind) {
|
|
31
|
+
case 'missing':
|
|
32
|
+
return DraftLoadResponseSchema.parse({ kind: load.kind, path: load.path });
|
|
33
|
+
case 'current':
|
|
34
|
+
return DraftLoadResponseSchema.parse({ kind: load.kind, path: load.path, draft });
|
|
35
|
+
case 'malformed':
|
|
36
|
+
return DraftLoadResponseSchema.parse({
|
|
37
|
+
kind: load.kind,
|
|
38
|
+
path: load.path,
|
|
39
|
+
fingerprint: load.fingerprint,
|
|
40
|
+
detail: load.detail,
|
|
41
|
+
});
|
|
42
|
+
case 'schemaInvalid':
|
|
43
|
+
return DraftLoadResponseSchema.parse({
|
|
44
|
+
kind: load.kind,
|
|
45
|
+
path: load.path,
|
|
46
|
+
fingerprint: load.fingerprint,
|
|
47
|
+
details: load.details,
|
|
48
|
+
});
|
|
49
|
+
case 'newerUnsupported':
|
|
50
|
+
return DraftLoadResponseSchema.parse({
|
|
51
|
+
kind: load.kind,
|
|
52
|
+
path: load.path,
|
|
53
|
+
foundVersion: load.foundVersion,
|
|
54
|
+
supportedVersion: load.supportedVersion,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function draftView(capabilities, load) {
|
|
59
|
+
return {
|
|
60
|
+
...load.draft,
|
|
61
|
+
comments: await Promise.all(load.draft.comments.map(async (comment) => ({
|
|
62
|
+
...comment,
|
|
63
|
+
verification: await capabilities.verifyAnchor(comment.anchor),
|
|
64
|
+
}))),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function publicMutationResult(result) {
|
|
68
|
+
if (result.kind === 'readOnly') {
|
|
69
|
+
return DraftMutationResultSchema.parse({ kind: result.kind, load: publicLoad(result.load) });
|
|
70
|
+
}
|
|
71
|
+
return DraftMutationResultSchema.parse(result);
|
|
72
|
+
}
|
|
73
|
+
function mutationResponse(reply, result, acceptedStatus) {
|
|
74
|
+
const response = publicMutationResult(result);
|
|
75
|
+
switch (response.kind) {
|
|
76
|
+
case 'accepted':
|
|
77
|
+
return reply.code(acceptedStatus).send(response);
|
|
78
|
+
case 'revisionConflict':
|
|
79
|
+
case 'illegalTransition':
|
|
80
|
+
case 'readOnly':
|
|
81
|
+
return reply.code(409).send(response);
|
|
82
|
+
case 'invalidTarget':
|
|
83
|
+
return reply.code(404).send(response);
|
|
84
|
+
case 'persistenceFailure':
|
|
85
|
+
return reply.code(500).send(response);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export function registerSessionRoutes(app, capabilities) {
|
|
89
|
+
app.get('/api/session', { schema: { querystring: EMPTY_QUERY_SCHEMA } }, async () => await capabilities.session());
|
|
90
|
+
if (capabilities.support !== undefined) {
|
|
91
|
+
app.get('/api/support/status', { schema: { querystring: EMPTY_QUERY_SCHEMA } }, async (request, reply) => {
|
|
92
|
+
if (request.body !== undefined || request.headers['content-length'] !== undefined)
|
|
93
|
+
return unavailable(reply, 400);
|
|
94
|
+
return SupportStatusSchema.parse(await capabilities.support.status());
|
|
95
|
+
});
|
|
96
|
+
app.post('/api/support/start', { schema: { querystring: EMPTY_QUERY_SCHEMA }, bodyLimit: 1024 }, async (request, reply) => {
|
|
97
|
+
if (request.headers['content-type']?.split(';', 1)[0] !== 'application/json')
|
|
98
|
+
return unavailable(reply, 400);
|
|
99
|
+
const body = SupportStartRequestSchema.safeParse(request.body);
|
|
100
|
+
if (!body.success)
|
|
101
|
+
return unavailable(reply, 400);
|
|
102
|
+
return SupportStartResultSchema.parse(await capabilities.support.start(body.data.action));
|
|
103
|
+
});
|
|
104
|
+
app.post('/api/support/refresh', { schema: { querystring: EMPTY_QUERY_SCHEMA }, bodyLimit: 1 }, async (request, reply) => {
|
|
105
|
+
if (request.body !== undefined || (request.headers['content-length'] !== undefined && request.headers['content-length'] !== '0') || request.headers['content-type'] !== undefined)
|
|
106
|
+
return unavailable(reply, 400);
|
|
107
|
+
return SupportStatusSchema.parse(await capabilities.support.refresh());
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (capabilities.attachedCompletion !== undefined) {
|
|
111
|
+
app.get('/api/review-completion', { schema: { querystring: EMPTY_QUERY_SCHEMA } }, async (request, reply) => {
|
|
112
|
+
if (Object.keys(request.query).length !== 0
|
|
113
|
+
|| request.body !== undefined
|
|
114
|
+
|| request.headers['content-length'] !== undefined
|
|
115
|
+
|| request.headers['content-type'] !== undefined) {
|
|
116
|
+
return unavailable(reply, 400);
|
|
117
|
+
}
|
|
118
|
+
return AttachedCompletionStatusSchema.parse(capabilities.attachedCompletion.status());
|
|
119
|
+
});
|
|
120
|
+
app.post('/api/review-completion/finish', { schema: { querystring: EMPTY_QUERY_SCHEMA }, bodyLimit: 256 }, async (request, reply) => {
|
|
121
|
+
if (Object.keys(request.query).length !== 0
|
|
122
|
+
|| request.headers['content-type']?.split(';', 1)[0] !== 'application/json') {
|
|
123
|
+
return unavailable(reply, 400);
|
|
124
|
+
}
|
|
125
|
+
const input = FinishReviewRequestSchema.safeParse(request.body);
|
|
126
|
+
if (!input.success)
|
|
127
|
+
return unavailable(reply, 400);
|
|
128
|
+
const result = FinishReviewResultSchema.parse(await capabilities.attachedCompletion.finish(input.data.expectedRevision));
|
|
129
|
+
switch (result.kind) {
|
|
130
|
+
case 'completed':
|
|
131
|
+
reply.raw.once('finish', () => capabilities.attachedCompletion.markResponseSettled());
|
|
132
|
+
return reply.code(201).send(result);
|
|
133
|
+
case 'alreadyCompleted':
|
|
134
|
+
return reply.code(200).send(result);
|
|
135
|
+
case 'revisionConflict':
|
|
136
|
+
case 'staleAnchors':
|
|
137
|
+
case 'scopeInvalid':
|
|
138
|
+
case 'draftReadOnly':
|
|
139
|
+
return reply.code(409).send(result);
|
|
140
|
+
case 'persistenceFailure':
|
|
141
|
+
case 'canonicalizationFailure':
|
|
142
|
+
case 'deliveryFailed':
|
|
143
|
+
return reply.code(500).send(result);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (capabilities.patchStatus !== undefined) {
|
|
148
|
+
app.get('/api/patch-status', { schema: { querystring: EMPTY_QUERY_SCHEMA } }, async (request, reply) => {
|
|
149
|
+
if (Object.keys(request.query).length !== 0 ||
|
|
150
|
+
request.body !== undefined ||
|
|
151
|
+
request.headers['content-length'] !== undefined ||
|
|
152
|
+
request.headers['content-type'] !== undefined) {
|
|
153
|
+
return unavailable(reply, 400);
|
|
154
|
+
}
|
|
155
|
+
return PatchStatusResponseSchema.parse(await capabilities.patchStatus());
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
const selectorDriftObserver = capabilities.selectorDriftObserver;
|
|
159
|
+
if (selectorDriftObserver !== undefined) {
|
|
160
|
+
app.get('/api/selector-drift', { schema: { querystring: EMPTY_QUERY_SCHEMA } }, async (request, reply) => {
|
|
161
|
+
if (Object.keys(request.query).length !== 0 ||
|
|
162
|
+
request.body !== undefined ||
|
|
163
|
+
request.headers['content-length'] !== undefined) {
|
|
164
|
+
return unavailable(reply, 400);
|
|
165
|
+
}
|
|
166
|
+
return SelectorDriftResponseSchema.parse(await selectorDriftObserver.observe());
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
if (capabilities.refresh !== undefined) {
|
|
170
|
+
app.post('/api/refresh', { schema: { querystring: EMPTY_QUERY_SCHEMA }, bodyLimit: 1 }, async (request, reply) => {
|
|
171
|
+
if (request.body !== undefined ||
|
|
172
|
+
(request.headers['content-length'] !== undefined && request.headers['content-length'] !== '0') ||
|
|
173
|
+
request.headers['content-type'] !== undefined) {
|
|
174
|
+
return unavailable(reply, 400);
|
|
175
|
+
}
|
|
176
|
+
return await capabilities.refresh();
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (capabilities.worktreeChangeIndicator !== undefined) {
|
|
180
|
+
app.get('/api/worktree-status', { schema: { querystring: EMPTY_QUERY_SCHEMA } }, async (request, reply) => {
|
|
181
|
+
if (Object.keys(request.query).length !== 0 ||
|
|
182
|
+
request.body !== undefined ||
|
|
183
|
+
request.headers['content-length'] !== undefined ||
|
|
184
|
+
request.headers['content-type'] !== undefined) {
|
|
185
|
+
return unavailable(reply, 400);
|
|
186
|
+
}
|
|
187
|
+
return await capabilities.worktreeChangeIndicator();
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (capabilities.recoverUncommittedDraft !== undefined) {
|
|
191
|
+
app.post('/api/uncommitted/recover-draft', { schema: { querystring: EMPTY_QUERY_SCHEMA }, bodyLimit: 1 }, async (request, reply) => {
|
|
192
|
+
if (request.body !== undefined ||
|
|
193
|
+
(request.headers['content-length'] !== undefined && request.headers['content-length'] !== '0') ||
|
|
194
|
+
request.headers['content-type'] !== undefined) {
|
|
195
|
+
return unavailable(reply, 400);
|
|
196
|
+
}
|
|
197
|
+
return UncommittedDraftRecoveryResultSchema.parse(await capabilities.recoverUncommittedDraft());
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
app.get('/api/files/:fileId', {
|
|
201
|
+
schema: {
|
|
202
|
+
params: FILE_PARAMS_SCHEMA,
|
|
203
|
+
querystring: EMPTY_QUERY_SCHEMA,
|
|
204
|
+
},
|
|
205
|
+
}, async (request, reply) => {
|
|
206
|
+
const fileId = OpaqueFileIdSchema.safeParse(request.params.fileId);
|
|
207
|
+
if (!fileId.success) {
|
|
208
|
+
return unavailable(reply, 404);
|
|
209
|
+
}
|
|
210
|
+
const metadata = await capabilities.lookup(fileId.data);
|
|
211
|
+
if (metadata === undefined) {
|
|
212
|
+
return unavailable(reply, 404);
|
|
213
|
+
}
|
|
214
|
+
return metadata;
|
|
215
|
+
});
|
|
216
|
+
app.get('/api/files/:fileId/content', async (request, reply) => {
|
|
217
|
+
if (Object.keys(request.query).length !== 0) {
|
|
218
|
+
return unavailable(reply, 400);
|
|
219
|
+
}
|
|
220
|
+
const fileId = OpaqueFileIdSchema.safeParse(request.params.fileId);
|
|
221
|
+
if (!fileId.success) {
|
|
222
|
+
return unavailable(reply, 404);
|
|
223
|
+
}
|
|
224
|
+
const metadata = await capabilities.lookup(fileId.data);
|
|
225
|
+
if (metadata === undefined) {
|
|
226
|
+
return unavailable(reply, 404);
|
|
227
|
+
}
|
|
228
|
+
if (metadata.availability.kind !== 'text') {
|
|
229
|
+
return unavailable(reply, 409);
|
|
230
|
+
}
|
|
231
|
+
const content = await capabilities.readContent(fileId.data);
|
|
232
|
+
if (content === undefined) {
|
|
233
|
+
return unavailable(reply, 409);
|
|
234
|
+
}
|
|
235
|
+
return content;
|
|
236
|
+
});
|
|
237
|
+
app.get('/api/files/:fileId/image', async (request, reply) => {
|
|
238
|
+
const query = FileImageQuerySchema.safeParse(request.query);
|
|
239
|
+
if (!query.success) {
|
|
240
|
+
return unavailable(reply, 400);
|
|
241
|
+
}
|
|
242
|
+
const fileId = OpaqueFileIdSchema.safeParse(request.params.fileId);
|
|
243
|
+
if (!fileId.success) {
|
|
244
|
+
return unavailable(reply, 404);
|
|
245
|
+
}
|
|
246
|
+
const metadata = await capabilities.lookup(fileId.data);
|
|
247
|
+
if (metadata === undefined) {
|
|
248
|
+
return unavailable(reply, 404);
|
|
249
|
+
}
|
|
250
|
+
const image = await capabilities.readRelativeImage(fileId.data, query.data.side, query.data.path);
|
|
251
|
+
if (image === undefined) {
|
|
252
|
+
return unavailable(reply, 409);
|
|
253
|
+
}
|
|
254
|
+
return reply.type(image.contentType).send(image.bytes);
|
|
255
|
+
});
|
|
256
|
+
app.get('/api/draft', { schema: { querystring: EMPTY_QUERY_SCHEMA } }, async (request, reply) => {
|
|
257
|
+
if (Object.keys(request.query).length !== 0) {
|
|
258
|
+
return unavailable(reply, 400);
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
const load = await capabilities.draftStore.loadState();
|
|
262
|
+
return publicLoad(load, load.kind === 'current' ? await draftView(capabilities, load) : undefined);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return unavailable(reply, 500);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
app.post('/api/draft/mutations', { schema: { querystring: EMPTY_QUERY_SCHEMA } }, async (request, reply) => {
|
|
269
|
+
const requestMutation = DraftMutationRequestSchema.safeParse(request.body);
|
|
270
|
+
if (!requestMutation.success) {
|
|
271
|
+
return unavailable(reply, 400);
|
|
272
|
+
}
|
|
273
|
+
const initialLoad = await capabilities.draftStore.loadState();
|
|
274
|
+
if (initialLoad.kind === 'malformed' || initialLoad.kind === 'schemaInvalid' || initialLoad.kind === 'newerUnsupported') {
|
|
275
|
+
return mutationResponse(reply, { kind: 'readOnly', load: initialLoad }, requestMutation.data.type === 'addComment' ? 201 : 200);
|
|
276
|
+
}
|
|
277
|
+
const { expectedRevision } = requestMutation.data;
|
|
278
|
+
let mutation;
|
|
279
|
+
if (requestMutation.data.type === 'addComment') {
|
|
280
|
+
const content = await capabilities.readContent(requestMutation.data.fileId);
|
|
281
|
+
const selectedSide = content?.[requestMutation.data.side];
|
|
282
|
+
if (selectedSide === undefined || selectedSide.exists === false) {
|
|
283
|
+
return unavailable(reply, 409);
|
|
284
|
+
}
|
|
285
|
+
if (/(?:\r\n|\n)$/u.test(selectedSide.text) &&
|
|
286
|
+
requestMutation.data.line === selectedSide.text.split(/\r\n|\n/u).length) {
|
|
287
|
+
return unavailable(reply, 409);
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
mutation = {
|
|
291
|
+
type: 'addComment',
|
|
292
|
+
body: requestMutation.data.body,
|
|
293
|
+
anchor: DurableAnchorV1Schema.parse(buildDurableAnchor({
|
|
294
|
+
path: selectedSide.path,
|
|
295
|
+
safeDisplayPath: selectedSide.path.display,
|
|
296
|
+
side: requestMutation.data.side,
|
|
297
|
+
blobOid: selectedSide.blobOid,
|
|
298
|
+
line: requestMutation.data.line,
|
|
299
|
+
endLine: requestMutation.data.endLine,
|
|
300
|
+
text: selectedSide.text,
|
|
301
|
+
})),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return unavailable(reply, 409);
|
|
306
|
+
}
|
|
307
|
+
if (capabilities.onAnchorAdd !== undefined) {
|
|
308
|
+
try {
|
|
309
|
+
await capabilities.onAnchorAdd({ body: requestMutation.data.body, anchor: mutation.anchor });
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return unavailable(reply, 500);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
if (capabilities.onAnchorAdd !== undefined) {
|
|
316
|
+
return reply.code(201).send({ kind: 'accepted' });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
const { expectedRevision: _expectedRevision, ...operation } = requestMutation.data;
|
|
321
|
+
mutation = operation;
|
|
322
|
+
}
|
|
323
|
+
const result = await capabilities.draftStore.mutate({ expectedRevision, mutation });
|
|
324
|
+
return mutationResponse(reply, result, requestMutation.data.type === 'addComment' ? 201 : 200);
|
|
325
|
+
});
|
|
326
|
+
app.post('/api/export', { schema: { querystring: EMPTY_QUERY_SCHEMA }, bodyLimit: 256 }, async (request, reply) => {
|
|
327
|
+
if (capabilities.exportReview === undefined)
|
|
328
|
+
return unavailable(reply, 404);
|
|
329
|
+
if (Object.keys(request.query).length !== 0 ||
|
|
330
|
+
request.headers['content-type']?.split(';', 1)[0] !== 'application/json') {
|
|
331
|
+
return unavailable(reply, 400);
|
|
332
|
+
}
|
|
333
|
+
const input = ExportReviewRequestSchema.safeParse(request.body);
|
|
334
|
+
if (!input.success) {
|
|
335
|
+
return unavailable(reply, 400);
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
const response = ExportReviewResultSchema.parse(await capabilities.exportReview(input.data));
|
|
339
|
+
switch (response.kind) {
|
|
340
|
+
case 'exported':
|
|
341
|
+
return reply.code(201).send(response);
|
|
342
|
+
case 'publicationFailed':
|
|
343
|
+
case 'recoveryRequired':
|
|
344
|
+
return reply.code(500).send(response);
|
|
345
|
+
case 'revisionConflict':
|
|
346
|
+
case 'driftAcknowledgementRequired':
|
|
347
|
+
case 'driftAcknowledgementStale':
|
|
348
|
+
case 'draftReadOnly':
|
|
349
|
+
case 'reExportUnsupported':
|
|
350
|
+
return reply.code(409).send(response);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return unavailable(reply, 500);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
app.get('/api/export/gitignore', { schema: { querystring: EMPTY_QUERY_SCHEMA } }, async (request, reply) => {
|
|
358
|
+
if (Object.keys(request.query).length !== 0 ||
|
|
359
|
+
request.body !== undefined ||
|
|
360
|
+
request.headers['content-length'] !== undefined ||
|
|
361
|
+
request.headers['content-type'] !== undefined) {
|
|
362
|
+
return unavailable(reply, 400);
|
|
363
|
+
}
|
|
364
|
+
return LoopnoteIgnoreStatusSchema.parse(await capabilities.inspectLoopnoteIgnore());
|
|
365
|
+
});
|
|
366
|
+
app.post('/api/export/gitignore', { schema: { querystring: EMPTY_QUERY_SCHEMA } }, async (request, reply) => {
|
|
367
|
+
if (Object.keys(request.query).length !== 0 ||
|
|
368
|
+
request.body !== undefined ||
|
|
369
|
+
request.headers['content-length'] !== undefined ||
|
|
370
|
+
request.headers['content-type'] !== undefined) {
|
|
371
|
+
return unavailable(reply, 400);
|
|
372
|
+
}
|
|
373
|
+
return reply.code(200).send(AppendLoopnoteIgnoreResultSchema.parse(await capabilities.appendLoopnoteIgnoreRule()));
|
|
374
|
+
});
|
|
375
|
+
app.post('/api/export/reveal', { schema: { querystring: EMPTY_QUERY_SCHEMA } }, async (request, reply) => {
|
|
376
|
+
if (capabilities.revealExportDirectory === undefined)
|
|
377
|
+
return unavailable(reply, 404);
|
|
378
|
+
if (Object.keys(request.query).length !== 0 ||
|
|
379
|
+
request.body !== undefined ||
|
|
380
|
+
request.headers['content-length'] !== undefined) {
|
|
381
|
+
return unavailable(reply, 400);
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
await capabilities.revealExportDirectory();
|
|
385
|
+
return reply.code(200).send(ExportDirectoryRevealResultSchema.parse({ kind: 'revealed' }));
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return reply.code(500).send(ExportDirectoryRevealResultSchema.parse({ kind: 'revealFailed' }));
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
app.post('/api/draft/recovery', { schema: { querystring: EMPTY_QUERY_SCHEMA } }, async (request, reply) => {
|
|
392
|
+
const input = DraftRecoveryRequestSchema.safeParse(request.body);
|
|
393
|
+
if (!input.success) {
|
|
394
|
+
return unavailable(reply, 400);
|
|
395
|
+
}
|
|
396
|
+
const result = await capabilities.draftStore.recover(input.data);
|
|
397
|
+
let response;
|
|
398
|
+
if (result.kind === 'recoveryUnavailable') {
|
|
399
|
+
response = DraftRecoveryResultSchema.parse({ kind: result.kind, load: publicLoad(result.load) });
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
response = DraftRecoveryResultSchema.parse(result);
|
|
403
|
+
}
|
|
404
|
+
return reply.code(response.kind === 'recovered' ? 201 : response.kind === 'persistenceFailure' ? 500 : 409).send(response);
|
|
405
|
+
});
|
|
406
|
+
app.post('/api/draft/reveal', { schema: { querystring: EMPTY_QUERY_SCHEMA } }, async (request, reply) => {
|
|
407
|
+
if (Object.keys(request.query).length !== 0 || request.body !== undefined) {
|
|
408
|
+
return unavailable(reply, 400);
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
await capabilities.revealDraftFile();
|
|
412
|
+
return reply.code(200).send(DraftRevealResultSchema.parse({ kind: 'revealed' }));
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return reply.code(500).send(DraftRevealResultSchema.parse({ kind: 'revealFailed' }));
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { ApiErrorSchema } from '../contracts/api.js';
|
|
3
|
+
import { RENAME_MAP } from '../domain/product-identity.js';
|
|
4
|
+
export const REQUEST_UNAVAILABLE_MESSAGE = `This request is not available in the current session. Relaunch ${RENAME_MAP.displayName} from the terminal.`;
|
|
5
|
+
export const REQUEST_UNAVAILABLE_ERROR = ApiErrorSchema.parse({
|
|
6
|
+
code: 'request-unavailable',
|
|
7
|
+
message: REQUEST_UNAVAILABLE_MESSAGE,
|
|
8
|
+
});
|
|
9
|
+
const CONTENT_SECURITY_POLICY = [
|
|
10
|
+
"default-src 'self'",
|
|
11
|
+
"script-src 'self'",
|
|
12
|
+
"style-src 'self'",
|
|
13
|
+
"style-src-elem 'self' 'unsafe-inline'",
|
|
14
|
+
"style-src-attr 'unsafe-inline'",
|
|
15
|
+
"connect-src 'self'",
|
|
16
|
+
"img-src 'self'",
|
|
17
|
+
"font-src 'self'",
|
|
18
|
+
"object-src 'none'",
|
|
19
|
+
"frame-ancestors 'none'",
|
|
20
|
+
"base-uri 'none'",
|
|
21
|
+
"form-action 'none'",
|
|
22
|
+
].join('; ');
|
|
23
|
+
function applySecurityHeaders(reply) {
|
|
24
|
+
void reply
|
|
25
|
+
.header('cache-control', 'no-store')
|
|
26
|
+
.header('referrer-policy', 'no-referrer')
|
|
27
|
+
.header('x-content-type-options', 'nosniff')
|
|
28
|
+
.header('x-frame-options', 'DENY')
|
|
29
|
+
.header('content-security-policy', CONTENT_SECURITY_POLICY);
|
|
30
|
+
}
|
|
31
|
+
function isApiRequest(request) {
|
|
32
|
+
return request.url === '/api' || request.url.startsWith('/api/') || request.url.startsWith('/api?');
|
|
33
|
+
}
|
|
34
|
+
// The rendered Markdown preview's relative images (GAP-01, 16-UI-REVIEW) are loaded via a
|
|
35
|
+
// literal <img src> tag, which structurally cannot attach an Authorization header. This
|
|
36
|
+
// matches only the image content route so no other /api/* path accepts a query-string token.
|
|
37
|
+
const IMAGE_ROUTE_PATTERN = /^\/api\/files\/[^/?]+\/image(?:\?.*)?$/u;
|
|
38
|
+
function tokenMatches(candidate, expectedToken) {
|
|
39
|
+
if (candidate === undefined) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const candidateBuffer = Buffer.from(candidate, 'utf8');
|
|
43
|
+
return candidateBuffer.length === expectedToken.length && timingSafeEqual(candidateBuffer, expectedToken);
|
|
44
|
+
}
|
|
45
|
+
function hasBearerToken(request, expectedToken) {
|
|
46
|
+
const authorization = request.headers.authorization;
|
|
47
|
+
if (authorization === undefined || !authorization.startsWith('Bearer ')) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return tokenMatches(authorization.slice('Bearer '.length), expectedToken);
|
|
51
|
+
}
|
|
52
|
+
function hasImageRouteQueryToken(request, expectedToken) {
|
|
53
|
+
const queryIndex = request.url.indexOf('?');
|
|
54
|
+
if (queryIndex === -1) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const candidate = new URLSearchParams(request.url.slice(queryIndex + 1)).get('token');
|
|
58
|
+
return tokenMatches(candidate ?? undefined, expectedToken);
|
|
59
|
+
}
|
|
60
|
+
export function registerSessionSecurity(app, options) {
|
|
61
|
+
const expectedToken = Buffer.from(options.sessionToken, 'utf8');
|
|
62
|
+
let target;
|
|
63
|
+
function diagnose(reason) {
|
|
64
|
+
options.diagnostics?.({
|
|
65
|
+
correlationId: randomBytes(12).toString('base64url'),
|
|
66
|
+
reason,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function deny(reply, statusCode, reason) {
|
|
70
|
+
diagnose(reason);
|
|
71
|
+
void reply.code(statusCode).send(REQUEST_UNAVAILABLE_ERROR);
|
|
72
|
+
}
|
|
73
|
+
app.addHook('onRequest', (request, reply, done) => {
|
|
74
|
+
applySecurityHeaders(reply);
|
|
75
|
+
if (target === undefined) {
|
|
76
|
+
deny(reply, 403, 'security-not-bound');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (request.headers.host !== target.expectedHost) {
|
|
80
|
+
deny(reply, 403, 'host-mismatch');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const origin = request.headers.origin;
|
|
84
|
+
if (origin !== undefined && origin !== target.expectedOrigin) {
|
|
85
|
+
deny(reply, 403, 'origin-mismatch');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const authorized = hasBearerToken(request, expectedToken)
|
|
89
|
+
|| (IMAGE_ROUTE_PATTERN.test(request.url) && hasImageRouteQueryToken(request, expectedToken));
|
|
90
|
+
if (isApiRequest(request) && !authorized) {
|
|
91
|
+
deny(reply, 401, 'token-mismatch');
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
done();
|
|
95
|
+
});
|
|
96
|
+
app.addHook('onSend', (_request, reply, payload, done) => {
|
|
97
|
+
applySecurityHeaders(reply);
|
|
98
|
+
done(null, payload);
|
|
99
|
+
});
|
|
100
|
+
app.setNotFoundHandler((_request, reply) => {
|
|
101
|
+
void reply.code(404).send(REQUEST_UNAVAILABLE_ERROR);
|
|
102
|
+
});
|
|
103
|
+
app.setErrorHandler((error, _request, reply) => {
|
|
104
|
+
const fastifyError = error;
|
|
105
|
+
diagnose(fastifyError.validation === undefined ? 'request-failed' : 'invalid-request');
|
|
106
|
+
const statusCode = fastifyError.validation === undefined && fastifyError.statusCode === undefined ? 500 : 400;
|
|
107
|
+
void reply.code(statusCode).send(REQUEST_UNAVAILABLE_ERROR);
|
|
108
|
+
});
|
|
109
|
+
return Object.freeze({
|
|
110
|
+
bind(nextTarget) {
|
|
111
|
+
if (target !== undefined) {
|
|
112
|
+
throw new Error('Session security is already bound.');
|
|
113
|
+
}
|
|
114
|
+
if (!/^127\.0\.0\.1:\d+$/.test(nextTarget.expectedHost)) {
|
|
115
|
+
throw new Error('Session security must bind to a loopback authority.');
|
|
116
|
+
}
|
|
117
|
+
if (nextTarget.expectedOrigin !== `http://${nextTarget.expectedHost}`) {
|
|
118
|
+
throw new Error('Session security origin must match its loopback authority.');
|
|
119
|
+
}
|
|
120
|
+
target = Object.freeze({ ...nextTarget });
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmod, open } from 'node:fs/promises';
|
|
3
|
+
export const MAX_SIDE_BYTES = 2 * 1024 * 1024;
|
|
4
|
+
export function digest(bytes) {
|
|
5
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
6
|
+
}
|
|
7
|
+
export function frame(value) {
|
|
8
|
+
const bytes = Buffer.from(value, 'utf8');
|
|
9
|
+
const length = Buffer.allocUnsafe(8);
|
|
10
|
+
length.writeBigUInt64BE(BigInt(bytes.byteLength));
|
|
11
|
+
return Buffer.concat([length, bytes]);
|
|
12
|
+
}
|
|
13
|
+
export function isMissing(error) {
|
|
14
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
|
15
|
+
}
|
|
16
|
+
export async function syncDirectory(path) {
|
|
17
|
+
const handle = await open(path, 'r');
|
|
18
|
+
try {
|
|
19
|
+
await handle.sync();
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
await handle.close();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export async function writeOwned(path, bytes) {
|
|
26
|
+
const handle = await open(path, 'wx', 0o600);
|
|
27
|
+
try {
|
|
28
|
+
await handle.writeFile(bytes);
|
|
29
|
+
await handle.sync();
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
await handle.close();
|
|
33
|
+
}
|
|
34
|
+
await chmod(path, 0o400);
|
|
35
|
+
}
|
|
36
|
+
export async function boundedFileBytes(path, expectedBytes) {
|
|
37
|
+
const handle = await open(path, 'r');
|
|
38
|
+
try {
|
|
39
|
+
const info = await handle.stat();
|
|
40
|
+
if (!info.isFile() || info.size !== expectedBytes || expectedBytes > MAX_SIDE_BYTES) {
|
|
41
|
+
throw new Error('Target file differs from its frozen size.');
|
|
42
|
+
}
|
|
43
|
+
const bytes = Buffer.allocUnsafe(expectedBytes + 1);
|
|
44
|
+
let offset = 0;
|
|
45
|
+
while (offset < bytes.byteLength) {
|
|
46
|
+
const { bytesRead } = await handle.read(bytes, offset, bytes.byteLength - offset, offset);
|
|
47
|
+
if (bytesRead === 0)
|
|
48
|
+
break;
|
|
49
|
+
offset += bytesRead;
|
|
50
|
+
}
|
|
51
|
+
if (offset !== expectedBytes)
|
|
52
|
+
throw new Error('Target file changed during observation.');
|
|
53
|
+
return bytes.subarray(0, expectedBytes);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
await handle.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { RENAME_MAP } from '../domain/product-identity.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
const responseLimit = 8192;
|
|
4
|
+
const timeoutMs = 5000;
|
|
5
|
+
const installationStatusSchema = z.strictObject({ status: z.enum(['unverified', 'verified']) });
|
|
6
|
+
const supportStartSchema = z.strictObject({ flowUrl: z.string().url() });
|
|
7
|
+
async function responseJson(response) {
|
|
8
|
+
const contentLength = Number(response.headers.get('content-length') ?? '0');
|
|
9
|
+
if (!response.ok || contentLength > responseLimit)
|
|
10
|
+
return undefined;
|
|
11
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
12
|
+
if (bytes.length > responseLimit)
|
|
13
|
+
return undefined;
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function createHostedSupportClient(options = {}) {
|
|
22
|
+
const configuredServiceUrl = options.serviceUrl ?? process.env[`${RENAME_MAP.envPrefix}SUPPORT_SERVICE_URL`];
|
|
23
|
+
let service;
|
|
24
|
+
try {
|
|
25
|
+
service = configuredServiceUrl === undefined ? undefined : new URL(configuredServiceUrl);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
service = undefined;
|
|
29
|
+
}
|
|
30
|
+
if (service?.protocol !== 'https:')
|
|
31
|
+
service = undefined;
|
|
32
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
33
|
+
const lifetimeController = new AbortController();
|
|
34
|
+
const fetchJson = async (path, init) => {
|
|
35
|
+
if (service === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
const requestController = new AbortController();
|
|
38
|
+
const timeout = setTimeout(() => requestController.abort(), timeoutMs);
|
|
39
|
+
try {
|
|
40
|
+
const signal = AbortSignal.any([lifetimeController.signal, requestController.signal]);
|
|
41
|
+
return await responseJson(await fetcher(new URL(path, service).toString(), { ...init, signal }));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
clearTimeout(timeout);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const validFlowUrl = (flowUrl) => {
|
|
51
|
+
try {
|
|
52
|
+
const flow = new URL(flowUrl);
|
|
53
|
+
return flow.protocol === 'https:' && flow.origin === service?.origin;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
return Object.freeze({
|
|
60
|
+
async start(action, installationId) {
|
|
61
|
+
const result = supportStartSchema.safeParse(await fetchJson('/functions/v1/support-api/start', {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'content-type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({ action, installationId }),
|
|
65
|
+
}));
|
|
66
|
+
return result.success && validFlowUrl(result.data.flowUrl) ? result.data : undefined;
|
|
67
|
+
},
|
|
68
|
+
async status(installationId) {
|
|
69
|
+
const result = installationStatusSchema.safeParse(await fetchJson(`/functions/v1/support-api/status?installationId=${encodeURIComponent(installationId)}`));
|
|
70
|
+
return result.success ? result.data.status : undefined;
|
|
71
|
+
},
|
|
72
|
+
close() {
|
|
73
|
+
lifetimeController.abort();
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|