@zaaxch/tailframe 2.2.0 → 3.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.
@@ -0,0 +1,479 @@
1
+ import {
2
+ appErrorSource,
3
+ applySchemaCliSource,
4
+ createRequestContextSource,
5
+ csrfSource,
6
+ firebaseSource,
7
+ httpErrorsSource,
8
+ mongoUsersSource,
9
+ rateLimitSource,
10
+ readEnvSource,
11
+ requestContextSource,
12
+ rpcHandlerSource,
13
+ rpcSource,
14
+ schemaLifecycleSource,
15
+ useCaseSource
16
+ } from "./service-templates.mjs";
17
+ import {
18
+ extensionAuthStoreSource,
19
+ notificationHostSource,
20
+ notificationStoreSource,
21
+ uiErrorMessagesSource,
22
+ uiErrorsSource,
23
+ uiHttpSource,
24
+ uiRpcSource
25
+ } from "./ui-templates.mjs";
26
+
27
+ const requestContextTestSource = `import type { Request } from "express";
28
+ import { verifyFirebaseToken } from "@/platform/auth/firebase";
29
+ import { createRequestContext } from "@/platform/http/createRequestContext";
30
+
31
+ jest.mock("@/platform/auth/firebase", () => ({ verifyFirebaseToken: jest.fn() }));
32
+
33
+ const verifyToken = verifyFirebaseToken as jest.MockedFunction<typeof verifyFirebaseToken>;
34
+ const request = (headers: Request["headers"] = {}) => ({ headers }) as Request;
35
+
36
+ beforeEach(() => verifyToken.mockReset());
37
+
38
+ it("creates and caches an anonymous context with a bounded request id", async () => {
39
+ const req = request({ "x-request-id": "request.safe:1" });
40
+ const first = await createRequestContext(req);
41
+ const second = await createRequestContext(req);
42
+ expect(first).toEqual({ requestId: "request.safe:1" });
43
+ expect(second).toBe(first);
44
+ expect(verifyToken).not.toHaveBeenCalled();
45
+ });
46
+
47
+ it("replaces malformed and oversized request ids", async () => {
48
+ for (const value of ["contains spaces", "x".repeat(129)]) {
49
+ const context = await createRequestContext(request({ "x-request-id": value }));
50
+ expect(context.requestId).not.toBe(value);
51
+ expect(context.requestId).toMatch(/^[a-f0-9-]{36}$/);
52
+ }
53
+ });
54
+
55
+ it("strictly parses one Bearer token", async () => {
56
+ for (const authorization of ["bearer token", "Bearer", "Bearer token", "Bearer token extra", "Basic token"]) {
57
+ await expect(createRequestContext(request({ authorization }))).rejects.toMatchObject({
58
+ code: "UNAUTHENTICATED",
59
+ kind: "unauthenticated"
60
+ });
61
+ }
62
+ expect(verifyToken).not.toHaveBeenCalled();
63
+ });
64
+
65
+ it("maps verified and unverified Firebase email claims and verifies only once", async () => {
66
+ verifyToken.mockResolvedValueOnce({ uid: "verified", email: "v@example.com", email_verified: true } as never);
67
+ const verifiedRequest = request({ authorization: "Bearer verified-token" });
68
+ const verified = await createRequestContext(verifiedRequest);
69
+ expect(verified.principal).toEqual({ uid: "verified", email: "v@example.com", emailVerified: true });
70
+ await createRequestContext(verifiedRequest);
71
+ expect(verifyToken).toHaveBeenCalledTimes(1);
72
+
73
+ verifyToken.mockResolvedValueOnce({ uid: "unverified", email: "u@example.com" } as never);
74
+ const unverified = await createRequestContext(request({ authorization: "Bearer unverified-token" }));
75
+ expect(unverified.principal).toEqual({ uid: "unverified", email: "u@example.com", emailVerified: false });
76
+ });
77
+ `;
78
+
79
+ const errorTranslationTestSource = `import { AppError, type FailureKind } from "@/core/errors";
80
+ import { errorHandler } from "@/platform/http/errors";
81
+
82
+ const statuses: Array<[FailureKind, number]> = [
83
+ ["invalid", 400],
84
+ ["unauthenticated", 401],
85
+ ["forbidden", 403],
86
+ ["not_found", 404],
87
+ ["conflict", 409],
88
+ ["unprocessable", 422],
89
+ ["rate_limited", 429],
90
+ ["unavailable", 503],
91
+ ["internal", 500]
92
+ ];
93
+
94
+ it.each(statuses)("translates %s to HTTP %i", (kind, status) => {
95
+ const json = jest.fn();
96
+ const response = { status: jest.fn(() => ({ json })) } as never;
97
+ errorHandler(new AppError("CODE", "message", kind), {} as never, response, jest.fn());
98
+ expect((response as { status: jest.Mock }).status).toHaveBeenCalledWith(status);
99
+ expect(json).toHaveBeenCalledWith({ error: { code: "CODE", message: "message" } });
100
+ });
101
+ `;
102
+
103
+ const rateLimitTestSource = `import type { Request } from "express";
104
+ import { RateLimiterRes } from "rate-limiter-flexible";
105
+ import { createRateLimiter } from "@/platform/http/rateLimit";
106
+
107
+ const mockConsume = jest.fn();
108
+ jest.mock("rate-limiter-flexible", () => {
109
+ class MockRateLimiterRes {}
110
+ return {
111
+ RateLimiterRes: MockRateLimiterRes,
112
+ RateLimiterRedis: jest.fn().mockImplementation(() => ({ consume: mockConsume }))
113
+ };
114
+ });
115
+
116
+ const policy = { keyPrefix: "test", points: 2, durationSeconds: 60, key: "identity-or-ip" as const };
117
+
118
+ beforeEach(() => mockConsume.mockReset());
119
+
120
+ it("uses the authenticated identity and allows a request within budget", async () => {
121
+ mockConsume.mockResolvedValue(undefined);
122
+ const request = {
123
+ headers: {},
124
+ ip: "127.0.0.1",
125
+ requestContext: { requestId: "test", principal: { uid: "user-1", emailVerified: false } }
126
+ } as Request;
127
+ const next = jest.fn();
128
+ await (createRateLimiter({} as never, policy) as never as Function)(request, {}, next);
129
+ expect(mockConsume).toHaveBeenCalledWith("user:user-1");
130
+ expect(next).toHaveBeenCalledWith();
131
+ });
132
+
133
+ it("translates exhausted and unavailable limiter failures", async () => {
134
+ const request = { headers: {}, ip: "127.0.0.1", requestContext: { requestId: "test" } } as Request;
135
+ const exhausted = jest.fn();
136
+ mockConsume.mockRejectedValueOnce(new RateLimiterRes());
137
+ await (createRateLimiter({} as never, policy) as never as Function)(request, {}, exhausted);
138
+ expect(exhausted.mock.calls[0]?.[0]).toMatchObject({ kind: "rate_limited", code: "RATE_LIMITED" });
139
+
140
+ const unavailable = jest.fn();
141
+ mockConsume.mockRejectedValueOnce(new Error("redis down"));
142
+ await (createRateLimiter({} as never, policy) as never as Function)(request, {}, unavailable);
143
+ expect(unavailable.mock.calls[0]?.[0]).toMatchObject({ kind: "unavailable", code: "RATE_LIMIT_UNAVAILABLE" });
144
+ });
145
+ `;
146
+
147
+ const notificationTestSource = `import { mount } from "@vue/test-utils";
148
+ import { createPinia, setActivePinia } from "pinia";
149
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
150
+ import { nextTick } from "vue";
151
+ import NotificationHost from "@/app/components/NotificationHost.vue";
152
+ import { useNotificationStore } from "@/app/stores/notification.store";
153
+
154
+ describe("notification shell", () => {
155
+ beforeEach(() => {
156
+ vi.useFakeTimers();
157
+ setActivePinia(createPinia());
158
+ });
159
+ afterEach(() => vi.useRealTimers());
160
+
161
+ it("orders, dismisses, times, and clears notifications", async () => {
162
+ const store = useNotificationStore();
163
+ const host = mount(NotificationHost);
164
+ const first = store.notify({ message: "first", kind: "warning", durationMs: 1000 });
165
+ const second = store.notify({ message: "second", kind: "success", durationMs: 1000 });
166
+ expect(store.active?.id).toBe(first);
167
+ await nextTick();
168
+ expect(host.text()).toContain("first");
169
+ vi.advanceTimersByTime(1000);
170
+ await nextTick();
171
+ expect(store.active?.id).toBe(second);
172
+ store.dismiss(second);
173
+ expect(store.active).toBeUndefined();
174
+ store.notify({ message: "third" });
175
+ store.clear();
176
+ expect(store.queue).toEqual([]);
177
+ host.unmount();
178
+ });
179
+ });
180
+ `;
181
+
182
+ const flutterErrorsSource = `enum FailureKind {
183
+ invalid,
184
+ unauthenticated,
185
+ forbidden,
186
+ notFound,
187
+ conflict,
188
+ unprocessable,
189
+ rateLimited,
190
+ unavailable,
191
+ internal,
192
+ network,
193
+ }
194
+
195
+ class ServiceException implements Exception {
196
+ const ServiceException({
197
+ required this.code,
198
+ required this.message,
199
+ required this.kind,
200
+ this.statusCode,
201
+ this.details,
202
+ });
203
+
204
+ final String code;
205
+ final String message;
206
+ final FailureKind kind;
207
+ final int? statusCode;
208
+ final Object? details;
209
+
210
+ @override
211
+ String toString() => message;
212
+ }
213
+
214
+ FailureKind failureKindForStatus(int? statusCode) {
215
+ if (statusCode == null) return FailureKind.network;
216
+ if (statusCode == 401) return FailureKind.unauthenticated;
217
+ if (statusCode == 403) return FailureKind.forbidden;
218
+ if (statusCode == 404) return FailureKind.notFound;
219
+ if (statusCode == 409) return FailureKind.conflict;
220
+ if (statusCode == 422) return FailureKind.unprocessable;
221
+ if (statusCode == 429) return FailureKind.rateLimited;
222
+ if (statusCode == 503) return FailureKind.unavailable;
223
+ if (statusCode >= 500) return FailureKind.internal;
224
+ return FailureKind.invalid;
225
+ }
226
+ `;
227
+
228
+ const flutterRpcSource = `typedef JsonObject = Map<String, dynamic>;
229
+
230
+ JsonObject? rpcErrorFrom(Object? value) {
231
+ if (value is! JsonObject) return null;
232
+ final error = value['error'];
233
+ return error is JsonObject ? error : null;
234
+ }
235
+
236
+ T rpcResult<T>(Object? value) {
237
+ if (value is! JsonObject || !value.containsKey('result')) {
238
+ throw const FormatException('The server returned an invalid response.');
239
+ }
240
+ return value['result'] as T;
241
+ }
242
+ `;
243
+
244
+ const flutterNotificationSource = `import 'dart:async';
245
+
246
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
247
+
248
+ enum NotificationKind { info, success, warning, error }
249
+
250
+ class AppNotification {
251
+ const AppNotification({
252
+ required this.id,
253
+ required this.message,
254
+ required this.kind,
255
+ required this.duration,
256
+ });
257
+
258
+ final int id;
259
+ final String message;
260
+ final NotificationKind kind;
261
+ final Duration duration;
262
+ }
263
+
264
+ class NotificationState {
265
+ const NotificationState(this.queue);
266
+
267
+ final List<AppNotification> queue;
268
+ AppNotification? get active => queue.isEmpty ? null : queue.first;
269
+ }
270
+
271
+ class NotificationNotifier extends Notifier<NotificationState> {
272
+ Timer? _timer;
273
+ int _nextId = 1;
274
+
275
+ @override
276
+ NotificationState build() => const NotificationState([]);
277
+
278
+ void notify(
279
+ String message, {
280
+ NotificationKind kind = NotificationKind.info,
281
+ Duration duration = const Duration(seconds: 4),
282
+ }) {
283
+ final notification = AppNotification(
284
+ id: _nextId++,
285
+ message: message,
286
+ kind: kind,
287
+ duration: duration,
288
+ );
289
+ final wasEmpty = state.queue.isEmpty;
290
+ state = NotificationState([...state.queue, notification]);
291
+ if (wasEmpty) _scheduleActive();
292
+ }
293
+
294
+ void dismiss(int id) {
295
+ final wasActive = state.active?.id == id;
296
+ state = NotificationState(
297
+ state.queue.where((notification) => notification.id != id).toList(),
298
+ );
299
+ if (wasActive) _scheduleActive();
300
+ }
301
+
302
+ void clear() {
303
+ _timer?.cancel();
304
+ _timer = null;
305
+ state = const NotificationState([]);
306
+ }
307
+
308
+ void _scheduleActive() {
309
+ _timer?.cancel();
310
+ _timer = null;
311
+ final notification = state.active;
312
+ if (notification != null && notification.duration > Duration.zero) {
313
+ _timer = Timer(notification.duration, () => dismiss(notification.id));
314
+ }
315
+ }
316
+ }
317
+
318
+ final notificationProvider =
319
+ NotifierProvider<NotificationNotifier, NotificationState>(
320
+ NotificationNotifier.new,
321
+ );
322
+ `;
323
+
324
+ const flutterNotificationHostSource = `import 'package:flutter/material.dart';
325
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
326
+
327
+ import '../state/notification_notifier.dart';
328
+
329
+ class NotificationHost extends ConsumerWidget {
330
+ const NotificationHost({super.key});
331
+
332
+ @override
333
+ Widget build(BuildContext context, WidgetRef ref) {
334
+ final notification = ref.watch(notificationProvider).active;
335
+ if (notification == null) return const SizedBox.shrink();
336
+ final colors = Theme.of(context).colorScheme;
337
+ final background = switch (notification.kind) {
338
+ NotificationKind.info => colors.primary,
339
+ NotificationKind.success => Colors.green.shade800,
340
+ NotificationKind.warning => Colors.orange.shade900,
341
+ NotificationKind.error => colors.error,
342
+ };
343
+ return Semantics(
344
+ liveRegion: true,
345
+ child: Material(
346
+ color: background,
347
+ borderRadius: BorderRadius.circular(12),
348
+ elevation: 6,
349
+ child: Padding(
350
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
351
+ child: Row(
352
+ mainAxisSize: MainAxisSize.min,
353
+ children: [
354
+ Flexible(
355
+ child: Text(
356
+ notification.message,
357
+ style: const TextStyle(color: Colors.white),
358
+ ),
359
+ ),
360
+ IconButton(
361
+ tooltip: 'Dismiss notification',
362
+ color: Colors.white,
363
+ onPressed: () => ref
364
+ .read(notificationProvider.notifier)
365
+ .dismiss(notification.id),
366
+ icon: const Icon(Icons.close),
367
+ ),
368
+ ],
369
+ ),
370
+ ),
371
+ ),
372
+ );
373
+ }
374
+ }
375
+ `;
376
+
377
+ const flutterNotificationsPublicSource = `export '../state/notification_notifier.dart'
378
+ show
379
+ NotificationKind,
380
+ NotificationNotifier,
381
+ NotificationState,
382
+ notificationProvider;
383
+ `;
384
+
385
+ const flutterNotificationTestSource = (packageName) => `import 'package:${packageName}/app/public/notifications.dart';
386
+ import 'package:flutter_test/flutter_test.dart';
387
+ import 'package:flutter_riverpod/flutter_riverpod.dart';
388
+
389
+ void main() {
390
+ testWidgets('orders, dismisses, times, and clears notifications', (
391
+ tester,
392
+ ) async {
393
+ final container = ProviderContainer();
394
+ addTearDown(container.dispose);
395
+ final notifier = container.read(notificationProvider.notifier);
396
+
397
+ notifier.notify(
398
+ 'first',
399
+ kind: NotificationKind.warning,
400
+ duration: const Duration(milliseconds: 10),
401
+ );
402
+ notifier.notify(
403
+ 'second',
404
+ kind: NotificationKind.success,
405
+ duration: Duration.zero,
406
+ );
407
+ expect(container.read(notificationProvider).active?.message, 'first');
408
+
409
+ await tester.pump(const Duration(milliseconds: 11));
410
+ expect(container.read(notificationProvider).active?.message, 'second');
411
+ final second = container.read(notificationProvider).active!;
412
+ notifier.dismiss(second.id);
413
+ expect(container.read(notificationProvider).active, isNull);
414
+
415
+ notifier.notify('third', duration: Duration.zero);
416
+ notifier.clear();
417
+ expect(container.read(notificationProvider).queue, isEmpty);
418
+ });
419
+ }
420
+ `;
421
+
422
+ export function ownedSources(config, { flutterPackageName } = {}) {
423
+ const profiles = new Set(config.profiles);
424
+ if (config.kind === "service") {
425
+ const files = {
426
+ "src/core/RequestContext.ts": requestContextSource,
427
+ "src/core/UseCase.ts": useCaseSource,
428
+ "src/core/errors.ts": appErrorSource,
429
+ "src/core/SchemaLifecycle.ts": schemaLifecycleSource,
430
+ "src/platform/config/readEnv.ts": readEnvSource,
431
+ "src/platform/http/createRequestContext.ts": createRequestContextSource(profiles.has("firebase") ? "firebase" : "none"),
432
+ "src/platform/http/csrf.ts": csrfSource,
433
+ "src/platform/http/errors.ts": httpErrorsSource,
434
+ "src/platform/http/rpc.ts": rpcSource,
435
+ "src/platform/http/rpcHandler.ts": rpcHandlerSource,
436
+ "src/app/cli/applySchema.ts": applySchemaCliSource,
437
+ "src/platform/http/__tests__/errors.test.ts": errorTranslationTestSource
438
+ };
439
+ if (profiles.has("firebase")) {
440
+ files["src/platform/auth/firebase.ts"] = firebaseSource;
441
+ files["src/platform/http/__tests__/createRequestContext.test.ts"] = requestContextTestSource;
442
+ }
443
+ if (profiles.has("rate-limit")) {
444
+ files["src/platform/http/rateLimit.ts"] = rateLimitSource;
445
+ files["src/platform/http/__tests__/rateLimit.test.ts"] = rateLimitTestSource;
446
+ }
447
+ if (profiles.has("mongo")) files["deploy/mongo/10-create-users.js"] = mongoUsersSource;
448
+ return files;
449
+ }
450
+ if (config.kind === "ui" || config.kind === "extension") {
451
+ const files = {
452
+ "src/core/errors.ts": uiErrorsSource,
453
+ "src/core/rpc.ts": uiRpcSource,
454
+ "src/platform/errors.ts": uiErrorMessagesSource,
455
+ "src/platform/http.ts": uiHttpSource
456
+ };
457
+ if (config.kind === "extension" && profiles.has("firebase")) {
458
+ files["src/app/stores/auth.store.ts"] = extensionAuthStoreSource;
459
+ }
460
+ if (profiles.has("notifications")) {
461
+ files["src/app/stores/notification.store.ts"] = notificationStoreSource;
462
+ files["src/app/components/NotificationHost.vue"] = notificationHostSource;
463
+ files["src/app/__tests__/notificationStore.test.ts"] = notificationTestSource;
464
+ }
465
+ return files;
466
+ }
467
+ const files = {
468
+ "lib/core/errors.dart": flutterErrorsSource,
469
+ "lib/core/rpc.dart": flutterRpcSource
470
+ };
471
+ if (profiles.has("notifications")) {
472
+ if (!flutterPackageName) throw new Error("Flutter notification sources require a package name");
473
+ files["lib/app/state/notification_notifier.dart"] = flutterNotificationSource;
474
+ files["lib/app/components/notification_host.dart"] = flutterNotificationHostSource;
475
+ files["lib/app/public/notifications.dart"] = flutterNotificationsPublicSource;
476
+ files["test/notification_notifier_test.dart"] = flutterNotificationTestSource(flutterPackageName);
477
+ }
478
+ return files;
479
+ }