rastack 0.0.53 → 0.0.54

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/CHANGELOG.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [0.0.54](https://github.com/theserverkid/reactapistack/compare/v0.0.53...v0.0.54) (2026-07-16)
6
+
5
7
  ### [0.0.53](https://github.com/theserverkid/reactapistack/compare/v0.0.52...v0.0.53) (2026-07-16)
6
8
 
7
9
  ### [0.0.52](https://github.com/theserverkid/reactapistack/compare/v0.0.51...v0.0.52) (2026-07-16)
package/auth/index.ts CHANGED
@@ -1,17 +1,26 @@
1
1
  /**
2
- * `rastack/auth` — the whole auth surface of the stack:
2
+ * `rastack/auth` — the whole auth surface of the stack, both halves:
3
3
  *
4
- * - **Authoring** (types the compiler reads): the entity markers
5
- * (`IsAuthenticated`, `AdminGroups<…>`, `OwnerScoped`, `GroupScoped`, …)
6
- * and the `Owner` / `Group` access-control field brands.
7
- * - **Runtime**: the TypeScript auth type ({@link RastackAuth}), the
8
- * client-side mirror of the server's authorization rules ({@link can},
9
- * row-level security helpers, policy lookup), and the React context the
10
- * provider and components share.
4
+ * **Authentication** (who you are) — the Cognito sign-in that produces a
5
+ * verified identity: `<CognitoAuthProvider>` + `useCognitoAuth()` driving the
6
+ * standard **Hosted UI, OAuth 2.0 Authorization Code + PKCE** browser flow
7
+ * (`tools/src/auth/cognito.ts` is the pure PKCE/JWT/session core), plus a
8
+ * `local` mode that mints an in-browser session (a server-less dev shim). The
9
+ * session feeds a {@link RastackAuth} straight into `<RAStackProvider auth={…}>`
10
+ * (see {@link cognitoToRastackAuth} / {@link useRastackAuthFromCognito}).
11
11
  *
12
- * See `docs/security-and-local-cache.md`.
12
+ * **Authorization** (what you can reach) — the type-level access rules and their
13
+ * runtime mirror: the entity markers (`IsAuthenticated`, `AdminGroups<…>`,
14
+ * `OwnerScoped`, `GroupScoped`, …) and `Owner` / `Group` field brands the
15
+ * compiler reads, the TypeScript auth type ({@link RastackAuth}), the
16
+ * client-side mirror of the server's rules ({@link can}, {@link ownsRow}, policy
17
+ * lookup), and the React context the provider and components share.
18
+ *
19
+ * See `docs/cognito-auth.md` (authentication) and
20
+ * `docs/security-and-local-cache.md` (authorization / row-level security).
13
21
  */
14
22
 
23
+ // -- authorization (the RLS mirror + authoring markers) --
15
24
  export * from "../src/define/auth";
16
25
  export * from "./core";
17
26
  export {
@@ -21,3 +30,7 @@ export {
21
30
  type RastackAccessValue,
22
31
  type RastackAuthProviderProps,
23
32
  } from "./context";
33
+
34
+ // -- authentication (Cognito sign-in) --
35
+ export * from "../src/auth/cognito";
36
+ export * from "./provider";
@@ -0,0 +1,486 @@
1
+ import {
2
+ createContext,
3
+ createElement,
4
+ ReactElement,
5
+ ReactNode,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from "react";
13
+ import {
14
+ authorizeUrl,
15
+ CognitoAuthConfig,
16
+ CognitoClaims,
17
+ CognitoSession,
18
+ CognitoTokenResponse,
19
+ CognitoUser,
20
+ createPkcePair,
21
+ isSessionValid,
22
+ localSession,
23
+ logoutUrl,
24
+ parseRedirect,
25
+ randomState,
26
+ sessionFromTokens,
27
+ tokenEndpoint,
28
+ tokenRequestBody,
29
+ } from "../src/auth/cognito";
30
+ import { anonymousAuth, authFromClaims, type RastackAuth } from "./core";
31
+
32
+ /** Where a sign-in should send the browser: the real Hosted UI, or a local
33
+ * synthetic session for server-less (`mode:"local"`) dev. */
34
+ export type CognitoAuthMode = "hosted-ui" | "local";
35
+
36
+ /** The minimal key-value storage the provider persists a session in. */
37
+ export interface AuthStorage {
38
+ getItem(key: string): string | null;
39
+ setItem(key: string, value: string): void;
40
+ removeItem(key: string): void;
41
+ }
42
+
43
+ export interface CognitoAuthProviderConfig extends Partial<CognitoAuthConfig> {
44
+ /**
45
+ * `hosted-ui` (default) runs the real OAuth Authorization Code + PKCE
46
+ * redirect against Cognito. `local` mints an unsigned in-browser session on
47
+ * sign-in — a server-less dev shim — so the entire app
48
+ * (which in `<RAStackProvider mode="local">` runs with no identity provider)
49
+ * can still exercise the login button and write-gating. Swap to `hosted-ui`
50
+ * for a deployed, Cognito-protected backend.
51
+ */
52
+ mode?: CognitoAuthMode;
53
+ /** The simulated user granted on sign-in in `local` mode. */
54
+ localUser?: {
55
+ sub?: string;
56
+ email?: string;
57
+ username?: string;
58
+ groups?: string[];
59
+ };
60
+ /** Which token to hand out as the Bearer credential. Default `"id"` (its
61
+ * `aud` is the app client id, matching `rastack-server --audience`). */
62
+ tokenType?: "id" | "access";
63
+ /** Persistence for the session (default `localStorage`, memory fallback). */
64
+ storage?: AuthStorage;
65
+ /** Storage key prefix (default `"rastack.auth"`). */
66
+ storageKey?: string;
67
+ /** Override `fetch` (SSR/tests). Defaults to the global `fetch`. */
68
+ fetchImpl?: typeof fetch;
69
+ /** Injectable clock for tests. Defaults to `Date.now`. */
70
+ now?: () => number;
71
+ onSignIn?: (session: CognitoSession) => void;
72
+ onSignOut?: () => void;
73
+ onError?: (error: unknown) => void;
74
+ }
75
+
76
+ export type CognitoAuthStatus =
77
+ | "loading"
78
+ | "authenticated"
79
+ | "unauthenticated"
80
+ | "error";
81
+
82
+ /** What `useCognitoAuth()` returns. */
83
+ export interface CognitoAuthContextValue {
84
+ status: CognitoAuthStatus;
85
+ isAuthenticated: boolean;
86
+ isLoading: boolean;
87
+ /**
88
+ * True once auth has resolved (a session was restored, a redirect completed,
89
+ * or we settled on signed-out) — i.e. any time we're not still booting. The
90
+ * generated read hooks gate their queries on this so *viewing works whether
91
+ * or not the user is signed in*; write UI gates on `isAuthenticated` instead.
92
+ */
93
+ isReady: boolean;
94
+ user: CognitoUser | null;
95
+ /** Verified `cognito:groups` (empty when signed out). */
96
+ groups: string[];
97
+ claims: CognitoClaims | null;
98
+ error: unknown;
99
+ /** Start sign-in (Hosted UI redirect, or a local session in `local` mode). */
100
+ login: () => Promise<void>;
101
+ /** Sign out — clears the local session (and, in `hosted-ui`, the Cognito cookie). */
102
+ logout: () => Promise<void>;
103
+ /**
104
+ * A valid bearer token, refreshing first if it's near expiry. Rejects if the
105
+ * user isn't signed in — pass this straight to `<RAStackProvider getToken>`.
106
+ */
107
+ getToken: () => Promise<string>;
108
+ }
109
+
110
+ const CognitoAuthContext = createContext<CognitoAuthContextValue | null>(null);
111
+
112
+ /**
113
+ * DOM globals accessed defensively through `globalThis`, so this module
114
+ * type-checks under a non-DOM lib (the pure `.ts` graph the components and
115
+ * tests import via the `rastack/auth` barrel) and stays SSR-safe — every use is
116
+ * still guarded before it runs.
117
+ */
118
+ const dom = globalThis as unknown as {
119
+ window?: any;
120
+ document?: any;
121
+ localStorage?: any;
122
+ };
123
+
124
+ /** An in-memory `AuthStorage` for SSR / environments without `localStorage`. */
125
+ function memoryStorage(): AuthStorage {
126
+ const map = new Map<string, string>();
127
+ return {
128
+ getItem: (k) => (map.has(k) ? map.get(k)! : null),
129
+ setItem: (k, v) => void map.set(k, v),
130
+ removeItem: (k) => void map.delete(k),
131
+ };
132
+ }
133
+
134
+ function defaultStorage(): AuthStorage {
135
+ try {
136
+ if (dom.localStorage) {
137
+ // Touch it — Safari private mode throws on write.
138
+ const probe = "__rastack_auth_probe__";
139
+ dom.localStorage.setItem(probe, "1");
140
+ dom.localStorage.removeItem(probe);
141
+ return dom.localStorage as AuthStorage;
142
+ }
143
+ } catch {
144
+ /* fall through to memory */
145
+ }
146
+ return memoryStorage();
147
+ }
148
+
149
+ function requireHostedConfig(
150
+ config: CognitoAuthProviderConfig,
151
+ ): CognitoAuthConfig {
152
+ if (!config.domain || !config.clientId || !config.redirectUri) {
153
+ throw new Error(
154
+ "CognitoAuthProvider: `domain`, `clientId` and `redirectUri` are required for hosted-ui mode.",
155
+ );
156
+ }
157
+ return config as CognitoAuthConfig;
158
+ }
159
+
160
+ /**
161
+ * Provides Cognito auth to the tree. Reads any persisted session on mount and,
162
+ * in `hosted-ui` mode, completes the code exchange if the current URL is a
163
+ * Cognito redirect (`?code=…&state=…`).
164
+ */
165
+ export function CognitoAuthProvider({
166
+ config,
167
+ children,
168
+ }: {
169
+ config: CognitoAuthProviderConfig;
170
+ children: ReactNode;
171
+ }): ReactElement {
172
+ const mode = config.mode ?? "hosted-ui";
173
+ const now = config.now ?? (() => Date.now());
174
+ const storage = useMemo(
175
+ () => config.storage ?? defaultStorage(),
176
+ [config.storage],
177
+ );
178
+ const keyBase = config.storageKey ?? "rastack.auth";
179
+ const sessionKey = `${keyBase}.session`;
180
+ const pkceKey = `${keyBase}.pkce`;
181
+ const doFetch = config.fetchImpl ?? (globalThis.fetch?.bind(globalThis) as typeof fetch);
182
+
183
+ const [session, setSession] = useState<CognitoSession | null>(null);
184
+ const [status, setStatus] = useState<CognitoAuthStatus>("loading");
185
+ const [error, setError] = useState<unknown>(null);
186
+ const sessionRef = useRef<CognitoSession | null>(null);
187
+ sessionRef.current = session;
188
+
189
+ const persist = useCallback(
190
+ (next: CognitoSession | null) => {
191
+ setSession(next);
192
+ if (next) storage.setItem(sessionKey, JSON.stringify(next));
193
+ else storage.removeItem(sessionKey);
194
+ },
195
+ [storage, sessionKey],
196
+ );
197
+
198
+ const fail = useCallback(
199
+ (err: unknown) => {
200
+ setError(err);
201
+ setStatus("error");
202
+ config.onError?.(err);
203
+ },
204
+ [config],
205
+ );
206
+
207
+ // -- boot: restore a stored session, or finish a redirect ------------------
208
+ useEffect(() => {
209
+ let cancelled = false;
210
+
211
+ async function boot() {
212
+ // A live stored session wins.
213
+ const stored = storage.getItem(sessionKey);
214
+ if (stored) {
215
+ try {
216
+ const parsed: CognitoSession = JSON.parse(stored);
217
+ const valid = isSessionValid(parsed, now());
218
+ if (valid) {
219
+ if (!cancelled) {
220
+ setSession(parsed);
221
+ setStatus("authenticated");
222
+ }
223
+ return;
224
+ }
225
+ // Expired but refreshable → try below; otherwise drop it.
226
+ if (mode === "hosted-ui" && parsed.refreshToken) {
227
+ const refreshed = await refreshSession(parsed);
228
+ if (refreshed && !cancelled) {
229
+ persist(refreshed);
230
+ setStatus("authenticated");
231
+ return;
232
+ }
233
+ }
234
+ storage.removeItem(sessionKey);
235
+ } catch {
236
+ storage.removeItem(sessionKey);
237
+ }
238
+ }
239
+
240
+ // Complete a Hosted UI redirect if this load is one.
241
+ if (mode === "hosted-ui" && dom.window) {
242
+ const redirect = parseRedirect(dom.window.location.search);
243
+ if (redirect.error) {
244
+ clearUrl();
245
+ if (!cancelled) fail(new Error(redirect.errorDescription ?? redirect.error));
246
+ return;
247
+ }
248
+ if (redirect.code) {
249
+ try {
250
+ const exchanged = await exchangeCode(redirect.code, redirect.state);
251
+ clearUrl();
252
+ if (!cancelled) {
253
+ persist(exchanged);
254
+ setStatus("authenticated");
255
+ config.onSignIn?.(exchanged);
256
+ }
257
+ } catch (err) {
258
+ clearUrl();
259
+ if (!cancelled) fail(err);
260
+ }
261
+ return;
262
+ }
263
+ }
264
+
265
+ if (!cancelled) setStatus("unauthenticated");
266
+ }
267
+
268
+ boot();
269
+ return () => {
270
+ cancelled = true;
271
+ };
272
+ // eslint-disable-next-line react-hooks/exhaustive-deps
273
+ }, []);
274
+
275
+ /** Drop the `?code=&state=` off the address bar after a successful exchange. */
276
+ function clearUrl() {
277
+ if (!dom.window || !dom.window.history?.replaceState) return;
278
+ dom.window.history.replaceState(
279
+ {},
280
+ dom.document?.title ?? "",
281
+ dom.window.location.pathname,
282
+ );
283
+ }
284
+
285
+ async function exchangeCode(
286
+ code: string,
287
+ state?: string,
288
+ ): Promise<CognitoSession> {
289
+ const hosted = requireHostedConfig(config);
290
+ const pending = storage.getItem(pkceKey);
291
+ if (!pending) throw new Error("No pending PKCE verifier — restart sign-in.");
292
+ const { verifier, state: expectedState } = JSON.parse(pending) as {
293
+ verifier: string;
294
+ state: string;
295
+ };
296
+ storage.removeItem(pkceKey);
297
+ if (expectedState && state && expectedState !== state) {
298
+ throw new Error("OAuth state mismatch — possible CSRF, sign-in rejected.");
299
+ }
300
+ const response = await doFetch(tokenEndpoint(hosted), {
301
+ method: "POST",
302
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
303
+ body: tokenRequestBody(hosted, {
304
+ grantType: "authorization_code",
305
+ code,
306
+ codeVerifier: verifier,
307
+ }),
308
+ });
309
+ if (!response.ok) {
310
+ throw new Error(`Token exchange failed (${response.status}).`);
311
+ }
312
+ const tokens = (await response.json()) as CognitoTokenResponse;
313
+ return sessionFromTokens(tokens, now());
314
+ }
315
+
316
+ async function refreshSession(
317
+ prev: CognitoSession,
318
+ ): Promise<CognitoSession | null> {
319
+ if (mode === "local") {
320
+ return localSession(config.localUser, now());
321
+ }
322
+ if (!prev.refreshToken) return null;
323
+ try {
324
+ const hosted = requireHostedConfig(config);
325
+ const response = await doFetch(tokenEndpoint(hosted), {
326
+ method: "POST",
327
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
328
+ body: tokenRequestBody(hosted, {
329
+ grantType: "refresh_token",
330
+ refreshToken: prev.refreshToken,
331
+ }),
332
+ });
333
+ if (!response.ok) return null;
334
+ const tokens = (await response.json()) as CognitoTokenResponse;
335
+ // A refresh grant doesn't return a new refresh token — carry the old one.
336
+ const next = sessionFromTokens(tokens, now());
337
+ return { ...next, refreshToken: next.refreshToken ?? prev.refreshToken };
338
+ } catch {
339
+ return null;
340
+ }
341
+ }
342
+
343
+ const login = useCallback(async () => {
344
+ setError(null);
345
+ if (mode === "local") {
346
+ const next = localSession(config.localUser, now());
347
+ persist(next);
348
+ setStatus("authenticated");
349
+ config.onSignIn?.(next);
350
+ return;
351
+ }
352
+ const hosted = requireHostedConfig(config);
353
+ const { verifier, challenge } = await createPkcePair();
354
+ const state = randomState();
355
+ storage.setItem(pkceKey, JSON.stringify({ verifier, state }));
356
+ const url = authorizeUrl(hosted, { state, codeChallenge: challenge });
357
+ if (dom.window) dom.window.location.assign(url);
358
+ // eslint-disable-next-line react-hooks/exhaustive-deps
359
+ }, [mode, config, persist, storage, pkceKey]);
360
+
361
+ const logout = useCallback(async () => {
362
+ persist(null);
363
+ setStatus("unauthenticated");
364
+ config.onSignOut?.();
365
+ if (mode === "hosted-ui" && config.domain && config.clientId) {
366
+ if (dom.window) {
367
+ dom.window.location.assign(logoutUrl(config as CognitoAuthConfig));
368
+ }
369
+ }
370
+ // eslint-disable-next-line react-hooks/exhaustive-deps
371
+ }, [mode, config, persist]);
372
+
373
+ const tokenType = config.tokenType ?? "id";
374
+ const getToken = useCallback(async () => {
375
+ let current = sessionRef.current;
376
+ if (current && !isSessionValid(current, now())) {
377
+ const refreshed = await refreshSession(current);
378
+ if (refreshed) {
379
+ persist(refreshed);
380
+ current = refreshed;
381
+ } else {
382
+ persist(null);
383
+ setStatus("unauthenticated");
384
+ current = null;
385
+ }
386
+ }
387
+ if (!current) throw new Error("Not authenticated.");
388
+ return tokenType === "access" ? current.accessToken : current.idToken;
389
+ // eslint-disable-next-line react-hooks/exhaustive-deps
390
+ }, [persist, tokenType]);
391
+
392
+ // Flip to "unauthenticated" the moment a session lapses (no refresh path).
393
+ useEffect(() => {
394
+ if (!session || mode === "local") return;
395
+ const ms = session.expiresAt - now();
396
+ if (ms <= 0) return;
397
+ const timer = setTimeout(() => {
398
+ if (!session.refreshToken) {
399
+ persist(null);
400
+ setStatus("unauthenticated");
401
+ }
402
+ }, ms);
403
+ return () => clearTimeout(timer);
404
+ // eslint-disable-next-line react-hooks/exhaustive-deps
405
+ }, [session, mode]);
406
+
407
+ const value = useMemo<CognitoAuthContextValue>(
408
+ () => ({
409
+ status,
410
+ isAuthenticated: status === "authenticated" && !!session,
411
+ isLoading: status === "loading",
412
+ isReady: status !== "loading",
413
+ user: session?.user ?? null,
414
+ groups: session?.user.groups ?? [],
415
+ claims: session?.claims ?? null,
416
+ error,
417
+ login,
418
+ logout,
419
+ getToken,
420
+ }),
421
+ [status, session, error, login, logout, getToken],
422
+ );
423
+
424
+ return createElement(CognitoAuthContext.Provider, { value }, children);
425
+ }
426
+
427
+ /**
428
+ * A safe default for `useCognitoAuth()` outside a `<CognitoAuthProvider>`:
429
+ * resolved-but-signed-out, so the generated read hooks (which only read
430
+ * `isReady`) keep working in an app that wires identity some other way, while
431
+ * the sign-in actions throw a clear error if actually invoked.
432
+ */
433
+ const NO_PROVIDER_VALUE: CognitoAuthContextValue = {
434
+ status: "unauthenticated",
435
+ isAuthenticated: false,
436
+ isLoading: false,
437
+ isReady: true,
438
+ user: null,
439
+ groups: [],
440
+ claims: null,
441
+ error: null,
442
+ login: async () => {
443
+ throw new Error("login() requires a <CognitoAuthProvider>.");
444
+ },
445
+ logout: async () => {
446
+ throw new Error("logout() requires a <CognitoAuthProvider>.");
447
+ },
448
+ getToken: async () => {
449
+ throw new Error("getToken() requires a <CognitoAuthProvider>.");
450
+ },
451
+ };
452
+
453
+ /**
454
+ * Consume the Cognito auth state + actions. Outside a `<CognitoAuthProvider>`
455
+ * this returns a resolved, signed-out value (never throws) so the generated
456
+ * read hooks work with or without Cognito wired up.
457
+ */
458
+ export function useCognitoAuth(): CognitoAuthContextValue {
459
+ return useContext(CognitoAuthContext) ?? NO_PROVIDER_VALUE;
460
+ }
461
+
462
+ /**
463
+ * Map the Cognito auth state to the framework-wide {@link RastackAuth} the
464
+ * authorization half (`can`/`ownsRow`, `<RAStackProvider auth={…}>`) consumes:
465
+ * the ID token's claims when signed in, an anonymous identity otherwise.
466
+ */
467
+ export function cognitoToRastackAuth(
468
+ auth: Pick<CognitoAuthContextValue, "isAuthenticated" | "claims">,
469
+ ): RastackAuth {
470
+ return auth.isAuthenticated && auth.claims
471
+ ? authFromClaims(auth.claims as unknown as Record<string, unknown>)
472
+ : anonymousAuth();
473
+ }
474
+
475
+ /**
476
+ * The signed-in {@link RastackAuth} derived from the Cognito context — hand it
477
+ * to `<RAStackProvider auth={…}>` (or `<RastackAuthProvider auth={…}>`) so one
478
+ * Cognito identity drives the engine, owner stamping, and component gating.
479
+ */
480
+ export function useRastackAuthFromCognito(): RastackAuth {
481
+ const auth = useCognitoAuth();
482
+ return useMemo(
483
+ () => cognitoToRastackAuth(auth),
484
+ [auth.isAuthenticated, auth.claims],
485
+ );
486
+ }