@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/dist/auth.cjs +147 -80
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +6 -1
- package/dist/auth.d.ts +6 -1
- package/dist/auth.mjs +94 -29
- package/dist/auth.mjs.map +1 -1
- package/dist/index.cjs +72 -20
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +72 -20
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/auth/__tests__/sharedContext.test.ts +16 -0
- package/src/auth/__tests__/trace.test.ts +38 -0
- package/src/auth/context/AccountsContext.tsx +3 -2
- package/src/auth/context/AuthContext.tsx +2 -2
- package/src/auth/context/sharedContext.ts +16 -0
- package/src/auth/trace.ts +75 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/api",
|
|
3
|
-
"version": "2.1.
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
109
|
+
load();
|
|
110
|
+
refresh();
|
|
111
|
+
const entries = memory;
|
|
92
112
|
const p = hasWindow && typeof performance !== 'undefined' ? Math.round(performance.now()) : 0;
|
|
93
|
-
|
|
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
|
|
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();
|