@securitydept/client-angular 0.3.0-beta.1 → 0.3.0-beta.10

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.
@@ -1,521 +1,610 @@
1
- import * as i0 from '@angular/core';
2
- import { InjectionToken, makeEnvironmentProviders, inject, Injectable } from '@angular/core';
3
- import { createPlannerHost, resolveEffectiveClientSet, RequirementsClientSetComposition } from '@securitydept/client/auth-coordination';
4
- export { resolveEffectiveClientSet } from '@securitydept/client/auth-coordination';
5
- import { Observable } from 'rxjs';
1
+ import { InjectionToken, makeEnvironmentProviders, inject, DestroyRef, Injector, Optional } from '@angular/core';
2
+ import { Router } from '@angular/router';
3
+ import { createSecuritydeptDestroyRef, SecuritydeptDestroyRef, SecuritydeptInjector, readSecuritydeptRouteMetadata, RouteCompositionRequirementPlanner, PipelineOutcome, injectRequirementPlannerHost, RequirementPlannerHost, ENVIRONMENT_TOKEN, writeSecuritydeptRouteMetadata, provideRequirementPlannerHost, UriReferenceStringSchema, validateTraitInput, throwValidationClientError, RouterNavigationMode, ClientError, ClientErrorKind, UriReferenceString, TransportErrorCode, ClientErrorSource, getSecuritydeptProviderToken, createProviderIfTokenMissing, ROUTER_TRAIT_TOKEN, TRANSPORT_TRAIT_TOKEN, notMissingProvider, SYMBOL_OBSERVABLE } from '@securitydept/client';
4
+ import { HttpErrorResponse, HttpClient } from '@angular/common/http';
5
+ import { createRouterForNativeWeb } from '@securitydept/client/web';
6
+ import { type } from 'arktype';
7
+ import { takeUntil, from, firstValueFrom } from 'rxjs';
8
+ import { toSignal, rxResource } from '@angular/core/rxjs-interop';
6
9
 
7
- // Angular planner-host integration — DI-based planner provider / lookup
10
+ const SECURITYDEPT_INJECTOR = new InjectionToken("SECURITYDEPT_INJECTOR");
11
+ function provideSecuritydept(options = {}) {
12
+ return makeEnvironmentProviders([
13
+ {
14
+ provide: SECURITYDEPT_INJECTOR,
15
+ useFactory: () => {
16
+ const parentInjector = inject(SECURITYDEPT_INJECTOR, {
17
+ optional: true,
18
+ skipSelf: true,
19
+ }) ?? undefined;
20
+ const angularDestroyRef = inject(DestroyRef);
21
+ const securitydeptDestroyRef = options.autoCreateDestroyRef === false
22
+ ? null
23
+ : createSecuritydeptDestroyRef();
24
+ if (securitydeptDestroyRef) {
25
+ angularDestroyRef.onDestroy(() => securitydeptDestroyRef.dispose());
26
+ }
27
+ const providers = [
28
+ ...(options.providers ?? []),
29
+ ...(securitydeptDestroyRef
30
+ ? [
31
+ {
32
+ provide: SecuritydeptDestroyRef,
33
+ useValue: securitydeptDestroyRef,
34
+ },
35
+ ]
36
+ : []),
37
+ ];
38
+ return parentInjector
39
+ ? SecuritydeptInjector.fromParentInjector(parentInjector, providers)
40
+ : SecuritydeptInjector.resolveAndCreate(providers);
41
+ },
42
+ },
43
+ ]);
44
+ }
45
+
46
+ // Angular route metadata projection
8
47
  //
9
48
  // Canonical import path:
10
- // import { AUTH_PLANNER_HOST, provideAuthPlannerHost, ... } from "@securitydept/client-angular"
49
+ // import { projectAngularRouteSegments } from "@securitydept/client-angular"
50
+ //
51
+ // Reads serializable auth requirement metadata declared in Angular route
52
+ // `data` and projects a matched route chain (`pathFromRoot`) into the SDK's
53
+ // framework-agnostic `RouteTreeSegment[]`. Composition folding is owned by the
54
+ // `RouteCompositionRequirementPlanner`, so this layer only extracts per-segment
55
+ // declarations.
11
56
  //
12
- // Provides Angular DI glue for the shared planner-host contract:
13
- // - InjectionToken for PlannerHost
14
- // - Provider helpers for app / module / route scoped planners
15
- // - InjectionToken + provider helpers for ScopedRequirementsClientSet
16
- // - injectPlannerHost() convenience function with fail-fast
57
+ // Route metadata storage is owned by `@securitydept/client`
58
+ // ({@link readSecuritydeptRouteMetadata} / {@link writeSecuritydeptRouteMetadata}).
17
59
  //
18
60
  // Architecture boundary:
19
- // - Does NOT own the planner-host contract (that lives in @securitydept/client)
20
- // - Does NOT carry token-set-specific mapping
21
- // - Provides the Angular DI wiring for the shared contract
61
+ // - Does NOT own auth enforcement, planner logic, or navigation.
62
+ // - Does NOT carry token-set-specific mapping or policy.
63
+ // - Framework-specific (Angular Router types) but requirement-system-agnostic.
22
64
  //
23
65
  // Stability: provisional
24
- // ---------------------------------------------------------------------------
25
- // PlannerHost injection token
26
- // ---------------------------------------------------------------------------
27
66
  /**
28
- * DI token for the shared {@link PlannerHost}.
29
- *
30
- * Provide at the app level via {@link provideAuthPlannerHost}.
31
- * Guards use {@link injectPlannerHost} to look up the nearest instance.
67
+ * Resolved route `data` for a segment: static `routeConfig.data` merged with
68
+ * snapshot `data` (snapshot wins on key collision).
32
69
  */
33
- const AUTH_PLANNER_HOST = new InjectionToken("AUTH_PLANNER_HOST");
70
+ function routeDataFromSegment(segment) {
71
+ return {
72
+ ...(segment.routeConfig?.data ?? {}),
73
+ ...segment.data,
74
+ };
75
+ }
34
76
  /**
35
- * Provide an {@link AUTH_PLANNER_HOST} at the current injector level.
77
+ * Project the `pathFromRoot` of an `ActivatedRouteSnapshot` into the SDK's
78
+ * `RouteTreeSegment[]` chain (root -> leaf).
36
79
  *
37
- * Use in app config, route providers, or NgModule providers to establish
38
- * a planner-host scope. Child routes inherit the nearest parent's planner.
80
+ * Each segment carries its declared requirements and composition strategy;
81
+ * inherit/merge/replace folding is applied later by the
82
+ * `RouteCompositionRequirementPlanner`.
39
83
  *
40
- * @example
41
- * ```ts
42
- * // app.config.ts — app-level planner (default sequential strategy)
43
- * export const appConfig: ApplicationConfig = {
44
- * providers: [provideAuthPlannerHost()],
45
- * };
46
- *
47
- * // Feature route — override with custom async chooser strategy
48
- * {
49
- * path: "admin",
50
- * providers: [provideAuthPlannerHost({
51
- * selectCandidate: async (candidates) => showAdminChooser(candidates),
52
- * })],
53
- * children: [...]
54
- * }
55
- * ```
84
+ * Segments without a `routeConfig` (e.g. the root) get routeId `"__root__"`;
85
+ * empty-path segments get `"__index__"`.
56
86
  */
57
- function provideAuthPlannerHost(options) {
58
- const plannerOptions = {};
59
- if (options?.selectCandidate) {
60
- plannerOptions.selectCandidate = options.selectCandidate;
87
+ function projectAngularRouteSegments(leafRoute) {
88
+ const chain = leafRoute.pathFromRoot ?? [leafRoute];
89
+ return chain.map((segment) => segmentToTreeNode(segment));
90
+ }
91
+ function segmentToTreeNode(segment) {
92
+ const metadata = readSecuritydeptRouteMetadata(routeDataFromSegment(segment));
93
+ const requirements = metadata?.requirements ? [...metadata.requirements] : [];
94
+ const composition = metadata?.composition;
95
+ return composition
96
+ ? { routeId: resolveRouteId(segment), requirements, composition }
97
+ : { routeId: resolveRouteId(segment), requirements };
98
+ }
99
+ function resolveRouteId(segment) {
100
+ if (!segment.routeConfig) {
101
+ return "__root__";
61
102
  }
62
- return makeEnvironmentProviders([
63
- { provide: AUTH_PLANNER_HOST, useValue: createPlannerHost(plannerOptions) },
64
- ]);
103
+ const path = segment.routeConfig.path;
104
+ if (path === undefined || path === null || path === "") {
105
+ return "__index__";
106
+ }
107
+ return path;
65
108
  }
66
- /**
67
- * Inject the nearest {@link PlannerHost} from the DI hierarchy.
68
- *
69
- * Throws an explicit error if no planner-host is provided, preventing
70
- * silent fallback behavior.
71
- *
72
- * @example
73
- * ```ts
74
- * const plannerHost = injectPlannerHost();
75
- * const result = await plannerHost.evaluate(candidates);
76
- * ```
77
- */
78
- function injectPlannerHost() {
79
- const host = inject(AUTH_PLANNER_HOST, { optional: true });
80
- if (!host) {
81
- throw new Error("[injectPlannerHost] No AUTH_PLANNER_HOST found in the injector hierarchy. " +
82
- "Use provideAuthPlannerHost() in your app config or route providers.");
109
+
110
+ // Angular route guards — bridge to RouteCompositionRequirementPlanner
111
+ //
112
+ // Canonical import path:
113
+ // import {
114
+ // createAngularCanActivate,
115
+ // createAngularCanActivateChild,
116
+ // } from "@securitydept/client-angular"
117
+ //
118
+ // Produces Angular `CanActivateFn` / `CanActivateChildFn` guards that project
119
+ // the matched route chain into `RouteTreeSegment[]`, run the SDK's
120
+ // `RouteCompositionRequirementPlanner` against the nearest
121
+ // `RequirementPlannerHost` resolved from the current Securitydept injector,
122
+ // and map the pipeline result onto Angular's guard return contract (`true` /
123
+ // `false` / `UrlTree`).
124
+ //
125
+ // Injection-context discipline: DI reads (host, Router) happen synchronously at
126
+ // guard entry, before any `await`. Behaviour that needs DI services should be
127
+ // provided through `provideSecuritydept({ providers: [...] })`, not inline
128
+ // callbacks that call `inject()` here.
129
+ //
130
+ // Architecture boundary:
131
+ // - Does NOT own behaviour (checkAuthenticated / onUnauthenticated) — that is
132
+ // resolved through the RequirementPlannerHost chain.
133
+ // - Does NOT carry token-set-specific mapping or policy.
134
+ //
135
+ // Stability: provisional
136
+ async function runRouteGuard(route, state, host, router) {
137
+ const segments = projectAngularRouteSegments(route);
138
+ const planner = RouteCompositionRequirementPlanner.fromRouteSegments(host, segments, { url: state.url });
139
+ const result = await planner.runUntilSettled();
140
+ switch (result.outcome) {
141
+ case PipelineOutcome.Settled:
142
+ return true;
143
+ case PipelineOutcome.Blocked:
144
+ return false;
145
+ case PipelineOutcome.Redirect:
146
+ return router.parseUrl(result.location);
83
147
  }
84
- return host;
85
148
  }
86
- // ---------------------------------------------------------------------------
87
- // Requirements client set injection token — stores the already-resolved
88
- // effective options for the current scope (parent + child composed).
89
- // ---------------------------------------------------------------------------
90
149
  /**
91
- * DI token for the effective requirements client set at the current scope.
92
- *
93
- * The value is always the **resolved** options after composing all ancestor
94
- * scopes — it is NOT the raw `ScopedRequirementsClientSet` declaration.
95
- *
96
- * Provide via {@link provideRouteScopedRequirements}.
97
- * Guards inject this token to get the effective scope requirements and then
98
- * merge their own declared candidates on top.
150
+ * Create a `CanActivateFn` bridging the route chain to the requirement
151
+ * planner pipeline.
99
152
  */
100
- const AUTH_REQUIREMENTS_CLIENT_SET = new InjectionToken("AUTH_REQUIREMENTS_CLIENT_SET");
153
+ function createAngularCanActivate(options) {
154
+ return (route, state) => {
155
+ const securitydeptInjector = inject(SECURITYDEPT_INJECTOR, {
156
+ optional: true,
157
+ });
158
+ const parentHost = securitydeptInjector
159
+ ? injectRequirementPlannerHost({ injector: securitydeptInjector })
160
+ : null;
161
+ const host = options?.plannerHost ??
162
+ (options?.behaviour
163
+ ? RequirementPlannerHost.fromBehaviour(typeof options.behaviour === "function"
164
+ ? options.behaviour()
165
+ : options.behaviour, {
166
+ parent: parentHost ?? undefined,
167
+ environment: securitydeptInjector?.get(ENVIRONMENT_TOKEN, null) ?? undefined,
168
+ })
169
+ : parentHost) ??
170
+ RequirementPlannerHost.fromBehaviour({});
171
+ const router = inject(Router);
172
+ return runRouteGuard(route, state, host, router);
173
+ };
174
+ }
101
175
  /**
102
- * Provide a {@link ScopedRequirementsClientSet} at the current route/module scope.
103
- *
104
- * Internally, the provider resolves the effective client set by composing the
105
- * declared `scopedSet` with the parent scope's already-resolved options
106
- * (injected via `skipSelf: true`). The token value stored in DI is the
107
- * resolved `readonly AuthGuardClientOption[]`, not the raw declaration.
108
- *
109
- * This mirrors the React `AuthRequirementsClientSetProvider` semantics:
110
- * each level stores its resolved effective options, so children inherit
111
- * the composed result without re-resolving the full ancestor chain.
112
- *
113
- * The `scopedSet` argument may also be a **factory function** — it will be
114
- * called inside Angular's `useFactory` context, so `inject()` is valid.
115
- * Use this when the requirement options need DI-resolved services (e.g. a
116
- * token-set registry or a redirect service).
117
- *
118
- * @example
119
- * ```ts
120
- * // routes.ts — static declaration (no DI needed)
121
- * {
122
- * path: "",
123
- * providers: [
124
- * provideRouteScopedRequirements({
125
- * composition: RequirementsClientSetComposition.Replace,
126
- * options: [sessionClientOption],
127
- * }),
128
- * ],
129
- * }
130
- *
131
- * // Feature route merges OIDC on top of the parent's session requirement
132
- * {
133
- * path: "confluence",
134
- * providers: [
135
- * provideRouteScopedRequirements({
136
- * composition: RequirementsClientSetComposition.Merge,
137
- * options: [confluenceOidcClientOption],
138
- * }),
139
- * ],
140
- * // Route-level DI scope only. Angular Router adopters should prefer
141
- * // secureRouteRoot()/secureRoute() over direct guard wiring.
142
- * }
143
- *
144
- * // Factory form — use when options depend on injected services
145
- * {
146
- * path: "protected",
147
- * providers: [
148
- * provideRouteScopedRequirements(() => {
149
- * const registry = inject(TokenSetAuthRegistry);
150
- * const authService = inject(AuthService);
151
- * return {
152
- * composition: RequirementsClientSetComposition.Replace,
153
- * options: [{
154
- * requirementId: "oidc",
155
- * requirementKind: "frontend_oidc",
156
- * checkAuthenticated: () => registry.require(clientKey).isAuthenticated(),
157
- * onUnauthenticated: () => { authService.redirectToLogin(clientKey).subscribe(); return false; },
158
- * }],
159
- * };
160
- * }),
161
- * ],
162
- * }
163
- * ```
176
+ * Create a `CanActivateChildFn` bridging the child route chain to the
177
+ * requirement planner pipeline.
164
178
  */
165
- function provideRouteScopedRequirements(scopedSet) {
166
- return makeEnvironmentProviders([
167
- {
168
- provide: AUTH_REQUIREMENTS_CLIENT_SET,
169
- useFactory: () => {
170
- // Walk up to the nearest parent scope's already-resolved effective set.
171
- // skipSelf: true ensures we don't read our own (not-yet-set) value.
172
- const parentOptions = inject(AUTH_REQUIREMENTS_CLIENT_SET, {
173
- optional: true,
174
- skipSelf: true,
175
- }) ?? [];
176
- // Support factory form so callers can use inject() to resolve
177
- // DI services (e.g. auth registry, redirect service) inside their
178
- // option callbacks without bypassing this helper.
179
- const resolved = typeof scopedSet === "function" ? scopedSet() : scopedSet;
180
- return resolveEffectiveClientSet(parentOptions, resolved);
181
- },
182
- },
183
- ]);
179
+ function createAngularCanActivateChild(options) {
180
+ return (childRoute, state) => {
181
+ const injector = inject(SECURITYDEPT_INJECTOR, {
182
+ optional: true,
183
+ });
184
+ const parentHost = injector
185
+ ? injectRequirementPlannerHost({ injector })
186
+ : null;
187
+ const host = options?.plannerHost ??
188
+ (options?.behaviour
189
+ ? RequirementPlannerHost.fromBehaviour(typeof options.behaviour === "function"
190
+ ? options.behaviour()
191
+ : options.behaviour, {
192
+ parent: parentHost ?? undefined,
193
+ environment: injector?.get(ENVIRONMENT_TOKEN, null) ?? undefined,
194
+ })
195
+ : parentHost) ??
196
+ RequirementPlannerHost.fromBehaviour({});
197
+ const router = inject(Router);
198
+ return runRouteGuard(childRoute, state, host, router);
199
+ };
184
200
  }
185
201
 
186
- // Angular Router route adapter for auth-coordination
202
+ // Angular secure route helpers — declarative route assembly
187
203
  //
188
204
  // Canonical import path:
189
- // import { ... } from "@securitydept/client-angular"
205
+ // import { secureRoute, secureRouteRoot } from "@securitydept/client-angular"
206
+ //
207
+ // Two composable helpers for wiring auth requirements onto Angular routes:
208
+ // - secureRoute: writes requirement metadata into route `data` ONLY.
209
+ // - secureRouteRoot: writes metadata AND attaches the canActivate /
210
+ // canActivateChild guards (and, optionally, a scoped
211
+ // RequirementPlannerHost provider).
190
212
  //
191
- // Provides the projection layer between Angular Router's ActivatedRouteSnapshot
192
- // tree and the SDK's framework-agnostic RouteMatchNode[] contract.
193
- // Uses real @angular/router types (ActivatedRouteSnapshot) so consuming apps
194
- // get full type-safety and IDE autocomplete without any casting.
213
+ // The split lets a single guarded root route cover a whole subtree: child
214
+ // segments declare their own requirements via `secureRoute`, and the root's
215
+ // canActivateChild folds the full chain through the planner.
195
216
  //
196
217
  // Architecture boundary:
197
- // - Does NOT own the Angular router, guards, or navigation lifecycle.
198
- // - Does NOT re-export core orchestration primitives.
199
- // - Does NOT carry token-set-specific mapping or policy.
200
- // - The adopter wires this into their guards / resolvers.
218
+ // - Does NOT own behaviour or token-set-specific policy.
219
+ // - Delegates enforcement to createAngularCanActivate/Child.
201
220
  //
202
221
  // Stability: provisional
203
- /** Default key for auth requirements in Angular route `data`. */
204
- const DEFAULT_ROUTE_REQUIREMENTS_KEY = "authRequirements";
205
- // ---------------------------------------------------------------------------
206
- // Service
207
- // ---------------------------------------------------------------------------
208
- /**
209
- * Angular service that bridges Angular Router's `ActivatedRouteSnapshot` to
210
- * the SDK's framework-agnostic `RouteMatchNode[]` contract.
211
- *
212
- * Inject this into `CanActivateFn` guards or class-based guards.
213
- *
214
- * @example
215
- * ```ts
216
- * export const authGuard: CanActivateFn = (route) => {
217
- * const adapter = inject(AuthRouteAdapter);
218
- * const result = adapter.check(route, orchestrator);
219
- * if (!result.canActivate && result.pendingRequirement) {
220
- * // Trigger auth flow for the pending requirement
221
- * }
222
- * return result.canActivate;
223
- * };
224
- * ```
225
- */
226
- class AuthRouteAdapter {
227
- /**
228
- * Project the `pathFromRoot` of an `ActivatedRouteSnapshot` into the SDK's
229
- * `RouteMatchNode[]` chain.
230
- *
231
- * Route segments without a `routeConfig` (e.g. the root segment) are
232
- * assigned routeId `"__root__"`. Segments with a config use the `path`
233
- * as their routeId (or `"__index__"` for empty-path segments).
234
- */
235
- projectRouteMatch(leafRoute, options) {
236
- const key = options?.requirementsKey ?? DEFAULT_ROUTE_REQUIREMENTS_KEY;
237
- const chain = leafRoute.pathFromRoot ?? [leafRoute];
238
- return chain.map((segment) => this.segmentToNode(segment, key));
239
- }
240
- /**
241
- * Project an explicit array of `ActivatedRouteSnapshot` segments into the
242
- * SDK's `RouteMatchNode[]` chain.
243
- *
244
- * Use when you already have a flattened chain (e.g. from
245
- * `ActivatedRoute.pathFromRoot`).
246
- */
247
- projectRouteSegments(segments, options) {
248
- const key = options?.requirementsKey ?? DEFAULT_ROUTE_REQUIREMENTS_KEY;
249
- return segments.map((segment) => this.segmentToNode(segment, key));
222
+ function writeRequirementData(security, base) {
223
+ const patch = {};
224
+ if (security.requirements !== undefined) {
225
+ patch.requirements = security.requirements;
250
226
  }
251
- /**
252
- * Project the route snapshot and check whether all requirements are settled.
253
- *
254
- * @param leafRoute - The leaf `ActivatedRouteSnapshot` from the guard call.
255
- * @param orchestrator - The SDK orchestrator managing the requirement plan.
256
- * @param options - Optional projection options.
257
- */
258
- check(leafRoute, orchestrator, options) {
259
- const chain = this.projectRouteMatch(leafRoute, options);
260
- orchestrator.activateMatchedRoutes(chain);
261
- const snap = orchestrator.snapshot();
262
- return {
263
- canActivate: snap.settled,
264
- pendingRequirement: snap.pendingRequirement,
265
- };
227
+ if (security.composition !== undefined) {
228
+ patch.composition = security.composition;
266
229
  }
267
- segmentToNode(segment, key) {
268
- const routeId = this.resolveRouteId(segment);
269
- const rawRequirements = segment.data[key] ?? segment.routeConfig?.data?.[key];
270
- const requirements = Array.isArray(rawRequirements)
271
- ? rawRequirements
272
- : [];
273
- return { routeId, requirements };
230
+ if (Object.keys(patch).length === 0) {
231
+ return { ...(base ?? {}) };
274
232
  }
275
- resolveRouteId(segment) {
276
- if (!segment.routeConfig)
277
- return "__root__";
278
- const path = segment.routeConfig.path;
279
- if (path === undefined || path === null || path === "")
280
- return "__index__";
281
- return path;
233
+ return writeSecuritydeptRouteMetadata(base, patch);
234
+ }
235
+ /**
236
+ * Declare a secure route segment: writes requirement metadata into route
237
+ * `data` only. No guard is attached — a guarded ancestor (see
238
+ * {@link secureRouteRoot}) enforces the folded chain.
239
+ */
240
+ function secureRoute(path, security, routeOptions) {
241
+ return {
242
+ ...routeOptions,
243
+ path,
244
+ data: writeRequirementData(security, routeOptions?.data),
245
+ };
246
+ }
247
+ /**
248
+ * Declare a guarded root route: writes requirement metadata AND attaches
249
+ * `canActivate` + `canActivateChild` guards bound to the requirement planner.
250
+ *
251
+ * When `security.behaviour` is set, a scoped {@link RequirementPlannerHost}
252
+ * provider is mounted so descendant guards inherit it through DI.
253
+ */
254
+ function secureRouteRoot(path, security, routeOptions) {
255
+ const guardOptions = security.plannerHost
256
+ ? { plannerHost: security.plannerHost }
257
+ : undefined;
258
+ const providers = security.behaviour
259
+ ? [
260
+ ...(routeOptions?.providers ?? []),
261
+ provideSecuritydept({
262
+ providers: [
263
+ provideRequirementPlannerHost(security.behaviour),
264
+ ],
265
+ }),
266
+ ]
267
+ : routeOptions?.providers;
268
+ const route = {
269
+ ...routeOptions,
270
+ path,
271
+ data: writeRequirementData(security, routeOptions?.data),
272
+ canActivate: [
273
+ ...(routeOptions?.canActivate ?? []),
274
+ createAngularCanActivate(guardOptions),
275
+ ],
276
+ canActivateChild: [
277
+ ...(routeOptions?.canActivateChild ?? []),
278
+ createAngularCanActivateChild(guardOptions),
279
+ ],
280
+ };
281
+ if (providers) {
282
+ route.providers = providers;
282
283
  }
283
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: AuthRouteAdapter, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
284
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: AuthRouteAdapter, providedIn: "root" });
284
+ return route;
285
285
  }
286
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: AuthRouteAdapter, decorators: [{
287
- type: Injectable,
288
- args: [{ providedIn: "root" }]
289
- }] });
290
286
 
291
- // Angular route metadata requirement helper
287
+ // Angular Router trait adapter
292
288
  //
293
289
  // Canonical import path:
294
- // import { withRouteRequirements, extractFullRouteRequirements, ... } from "@securitydept/client-angular"
290
+ // import { createRouterForAngular } from "@securitydept/client-angular"
295
291
  //
296
- // Provides the canonical route-metadata layer for Angular Router auth requirement
297
- // declaration. Requirements are stored in route `data` rather than in DI scope,
298
- // enabling full-route aggregation guards to collect the complete requirement set
299
- // from the entire path-from-root chain in a single pass.
292
+ // Projects an Angular `Router` (or any navigation-capable object) onto the
293
+ // framework-agnostic `RouterTrait` (currentUrl + navigate) from
294
+ // `@securitydept/client`. Adopters use this to give SDK primitives a way to
295
+ // read the current URL and start navigations without depending on Angular.
300
296
  //
301
297
  // Architecture boundary:
302
- // - Does NOT own auth enforcement or planner logic.
298
+ // - Does NOT own the Angular router, guards, or navigation lifecycle.
299
+ // - Does NOT read auth requirements or run planners (see route-guard.ts).
303
300
  // - Does NOT carry token-set-specific mapping or policy.
304
- // - Provides typed helpers for declaring and reading requirements from route data.
305
- // - framework-specific (Angular Router types) but requirement-system-agnostic.
306
- //
307
- // Relationship to provideRouteScopedRequirements():
308
- // - provideRouteScopedRequirements() is a lower-level DI-scope helper.
309
- // - This module is the canonical path for Angular Router auth requirement
310
- // declaration when full-route aggregation is needed.
311
- //
312
- // Key sentinel note:
313
- // DEFAULT_ROUTE_REQUIREMENTS_KEY is defined in router.ts (AuthRouteAdapter's
314
- // companion constant). ROUTE_REQUIREMENTS_DATA_KEY exported here is its
315
- // semantic alias — both hold the same value ("authRequirements").
316
- // Adopters may use either name; they are interchangeable.
317
301
  //
318
302
  // Stability: provisional
319
- // ---------------------------------------------------------------------------
320
- // Route data key
321
- // ---------------------------------------------------------------------------
303
+ const AngularRouterNavigationLikeSchema = type({
304
+ url: "string?",
305
+ navigateByUrl: "Function",
306
+ });
307
+ const ResolvedRouterForAngularCreateOptionsSchema = type({
308
+ router: AngularRouterNavigationLikeSchema,
309
+ currentUrl: UriReferenceStringSchema.or("null"),
310
+ });
322
311
  /**
323
- * Default key for auth requirements in Angular route `data`.
324
- *
325
- * Semantic alias for {@link DEFAULT_ROUTE_REQUIREMENTS_KEY} (defined in
326
- * `router.ts`). Both hold the same value (`"authRequirements"`).
327
- * Use either name — they are interchangeable.
328
- *
329
- * Set this key in `Route.data` to declare auth requirements for a route segment:
330
- * ```ts
331
- * { path: "dashboard", data: withRouteRequirements([{ id: "session", kind: "session" }]) }
332
- * ```
333
- *
334
- * The key is also read by {@link AuthRouteAdapter} when projecting snapshots.
335
- *
336
- * @see {@link withRouteRequirements}
337
- * @see {@link extractFullRouteRequirements}
338
- */
339
- const ROUTE_REQUIREMENTS_DATA_KEY = DEFAULT_ROUTE_REQUIREMENTS_KEY;
340
- /**
341
- * Default key for the route-level composition strategy in Angular route `data`.
342
- *
343
- * Used together with {@link ROUTE_REQUIREMENTS_DATA_KEY}. The default
344
- * composition is `merge` when the key is omitted.
345
- */
346
- const ROUTE_REQUIREMENTS_COMPOSITION_DATA_KEY = "authRequirementsComposition";
347
- // ---------------------------------------------------------------------------
348
- // Declaration helper
349
- // ---------------------------------------------------------------------------
350
- /**
351
- * Build an Angular route `data` object declaring auth requirements for a
352
- * single route segment.
353
- *
354
- * Use this as the `data` field in an Angular `Route` config. Nested routes
355
- * can declare their own requirements; a full-route aggregation guard will
356
- * walk the entire `pathFromRoot` chain to collect all requirements in one pass.
312
+ * Create a {@link RouterTrait} backed by an Angular `Router`.
357
313
  *
358
314
  * @example
359
315
  * ```ts
360
- * // Parent route declares base requirement
361
- * {
362
- * path: "app",
363
- * data: withRouteRequirements([{ id: "session", kind: "session" }]),
364
- * children: [
365
- * // Child route adds an additional requirement
366
- * {
367
- * path: "admin",
368
- * data: withRouteRequirements([{ id: "admin-oidc", kind: "backend_oidc" }]),
369
- * canActivate: [createTokenSetRouteAggregationGuard(...)],
370
- * },
371
- * ],
372
- * }
316
+ * const routerTrait = createRouterForAngular({
317
+ * router: inject(Router),
318
+ * location: window.location,
319
+ * });
373
320
  * ```
374
- *
375
- * @param requirements - One or more auth requirements for this route segment.
376
- * @param extra - Optional additional data properties to merge.
377
321
  */
378
- function withRouteRequirements(requirements, optionsOrExtra, maybeExtra) {
379
- const { composition, extra } = normalizeRequirementOptions(optionsOrExtra, maybeExtra);
322
+ function createRouterForAngular(options) {
323
+ const routerInput = options.router;
324
+ const resolvedCreateOptions = {
325
+ router: options.router,
326
+ get currentUrl() {
327
+ return Object.hasOwn(options, "currentUrl")
328
+ ? (options.currentUrl ?? null)
329
+ : (routerInput?.url ?? null);
330
+ },
331
+ };
332
+ validateTraitInput({
333
+ value: resolvedCreateOptions,
334
+ bundledSchema: ResolvedRouterForAngularCreateOptionsSchema,
335
+ validator: options.validators?.router,
336
+ onInvalid: (failure) => throwValidationClientError({
337
+ code: "client_angular.router.invalid_router_options",
338
+ source: "client-angular",
339
+ messagePrefix: "createRouterForAngular could not validate routerForAngularCreateOptions",
340
+ failure,
341
+ }),
342
+ });
343
+ const { router } = resolvedCreateOptions;
344
+ const nativeWebRouter = createRouterForNativeWeb(projectNativeWebRouterCreateOptions(options));
380
345
  return {
381
- ...extra,
382
- [ROUTE_REQUIREMENTS_DATA_KEY]: requirements,
383
- [ROUTE_REQUIREMENTS_COMPOSITION_DATA_KEY]: composition,
346
+ currentUrl() {
347
+ const currentUrl = resolvedCreateOptions.currentUrl;
348
+ return currentUrl == null ? null : UriReferenceString.parse(currentUrl);
349
+ },
350
+ async navigate(request) {
351
+ if (request.mode === RouterNavigationMode.External) {
352
+ if (!nativeWebRouter) {
353
+ throw new ClientError({
354
+ kind: ClientErrorKind.Configuration,
355
+ code: "client_angular.router.native_web_router_unavailable",
356
+ message: "Angular router cannot perform external navigation without a native web router",
357
+ source: "client-angular.router",
358
+ });
359
+ }
360
+ await nativeWebRouter.navigate(request);
361
+ return;
362
+ }
363
+ await router.navigateByUrl(request.url.toString(), {
364
+ replaceUrl: request.mode === RouterNavigationMode.Replace,
365
+ state: request.state,
366
+ });
367
+ },
384
368
  };
385
369
  }
386
- // ---------------------------------------------------------------------------
387
- // Aggregation helper
388
- // ---------------------------------------------------------------------------
389
- /**
390
- * Extract and flatten auth requirements from a complete Angular route chain.
391
- *
392
- * Walks all segments from root to leaf (from `leafRoute.pathFromRoot`)
393
- * and concatenates the declared {@link AuthRequirement} arrays in order.
394
- * Requirements from ancestor routes appear before requirements from
395
- * descendant routes. Duplicate `id` values are NOT de-duplicated here —
396
- * the caller (guard / enforcement layer) is responsible for deduplication
397
- * or merge logic.
398
- *
399
- * This is the extraction counter-part to {@link withRouteRequirements}.
400
- * A full-route aggregation guard calls this once and hands the complete
401
- * flat array to the planner / enforcement layer.
402
- *
403
- * @param leafRoute - The leaf `ActivatedRouteSnapshot` from the guard call.
404
- * @param requirementsKey - Override the route data key (default: `"authRequirements"`).
405
- */
406
- function extractFullRouteRequirements(leafRoute, requirementsKey = ROUTE_REQUIREMENTS_DATA_KEY, compositionKey = ROUTE_REQUIREMENTS_COMPOSITION_DATA_KEY) {
407
- const chain = leafRoute.pathFromRoot ?? [leafRoute];
408
- let result = [];
409
- for (const segment of chain) {
410
- const raw = segment.data[requirementsKey] ??
411
- segment.routeConfig?.data?.[requirementsKey];
412
- const composition = readRouteComposition(segment, compositionKey) ??
413
- RequirementsClientSetComposition.Merge;
414
- const requirements = Array.isArray(raw) ? raw : [];
415
- result = resolveEffectiveRequirements(result, {
416
- composition,
417
- requirements,
418
- });
419
- }
420
- return result;
370
+ function projectNativeWebRouterCreateOptions(options) {
371
+ return {
372
+ ...(Object.hasOwn(options, "navigation")
373
+ ? { navigation: options.navigation }
374
+ : {}),
375
+ ...(Object.hasOwn(options, "location")
376
+ ? { location: options.location }
377
+ : {}),
378
+ ...(Object.hasOwn(options, "history") ? { history: options.history } : {}),
379
+ ...(Object.hasOwn(options, "window") ? { window: options.window } : {}),
380
+ };
421
381
  }
422
- /**
423
- * Resolve the effective auth requirements by composing the parent chain with a
424
- * child route declaration.
425
- *
426
- * Composition semantics mirror `resolveEffectiveClientSet()`:
427
- * - `inherit` keeps the parent requirements unchanged
428
- * - `merge` appends child requirements and replaces same-id parent entries
429
- * - `replace` discards the parent chain and uses only the child's requirements
430
- */
431
- function resolveEffectiveRequirements(parentRequirements, child) {
432
- switch (child.composition) {
433
- case RequirementsClientSetComposition.Inherit:
434
- return [...parentRequirements];
435
- case RequirementsClientSetComposition.Merge: {
436
- const merged = new Map();
437
- for (const requirement of parentRequirements) {
438
- merged.set(requirement.id, requirement);
382
+
383
+ const BaseTransportForAngularCreateOptionsSchema = type({
384
+ httpClient: {
385
+ request: "Function",
386
+ },
387
+ baseUrl: "unknown",
388
+ });
389
+ function createBaseTransportForAngular(options) {
390
+ const resolvedCreateOptions = {
391
+ httpClient: options.httpClient,
392
+ baseUrl: options.baseUrl,
393
+ };
394
+ validateTraitInput({
395
+ value: resolvedCreateOptions,
396
+ bundledSchema: BaseTransportForAngularCreateOptionsSchema,
397
+ validator: options.validators,
398
+ onInvalid: (failure) => throwValidationClientError({
399
+ code: TransportErrorCode.InvalidAngularOptions,
400
+ source: "client-angular",
401
+ messagePrefix: "createBaseTransportForAngular could not validate transportForAngularCreateOptions",
402
+ failure,
403
+ }),
404
+ });
405
+ const { httpClient, baseUrl } = resolvedCreateOptions;
406
+ return {
407
+ async execute(request) {
408
+ const requestUrl = baseUrl
409
+ ? new URL(request.url, baseUrl).toString()
410
+ : request.url;
411
+ const requestOptions = {
412
+ headers: request.headers,
413
+ observe: "response",
414
+ responseType: "text",
415
+ ...(request.body !== undefined &&
416
+ request.method !== "GET" &&
417
+ request.method !== "HEAD"
418
+ ? {
419
+ body: typeof request.body === "string"
420
+ ? request.body
421
+ : JSON.stringify(request.body),
422
+ }
423
+ : {}),
424
+ };
425
+ try {
426
+ const responseSource = request.cancellationToken
427
+ ? httpClient
428
+ .request(request.method, requestUrl, requestOptions)
429
+ .pipe(takeUntil(from(request.cancellationToken)))
430
+ : httpClient.request(request.method, requestUrl, requestOptions);
431
+ const response = await firstValueFrom(responseSource);
432
+ try {
433
+ return angularResponseToHttpResponse(response);
434
+ }
435
+ catch (error) {
436
+ throw new ClientError({
437
+ kind: ClientErrorKind.Protocol,
438
+ message: "The HTTP response body could not be decoded",
439
+ code: TransportErrorCode.ResponseDecodeFailed,
440
+ source: ClientErrorSource.Transport,
441
+ cause: error,
442
+ });
443
+ }
439
444
  }
440
- for (const requirement of child.requirements) {
441
- merged.set(requirement.id, requirement);
445
+ catch (error) {
446
+ if (error instanceof HttpErrorResponse && error.status !== 0) {
447
+ try {
448
+ return angularErrorResponseToHttpResponse(error);
449
+ }
450
+ catch (decodeError) {
451
+ throw new ClientError({
452
+ kind: ClientErrorKind.Protocol,
453
+ message: "The HTTP response body could not be decoded",
454
+ code: TransportErrorCode.ResponseDecodeFailed,
455
+ source: ClientErrorSource.Transport,
456
+ cause: decodeError,
457
+ });
458
+ }
459
+ }
460
+ const normalized = normalizeAngularTransportError(request.cancellationToken, error);
461
+ if (normalized instanceof ClientError) {
462
+ throw normalized;
463
+ }
464
+ throw new ClientError({
465
+ kind: ClientErrorKind.Transport,
466
+ message: "The HTTP request could not reach the remote service",
467
+ code: TransportErrorCode.RequestFailed,
468
+ source: ClientErrorSource.Transport,
469
+ retryable: true,
470
+ cause: error,
471
+ });
442
472
  }
443
- return [...merged.values()];
444
- }
445
- case RequirementsClientSetComposition.Replace:
446
- return [...child.requirements];
447
- default: {
448
- const _exhaustive = child.composition;
449
- throw new Error(`[resolveEffectiveRequirements] Unknown composition: ${_exhaustive}`);
450
- }
451
- }
473
+ },
474
+ };
452
475
  }
453
- function normalizeRequirementOptions(optionsOrExtra, maybeExtra) {
454
- if (optionsOrExtra &&
455
- ("composition" in optionsOrExtra || "extra" in optionsOrExtra)) {
456
- const options = optionsOrExtra;
457
- return {
458
- composition: options.composition ?? RequirementsClientSetComposition.Merge,
459
- extra: maybeExtra ?? options.extra,
460
- };
461
- }
476
+ function angularResponseToHttpResponse(response) {
477
+ return {
478
+ status: response.status,
479
+ headers: angularHeadersToRecord(response.headers),
480
+ body: parseAngularResponseBody(response.status, response.headers, response.body),
481
+ };
482
+ }
483
+ function angularErrorResponseToHttpResponse(error) {
462
484
  return {
463
- composition: RequirementsClientSetComposition.Merge,
464
- extra: optionsOrExtra,
485
+ status: error.status,
486
+ headers: angularHeadersToRecord(error.headers),
487
+ body: parseAngularResponseBody(error.status, error.headers, error.error),
465
488
  };
466
489
  }
467
- function readRouteComposition(segment, compositionKey) {
468
- const raw = segment.data[compositionKey] ?? segment.routeConfig?.data?.[compositionKey];
469
- if (raw === RequirementsClientSetComposition.Inherit ||
470
- raw === RequirementsClientSetComposition.Merge ||
471
- raw === RequirementsClientSetComposition.Replace) {
472
- return raw;
490
+ function angularHeadersToRecord(headers) {
491
+ const responseHeaders = {};
492
+ for (const key of headers.keys()) {
493
+ const value = headers.get(key);
494
+ if (value !== null) {
495
+ responseHeaders[key] = value;
496
+ }
497
+ }
498
+ return responseHeaders;
499
+ }
500
+ function parseAngularResponseBody(status, headers, body) {
501
+ const contentType = headers.get("content-type");
502
+ if (contentType?.includes("application/json") && typeof body === "string") {
503
+ return body.length > 0 ? JSON.parse(body) : undefined;
504
+ }
505
+ if (status !== 302 && status !== 303 && status !== 301) {
506
+ return body;
473
507
  }
474
508
  return undefined;
475
509
  }
510
+ function normalizeAngularTransportError(token, error) {
511
+ if (!token?.isCancellationRequested) {
512
+ return error;
513
+ }
514
+ return token.cancellationError ?? error;
515
+ }
476
516
 
477
- // Angular signal / Observable bridge utilities
478
- //
479
- // Canonical import path:
480
- // import { bridgeToAngularSignal, signalToObservable } from "@securitydept/client-angular"
481
- //
482
- // These utilities convert SDK-native ReadableSignalTrait values to Angular-native
483
- // primitives (WritableSignal, Observable) so that framework adapter packages
484
- // can surface reactive state using idiomatic Angular APIs.
485
- //
486
- // Owner: @securitydept/client-angular — these are generic framework bridges,
487
- // not token-set-specific utilities. All Angular adapters (basic-auth, session,
488
- // token-set, …) should import from here instead of implementing their own.
489
- //
490
- // Stability: provisional (framework adapter)
491
- /**
492
- * Bridge an SDK `ReadableSignalTrait` to an Angular writable signal.
493
- *
494
- * Immediately syncs the current value, then subscribes to future changes.
495
- *
496
- * @returns Cleanup function that unsubscribes from the SDK signal.
497
- */
498
- function bridgeToAngularSignal(source, target) {
499
- target.set(source.get());
500
- return source.subscribe(() => {
501
- target.set(source.get());
517
+ const ENVIRONMENT = new InjectionToken("ENVIRONMENT");
518
+ const ENVIRONMENT_PROVIDER = new InjectionToken("ENVIRONMENT_PROVIDER");
519
+ function createEnvironmentForAngular(options) {
520
+ const { createBaseEnvironment, routerForAngularCreateOptions, transportForAngularCreateOptions, injectorForAngularProvider, providers = [], ...baseOptions } = options;
521
+ const externalProviderTokens = new Set(providers.map((provider) => getSecuritydeptProviderToken(provider)));
522
+ const angularProviders = [
523
+ createProviderIfTokenMissing(externalProviderTokens, ROUTER_TRAIT_TOKEN, () => ({
524
+ provide: ROUTER_TRAIT_TOKEN,
525
+ useValue: createRouterForAngular(routerForAngularCreateOptions),
526
+ })),
527
+ createProviderIfTokenMissing(externalProviderTokens, TRANSPORT_TRAIT_TOKEN, () => ({
528
+ provide: TRANSPORT_TRAIT_TOKEN,
529
+ useValue: createBaseTransportForAngular(transportForAngularCreateOptions),
530
+ })),
531
+ injectorForAngularProvider
532
+ ? createProviderIfTokenMissing(externalProviderTokens, Injector, () => ({
533
+ provide: Injector,
534
+ useValue: injectorForAngularProvider,
535
+ }))
536
+ : null,
537
+ ].filter(notMissingProvider);
538
+ return createBaseEnvironment({
539
+ ...baseOptions,
540
+ providers: [...angularProviders, ...providers],
502
541
  });
503
542
  }
504
- /**
505
- * Bridge an SDK `ReadableSignalTrait` to an RxJS Observable.
506
- *
507
- * The observable emits the current value immediately on subscribe, then
508
- * emits whenever the SDK signal value changes. Completes on unsubscribe.
509
- */
510
- function signalToObservable(source) {
511
- return new Observable((subscriber) => {
512
- subscriber.next(source.get());
513
- const unsubscribe = source.subscribe(() => {
514
- subscriber.next(source.get());
515
- });
516
- return () => unsubscribe();
517
- });
543
+ function provideEnvironment(options) {
544
+ return [
545
+ {
546
+ provide: ENVIRONMENT,
547
+ useFactory: (router, httpClient, injector, destroyRef, providers) => {
548
+ const { autoCreateDestroyRef, providers: optionProviders, ...environmentOptions } = options;
549
+ const securitydeptDestroyRef = autoCreateDestroyRef === false
550
+ ? null
551
+ : createSecuritydeptDestroyRef();
552
+ if (securitydeptDestroyRef) {
553
+ destroyRef.onDestroy(() => securitydeptDestroyRef.dispose());
554
+ }
555
+ const environmentProviders = [
556
+ ...(optionProviders ?? []),
557
+ ...(providers ?? []),
558
+ ...(securitydeptDestroyRef
559
+ ? [
560
+ {
561
+ provide: SecuritydeptDestroyRef,
562
+ useValue: securitydeptDestroyRef,
563
+ },
564
+ ]
565
+ : []),
566
+ ];
567
+ return createEnvironmentForAngular({
568
+ ...environmentOptions,
569
+ routerForAngularCreateOptions: {
570
+ router,
571
+ ...environmentOptions.routerForAngularCreateOptions,
572
+ },
573
+ transportForAngularCreateOptions: {
574
+ httpClient,
575
+ ...environmentOptions.transportForAngularCreateOptions,
576
+ },
577
+ injectorForAngularProvider: injector,
578
+ providers: environmentProviders,
579
+ });
580
+ },
581
+ deps: [
582
+ Router,
583
+ HttpClient,
584
+ Injector,
585
+ DestroyRef,
586
+ [new Optional(), ENVIRONMENT_PROVIDER],
587
+ ],
588
+ },
589
+ {
590
+ provide: SECURITYDEPT_INJECTOR,
591
+ useFactory: (environment) => environment.injector,
592
+ deps: [ENVIRONMENT],
593
+ },
594
+ ];
595
+ }
596
+ function provideEnvironmentProvider(provider) {
597
+ return {
598
+ provide: ENVIRONMENT_PROVIDER,
599
+ useValue: provider,
600
+ multi: true,
601
+ };
602
+ }
603
+
604
+ function toNgSignal(source, options) {
605
+ return toSignal(source[SYMBOL_OBSERVABLE](), options);
518
606
  }
607
+ const toNgResource = rxResource;
519
608
 
520
609
  /*
521
610
  * Public API Surface of @securitydept/client-angular
@@ -525,5 +614,5 @@ function signalToObservable(source) {
525
614
  * Generated bundle index. Do not edit.
526
615
  */
527
616
 
528
- export { AUTH_PLANNER_HOST, AUTH_REQUIREMENTS_CLIENT_SET, AuthRouteAdapter, DEFAULT_ROUTE_REQUIREMENTS_KEY, ROUTE_REQUIREMENTS_COMPOSITION_DATA_KEY, ROUTE_REQUIREMENTS_DATA_KEY, bridgeToAngularSignal, extractFullRouteRequirements, injectPlannerHost, provideAuthPlannerHost, provideRouteScopedRequirements, resolveEffectiveRequirements, signalToObservable, withRouteRequirements };
617
+ export { AngularRouterNavigationLikeSchema, BaseTransportForAngularCreateOptionsSchema, ENVIRONMENT, ENVIRONMENT_PROVIDER, SECURITYDEPT_INJECTOR, createAngularCanActivate, createAngularCanActivateChild, createBaseTransportForAngular, createEnvironmentForAngular, createRouterForAngular, projectAngularRouteSegments, provideEnvironment, provideEnvironmentProvider, provideSecuritydept, routeDataFromSegment, secureRoute, secureRouteRoot, toNgResource, toNgSignal };
529
618
  //# sourceMappingURL=securitydept-client-angular.mjs.map