@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.
@@ -16,6 +16,7 @@ export function serviceOperationName(moduleName, verbNoun) {
16
16
  export const requestContextSource = `export interface AuthenticatedPrincipal {
17
17
  uid: string;
18
18
  email?: string;
19
+ emailVerified: boolean;
19
20
  }
20
21
 
21
22
  export interface RequestContext {
@@ -64,14 +65,40 @@ import type { RequestContext } from "@/core/RequestContext";
64
65
  import { AppError } from "@/core/errors";
65
66
  import { verifyFirebaseToken } from "@/platform/auth/firebase";
66
67
 
68
+ declare global {
69
+ namespace Express {
70
+ interface Request {
71
+ requestContext?: RequestContext;
72
+ }
73
+ }
74
+ }
75
+
76
+ function validRequestId(value: string | undefined): value is string {
77
+ return Boolean(value && value.length <= 128 && /^[a-zA-Z0-9._:-]+$/.test(value));
78
+ }
79
+
67
80
  export async function createRequestContext(req: Request): Promise<RequestContext> {
68
- const requestId = String(req.headers["x-request-id"] ?? randomUUID());
81
+ if (req.requestContext) return req.requestContext;
82
+ const headerRequestId = typeof req.headers["x-request-id"] === "string" ? req.headers["x-request-id"] : undefined;
83
+ const requestId = validRequestId(headerRequestId) ? headerRequestId : randomUUID();
69
84
  const header = req.headers.authorization;
70
- if (!header?.startsWith("Bearer ")) return { requestId };
85
+ if (!header) {
86
+ const context = { requestId };
87
+ req.requestContext = context;
88
+ return context;
89
+ }
90
+ if (!/^Bearer [^\\s]+$/.test(header)) {
91
+ throw new AppError("UNAUTHENTICATED", "Unauthorized", "unauthenticated");
92
+ }
71
93
 
72
94
  try {
73
95
  const decoded = await verifyFirebaseToken(header.slice(7));
74
- return { requestId, principal: { uid: decoded.uid, email: decoded.email } };
96
+ const context = {
97
+ requestId,
98
+ principal: { uid: decoded.uid, email: decoded.email, emailVerified: decoded.email_verified === true }
99
+ };
100
+ req.requestContext = context;
101
+ return context;
75
102
  } catch {
76
103
  throw new AppError("UNAUTHENTICATED", "Unauthorized", "unauthenticated");
77
104
  }
@@ -86,8 +113,24 @@ export function systemRequestContext(requestId = "system"): RequestContext {
86
113
  import type { Request } from "express";
87
114
  import type { RequestContext } from "@/core/RequestContext";
88
115
 
116
+ declare global {
117
+ namespace Express {
118
+ interface Request {
119
+ requestContext?: RequestContext;
120
+ }
121
+ }
122
+ }
123
+
124
+ function validRequestId(value: string | undefined): value is string {
125
+ return Boolean(value && value.length <= 128 && /^[a-zA-Z0-9._:-]+$/.test(value));
126
+ }
127
+
89
128
  export async function createRequestContext(req: Request): Promise<RequestContext> {
90
- return { requestId: String(req.headers["x-request-id"] ?? randomUUID()) };
129
+ if (req.requestContext) return req.requestContext;
130
+ const headerRequestId = typeof req.headers["x-request-id"] === "string" ? req.headers["x-request-id"] : undefined;
131
+ const context = { requestId: validRequestId(headerRequestId) ? headerRequestId : randomUUID() };
132
+ req.requestContext = context;
133
+ return context;
91
134
  }
92
135
 
93
136
  export function systemRequestContext(requestId = "system"): RequestContext {
@@ -96,13 +139,16 @@ export function systemRequestContext(requestId = "system"): RequestContext {
96
139
  `;
97
140
  }
98
141
 
99
- export const firebaseSource = `import { applicationDefault, getApps, initializeApp } from "firebase-admin/app";
142
+ export const firebaseSource = `import { applicationDefault, cert, getApps, initializeApp } from "firebase-admin/app";
100
143
  import { getAuth, type DecodedIdToken } from "firebase-admin/auth";
101
144
  import { env } from "@/platform/config/env";
102
145
 
103
146
  export function verifyFirebaseToken(token: string): Promise<DecodedIdToken> {
104
147
  if (!getApps().length) {
105
- initializeApp({ credential: applicationDefault(), projectId: env.firebaseProjectId || undefined });
148
+ initializeApp({
149
+ credential: env.firebaseServiceAccountPath ? cert(env.firebaseServiceAccountPath) : applicationDefault(),
150
+ projectId: env.firebaseProjectId || undefined
151
+ });
106
152
  }
107
153
  return getAuth().verifyIdToken(token);
108
154
  }
@@ -127,6 +173,7 @@ interface RpcOperation<Input, Output> {
127
173
 
128
174
  export interface RpcHandlerOptions<Input, WireInput> {
129
175
  mapInput?: (input: WireInput) => Input;
176
+ fromRequest?: (req: Request) => Input;
130
177
  status?: number;
131
178
  }
132
179
 
@@ -139,7 +186,11 @@ export function rpcHandler<Input, Output, WireInput = Input>(
139
186
  return async (req: Request, res: Response, next: NextFunction) => {
140
187
  try {
141
188
  const validated = await schema.validateAsync(req.body ?? {}, { abortEarly: false });
142
- const input = options.mapInput ? options.mapInput(validated) : (validated as unknown as Input);
189
+ const input = options.fromRequest
190
+ ? options.fromRequest(req)
191
+ : options.mapInput
192
+ ? options.mapInput(validated)
193
+ : (validated as unknown as Input);
143
194
  if (options.status !== undefined) res.status(options.status);
144
195
  rpcResult(res, await operation.execute(await createRequestContext(req), input));
145
196
  } catch (error) {
@@ -196,13 +247,152 @@ import { AppError } from "@/core/errors";
196
247
 
197
248
  const MUTATING = ["POST", "PUT", "PATCH", "DELETE"];
198
249
 
199
- export const requireCsrfHeader: RequestHandler = (req, _res, next) => {
200
- if (MUTATING.includes(req.method) && req.headers["x-requested-with"] !== "XMLHttpRequest") {
201
- next(new AppError("CSRF_HEADER_MISSING", "CSRF header missing", "forbidden"));
202
- return;
250
+ export function csrfHeaderGuard(exemptPaths: readonly string[] = []): RequestHandler {
251
+ const exempt = new Set(exemptPaths);
252
+ return (req, _res, next) => {
253
+ if (
254
+ MUTATING.includes(req.method) &&
255
+ !exempt.has(req.path) &&
256
+ req.headers["x-requested-with"] !== "XMLHttpRequest"
257
+ ) {
258
+ next(new AppError("CSRF_HEADER_MISSING", "CSRF header missing", "forbidden"));
259
+ return;
260
+ }
261
+ next();
262
+ };
263
+ }
264
+ `;
265
+
266
+ export const schemaLifecycleSource = `export interface SchemaLifecycle {
267
+ apply(): Promise<void>;
268
+ close(): Promise<void>;
269
+ }
270
+ `;
271
+
272
+ export const applySchemaCliSource = `import "reflect-metadata";
273
+ import { schemaLifecycle } from "@/app/schema";
274
+
275
+ async function main() {
276
+ try {
277
+ await schemaLifecycle.apply();
278
+ } finally {
279
+ await schemaLifecycle.close();
203
280
  }
204
- next();
281
+ }
282
+
283
+ void main().catch((error) => {
284
+ console.error(error instanceof Error ? error.message : error);
285
+ process.exitCode = 1;
286
+ });
287
+ `;
288
+
289
+ export const readEnvSource = `export function requiredEnv(name: string): string {
290
+ const value = process.env[name];
291
+ if (!value) throw new Error(\`Missing required environment variable \${name}\`);
292
+ return value;
293
+ }
294
+
295
+ export function integerEnv(name: string, fallback: number): number {
296
+ const value = process.env[name];
297
+ if (value === undefined || value === "") return fallback;
298
+ const parsed = Number(value);
299
+ if (!Number.isInteger(parsed)) throw new Error(\`Environment variable \${name} must be an integer\`);
300
+ return parsed;
301
+ }
302
+
303
+ export function listEnv(name: string, fallback: readonly string[] = []): string[] {
304
+ const value = process.env[name];
305
+ return value
306
+ ? value
307
+ .split(",")
308
+ .map((item) => item.trim())
309
+ .filter(Boolean)
310
+ : [...fallback];
311
+ }
312
+ `;
313
+
314
+ export const rateLimitSource = `import type { NextFunction, Request, RequestHandler, Response } from "express";
315
+ import { RateLimiterRedis, RateLimiterRes } from "rate-limiter-flexible";
316
+ import type { RedisClientType } from "redis";
317
+ import { AppError } from "@/core/errors";
318
+ import { createRequestContext } from "@/platform/http/createRequestContext";
319
+
320
+ export interface RateLimitPolicy {
321
+ keyPrefix: string;
322
+ points: number;
323
+ durationSeconds: number;
324
+ key: "identity-or-ip" | "ip";
325
+ }
326
+
327
+ export function createRateLimiter(client: RedisClientType, policy: RateLimitPolicy): RequestHandler {
328
+ const limiter = new RateLimiterRedis({
329
+ useRedisPackage: true,
330
+ storeClient: client,
331
+ keyPrefix: policy.keyPrefix,
332
+ points: policy.points,
333
+ duration: policy.durationSeconds
334
+ });
335
+ return async (req: Request, _res: Response, next: NextFunction) => {
336
+ try {
337
+ const context = await createRequestContext(req);
338
+ const key =
339
+ policy.key === "identity-or-ip" && context.principal?.uid
340
+ ? \`user:\${context.principal.uid}\`
341
+ : \`ip:\${req.ip ?? "unknown"}\`;
342
+ await limiter.consume(key);
343
+ next();
344
+ } catch (error) {
345
+ if (error instanceof RateLimiterRes)
346
+ next(new AppError("RATE_LIMITED", "Too many requests", "rate_limited"));
347
+ else if (error instanceof AppError) next(error);
348
+ else next(new AppError("RATE_LIMIT_UNAVAILABLE", "Request protection unavailable", "unavailable"));
349
+ }
350
+ };
351
+ }
352
+ `;
353
+
354
+ export const mongoUsersSource = `const required = (name) => {
355
+ const value = process.env[name];
356
+ if (!value) throw new Error(\`\${name} is required\`);
357
+ return value;
205
358
  };
359
+
360
+ const databaseName = required("MONGODB_DB_NAME");
361
+ const applicationUsername = required("MONGODB_APP_USERNAME");
362
+ const applicationPassword = required("MONGODB_APP_PASSWORD");
363
+ const backupUsername = required("MONGODB_BACKUP_USERNAME");
364
+ const backupPassword = required("MONGODB_BACKUP_PASSWORD");
365
+ const applicationCollections = required("MONGODB_APP_COLLECTIONS")
366
+ .split(",")
367
+ .map((name) => name.trim())
368
+ .filter(Boolean);
369
+
370
+ if (applicationCollections.length === 0) throw new Error("MONGODB_APP_COLLECTIONS must name at least one collection");
371
+
372
+ const applicationDatabase = db.getSiblingDB(databaseName);
373
+ applicationDatabase.createRole({
374
+ role: "applicationDml",
375
+ privileges: [
376
+ ...applicationCollections.map((collection) => ({
377
+ resource: { db: databaseName, collection },
378
+ actions: ["find", "insert", "remove", "update", "changeStream", "listIndexes"]
379
+ })),
380
+ { resource: { db: databaseName, collection: "" }, actions: ["listCollections"] }
381
+ ],
382
+ roles: []
383
+ });
384
+
385
+ applicationDatabase.createUser({
386
+ user: applicationUsername,
387
+ pwd: applicationPassword,
388
+ roles: [{ role: "applicationDml", db: databaseName }]
389
+ });
390
+
391
+ db.getSiblingDB("admin").createUser({
392
+ user: backupUsername,
393
+ pwd: backupPassword,
394
+ roles: [{ role: "backup", db: "admin" }]
395
+ });
206
396
  `;
207
397
 
208
398
  export const getHealthSource = `import type { RequestContext } from "@/core/RequestContext";
@@ -280,6 +470,18 @@ export class MongoReadinessProbe implements ReadinessProbe {
280
470
  }
281
471
  `;
282
472
 
473
+ export const postgresReadinessProbeSource = `import type { Pool } from "pg";
474
+ import type { ReadinessProbe } from "@/modules/health/use-cases/ports/ReadinessProbe";
475
+
476
+ export class PostgresReadinessProbe implements ReadinessProbe {
477
+ constructor(private readonly pool: Pool) {}
478
+
479
+ async check(): Promise<void> {
480
+ await this.pool.query("SELECT 1");
481
+ }
482
+ }
483
+ `;
484
+
283
485
  export const redisReadinessProbeSource = `import type { RedisClientType } from "redis";
284
486
  import type { ReadinessProbe } from "@/modules/health/use-cases/ports/ReadinessProbe";
285
487
 
@@ -308,25 +510,26 @@ export async function closeRedis() {
308
510
  }
309
511
  `;
310
512
 
311
- export function containerSource({ redis }) {
513
+ export function containerSource({ database = "mongo", redis }) {
514
+ const postgres = database === "postgres";
312
515
  return `import { container, type InjectionToken } from "tsyringe";
313
- import type { Db } from "mongodb";
516
+ import type { ${postgres ? "Pool" : "Db"} } from "${postgres ? "pg" : "mongodb"}";
314
517
  ${redis ? 'import type { RedisClientType } from "redis";\n' : ""}import { GetHealth } from "@/modules/health/use-cases/GetHealth";
315
518
  import { GetReadiness } from "@/modules/health/use-cases/GetReadiness";
316
519
  import type { ReadinessProbe } from "@/modules/health/use-cases/ports/ReadinessProbe";
317
- import { MongoReadinessProbe } from "@/platform/integrations/mongodb/MongoReadinessProbe";
520
+ import { ${postgres ? "PostgresReadinessProbe" : "MongoReadinessProbe"} } from "@/platform/integrations/${postgres ? "postgres/PostgresReadinessProbe" : "mongodb/MongoReadinessProbe"}";
318
521
  ${redis ? 'import { RedisReadinessProbe } from "@/platform/integrations/redis/RedisReadinessProbe";\n' : ""}
319
522
  const register = <T>(token: InjectionToken<T>, value: T) => container.registerInstance(token, value);
320
523
 
321
524
  export interface ApplicationDependencies {
322
- db: Db;
525
+ db: ${postgres ? "Pool" : "Db"};
323
526
  ${redis ? "\tredis: RedisClientType;\n" : ""}}
324
527
 
325
528
  /** App-owned composition root. Domain and use-case classes remain dependency-injection-framework free. */
326
529
  export function registerDependencies(dependencies: ApplicationDependencies) {
327
530
  container.reset();
328
531
 
329
- const readinessProbes: ReadinessProbe[] = [new MongoReadinessProbe(dependencies.db)];
532
+ const readinessProbes: ReadinessProbe[] = [new ${postgres ? "PostgresReadinessProbe" : "MongoReadinessProbe"}(dependencies.db)];
330
533
  ${redis ? "\treadinessProbes.push(new RedisReadinessProbe(dependencies.redis));\n" : ""}
331
534
  register(GetHealth, new GetHealth());
332
535
  register(GetReadiness, new GetReadiness(readinessProbes));
@@ -363,7 +566,7 @@ import { registerDependencies } from "@/app/container";
363
566
  import { applicationRoutes } from "@/app/routes";
364
567
  import { env } from "@/platform/config/env";
365
568
  import { closeDatabase, connectDatabase } from "@/platform/database";
366
- ${redis ? 'import { closeRedis, connectRedis } from "@/platform/redis";\n' : ""}${csrf ? 'import { requireCsrfHeader } from "@/platform/http/csrf";\n' : ""}import { errorHandler } from "@/platform/http/errors";
569
+ ${redis ? 'import { closeRedis, connectRedis } from "@/platform/redis";\n' : ""}${csrf ? 'import { csrfHeaderGuard } from "@/platform/http/csrf";\n' : ""}import { errorHandler } from "@/platform/http/errors";
367
570
 
368
571
  export interface ApplicationOptions {
369
572
  ${ui ? "\tpublicPath?: string;\n\tproduction?: boolean;\n" : ""}}
@@ -373,7 +576,7 @@ export function createApplication(options: ApplicationOptions = {}) {
373
576
  ${ui ? '\tconst publicPath = options.publicPath ?? path.join(__dirname, "..", "public");\n\tconst production = options.production ?? env.nodeEnv === "production";\n' : ""} app.set("trust proxy", 1);
374
577
  ${ui ? "\tif (production) {\n\t\tapp.use(express.static(publicPath, { index: false, maxAge: \"1y\", immutable: true }));\n\t}\n" : ""} app.use(cors({ origin: env.corsOrigin }));
375
578
  app.use(express.json());
376
- app.use("/api/v1", ${csrf ? "requireCsrfHeader, " : ""}applicationRoutes());
579
+ app.use("/api/v1", ${csrf ? "csrfHeaderGuard(), " : ""}applicationRoutes());
377
580
  ${ui ? '\tif (production) {\n\t\tapp.get(/^(?!\\/api(?:\\/|$)).*/, (_req, res) => res.sendFile(path.join(publicPath, "index.html")));\n\t}\n' : ""} app.use(errorHandler);
378
581
  return app;
379
582
  }
package/src/sync.mjs ADDED
@@ -0,0 +1,57 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { loadConfig } from "./config.mjs";
4
+ import { readFlutterPackageName } from "./flutter.mjs";
5
+ import { OWNED_GUIDANCE_END, OWNED_GUIDANCE_START, ownedGuidance } from "./owned-guidance.mjs";
6
+ import { ownedSources } from "./owned-sources.mjs";
7
+
8
+ export function runSync(rootArgument, mode, runningVersion) {
9
+ const loaded = loadConfig(rootArgument);
10
+ if (loaded.errors.length) return { changed: [], errors: loaded.errors };
11
+ if (loaded.config.contractVersion !== runningVersion) {
12
+ return { changed: [], errors: [`tailframe.json requires ${loaded.config.contractVersion}, but this CLI is ${runningVersion}`] };
13
+ }
14
+ const changed = [];
15
+ const errors = [];
16
+ const sourceOptions = {};
17
+ if (loaded.config.kind === "flutter") {
18
+ const packageName = readFlutterPackageName(loaded.root);
19
+ if (!packageName) return { changed, errors: ["pubspec.yaml must declare a package name"] };
20
+ sourceOptions.flutterPackageName = packageName;
21
+ }
22
+ const guidanceFile = path.join(loaded.root, "AGENTS.md");
23
+ const guidance = ownedGuidance(loaded.config);
24
+ const existingGuidance = fs.existsSync(guidanceFile) ? fs.readFileSync(guidanceFile, "utf8") : "";
25
+ const start = existingGuidance.indexOf(OWNED_GUIDANCE_START);
26
+ const end = existingGuidance.indexOf(OWNED_GUIDANCE_END);
27
+ const validSentinels = start >= 0 && end > start;
28
+ const currentSection = validSentinels
29
+ ? existingGuidance.slice(start, end + OWNED_GUIDANCE_END.length)
30
+ : undefined;
31
+ if (currentSection !== guidance) {
32
+ if (mode === "check") errors.push("AGENTS.md Tailframe-owned section differs from the canonical guidance");
33
+ else {
34
+ let appendix = validSentinels
35
+ ? `${existingGuidance.slice(0, start)}${existingGuidance.slice(end + OWNED_GUIDANCE_END.length)}`.trim()
36
+ : existingGuidance.trim();
37
+ appendix = appendix.replace(/^## Product-specific appendix\s*/u, "");
38
+ const next = appendix
39
+ ? `${guidance}\n\n## Product-specific appendix\n\n${appendix}\n`
40
+ : `${guidance}\n`;
41
+ fs.writeFileSync(guidanceFile, next);
42
+ changed.push("AGENTS.md");
43
+ }
44
+ }
45
+ for (const [relative, expected] of Object.entries(ownedSources(loaded.config, sourceOptions))) {
46
+ const absolute = path.join(loaded.root, relative);
47
+ const actual = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : undefined;
48
+ if (actual === expected) continue;
49
+ if (mode === "check") errors.push(`${relative} differs from the Tailframe ${runningVersion} canonical source`);
50
+ else {
51
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
52
+ fs.writeFileSync(absolute, expected);
53
+ changed.push(relative);
54
+ }
55
+ }
56
+ return { changed, errors };
57
+ }
@@ -37,6 +37,7 @@ export interface RpcErrorEnvelope {
37
37
  export const uiHttpSource = `import axios, { type AxiosError } from "axios";
38
38
  import type { ServiceError } from "@/core/errors";
39
39
  import type { RpcErrorEnvelope, RpcResponse } from "@/core/rpc";
40
+ import { apiBaseUrl } from "@/platform/config";
40
41
 
41
42
  const failureKind = (status?: number): ServiceError["kind"] => {
42
43
  if (!status) return "network";
@@ -52,7 +53,7 @@ const failureKind = (status?: number): ServiceError["kind"] => {
52
53
  };
53
54
 
54
55
  export const http = axios.create({
55
- baseURL: \`\${window.location.origin}/api/v1\`,
56
+ baseURL: apiBaseUrl,
56
57
  headers: {
57
58
  "Content-Type": "application/json",
58
59
  Accept: "application/json",
@@ -98,6 +99,184 @@ export async function rpc<T>(operation: string, payload: unknown = {}): Promise<
98
99
  }
99
100
  `;
100
101
 
102
+ export const uiErrorMessagesSource = `import { isServiceError } from "@/core/errors";
103
+
104
+ export type ErrorMessageMap = Record<string, string>;
105
+
106
+ export function getErrorMessage(
107
+ error: unknown,
108
+ fallback = "Please try again.",
109
+ messages: ErrorMessageMap = {}
110
+ ): string {
111
+ if (isServiceError(error)) return messages[error.code] ?? fallback;
112
+ return fallback;
113
+ }
114
+
115
+ export const hasErrorCode = (error: unknown, code: string) => isServiceError(error) && error.code === code;
116
+ `;
117
+
118
+ export const notificationStoreSource = `import { defineStore } from "pinia";
119
+ import { computed, ref } from "vue";
120
+
121
+ export type NotificationKind = "info" | "success" | "warning" | "error";
122
+
123
+ export interface NotificationInput {
124
+ message: string;
125
+ kind?: NotificationKind;
126
+ durationMs?: number;
127
+ }
128
+
129
+ export interface AppNotification {
130
+ id: number;
131
+ message: string;
132
+ kind: NotificationKind;
133
+ durationMs: number;
134
+ }
135
+
136
+ export const useNotificationStore = defineStore("notification", () => {
137
+ const queue = ref<AppNotification[]>([]);
138
+ const active = computed<AppNotification | undefined>(() => queue.value[0]);
139
+ let nextId = 1;
140
+
141
+ function notify({ message, kind = "info", durationMs = 4000 }: NotificationInput) {
142
+ const id = nextId++;
143
+ queue.value.push({ id, message, kind, durationMs });
144
+ return id;
145
+ }
146
+
147
+ function dismiss(id: number) {
148
+ queue.value = queue.value.filter((notification) => notification.id !== id);
149
+ }
150
+
151
+ function clear() {
152
+ queue.value = [];
153
+ }
154
+
155
+ return { queue, active, notify, dismiss, clear };
156
+ });
157
+ `;
158
+
159
+ export const notificationHostSource = `<template>
160
+ <div class="tailframe-notifications" aria-live="polite" aria-atomic="true">
161
+ <div v-if="active" :class="['tailframe-notification', \`is-\${active.kind}\`]" role="status">
162
+ <span>{{ active.message }}</span>
163
+ <button type="button" aria-label="Dismiss notification" @click="notifications.dismiss(active.id)">×</button>
164
+ </div>
165
+ </div>
166
+ </template>
167
+
168
+ <script setup lang="ts">
169
+ import { computed, onBeforeUnmount, watch } from "vue";
170
+ import { useNotificationStore } from "@/app/stores/notification.store";
171
+
172
+ const notifications = useNotificationStore();
173
+ const active = computed(() => notifications.active);
174
+ let timer: number | undefined;
175
+
176
+ watch(
177
+ active,
178
+ (notification) => {
179
+ if (timer !== undefined) window.clearTimeout(timer);
180
+ timer = undefined;
181
+ if (notification && notification.durationMs > 0) {
182
+ timer = window.setTimeout(() => notifications.dismiss(notification.id), notification.durationMs);
183
+ }
184
+ },
185
+ { immediate: true }
186
+ );
187
+
188
+ onBeforeUnmount(() => {
189
+ if (timer !== undefined) window.clearTimeout(timer);
190
+ });
191
+ </script>
192
+
193
+ <style scoped>
194
+ .tailframe-notifications {
195
+ position: fixed;
196
+ inset: auto 1rem 1rem;
197
+ z-index: 10000;
198
+ display: grid;
199
+ justify-items: end;
200
+ pointer-events: none;
201
+ }
202
+ .tailframe-notification {
203
+ display: flex;
204
+ max-width: min(32rem, calc(100vw - 2rem));
205
+ align-items: center;
206
+ gap: 0.75rem;
207
+ border-radius: 0.75rem;
208
+ background: #1e293b;
209
+ color: white;
210
+ padding: 0.75rem 1rem;
211
+ box-shadow: 0 12px 30px rgb(15 23 42 / 0.28);
212
+ pointer-events: auto;
213
+ }
214
+ .tailframe-notification.is-success {
215
+ background: #166534;
216
+ }
217
+ .tailframe-notification.is-warning {
218
+ background: #92400e;
219
+ }
220
+ .tailframe-notification.is-error {
221
+ background: #991b1b;
222
+ }
223
+ .tailframe-notification button {
224
+ border: 0;
225
+ background: transparent;
226
+ color: inherit;
227
+ cursor: pointer;
228
+ font: inherit;
229
+ font-size: 1.25rem;
230
+ line-height: 1;
231
+ }
232
+ </style>
233
+ `;
234
+
235
+ export const extensionAuthStoreSource = `import { auth, signInWithGoogleAccessToken } from "@/platform/firebase";
236
+ import { invalidateGoogleAccessToken, requestGoogleAccessToken, signOutGoogleSession } from "@/platform/extensionAuth";
237
+ import { onIdTokenChanged, signOut, type User } from "firebase/auth/web-extension";
238
+ import { defineStore } from "pinia";
239
+ import { ref } from "vue";
240
+
241
+ export const useAuthStore = defineStore("auth", () => {
242
+ const firebaseUser = ref<User | null>(null);
243
+ const ready = ref(false);
244
+
245
+ onIdTokenChanged(auth, (value) => {
246
+ firebaseUser.value = value;
247
+ ready.value = true;
248
+ });
249
+
250
+ async function signInWithGoogle(interactive = true) {
251
+ let accessToken = await requestGoogleAccessToken(interactive);
252
+ try {
253
+ return await signInWithGoogleAccessToken(accessToken);
254
+ } catch (reason: unknown) {
255
+ if (
256
+ !interactive ||
257
+ typeof reason !== "object" ||
258
+ reason === null ||
259
+ !("code" in reason) ||
260
+ reason.code !== "auth/invalid-credential"
261
+ )
262
+ throw reason;
263
+ await invalidateGoogleAccessToken(accessToken);
264
+ accessToken = await requestGoogleAccessToken(true);
265
+ return signInWithGoogleAccessToken(accessToken);
266
+ }
267
+ }
268
+
269
+ async function logout() {
270
+ await signOut(auth);
271
+ await signOutGoogleSession();
272
+ }
273
+
274
+ const getIdToken = () => firebaseUser.value?.getIdToken();
275
+
276
+ return { firebaseUser, ready, signInWithGoogle, logout, getIdToken };
277
+ });
278
+ `;
279
+
101
280
  export const configureHttpSource = `import type { Pinia } from "pinia";
102
281
  import router from "@/app/router";
103
282
  import { useAuthStore } from "@/app/stores/auth.store";
package/src/validate.mjs CHANGED
@@ -1,10 +1,16 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { validateArchitecture } from "./architecture.mjs";
2
4
  import { validateConventions } from "./conventions.mjs";
5
+ import { loadConfig } from "./config.mjs";
3
6
  import { isExcepted, loadExceptions } from "./exceptions.mjs";
7
+ import { validateFlutter } from "./flutter.mjs";
8
+ import { runSync } from "./sync.mjs";
4
9
 
5
10
  const NON_EXCEPTABLE_RULES = new Set(["S8", "S9", "U5", "U6", "U7"]);
6
11
 
7
12
  export function runValidate(root, kind) {
13
+ if (kind === "flutter") return validateFlutter(root);
8
14
  const structural = validateArchitecture(root, kind);
9
15
  if (structural.some((error) => error.startsWith("Architecture root") || error.startsWith("Architecture kind"))) {
10
16
  return structural;
@@ -15,3 +21,41 @@ export function runValidate(root, kind) {
15
21
  .map((violation) => `${violation.rule}: ${violation.message}`);
16
22
  return [...structural, ...conventions];
17
23
  }
24
+
25
+ function validateVersionMetadata(root, runningVersion) {
26
+ const errors = [];
27
+ const packageFile = path.join(root, "package.json");
28
+ const lockFile = path.join(root, "package-lock.json");
29
+ if (!fs.existsSync(packageFile)) return ["package.json is required to pin the Tailframe build-time contract"];
30
+ let manifest;
31
+ try { manifest = JSON.parse(fs.readFileSync(packageFile, "utf8")); }
32
+ catch { return ["package.json is not valid JSON"]; }
33
+ if (manifest.devDependencies?.["@zaaxch/tailframe"] !== runningVersion) {
34
+ errors.push(`package.json must pin @zaaxch/tailframe exactly to ${runningVersion}`);
35
+ }
36
+ if (!fs.existsSync(lockFile)) return [...errors, "package-lock.json is required to lock the Tailframe contract"];
37
+ try {
38
+ const lock = JSON.parse(fs.readFileSync(lockFile, "utf8"));
39
+ const rootPin = lock.packages?.[""]?.devDependencies?.["@zaaxch/tailframe"];
40
+ const installed = lock.packages?.["node_modules/@zaaxch/tailframe"]?.version;
41
+ if (rootPin !== runningVersion) errors.push(`package-lock.json root must pin @zaaxch/tailframe exactly to ${runningVersion}`);
42
+ if (installed !== runningVersion) errors.push(`package-lock.json resolves @zaaxch/tailframe ${installed ?? "nowhere"}, expected ${runningVersion}`);
43
+ } catch {
44
+ errors.push("package-lock.json is not valid JSON");
45
+ }
46
+ return errors;
47
+ }
48
+
49
+ export function runConfiguredValidate(rootArgument, runningVersion) {
50
+ const loaded = loadConfig(rootArgument);
51
+ if (loaded.errors.length) return loaded.errors;
52
+ const errors = [];
53
+ if (loaded.config.contractVersion !== runningVersion) {
54
+ errors.push(`tailframe.json requires ${loaded.config.contractVersion}, but this CLI is ${runningVersion}`);
55
+ }
56
+ errors.push(...validateVersionMetadata(loaded.root, runningVersion));
57
+ if (errors.length) return errors;
58
+ errors.push(...runValidate(loaded.root, loaded.config.kind));
59
+ errors.push(...runSync(loaded.root, "check", runningVersion).errors);
60
+ return errors;
61
+ }