@djangocfg/api 2.1.621 → 2.1.622

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/api",
3
- "version": "2.1.621",
3
+ "version": "2.1.622",
4
4
  "description": "Auto-generated TypeScript API client with React hooks, SWR integration, and Zod validation for Django REST Framework backends",
5
5
  "keywords": [
6
6
  "django",
@@ -91,7 +91,7 @@
91
91
  "zod": "^4.3.6"
92
92
  },
93
93
  "devDependencies": {
94
- "@djangocfg/typescript-config": "^2.1.621",
94
+ "@djangocfg/typescript-config": "^2.1.622",
95
95
  "@testing-library/react": "^16.3.3",
96
96
  "@types/node": "^25.9.5",
97
97
  "@types/react": "19.3.0",
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ describe('sharedContext', () => {
4
+ it('returns one context object for a key, even from a second module copy', async () => {
5
+ const a = await import('../context/sharedContext');
6
+ vi.resetModules();
7
+ const b = await import('../context/sharedContext');
8
+ expect(a.sharedContext).not.toBe(b.sharedContext);
9
+ expect(a.sharedContext('k', 1)).toBe(b.sharedContext('k', 2));
10
+ });
11
+
12
+ it('keeps different keys apart', async () => {
13
+ const { sharedContext } = await import('../context/sharedContext');
14
+ expect(sharedContext('x', 1)).not.toBe(sharedContext('y', 1));
15
+ });
16
+ });
@@ -0,0 +1,38 @@
1
+ // @vitest-environment jsdom
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+
4
+ import { authTrace, clearAuthTrace, dumpAuthTrace, readAuthTrace } from '../trace';
5
+
6
+ describe('auth trace', () => {
7
+ beforeEach(() => {
8
+ localStorage.clear();
9
+ clearAuthTrace();
10
+ });
11
+
12
+ it('drops sensitive keys and cuts long strings', () => {
13
+ authTrace('x', { token: 'abc', email: 'a@b.c', otp: '123456', path: '/auth', long: 'y'.repeat(300), ok: true });
14
+ const f = readAuthTrace()[0]!.f!;
15
+ expect(Object.keys(f).sort()).toEqual(['long', 'ok', 'path']);
16
+ expect((f.long as string).length).toBeLessThan(130);
17
+ expect(localStorage.getItem('cfg.auth.trace')).not.toContain('123456');
18
+ });
19
+
20
+ it('collapses an identical repeat so a loop cannot flush the start of the chain', () => {
21
+ authTrace('start');
22
+ for (let i = 0; i < 500; i++) authTrace('guard.redirect', { from: '/', to: '/auth' });
23
+ const t = readAuthTrace();
24
+ expect(t.map((e) => e.ev)).toEqual(['start', 'guard.redirect']);
25
+ expect(t[1]!.n).toBe(500);
26
+ expect(dumpAuthTrace()).toContain('x500');
27
+ });
28
+
29
+ it('keeps at most 200 distinct events', () => {
30
+ for (let i = 0; i < 300; i++) authTrace('e', { i });
31
+ expect(readAuthTrace()).toHaveLength(200);
32
+ });
33
+
34
+ it('survives in storage for the next page', () => {
35
+ authTrace('a');
36
+ expect(JSON.parse(localStorage.getItem('cfg.auth.trace')!)).toHaveLength(1);
37
+ });
38
+ });
@@ -14,7 +14,7 @@
14
14
  "use client";
15
15
 
16
16
  import {
17
- createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState
17
+ ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState
18
18
  } from 'react';
19
19
 
20
20
  import { auth } from '../../_api/generated/helpers/auth';
@@ -61,6 +61,7 @@ import type {
61
61
  PasswordLoginResponse,
62
62
  } from '../../_api/generated/_cfg_accounts/types.gen';
63
63
  import { authTrace } from '../trace';
64
+ import { sharedContext } from './sharedContext';
64
65
 
65
66
  export type { PatchedCfgUserUpdateRequest, User, CfgUserUpdateRequest as UserProfileUpdateRequest };
66
67
 
@@ -84,7 +85,7 @@ export interface AccountsContextValue {
84
85
  logout: () => void;
85
86
  }
86
87
 
87
- const AccountsContext = createContext<AccountsContextValue | undefined>(undefined);
88
+ const AccountsContext = sharedContext<AccountsContextValue | undefined>('accounts', undefined);
88
89
 
89
90
  interface AccountsProviderProps {
90
91
  children: ReactNode;
@@ -18,7 +18,6 @@
18
18
 
19
19
  import { usePathname } from 'next/navigation';
20
20
  import React, {
21
- createContext,
22
21
  memo,
23
22
  useCallback,
24
23
  useContext,
@@ -43,6 +42,7 @@ import { authTrace } from '../trace';
43
42
  import { isDev, isBrowser } from '../utils/env';
44
43
  import { authLogger } from '../utils/logger';
45
44
  import { AccountsProvider, useAccountsContext } from './AccountsContext';
45
+ import { sharedContext } from './sharedContext';
46
46
 
47
47
  import type { AuthContextType, AuthProviderProps, UserProfile } from './types';
48
48
  import type {
@@ -74,7 +74,7 @@ export const resolveAuthRoutes = (
74
74
 
75
75
  const EMAIL_STORAGE_KEY = CFG_KEYS.email.key;
76
76
 
77
- const AuthContext = createContext<AuthContextType | undefined>(undefined);
77
+ const AuthContext = sharedContext<AuthContextType | undefined>('auth', undefined);
78
78
 
79
79
  const AuthProviderInternal: React.FC<AuthProviderProps> = ({ children, config }) => {
80
80
  const accounts = useAccountsContext();
@@ -0,0 +1,16 @@
1
+ import { createContext, type Context } from 'react';
2
+
3
+ /**
4
+ * A React context that stays ONE object even when the bundle holds several copies of this package.
5
+ *
6
+ * pnpm builds a separate physical copy of a package per distinct peer set, and a dual ESM/CJS load can do the same.
7
+ * Each copy would call `createContext` itself: the provider mounted from copy A and a `useAuth()` called from copy B
8
+ * would then never meet, B would read the empty default and the page guard would redirect a signed-in user forever
9
+ * (hub.reforms.ai, 5 Nov 2026). Registering the context on `globalThis` makes every copy share it. React itself must
10
+ * still be a single copy, which every Next.js app already requires.
11
+ */
12
+ export function sharedContext<T>(key: string, initial: T): Context<T> {
13
+ const g = globalThis as Record<string, unknown>;
14
+ const registry = (g.__cfgAuthContexts ??= {}) as Record<string, Context<unknown>>;
15
+ return (registry[key] ??= createContext<unknown>(initial)) as Context<T>;
16
+ }
package/src/auth/trace.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * __cfgAuth.dump() // copy-ready text with a header; paste it into the report
11
11
  * __cfgAuth.trace() // the raw array
12
12
  * __cfgAuth.clear() // empty it before reproducing
13
+ * __cfgAuth.copy() // dump to the clipboard; __cfgAuth.download() saves the raw JSON
13
14
  *
14
15
  * What is recorded is the decision, never the secret: no token, code, address, cookie value or password.
15
16
  * Values are primitives, strings are cut, and keys that look sensitive are dropped (`SENSITIVE`).
@@ -27,6 +28,8 @@ export interface TraceEntry {
27
28
  tab: string;
28
29
  ev: string;
29
30
  f?: TraceFields;
31
+ /** Times this exact event repeated back to back (absent = once). A loop must not push the start of the chain out of the buffer. */
32
+ n?: number;
30
33
  }
31
34
 
32
35
  const KEY = 'cfg.auth.trace';
@@ -34,7 +37,11 @@ const MAX = 200;
34
37
  const MAX_STRING = 120;
35
38
  const SENSITIVE = /token|otp|code|email|password|secret|cookie|authorization|key|identifier/i;
36
39
 
37
- const TAB = Math.random().toString(36).slice(2, 6);
40
+ // One id per page load, shared by every copy of this module on the page (a second copy must not look like a second tab).
41
+ const TAB: string = (() => {
42
+ const g = globalThis as Record<string, unknown>;
43
+ return (g.__cfgAuthTab ??= Math.random().toString(36).slice(2, 6)) as string;
44
+ })();
38
45
  const hasWindow = typeof window !== 'undefined';
39
46
 
40
47
  let memory: TraceEntry[] = [];
@@ -87,10 +94,32 @@ function clean(fields?: TraceFields): TraceFields | undefined {
87
94
  return Object.keys(out).length ? out : undefined;
88
95
  }
89
96
 
97
+ /** Re-read storage before every write: another tab (or another copy) may have appended since this one last looked. */
98
+ function refresh(): void {
99
+ try {
100
+ const raw = backend()?.getItem(KEY);
101
+ const parsed: unknown = raw ? JSON.parse(raw) : null;
102
+ if (Array.isArray(parsed)) memory = parsed.filter((e): e is TraceEntry => typeof e === 'object' && e !== null);
103
+ } catch {
104
+ /* keep the in-memory copy */
105
+ }
106
+ }
107
+
90
108
  function record(at: number, ev: string, fields?: TraceFields): void {
91
- const entries = load();
109
+ load();
110
+ refresh();
111
+ const entries = memory;
92
112
  const p = hasWindow && typeof performance !== 'undefined' ? Math.round(performance.now()) : 0;
93
- entries.push({ t: at, p, tab: TAB, ev, f: clean(fields) });
113
+ const f = clean(fields);
114
+ const last = entries[entries.length - 1];
115
+ if (last && last.tab === TAB && last.ev === ev && JSON.stringify(last.f) === JSON.stringify(f)) {
116
+ last.n = (last.n ?? 1) + 1;
117
+ last.t = at;
118
+ last.p = p;
119
+ persist();
120
+ return;
121
+ }
122
+ entries.push({ t: at, p, tab: TAB, ev, f });
94
123
  if (entries.length > MAX) entries.splice(0, entries.length - MAX);
95
124
  persist();
96
125
  }
@@ -106,6 +135,12 @@ export function authTrace(ev: string, fields?: TraceFields): void {
106
135
  }
107
136
 
108
137
  /** The recorded events, oldest first. */
138
+ /** @internal for tests */
139
+ export function resetAuthTraceForTests(): void {
140
+ memory = [];
141
+ loaded = true;
142
+ }
143
+
109
144
  export function readAuthTrace(): TraceEntry[] {
110
145
  return load().slice();
111
146
  }
@@ -123,7 +158,8 @@ export function clearAuthTrace(): void {
123
158
 
124
159
  function context(): string[] {
125
160
  if (!hasWindow) return [];
126
- const lines = [`url: ${window.location.href.split('?')[0]}`, `online: ${navigator.onLine}`, `ua: ${navigator.userAgent}`];
161
+ const copies = (globalThis as Record<string, unknown>).__cfgAuthCopies ?? 1;
162
+ const lines = [`url: ${window.location.href.split('?')[0]}`, `copies: ${String(copies)}`, `online: ${navigator.onLine}`, `ua: ${navigator.userAgent}`];
127
163
  try {
128
164
  const auth = (globalThis as Record<string, unknown>).__cfgAuthNow as (() => TraceFields) | undefined;
129
165
  const now = auth?.();
@@ -141,7 +177,7 @@ export function dumpAuthTrace(): string {
141
177
  const rows = entries.map((e) => {
142
178
  const fields = e.f ? ` ${Object.entries(e.f).map(([k, v]) => `${k}=${String(v)}`).join(' ')}` : '';
143
179
  const at = new Date(e.t).toISOString().slice(11, 23);
144
- return `${at} +${String(e.t - first).padStart(6)}ms [${e.tab} p${e.p}] ${e.ev}${fields}`;
180
+ return `${at} +${String(e.t - first).padStart(6)}ms [${e.tab} p${e.p}] ${e.ev}${fields}${e.n && e.n > 1 ? ` x${e.n}` : ''}`;
145
181
  });
146
182
  return [`# cfg auth trace, ${entries.length} events`, ...context(), '', ...rows].join('\n');
147
183
  }
@@ -163,6 +199,20 @@ export function installAuthTrace(): void {
163
199
  return text;
164
200
  },
165
201
  clear: clearAuthTrace,
202
+ /** Put the dump on the clipboard (works when the console output is blocked or too long to select). */
203
+ copy: () => {
204
+ const text = dumpAuthTrace();
205
+ void navigator.clipboard?.writeText(text);
206
+ return `${text.split('\n').length} lines copied`;
207
+ },
208
+ /** Download the raw trace as a JSON file. */
209
+ download: () => {
210
+ const blob = new Blob([JSON.stringify(readAuthTrace(), null, 1)], { type: 'application/json' });
211
+ const a = document.createElement('a');
212
+ a.href = URL.createObjectURL(blob);
213
+ a.download = `auth-trace-${Date.now()}.json`;
214
+ a.click();
215
+ },
166
216
  };
167
217
  const queued = g.__cfgAuthTraceQ;
168
218
  if (Array.isArray(queued)) {
@@ -179,5 +229,25 @@ export function installAuthTrace(): void {
179
229
  }
180
230
  }
181
231
 
232
+ /**
233
+ * Count the copies of this package in the page. More than one means the bundle holds duplicates (pnpm peer-set
234
+ * variants, a dual ESM/CJS load): the contexts are shared (`sharedContext`), but the build should still be fixed.
235
+ * Visible as `dup.copy` in the trace and once in the console.
236
+ */
237
+ function registerCopy(): void {
238
+ if (!hasWindow) return;
239
+ const g = globalThis as Record<string, unknown>;
240
+ const n = ((g.__cfgAuthCopies as number | undefined) ?? 0) + 1;
241
+ g.__cfgAuthCopies = n;
242
+ if (n > 1) {
243
+ authTrace('dup.copy', { copies: n });
244
+ if (n === 2) {
245
+ // eslint-disable-next-line no-console
246
+ console.warn('[@djangocfg/api] more than one copy of the auth package is loaded; see __cfgAuth.dump() (dup.copy). Align the peer set so pnpm installs one instance.');
247
+ }
248
+ }
249
+ }
250
+ registerCopy();
251
+
182
252
  // The reader is installed as soon as the package loads, so a trace exists from the first event.
183
253
  installAuthTrace();