@alliance-droid/status-feedback-system 4.1.5 → 4.2.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.
@@ -15,7 +15,8 @@ export interface CategoryConfig {
15
15
  }
16
16
  export interface FeedbackBoardOptions {
17
17
  apiUrl: string;
18
- apiKey: string;
18
+ /** Omit when apiUrl points at a same-origin proxy. */
19
+ apiKey?: string;
19
20
  categoryConfig?: Record<string, CategoryConfig>;
20
21
  /** Authenticated user email. Required for submitting feedback and replies. */
21
22
  userEmail?: string;
@@ -9,7 +9,7 @@
9
9
  import { FEEDBACK_CATEGORY_ICONS } from '../../types.js';
10
10
  import { createStatusClient, } from '../../api.js';
11
11
  export function createFeedbackBoard(options) {
12
- const { apiUrl, apiKey, categoryConfig = {}, userEmail, getAuthToken, pageSize = 50 } = options;
12
+ const { apiUrl, apiKey = '', categoryConfig = {}, userEmail, getAuthToken, pageSize = 50 } = options;
13
13
  const client = createStatusClient({ apiUrl, apiKey });
14
14
  const resolvedEmail = userEmail?.trim() || null;
15
15
  // Reactive state
@@ -3,5 +3,11 @@ export declare function timeAgo(timestamp: number): string;
3
3
  import type { PublicStatus } from '../../types.js';
4
4
  /** Human label for a public board status. */
5
5
  export declare function publicStatusLabel(status: PublicStatus): string;
6
+ /**
7
+ * Human label for an item's internal status. Falls back to a title-cased
8
+ * version of an unknown status value (e.g. a status added to the backend after
9
+ * this package was built) so the badge always shows something readable.
10
+ */
11
+ export declare function internalStatusLabel(status: string): string;
6
12
  /** Map a public board status to a badge variant. */
7
13
  export declare function getStatusBadgeVariant(status: PublicStatus): 'success' | 'neutral' | 'info' | 'warning';
@@ -30,6 +30,42 @@ const PUBLIC_STATUS_VARIANTS = {
30
30
  export function publicStatusLabel(status) {
31
31
  return PUBLIC_STATUS_LABELS[status] ?? status;
32
32
  }
33
+ /**
34
+ * Display labels for the backend's internal statuses. Mirrors `STATUS_META`
35
+ * (display) in status-feedback-admin's `status.ts`. Privileged users (who can
36
+ * manage status) see these precise labels; the public sees the coarser
37
+ * `publicStatus` bucket instead. `internalStatusLabel` degrades gracefully for
38
+ * any status not in this map, so drift only loses polish — never breaks.
39
+ */
40
+ const INTERNAL_STATUS_LABELS = {
41
+ open: 'Open',
42
+ considering: 'Considering',
43
+ accepted: 'Accepted',
44
+ investigating: 'Investigating',
45
+ waiting_on_reply: 'Waiting on reply',
46
+ ready_to_start: 'Ready to start',
47
+ in_progress: 'In progress',
48
+ technical_review: 'Technical review',
49
+ qa_uat: 'QA / UAT',
50
+ reopened: 'Reopened',
51
+ resolved: 'Resolved',
52
+ wont_do: "Won't do",
53
+ duplicate: 'Duplicate',
54
+ cannot_reproduce: "Can't reproduce"
55
+ };
56
+ /**
57
+ * Human label for an item's internal status. Falls back to a title-cased
58
+ * version of an unknown status value (e.g. a status added to the backend after
59
+ * this package was built) so the badge always shows something readable.
60
+ */
61
+ export function internalStatusLabel(status) {
62
+ if (INTERNAL_STATUS_LABELS[status])
63
+ return INTERNAL_STATUS_LABELS[status];
64
+ if (!status)
65
+ return '';
66
+ const spaced = status.replace(/_/g, ' ');
67
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
68
+ }
33
69
  /** Map a public board status to a badge variant. */
34
70
  export function getStatusBadgeVariant(status) {
35
71
  return PUBLIC_STATUS_VARIANTS[status] ?? 'neutral';
@@ -0,0 +1,85 @@
1
+ /**
2
+ * SvelteKit server adapter for hiding status-feedback-admin project API keys
3
+ * behind a same-origin proxy route.
4
+ *
5
+ * Recommended consumer route:
6
+ *
7
+ * // src/routes/svc/feedback/[...path]/+server.ts
8
+ * import { createSvelteKitFeedbackProxy } from '@alliance-droid/status-feedback-system/sveltekit/server';
9
+ *
10
+ * export const { GET, POST, PATCH } = createSvelteKitFeedbackProxy({
11
+ * apiUrl: env.SF_API_URL,
12
+ * apiKey: env.SF_API_KEY,
13
+ * resolveUser,
14
+ * });
15
+ */
16
+ type MaybePromise<T> = T | Promise<T>;
17
+ type ConfigValue = string | null | undefined | (() => MaybePromise<string | null | undefined>);
18
+ export type FeedbackProxySource = 'default' | 'devFeedback';
19
+ export type FeedbackProxyOperation = 'status' | 'incidents' | 'uptime' | 'submitFeedback' | 'board' | 'vote' | 'responses' | 'submitResponse' | 'transitions' | 'updateStatus';
20
+ export interface FeedbackProxyUser {
21
+ email?: string | null;
22
+ roles?: readonly string[];
23
+ }
24
+ export interface SvelteKitFeedbackProxyEvent {
25
+ request: Request;
26
+ url: URL;
27
+ params?: Record<string, string | undefined>;
28
+ fetch?: typeof globalThis.fetch;
29
+ }
30
+ export interface FeedbackProxyContext<User> {
31
+ event: SvelteKitFeedbackProxyEvent;
32
+ user: User | null;
33
+ operation: FeedbackProxyOperation;
34
+ source: FeedbackProxySource;
35
+ }
36
+ export interface FeedbackProxyRequireUserContext {
37
+ event: SvelteKitFeedbackProxyEvent;
38
+ operation: FeedbackProxyOperation;
39
+ source: FeedbackProxySource;
40
+ }
41
+ export interface SvelteKitFeedbackProxyOptions<User = FeedbackProxyUser> {
42
+ /** Base URL of the status-feedback-admin app. Keep this server-side. */
43
+ apiUrl: ConfigValue;
44
+ /** Project API key. Keep this server-side. */
45
+ apiKey: ConfigValue;
46
+ /** Resolve the host app's authenticated user, or null when unauthenticated. */
47
+ resolveUser?: (event: SvelteKitFeedbackProxyEvent) => MaybePromise<User | null>;
48
+ /** Extract an email from the resolved user. Defaults to `user.email`. */
49
+ getUserEmail?: (user: User) => string | null | undefined;
50
+ /**
51
+ * Require an authenticated user before proxying. Use `true` for private apps,
52
+ * or a callback when public status reads should remain anonymous.
53
+ */
54
+ requireUser?: boolean | ((context: FeedbackProxyRequireUserContext) => MaybePromise<boolean>);
55
+ /**
56
+ * Additional host-app authorization. Return false for a standard 403 or a
57
+ * Response for a custom denial body/status.
58
+ */
59
+ authorize?: (context: FeedbackProxyContext<User>) => MaybePromise<boolean | Response | void>;
60
+ /**
61
+ * Extra gate for DevFeedback submissions. Triggered for requests under a
62
+ * `dev/` prefix, such as `/svc/feedback/dev/api/feedback`.
63
+ */
64
+ canSubmitInAppFeedback?: (context: FeedbackProxyContext<User>) => MaybePromise<boolean>;
65
+ /**
66
+ * When true, feedback `email` and response `author` are derived from the
67
+ * authenticated user. Defaults to true when `resolveUser` is supplied.
68
+ */
69
+ deriveUserFields?: boolean;
70
+ /** Rest parameter name used by the SvelteKit catch-all route. Default: path. */
71
+ pathParam?: string;
72
+ /** Optional fetch override for tests. Defaults to event.fetch, then global fetch. */
73
+ fetch?: typeof globalThis.fetch;
74
+ }
75
+ export interface SvelteKitFeedbackProxyHandlers {
76
+ GET: (event: SvelteKitFeedbackProxyEvent) => Promise<Response>;
77
+ POST: (event: SvelteKitFeedbackProxyEvent) => Promise<Response>;
78
+ PATCH: (event: SvelteKitFeedbackProxyEvent) => Promise<Response>;
79
+ }
80
+ export declare function createSvelteKitFeedbackProxy<User = FeedbackProxyUser>(options: SvelteKitFeedbackProxyOptions<User>): SvelteKitFeedbackProxyHandlers;
81
+ export declare function isFeedbackProxyConfigured(config: {
82
+ apiUrl: string | null | undefined;
83
+ apiKey: string | null | undefined;
84
+ }): boolean;
85
+ export {};
@@ -0,0 +1,383 @@
1
+ /**
2
+ * SvelteKit server adapter for hiding status-feedback-admin project API keys
3
+ * behind a same-origin proxy route.
4
+ *
5
+ * Recommended consumer route:
6
+ *
7
+ * // src/routes/svc/feedback/[...path]/+server.ts
8
+ * import { createSvelteKitFeedbackProxy } from '@alliance-droid/status-feedback-system/sveltekit/server';
9
+ *
10
+ * export const { GET, POST, PATCH } = createSvelteKitFeedbackProxy({
11
+ * apiUrl: env.SF_API_URL,
12
+ * apiKey: env.SF_API_KEY,
13
+ * resolveUser,
14
+ * });
15
+ */
16
+ const INCIDENT_QUERY_KEYS = ['limit', 'includeResolved'];
17
+ const UPTIME_QUERY_KEYS = ['days'];
18
+ const BOARD_QUERY_KEYS = ['category', 'status', 'sort', 'limit', 'offset'];
19
+ export function createSvelteKitFeedbackProxy(options) {
20
+ return {
21
+ GET: (event) => handleProxyRequest(event, options),
22
+ POST: (event) => handleProxyRequest(event, options),
23
+ PATCH: (event) => handleProxyRequest(event, options),
24
+ };
25
+ }
26
+ export function isFeedbackProxyConfigured(config) {
27
+ return Boolean(config.apiUrl?.trim() && config.apiKey?.trim());
28
+ }
29
+ async function handleProxyRequest(event, options) {
30
+ const resolvedPath = getResolvedPath(event, options.pathParam ?? 'path');
31
+ const route = getRouteSpec(event.request.method, resolvedPath.path);
32
+ if (!route)
33
+ return feedbackError(404, 'Feedback endpoint not found');
34
+ const config = await getResolvedConfig(options);
35
+ if (!config)
36
+ return feedbackError(503, 'Feedback service is not configured');
37
+ const user = options.resolveUser ? await options.resolveUser(event) : null;
38
+ const requireUser = await shouldRequireUser(event, options, route.operation, resolvedPath.source);
39
+ if (requireUser && !user)
40
+ return feedbackError(401, 'Unauthorized');
41
+ const authResponse = await authorizeRequest(event, options, user, route.operation, resolvedPath.source);
42
+ if (authResponse)
43
+ return authResponse;
44
+ if (resolvedPath.source === 'devFeedback' && route.operation === 'submitFeedback') {
45
+ if (!user)
46
+ return feedbackError(401, 'Unauthorized');
47
+ if (options.canSubmitInAppFeedback) {
48
+ const allowed = await options.canSubmitInAppFeedback({
49
+ event,
50
+ user,
51
+ operation: route.operation,
52
+ source: resolvedPath.source,
53
+ });
54
+ if (!allowed) {
55
+ return feedbackError(403, 'Forbidden: in-app feedback is not allowed for this user');
56
+ }
57
+ }
58
+ }
59
+ const upstreamUrl = buildUpstreamUrl(config.apiUrl, route.upstreamPath, copyAllowedSearchParams(event.url.searchParams, route.allowedSearchParams ?? []));
60
+ const headers = new Headers({
61
+ Accept: 'application/json',
62
+ 'x-api-key': config.apiKey,
63
+ });
64
+ if (route.forwardBearerToken) {
65
+ const authorization = event.request.headers.get('authorization');
66
+ if (!authorization?.toLowerCase().startsWith('bearer ')) {
67
+ return feedbackError(401, 'Status feedback access token required');
68
+ }
69
+ headers.set('Authorization', authorization);
70
+ }
71
+ const method = event.request.method.toUpperCase();
72
+ let body;
73
+ if (method !== 'GET' && method !== 'HEAD') {
74
+ headers.set('Content-Type', 'application/json');
75
+ const parsedBody = await parseProxyBody(event, options, route.bodyKind, user);
76
+ if (parsedBody instanceof Response)
77
+ return parsedBody;
78
+ body = JSON.stringify(parsedBody);
79
+ }
80
+ try {
81
+ const fetchFn = options.fetch ?? event.fetch ?? globalThis.fetch;
82
+ const upstreamResponse = await fetchFn(upstreamUrl, {
83
+ method,
84
+ headers,
85
+ body,
86
+ });
87
+ return await toProxyResponse(upstreamResponse);
88
+ }
89
+ catch {
90
+ return feedbackError(502, 'Feedback service request failed');
91
+ }
92
+ }
93
+ function getResolvedPath(event, pathParam) {
94
+ let path = event.params?.[pathParam] ?? '';
95
+ path = trimSlashes(path);
96
+ let source = 'default';
97
+ if (path === 'dev' || path.startsWith('dev/')) {
98
+ source = 'devFeedback';
99
+ path = trimSlashes(path.slice(3));
100
+ }
101
+ return { path, source };
102
+ }
103
+ function getRouteSpec(method, path) {
104
+ const normalizedMethod = method.toUpperCase();
105
+ const normalizedPath = normalizeApiPath(path);
106
+ if (normalizedMethod === 'GET' && normalizedPath === 'api/status') {
107
+ return { operation: 'status', upstreamPath: '/api/status', bodyKind: 'none' };
108
+ }
109
+ if (normalizedMethod === 'GET' && normalizedPath === 'api/incidents') {
110
+ return {
111
+ operation: 'incidents',
112
+ upstreamPath: '/api/incidents',
113
+ bodyKind: 'none',
114
+ allowedSearchParams: INCIDENT_QUERY_KEYS,
115
+ };
116
+ }
117
+ if (normalizedMethod === 'GET' && normalizedPath === 'api/uptime') {
118
+ return {
119
+ operation: 'uptime',
120
+ upstreamPath: '/api/uptime',
121
+ bodyKind: 'none',
122
+ allowedSearchParams: UPTIME_QUERY_KEYS,
123
+ };
124
+ }
125
+ if (normalizedMethod === 'POST' && normalizedPath === 'api/feedback') {
126
+ return { operation: 'submitFeedback', upstreamPath: '/api/feedback', bodyKind: 'feedback' };
127
+ }
128
+ if (normalizedMethod === 'GET' && normalizedPath === 'api/board') {
129
+ return {
130
+ operation: 'board',
131
+ upstreamPath: '/api/board',
132
+ bodyKind: 'none',
133
+ allowedSearchParams: BOARD_QUERY_KEYS,
134
+ };
135
+ }
136
+ if (normalizedMethod === 'POST' && normalizedPath === 'api/board/vote') {
137
+ return { operation: 'vote', upstreamPath: '/api/board/vote', bodyKind: 'vote' };
138
+ }
139
+ const responsesMatch = /^api\/board\/([^/]+)\/responses$/.exec(normalizedPath);
140
+ if (responsesMatch && normalizedMethod === 'GET') {
141
+ return {
142
+ operation: 'responses',
143
+ upstreamPath: `/api/board/${encodeURIComponent(responsesMatch[1])}/responses`,
144
+ bodyKind: 'none',
145
+ };
146
+ }
147
+ if (responsesMatch && normalizedMethod === 'POST') {
148
+ return {
149
+ operation: 'submitResponse',
150
+ upstreamPath: `/api/board/${encodeURIComponent(responsesMatch[1])}/responses`,
151
+ bodyKind: 'response',
152
+ };
153
+ }
154
+ const transitionsMatch = /^api\/board\/feedback\/([^/]+)\/transitions$/.exec(normalizedPath);
155
+ if (transitionsMatch && normalizedMethod === 'GET') {
156
+ return {
157
+ operation: 'transitions',
158
+ upstreamPath: `/api/board/feedback/${encodeURIComponent(transitionsMatch[1])}/transitions`,
159
+ bodyKind: 'none',
160
+ forwardBearerToken: true,
161
+ };
162
+ }
163
+ const statusMatch = /^api\/board\/feedback\/([^/]+)\/status$/.exec(normalizedPath);
164
+ if (statusMatch && normalizedMethod === 'PATCH') {
165
+ return {
166
+ operation: 'updateStatus',
167
+ upstreamPath: `/api/board/feedback/${encodeURIComponent(statusMatch[1])}/status`,
168
+ bodyKind: 'status',
169
+ forwardBearerToken: true,
170
+ };
171
+ }
172
+ return null;
173
+ }
174
+ function normalizeApiPath(path) {
175
+ const normalized = trimSlashes(path);
176
+ if (!normalized)
177
+ return 'api';
178
+ return normalized.startsWith('api/') ? normalized : `api/${normalized}`;
179
+ }
180
+ async function getResolvedConfig(options) {
181
+ const apiUrl = (await resolveConfigValue(options.apiUrl))?.trim().replace(/\/+$/, '');
182
+ const apiKey = (await resolveConfigValue(options.apiKey))?.trim();
183
+ if (!apiUrl || !apiKey)
184
+ return null;
185
+ return { apiUrl, apiKey };
186
+ }
187
+ async function resolveConfigValue(value) {
188
+ return typeof value === 'function' ? value() : value;
189
+ }
190
+ async function shouldRequireUser(event, options, operation, source) {
191
+ if (typeof options.requireUser === 'function') {
192
+ return options.requireUser({ event, operation, source });
193
+ }
194
+ return options.requireUser ?? false;
195
+ }
196
+ async function authorizeRequest(event, options, user, operation, source) {
197
+ if (!options.authorize)
198
+ return null;
199
+ const result = await options.authorize({ event, user, operation, source });
200
+ if (result instanceof Response)
201
+ return result;
202
+ if (result === false)
203
+ return feedbackError(403, 'Forbidden');
204
+ return null;
205
+ }
206
+ function copyAllowedSearchParams(source, allowedKeys) {
207
+ const result = new URLSearchParams();
208
+ for (const key of allowedKeys) {
209
+ if (key === 'key')
210
+ continue;
211
+ const value = source.get(key);
212
+ if (value !== null)
213
+ result.set(key, value);
214
+ }
215
+ return result;
216
+ }
217
+ function buildUpstreamUrl(apiUrl, upstreamPath, searchParams) {
218
+ const upstreamUrl = new URL(upstreamPath.replace(/^\/+/, ''), `${apiUrl}/`);
219
+ for (const [key, value] of searchParams) {
220
+ if (key === 'key')
221
+ continue;
222
+ upstreamUrl.searchParams.set(key, value);
223
+ }
224
+ return upstreamUrl;
225
+ }
226
+ async function parseProxyBody(event, options, bodyKind, user) {
227
+ if (bodyKind === 'none')
228
+ return {};
229
+ let rawBody;
230
+ try {
231
+ rawBody = await event.request.json();
232
+ }
233
+ catch {
234
+ return feedbackError(400, 'Invalid JSON body');
235
+ }
236
+ try {
237
+ const deriveUserFields = options.deriveUserFields ?? Boolean(options.resolveUser);
238
+ if (bodyKind === 'feedback') {
239
+ const body = parseFeedbackBody(rawBody);
240
+ if (deriveUserFields) {
241
+ const email = getResolvedUserEmail(options, user);
242
+ if (!email)
243
+ return feedbackError(403, 'Authenticated user email required');
244
+ body.email = email;
245
+ }
246
+ return body;
247
+ }
248
+ if (bodyKind === 'vote')
249
+ return parseVoteBody(rawBody);
250
+ if (bodyKind === 'response') {
251
+ const body = parseResponseBody(rawBody);
252
+ if (deriveUserFields) {
253
+ const email = getResolvedUserEmail(options, user);
254
+ if (!email)
255
+ return feedbackError(403, 'Authenticated user email required');
256
+ body.author = email;
257
+ }
258
+ return body;
259
+ }
260
+ return parseStatusBody(rawBody);
261
+ }
262
+ catch {
263
+ return feedbackError(400, 'Invalid feedback request');
264
+ }
265
+ }
266
+ function getResolvedUserEmail(options, user) {
267
+ if (!user)
268
+ return null;
269
+ const email = options.getUserEmail
270
+ ? options.getUserEmail(user)
271
+ : user.email;
272
+ const normalized = email?.trim();
273
+ return normalized || null;
274
+ }
275
+ function parseFeedbackBody(rawBody) {
276
+ const body = requireRecord(rawBody);
277
+ const result = {
278
+ message: requireString(body, 'message', 1, 20_000),
279
+ };
280
+ assignOptionalString(result, body, 'title', 500);
281
+ assignOptionalString(result, body, 'category', 100);
282
+ assignOptionalString(result, body, 'pageUrl', 2_048);
283
+ assignOptionalString(result, body, 'email', 320);
284
+ if (body.metadata !== undefined) {
285
+ if (!isRecord(body.metadata))
286
+ throw new Error('Invalid metadata');
287
+ result.metadata = body.metadata;
288
+ }
289
+ return result;
290
+ }
291
+ function parseVoteBody(rawBody) {
292
+ const body = requireRecord(rawBody);
293
+ const result = {
294
+ feedbackId: requireString(body, 'feedbackId', 1, 200),
295
+ };
296
+ if (body.direction !== undefined) {
297
+ const direction = requireString(body, 'direction', 1, 10);
298
+ if (direction !== 'up' && direction !== 'down')
299
+ throw new Error('Invalid direction');
300
+ result.direction = direction;
301
+ }
302
+ return result;
303
+ }
304
+ function parseResponseBody(rawBody) {
305
+ const body = requireRecord(rawBody);
306
+ const result = {
307
+ message: requireString(body, 'message', 1, 20_000),
308
+ };
309
+ assignOptionalString(result, body, 'author', 320);
310
+ assignOptionalString(result, body, 'pastedImage');
311
+ return result;
312
+ }
313
+ function parseStatusBody(rawBody) {
314
+ const body = requireRecord(rawBody);
315
+ const result = {
316
+ status: requireString(body, 'status', 1, 100),
317
+ };
318
+ assignOptionalString(result, body, 'reason', 2_000);
319
+ return result;
320
+ }
321
+ function requireRecord(value) {
322
+ if (!isRecord(value))
323
+ throw new Error('Expected object');
324
+ return value;
325
+ }
326
+ function isRecord(value) {
327
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
328
+ }
329
+ function requireString(body, key, minLength, maxLength) {
330
+ const value = body[key];
331
+ if (typeof value !== 'string')
332
+ throw new Error(`Invalid ${key}`);
333
+ const trimmed = value.trim();
334
+ if (trimmed.length < minLength)
335
+ throw new Error(`Invalid ${key}`);
336
+ if (maxLength !== undefined && trimmed.length > maxLength)
337
+ throw new Error(`Invalid ${key}`);
338
+ return trimmed;
339
+ }
340
+ function assignOptionalString(target, body, key, maxLength) {
341
+ const value = body[key];
342
+ if (value === undefined)
343
+ return;
344
+ if (value === null) {
345
+ target[key] = null;
346
+ return;
347
+ }
348
+ if (typeof value !== 'string')
349
+ throw new Error(`Invalid ${key}`);
350
+ const trimmed = value.trim();
351
+ if (maxLength !== undefined && trimmed.length > maxLength)
352
+ throw new Error(`Invalid ${key}`);
353
+ target[key] = trimmed;
354
+ }
355
+ async function toProxyResponse(upstreamResponse) {
356
+ const body = await upstreamResponse.arrayBuffer();
357
+ const headers = new Headers({
358
+ 'Cache-Control': 'no-store',
359
+ });
360
+ const contentType = upstreamResponse.headers.get('content-type');
361
+ if (contentType)
362
+ headers.set('Content-Type', contentType);
363
+ return new Response(body, {
364
+ status: upstreamResponse.status,
365
+ headers,
366
+ });
367
+ }
368
+ function feedbackError(status, message) {
369
+ return new Response(JSON.stringify({
370
+ success: false,
371
+ error: message,
372
+ message,
373
+ }), {
374
+ status,
375
+ headers: {
376
+ 'Content-Type': 'application/json',
377
+ 'Cache-Control': 'no-store',
378
+ },
379
+ });
380
+ }
381
+ function trimSlashes(value) {
382
+ return value.replace(/^\/+|\/+$/g, '');
383
+ }