@nkzw/codiff-service 1.9.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/api.test.ts +44 -0
- package/api.ts +707 -0
- package/db.ts +45 -0
- package/dist/api.mjs +403 -0
- package/dist/db.mjs +26 -0
- package/dist/fate.mjs +700 -0
- package/dist/index.mjs +6 -0
- package/dist/react.mjs +62 -0
- package/dist/schema.mjs +219 -0
- package/dist/upload-intent.mjs +48 -0
- package/dist/views.mjs +142 -0
- package/fate.test.ts +21 -0
- package/fate.ts +1258 -0
- package/index.ts +5 -0
- package/package.json +79 -0
- package/react.test.ts +70 -0
- package/react.tsx +151 -0
- package/schema.ts +320 -0
- package/upload-intent.test.ts +24 -0
- package/upload-intent.ts +92 -0
- package/views.ts +269 -0
- package/vite.config.ts +8 -0
package/upload-intent.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export type UploadIntentTokenEnv = {
|
|
2
|
+
BETTER_AUTH_SECRET?: string;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
const encoder = new TextEncoder();
|
|
6
|
+
const tokenVersion = 'v1';
|
|
7
|
+
|
|
8
|
+
const base64UrlEncode = (value: Uint8Array) =>
|
|
9
|
+
btoa(String.fromCharCode(...value))
|
|
10
|
+
.replaceAll('+', '-')
|
|
11
|
+
.replaceAll('/', '_')
|
|
12
|
+
.replaceAll('=', '');
|
|
13
|
+
|
|
14
|
+
const base64UrlDecode = (value: string) => {
|
|
15
|
+
const padding = '='.repeat((4 - (value.length % 4)) % 4);
|
|
16
|
+
const decoded = atob(value.replaceAll('-', '+').replaceAll('_', '/') + padding);
|
|
17
|
+
return Uint8Array.from(decoded, (character) => character.charCodeAt(0));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const signingKey = (env: UploadIntentTokenEnv) => {
|
|
21
|
+
const secret = env.BETTER_AUTH_SECRET?.trim();
|
|
22
|
+
if (!secret) {
|
|
23
|
+
throw new Error('Codiff sharing requires BETTER_AUTH_SECRET.');
|
|
24
|
+
}
|
|
25
|
+
return crypto.subtle.importKey(
|
|
26
|
+
'raw',
|
|
27
|
+
encoder.encode(secret),
|
|
28
|
+
{ hash: 'SHA-256', name: 'HMAC' },
|
|
29
|
+
false,
|
|
30
|
+
['sign', 'verify'],
|
|
31
|
+
);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const signedValue = (code: string, payload: string) => `${code}.${payload}`;
|
|
35
|
+
|
|
36
|
+
export const createUploadIntentSecret = async (
|
|
37
|
+
env: UploadIntentTokenEnv,
|
|
38
|
+
code: string,
|
|
39
|
+
expiresAt: Date,
|
|
40
|
+
) => {
|
|
41
|
+
const nonce = new Uint8Array(24);
|
|
42
|
+
crypto.getRandomValues(nonce);
|
|
43
|
+
const payload = [tokenVersion, expiresAt.getTime().toString(36), base64UrlEncode(nonce)].join(
|
|
44
|
+
'.',
|
|
45
|
+
);
|
|
46
|
+
const signature = await crypto.subtle.sign(
|
|
47
|
+
'HMAC',
|
|
48
|
+
await signingKey(env),
|
|
49
|
+
encoder.encode(signedValue(code, payload)),
|
|
50
|
+
);
|
|
51
|
+
return `${payload}.${base64UrlEncode(new Uint8Array(signature))}`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const hashUploadIntentSecret = async (secret: string) => {
|
|
55
|
+
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(secret));
|
|
56
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const verifyUploadIntentSecret = async (
|
|
60
|
+
env: UploadIntentTokenEnv,
|
|
61
|
+
code: string,
|
|
62
|
+
secret: string,
|
|
63
|
+
): Promise<{ expiresAt: Date } | null> => {
|
|
64
|
+
const parts = secret.split('.');
|
|
65
|
+
if (parts.length !== 4 || parts[0] !== tokenVersion) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const [version, encodedExpiresAt, nonce, encodedSignature] = parts;
|
|
69
|
+
const expiresAtMs = Number.parseInt(encodedExpiresAt!, 36);
|
|
70
|
+
if (
|
|
71
|
+
version !== tokenVersion ||
|
|
72
|
+
!Number.isSafeInteger(expiresAtMs) ||
|
|
73
|
+
expiresAtMs <= 0 ||
|
|
74
|
+
!nonce ||
|
|
75
|
+
!encodedSignature
|
|
76
|
+
) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const payload = `${version}.${encodedExpiresAt}.${nonce}`;
|
|
82
|
+
const valid = await crypto.subtle.verify(
|
|
83
|
+
'HMAC',
|
|
84
|
+
await signingKey(env),
|
|
85
|
+
base64UrlDecode(encodedSignature),
|
|
86
|
+
encoder.encode(signedValue(code, payload)),
|
|
87
|
+
);
|
|
88
|
+
return valid ? { expiresAt: new Date(expiresAtMs) } : null;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
};
|
package/views.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { computed, dataView, type Entity, FateRequestError, field, list } from '@nkzw/fate/server';
|
|
2
|
+
import type {
|
|
3
|
+
PlanRow,
|
|
4
|
+
ShareCommentMessageRow,
|
|
5
|
+
ShareCommentThreadRow,
|
|
6
|
+
UploadIntentRow,
|
|
7
|
+
WalkthroughRow,
|
|
8
|
+
} from './schema.ts';
|
|
9
|
+
|
|
10
|
+
export const uploadIntentSecretHeader = 'x-codiff-upload-secret';
|
|
11
|
+
|
|
12
|
+
type PermissionContext = {
|
|
13
|
+
auth: null | {
|
|
14
|
+
api: {
|
|
15
|
+
getSession(options: { asResponse?: false; headers: Headers }): Promise<unknown>;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
request: Request;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
22
|
+
value !== null && typeof value === 'object';
|
|
23
|
+
|
|
24
|
+
export const getSessionUserId = (value: unknown) => {
|
|
25
|
+
const result = isRecord(value) && isRecord(value.response) ? value.response : value;
|
|
26
|
+
const sessionUser = isRecord(result) ? result.user : null;
|
|
27
|
+
return isRecord(sessionUser) && typeof sessionUser.id === 'string' && sessionUser.id
|
|
28
|
+
? sessionUser.id
|
|
29
|
+
: null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const sessionUserIds = new WeakMap<Request, Promise<null | string>>();
|
|
33
|
+
|
|
34
|
+
const readContextSessionUserId = (context: PermissionContext) => {
|
|
35
|
+
if (!context.auth) {
|
|
36
|
+
return Promise.resolve(null);
|
|
37
|
+
}
|
|
38
|
+
const existing = sessionUserIds.get(context.request);
|
|
39
|
+
if (existing) {
|
|
40
|
+
return existing;
|
|
41
|
+
}
|
|
42
|
+
const result = context.auth.api
|
|
43
|
+
.getSession({
|
|
44
|
+
asResponse: false,
|
|
45
|
+
headers: context.request.headers,
|
|
46
|
+
})
|
|
47
|
+
.then(getSessionUserId);
|
|
48
|
+
sessionUserIds.set(context.request, result);
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const canManageShare = <
|
|
53
|
+
Row extends {
|
|
54
|
+
sharedByUserId: null | string;
|
|
55
|
+
},
|
|
56
|
+
>() =>
|
|
57
|
+
computed<Row, boolean, PermissionContext>({
|
|
58
|
+
resolve: async (_record, values, context) =>
|
|
59
|
+
Boolean(
|
|
60
|
+
values.sharedByUserId &&
|
|
61
|
+
context?.auth &&
|
|
62
|
+
(await readContextSessionUserId(context)) === values.sharedByUserId,
|
|
63
|
+
),
|
|
64
|
+
select: {
|
|
65
|
+
sharedByUserId: field('sharedByUserId'),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const canEditShareComment = () =>
|
|
70
|
+
computed<ShareCommentMessageRow, boolean, PermissionContext>({
|
|
71
|
+
resolve: async (_record, values, context) =>
|
|
72
|
+
Boolean(
|
|
73
|
+
values.authorUserId &&
|
|
74
|
+
context?.auth &&
|
|
75
|
+
(await readContextSessionUserId(context)) === values.authorUserId,
|
|
76
|
+
),
|
|
77
|
+
select: {
|
|
78
|
+
authorUserId: field('authorUserId'),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const sha256 = async (value: string) => {
|
|
83
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
|
|
84
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const protectedUploadIntentField = <
|
|
88
|
+
FieldName extends 'expiresAt' | 'shareKind' | 'status' | 'walkthroughSlug',
|
|
89
|
+
>(
|
|
90
|
+
fieldName: FieldName,
|
|
91
|
+
) =>
|
|
92
|
+
computed<UploadIntentRow, UploadIntentRow[FieldName], { request: Request }>({
|
|
93
|
+
resolve: async (_intent, values, context) => {
|
|
94
|
+
const secret = context?.request.headers.get(uploadIntentSecretHeader);
|
|
95
|
+
if (!secret || values.secretHash !== (await sha256(secret))) {
|
|
96
|
+
throw new FateRequestError('NOT_FOUND', 'Upload intent not found.');
|
|
97
|
+
}
|
|
98
|
+
return values.value as UploadIntentRow[FieldName];
|
|
99
|
+
},
|
|
100
|
+
select: {
|
|
101
|
+
secretHash: field('secretHash'),
|
|
102
|
+
value: field(fieldName),
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export const uploadIntentDataView = dataView<UploadIntentRow>('UploadIntent')({
|
|
107
|
+
expiresAt: protectedUploadIntentField('expiresAt'),
|
|
108
|
+
id: true,
|
|
109
|
+
shareKind: protectedUploadIntentField('shareKind'),
|
|
110
|
+
status: protectedUploadIntentField('status'),
|
|
111
|
+
walkthroughSlug: protectedUploadIntentField('walkthroughSlug'),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export type UploadIntent = Entity<typeof uploadIntentDataView, 'UploadIntent'>;
|
|
115
|
+
|
|
116
|
+
export const shareCommentMessageDataView = dataView<ShareCommentMessageRow>('ShareCommentMessage')({
|
|
117
|
+
authorImage: true,
|
|
118
|
+
authorName: true,
|
|
119
|
+
authorUsername: true,
|
|
120
|
+
body: true,
|
|
121
|
+
canEdit: canEditShareComment(),
|
|
122
|
+
createdAt: true,
|
|
123
|
+
id: true,
|
|
124
|
+
threadId: true,
|
|
125
|
+
updatedAt: true,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
export type ShareCommentMessage = Entity<typeof shareCommentMessageDataView, 'ShareCommentMessage'>;
|
|
129
|
+
|
|
130
|
+
export const shareCommentThreadDataView = dataView<ShareCommentThreadRow>('ShareCommentThread')({
|
|
131
|
+
anchorJson: true,
|
|
132
|
+
createdAt: true,
|
|
133
|
+
filePath: true,
|
|
134
|
+
id: true,
|
|
135
|
+
kind: true,
|
|
136
|
+
lineNumber: true,
|
|
137
|
+
messages: list(shareCommentMessageDataView, {
|
|
138
|
+
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
|
139
|
+
}),
|
|
140
|
+
planId: true,
|
|
141
|
+
resolvedAt: true,
|
|
142
|
+
sectionId: true,
|
|
143
|
+
side: true,
|
|
144
|
+
startLineNumber: true,
|
|
145
|
+
startSide: true,
|
|
146
|
+
status: true,
|
|
147
|
+
updatedAt: true,
|
|
148
|
+
walkthroughId: true,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export type ShareCommentThread = Entity<
|
|
152
|
+
typeof shareCommentThreadDataView,
|
|
153
|
+
'ShareCommentThread',
|
|
154
|
+
{
|
|
155
|
+
messages: Array<ShareCommentMessage>;
|
|
156
|
+
}
|
|
157
|
+
>;
|
|
158
|
+
|
|
159
|
+
export const walkthroughDataView = dataView<WalkthroughRow>('Walkthrough')({
|
|
160
|
+
branch: true,
|
|
161
|
+
byteSize: true,
|
|
162
|
+
canDelete: canManageShare<WalkthroughRow>(),
|
|
163
|
+
canResolveComments: canManageShare<WalkthroughRow>(),
|
|
164
|
+
codiffVersion: true,
|
|
165
|
+
commentThreads: list(shareCommentThreadDataView, {
|
|
166
|
+
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
|
167
|
+
}),
|
|
168
|
+
createdAt: true,
|
|
169
|
+
description: true,
|
|
170
|
+
id: true,
|
|
171
|
+
pullRequestNumber: true,
|
|
172
|
+
pullRequestTitle: true,
|
|
173
|
+
pullRequestUrl: true,
|
|
174
|
+
repositoryHost: true,
|
|
175
|
+
repositoryName: true,
|
|
176
|
+
repositoryOwner: true,
|
|
177
|
+
repositoryUrl: true,
|
|
178
|
+
schemaVersion: true,
|
|
179
|
+
sha256: true,
|
|
180
|
+
slug: true,
|
|
181
|
+
sourceType: true,
|
|
182
|
+
title: true,
|
|
183
|
+
updatedAt: true,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
export type Walkthrough = Entity<
|
|
187
|
+
typeof walkthroughDataView,
|
|
188
|
+
'Walkthrough',
|
|
189
|
+
{
|
|
190
|
+
commentThreads: Array<ShareCommentThread>;
|
|
191
|
+
}
|
|
192
|
+
>;
|
|
193
|
+
|
|
194
|
+
export const planDataView = dataView<PlanRow>('Plan')({
|
|
195
|
+
agent: true,
|
|
196
|
+
byteSize: true,
|
|
197
|
+
canDelete: canManageShare<PlanRow>(),
|
|
198
|
+
canResolveComments: canManageShare<PlanRow>(),
|
|
199
|
+
codiffVersion: true,
|
|
200
|
+
commentThreads: list(shareCommentThreadDataView, {
|
|
201
|
+
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
|
202
|
+
}),
|
|
203
|
+
createdAt: true,
|
|
204
|
+
id: true,
|
|
205
|
+
schemaVersion: true,
|
|
206
|
+
slug: true,
|
|
207
|
+
sourceFileName: true,
|
|
208
|
+
title: true,
|
|
209
|
+
updatedAt: true,
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
export type Plan = Entity<
|
|
213
|
+
typeof planDataView,
|
|
214
|
+
'Plan',
|
|
215
|
+
{
|
|
216
|
+
commentThreads: Array<ShareCommentThread>;
|
|
217
|
+
}
|
|
218
|
+
>;
|
|
219
|
+
|
|
220
|
+
export type ShareStatsDayRecord = {
|
|
221
|
+
date: string;
|
|
222
|
+
id: string;
|
|
223
|
+
mergeRequestWalkthroughs: number;
|
|
224
|
+
plans: number;
|
|
225
|
+
walkthroughs: number;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export type ShareStatsRecord = {
|
|
229
|
+
days: Array<ShareStatsDayRecord>;
|
|
230
|
+
id: string;
|
|
231
|
+
maxDailyShares: number;
|
|
232
|
+
totalMergeRequestWalkthroughs: number;
|
|
233
|
+
totalPlans: number;
|
|
234
|
+
totalWalkthroughs: number;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
export const shareStatsDayDataView = dataView<ShareStatsDayRecord>('ShareStatsDay')({
|
|
238
|
+
date: true,
|
|
239
|
+
id: true,
|
|
240
|
+
mergeRequestWalkthroughs: true,
|
|
241
|
+
plans: true,
|
|
242
|
+
walkthroughs: true,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
export type ShareStatsDay = Entity<typeof shareStatsDayDataView, 'ShareStatsDay'>;
|
|
246
|
+
|
|
247
|
+
export const shareStatsDataView = dataView<ShareStatsRecord>('ShareStats')({
|
|
248
|
+
days: list(shareStatsDayDataView),
|
|
249
|
+
id: true,
|
|
250
|
+
maxDailyShares: true,
|
|
251
|
+
totalMergeRequestWalkthroughs: true,
|
|
252
|
+
totalPlans: true,
|
|
253
|
+
totalWalkthroughs: true,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
export type ShareStats = Entity<
|
|
257
|
+
typeof shareStatsDataView,
|
|
258
|
+
'ShareStats',
|
|
259
|
+
{
|
|
260
|
+
days: Array<ShareStatsDay>;
|
|
261
|
+
}
|
|
262
|
+
>;
|
|
263
|
+
|
|
264
|
+
export const SharingRoot = {
|
|
265
|
+
planBySlug: planDataView,
|
|
266
|
+
sharingStats: shareStatsDataView,
|
|
267
|
+
uploadIntentByCode: uploadIntentDataView,
|
|
268
|
+
walkthroughBySlug: walkthroughDataView,
|
|
269
|
+
} as const;
|