@chipmobilesdk/rn-auth 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +239 -113
- package/README.md +221 -190
- package/package.json +72 -54
- package/skills/sdk-rn-auth/SKILL.md +65 -63
- package/src/access/accessTier.ts +88 -88
- package/src/app-auth.ts +6 -0
- package/src/config/types.ts +154 -154
- package/src/config/validateConfig.ts +114 -108
- package/src/core/authMachine.ts +380 -364
- package/src/core/profile.ts +57 -60
- package/src/core/session.ts +105 -112
- package/src/diagnostics/errors.ts +99 -85
- package/src/diagnostics/messageKeys.ts +72 -61
- package/src/index.ts +72 -83
- package/src/keychain.ts +6 -0
- package/src/oidc/authClient.ts +236 -199
- package/src/oidc/classifyError.ts +76 -76
- package/src/oidc/socialRouting.ts +66 -73
- package/src/react/AuthProvider.tsx +30 -47
- package/src/react/hooks.ts +103 -91
- package/src/testing/conformance.ts +177 -0
- package/src/testing/index.ts +30 -0
- package/src/tokens/accessToken.ts +159 -164
- package/src/tokens/secureStore.ts +85 -52
package/src/core/profile.ts
CHANGED
|
@@ -1,60 +1,57 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Claims → Profile mapping (FR-006). Tolerant of Apple private-relay emails and
|
|
3
|
-
* missing display name / given name (edge cases).
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import type { Profile, ProviderId, ProviderIdentity } from '../config/types';
|
|
7
|
-
|
|
8
|
-
function asString(value: unknown): string | undefined {
|
|
9
|
-
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function looksLikeEmail(value: string | undefined): boolean {
|
|
13
|
-
return !!value && /.+@.+\..+/.test(value);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** Map id-token claims to the app-visible Profile. */
|
|
17
|
-
export function mapClaimsToProfile(
|
|
18
|
-
claims: Record<string, unknown>,
|
|
19
|
-
providerId?: ProviderId,
|
|
20
|
-
): Profile {
|
|
21
|
-
const tenantUserId =
|
|
22
|
-
asString(claims.oid) ?? asString(claims.sub) ?? asString(claims.unique_name) ?? 'unknown';
|
|
23
|
-
|
|
24
|
-
const givenName = asString(claims.given_name);
|
|
25
|
-
const familyName = asString(claims.family_name);
|
|
26
|
-
const composedName = [givenName, familyName].filter(Boolean).join(' ') || undefined;
|
|
27
|
-
const displayName = asString(claims.name) ?? composedName;
|
|
28
|
-
|
|
29
|
-
const preferred = asString(claims.preferred_username);
|
|
30
|
-
const email =
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
return { provider, subject, emailBehavior };
|
|
60
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Claims → Profile mapping (FR-006). Tolerant of Apple private-relay emails and
|
|
3
|
+
* missing display name / given name (edge cases).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Profile, ProviderId, ProviderIdentity } from '../config/types';
|
|
7
|
+
|
|
8
|
+
function asString(value: unknown): string | undefined {
|
|
9
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function looksLikeEmail(value: string | undefined): boolean {
|
|
13
|
+
return !!value && /.+@.+\..+/.test(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Map id-token claims to the app-visible Profile. */
|
|
17
|
+
export function mapClaimsToProfile(
|
|
18
|
+
claims: Record<string, unknown>,
|
|
19
|
+
providerId?: ProviderId,
|
|
20
|
+
): Profile {
|
|
21
|
+
const tenantUserId =
|
|
22
|
+
asString(claims.oid) ?? asString(claims.sub) ?? asString(claims.unique_name) ?? 'unknown';
|
|
23
|
+
|
|
24
|
+
const givenName = asString(claims.given_name);
|
|
25
|
+
const familyName = asString(claims.family_name);
|
|
26
|
+
const composedName = [givenName, familyName].filter(Boolean).join(' ') || undefined;
|
|
27
|
+
const displayName = asString(claims.name) ?? composedName;
|
|
28
|
+
|
|
29
|
+
const preferred = asString(claims.preferred_username);
|
|
30
|
+
const email = asString(claims.email) ?? (looksLikeEmail(preferred) ? preferred : undefined);
|
|
31
|
+
|
|
32
|
+
const provider =
|
|
33
|
+
providerId ?? asString(claims.idp) ?? asString(claims.identityProvider) ?? 'unknown';
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
tenantUserId,
|
|
37
|
+
displayName,
|
|
38
|
+
email,
|
|
39
|
+
provider,
|
|
40
|
+
claims,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Describe the external provider identity behind the profile (FR-004 edge cases). */
|
|
45
|
+
export function describeProviderIdentity(
|
|
46
|
+
claims: Record<string, unknown>,
|
|
47
|
+
providerId?: ProviderId,
|
|
48
|
+
): ProviderIdentity {
|
|
49
|
+
const provider = providerId ?? asString(claims.idp) ?? 'unknown';
|
|
50
|
+
const subject = asString(claims.sub) ?? asString(claims.oid) ?? 'unknown';
|
|
51
|
+
const email = asString(claims.email);
|
|
52
|
+
let emailBehavior: ProviderIdentity['emailBehavior'] = 'none';
|
|
53
|
+
if (email) {
|
|
54
|
+
emailBehavior = email.endsWith('@privaterelay.appleid.com') ? 'private-relay' : 'direct';
|
|
55
|
+
}
|
|
56
|
+
return { provider, subject, emailBehavior };
|
|
57
|
+
}
|
package/src/core/session.ts
CHANGED
|
@@ -1,112 +1,105 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Session restoration (FR-008, R5). Reads the persisted artifact, attempts a
|
|
3
|
-
* silent refresh, and classifies the outcome:
|
|
4
|
-
* - restored: fresh tokens + claims → authenticated
|
|
5
|
-
* - offline: the refresh could not complete for any reason other than the
|
|
6
|
-
* session being invalid — no network, a timeout, a 5xx, an error we
|
|
7
|
-
* cannot classify → cached profile read-only, tier unverified (fail
|
|
8
|
-
* closed) — clarify Q3
|
|
9
|
-
* - unavailable: no artifact, or the provider said the refresh token is no
|
|
10
|
-
* longer valid → signed-out reauth
|
|
11
|
-
*
|
|
12
|
-
* Only `unavailable` deletes the stored session, so it is reserved for a verdict
|
|
13
|
-
* the provider actually gave. Treating every unclassified error as expiry signed
|
|
14
|
-
* users out whenever the token endpoint had a bad moment.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} from '../
|
|
23
|
-
import
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
profile: persisted.profile,
|
|
107
|
-
accessTier: failClosedTier(),
|
|
108
|
-
refreshToken: persisted.refreshToken,
|
|
109
|
-
idToken: persisted.idToken,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Session restoration (FR-008, R5). Reads the persisted artifact, attempts a
|
|
3
|
+
* silent refresh, and classifies the outcome:
|
|
4
|
+
* - restored: fresh tokens + claims → authenticated
|
|
5
|
+
* - offline: the refresh could not complete for any reason other than the
|
|
6
|
+
* session being invalid — no network, a timeout, a 5xx, an error we
|
|
7
|
+
* cannot classify → cached profile read-only, tier unverified (fail
|
|
8
|
+
* closed) — clarify Q3
|
|
9
|
+
* - unavailable: no artifact, or the provider said the refresh token is no
|
|
10
|
+
* longer valid → signed-out reauth
|
|
11
|
+
*
|
|
12
|
+
* Only `unavailable` deletes the stored session, so it is reserved for a verdict
|
|
13
|
+
* the provider actually gave. Treating every unclassified error as expiry signed
|
|
14
|
+
* users out whenever the token endpoint had a bad moment.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { AccessTier, AccessTierMapping, Profile, RestoreStatus } from '../config/types';
|
|
18
|
+
import type { OidcAuthClient } from '../oidc/authClient';
|
|
19
|
+
import type { SecureTokenStore } from '../tokens/secureStore';
|
|
20
|
+
import { AUTH_STORAGE_KEY } from '../tokens/secureStore';
|
|
21
|
+
import { mapClaimsToProfile } from './profile';
|
|
22
|
+
import { evaluateAccessTier, failClosedTier } from '../access/accessTier';
|
|
23
|
+
import { isAuthError } from '../diagnostics/errors';
|
|
24
|
+
|
|
25
|
+
export interface PersistedSession {
|
|
26
|
+
refreshToken: string;
|
|
27
|
+
profile: Profile;
|
|
28
|
+
/** The id token hint for end-session (AUTH-04). Absent in artifacts written before 0.2.2. */
|
|
29
|
+
idToken?: string | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RestoreDeps {
|
|
33
|
+
secureStore: SecureTokenStore;
|
|
34
|
+
authority: string;
|
|
35
|
+
clientId: string;
|
|
36
|
+
redirectUrl: string;
|
|
37
|
+
scopes: string[];
|
|
38
|
+
authClient: OidcAuthClient;
|
|
39
|
+
mapping: AccessTierMapping;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RestoreResult {
|
|
43
|
+
outcome: RestoreStatus;
|
|
44
|
+
profile?: Profile;
|
|
45
|
+
accessTier?: AccessTier;
|
|
46
|
+
refreshToken?: string;
|
|
47
|
+
idToken?: string | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parsePersistedSession(raw: string | null): PersistedSession | null {
|
|
51
|
+
if (!raw) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(raw) as PersistedSession;
|
|
56
|
+
if (parsed && typeof parsed.refreshToken === 'string' && parsed.profile) {
|
|
57
|
+
return parsed;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function restoreSession(deps: RestoreDeps): Promise<RestoreResult> {
|
|
66
|
+
const raw = await deps.secureStore.getItem(AUTH_STORAGE_KEY);
|
|
67
|
+
const persisted = parsePersistedSession(raw);
|
|
68
|
+
if (!persisted) {
|
|
69
|
+
return { outcome: 'unavailable' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const tokens = await deps.authClient.refresh({
|
|
74
|
+
authority: deps.authority,
|
|
75
|
+
clientId: deps.clientId,
|
|
76
|
+
redirectUrl: deps.redirectUrl,
|
|
77
|
+
refreshToken: persisted.refreshToken,
|
|
78
|
+
scopes: deps.scopes,
|
|
79
|
+
});
|
|
80
|
+
// A refresh response may carry no id token, and so no claims. The stored
|
|
81
|
+
// profile is then still the best identity there is; an empty one is not.
|
|
82
|
+
const hasClaims = Object.keys(tokens.claims).length > 0;
|
|
83
|
+
return {
|
|
84
|
+
outcome: 'restored',
|
|
85
|
+
profile: hasClaims ? mapClaimsToProfile(tokens.claims) : persisted.profile,
|
|
86
|
+
accessTier: hasClaims ? evaluateAccessTier(tokens.claims, deps.mapping) : failClosedTier(),
|
|
87
|
+
refreshToken: tokens.refreshToken || persisted.refreshToken,
|
|
88
|
+
idToken: tokens.idToken || persisted.idToken,
|
|
89
|
+
};
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (isAuthError(error) && error.code === 'session-expired') {
|
|
92
|
+
// The provider rejected the refresh token: reauthentication is required.
|
|
93
|
+
return { outcome: 'unavailable' };
|
|
94
|
+
}
|
|
95
|
+
// Anything else is no verdict on the session. Keep the cached identity
|
|
96
|
+
// read-only and fail closed on entitlement; the next restore tries again.
|
|
97
|
+
return {
|
|
98
|
+
outcome: 'offline',
|
|
99
|
+
profile: persisted.profile,
|
|
100
|
+
accessTier: failClosedTier(),
|
|
101
|
+
refreshToken: persisted.refreshToken,
|
|
102
|
+
idToken: persisted.idToken,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -1,85 +1,99 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Developer-facing diagnostics. AuthError carries a stable machine-readable
|
|
3
|
-
* code, an audience (user vs developer/tenant), and a localizable message key.
|
|
4
|
-
* `detail` is always passed through `redact` so tokens and secrets never appear
|
|
5
|
-
* in logs or error objects (FR-014).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
| '
|
|
12
|
-
| '
|
|
13
|
-
| '
|
|
14
|
-
| '
|
|
15
|
-
| '
|
|
16
|
-
| '
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
super(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Developer-facing diagnostics. AuthError carries a stable machine-readable
|
|
3
|
+
* code, an audience (user vs developer/tenant), and a localizable message key.
|
|
4
|
+
* `detail` is always passed through `redact` so tokens and secrets never appear
|
|
5
|
+
* in logs or error objects (FR-014).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { COMMON_PATTERNS, SdkError, redactPatterns } from '@chipmobilesdk/rn-core';
|
|
9
|
+
|
|
10
|
+
export type AuthErrorCode =
|
|
11
|
+
| 'configuration-error'
|
|
12
|
+
| 'provider-cancelled'
|
|
13
|
+
| 'redirect-mismatch'
|
|
14
|
+
| 'token-acquisition-failed'
|
|
15
|
+
| 'session-expired'
|
|
16
|
+
| 'consent-denied'
|
|
17
|
+
| 'entitlement-unverified'
|
|
18
|
+
| 'network-unavailable';
|
|
19
|
+
|
|
20
|
+
export type AuthErrorAudience = 'user' | 'developer';
|
|
21
|
+
|
|
22
|
+
export interface AuthErrorArgs {
|
|
23
|
+
code: AuthErrorCode;
|
|
24
|
+
messageKey: string;
|
|
25
|
+
audience?: AuthErrorAudience;
|
|
26
|
+
detail?: string | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** JWT-shaped values: three base64url segments separated by dots. */
|
|
30
|
+
const JWT_PATTERN = /[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g;
|
|
31
|
+
/** Any other long opaque token-like run; shorter than rn-core's payload run on purpose. */
|
|
32
|
+
const TOKEN_PATTERN = /[A-Za-z0-9_-]{24,}/g;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Replace token-like substrings with a placeholder so diagnostics never leak
|
|
36
|
+
* secrets or token values (FR-014): JWTs become `[redacted-token]`; URLs, e-mail
|
|
37
|
+
* addresses, UUIDs and other opaque runs become `[redacted]` (rn-core's patterns).
|
|
38
|
+
*/
|
|
39
|
+
export function redact(value: string | undefined): string | undefined {
|
|
40
|
+
if (!value) {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
return redactPatterns(value.replace(JWT_PATTERN, '[redacted-token]'), [
|
|
44
|
+
...COMMON_PATTERNS,
|
|
45
|
+
TOKEN_PATTERN,
|
|
46
|
+
]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Retrying can only help when the network was the problem. */
|
|
50
|
+
const RETRYABLE: ReadonlySet<AuthErrorCode> = new Set<AuthErrorCode>(['network-unavailable']);
|
|
51
|
+
|
|
52
|
+
/** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-auth'`. */
|
|
53
|
+
export class AuthError extends SdkError<AuthErrorCode> {
|
|
54
|
+
readonly audience: AuthErrorAudience;
|
|
55
|
+
readonly messageKey: string;
|
|
56
|
+
readonly detail?: string | undefined;
|
|
57
|
+
|
|
58
|
+
constructor(args: AuthErrorArgs) {
|
|
59
|
+
const detail = redact(args.detail);
|
|
60
|
+
super('rn-auth', args.code, {
|
|
61
|
+
message: args.messageKey,
|
|
62
|
+
retryable: RETRYABLE.has(args.code),
|
|
63
|
+
redactedCause: detail,
|
|
64
|
+
});
|
|
65
|
+
this.name = 'AuthError';
|
|
66
|
+
this.audience = args.audience ?? 'developer';
|
|
67
|
+
this.messageKey = args.messageKey;
|
|
68
|
+
this.detail = detail;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class AuthConfigError extends AuthError {
|
|
73
|
+
constructor(messageKey: string, detail?: string) {
|
|
74
|
+
super({ code: 'configuration-error', messageKey, audience: 'developer', detail });
|
|
75
|
+
this.name = 'AuthConfigError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isAuthError(value: unknown): value is AuthError {
|
|
80
|
+
return value instanceof AuthError;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Normalize any thrown value into an AuthError without leaking secrets. */
|
|
84
|
+
export function toAuthError(
|
|
85
|
+
value: unknown,
|
|
86
|
+
fallbackCode: AuthErrorCode = 'token-acquisition-failed',
|
|
87
|
+
fallbackMessageKey = 'auth.error.token',
|
|
88
|
+
): AuthError {
|
|
89
|
+
if (isAuthError(value)) {
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
const message = value instanceof Error ? value.message : String(value);
|
|
93
|
+
return new AuthError({
|
|
94
|
+
code: fallbackCode,
|
|
95
|
+
messageKey: fallbackMessageKey,
|
|
96
|
+
audience: 'developer',
|
|
97
|
+
detail: message,
|
|
98
|
+
});
|
|
99
|
+
}
|