fix: 갱신 비용에 따라 접속 점수를 기록
revision 확인과 서버 event 기반 선택 갱신은 무가점으로 두고 PostgreSQL projection을 다시 만드는 초기·수동·복구 갱신만 1점을 기록한다. 인증 범위에 묶인 1회용 realtime 증표와 회귀 검증을 추가한다.
This commit is contained in:
@@ -15,6 +15,8 @@ const autoRefreshArtifactRoot = process.env.AUTO_REFRESH_ARTIFACT_DIR;
|
||||
const productionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production';
|
||||
const basePath = `/${(process.env.PLAYWRIGHT_GAME_BASE_PATH ?? 'che').replace(/^\/+|\/+$/g, '')}`;
|
||||
const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default';
|
||||
const realtimeAccessGrantHeader = 'x-sammo-realtime-access-grant';
|
||||
const fixtureRealtimeAccessGrant = 'fixture-realtime-grant';
|
||||
const operationNames = (route: Route) =>
|
||||
decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(',');
|
||||
|
||||
@@ -63,6 +65,7 @@ type NavigationFixture = {
|
||||
boardAccessKind: string | null;
|
||||
}>;
|
||||
dashboardRequests?: DashboardBundleInput[];
|
||||
dashboardGrantHeaders?: Array<string | null>;
|
||||
};
|
||||
|
||||
type JsonPatchOperation = {
|
||||
@@ -384,7 +387,13 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
|
||||
}
|
||||
if (operation === 'dashboard.getContextBundleDelta') {
|
||||
state.generalMeCalls += 1;
|
||||
if (state.accessLimitAfterCalls !== undefined && state.generalMeCalls > state.accessLimitAfterCalls) {
|
||||
const refreshGrant = route.request().headers()[realtimeAccessGrantHeader] ?? null;
|
||||
(state.dashboardGrantHeaders ??= []).push(refreshGrant);
|
||||
if (
|
||||
state.accessLimitAfterCalls !== undefined &&
|
||||
state.generalMeCalls > state.accessLimitAfterCalls &&
|
||||
refreshGrant !== fixtureRealtimeAccessGrant
|
||||
) {
|
||||
return errorResponse(
|
||||
operation,
|
||||
'접속 제한중입니다. 1턴 이내에 너무 많은 갱신을 하셨습니다. ' +
|
||||
@@ -588,7 +597,15 @@ const installRealtimeHarness = async (page: Page) => {
|
||||
configurable: true,
|
||||
value: (type: string, payload: unknown) => {
|
||||
TestEventSource.latest?.dispatchEvent(
|
||||
new MessageEvent(type, { data: JSON.stringify({ type, ...((payload as object) ?? {}) }) })
|
||||
new MessageEvent(type, {
|
||||
data: JSON.stringify({
|
||||
type,
|
||||
...(type === 'readModelInvalidated' || type === 'messagesInvalidated'
|
||||
? { refreshGrant: 'fixture-realtime-grant' }
|
||||
: {}),
|
||||
...((payload as object) ?? {}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -2387,6 +2404,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
.toBe(true);
|
||||
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
|
||||
await expect(page.locator('[data-navigation-id="tournament"]')).not.toHaveClass(/highlight/u);
|
||||
expect(state.dashboardGrantHeaders).toContain(null);
|
||||
|
||||
const operationsBeforeTournament = state.operations.length;
|
||||
state.stage = 1;
|
||||
@@ -2396,6 +2414,7 @@ test('realtime read-model events skip clock-only work, merge bursts, patch in pl
|
||||
.toEqual(['dashboard.getContextBundleDelta', 'tournament.getState']);
|
||||
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
|
||||
await expect(page.locator('[data-navigation-id="tournament"]')).toHaveClass(/highlight/u);
|
||||
expect(state.dashboardGrantHeaders?.at(-1)).toBe(fixtureRealtimeAccessGrant);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const general = document.querySelector('[data-main-target="general"]');
|
||||
@@ -2708,7 +2727,15 @@ test('access limit stops automatic main refresh and closes realtime until a manu
|
||||
.toBe(true);
|
||||
|
||||
const operationsBeforeLimit = state.operations.length;
|
||||
await emitReadModelInvalidation(page, readModelInvalidation({ records: true, map: true }));
|
||||
await page.evaluate(
|
||||
(invalidation) => {
|
||||
(window as unknown as { __emitMainRealtime: (type: string, payload: unknown) => void }).__emitMainRealtime(
|
||||
'readModelInvalidated',
|
||||
{ invalidation, refreshGrant: 'expired-grant' }
|
||||
);
|
||||
},
|
||||
readModelInvalidation({ records: true, map: true })
|
||||
);
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText('접속 제한중입니다.');
|
||||
await expect
|
||||
@@ -2731,6 +2758,7 @@ test('access limit stops automatic main refresh and closes realtime until a manu
|
||||
page.evaluate(() => (window as unknown as { __hasMainRealtime: () => boolean }).__hasMainRealtime())
|
||||
)
|
||||
.toBe(true);
|
||||
expect(state.dashboardGrantHeaders?.at(-1)).toBeNull();
|
||||
});
|
||||
|
||||
test('global activity, world history, and a month boundary refresh their visible main slices', async ({ page }) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../utils/dashboardReadModel';
|
||||
import { createBroadcastTabCoordinator, type BroadcastTabCoordinator } from '../utils/broadcastTabCoordinator';
|
||||
import { resolveWithReadModelSnapshotFallback } from '../utils/readModelDeltaRecovery';
|
||||
import { createRealtimeRequestOptions } from '../utils/realtimeAccessGrant';
|
||||
|
||||
const REALTIME_FULL_REFRESH_MIN_INTERVAL_MS = 5_000;
|
||||
|
||||
@@ -519,27 +520,32 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
|
||||
const fetchContextBundlePatch = async (
|
||||
include: DashboardContextBundleInclude,
|
||||
forceSnapshot = false
|
||||
forceSnapshot = false,
|
||||
refreshGrant?: string
|
||||
): Promise<DashboardReadModelPatch> => {
|
||||
const queryOptions = createRealtimeRequestOptions(refreshGrant);
|
||||
const request = (force: boolean) =>
|
||||
trpc.dashboard.getContextBundleDelta.query({
|
||||
include,
|
||||
known: force
|
||||
? undefined
|
||||
: {
|
||||
...(contextRevision ? { context: contextRevision } : {}),
|
||||
...(commandTableRevision ? { commandTable: commandTableRevision } : {}),
|
||||
...(boardAccessRevision ? { boardAccess: boardAccessRevision } : {}),
|
||||
},
|
||||
knownSource: force
|
||||
? undefined
|
||||
: {
|
||||
...(contextSourceRevision ? { context: contextSourceRevision } : {}),
|
||||
...(commandTableSourceRevision ? { commandTable: commandTableSourceRevision } : {}),
|
||||
...(boardAccessSourceRevision ? { boardAccess: boardAccessSourceRevision } : {}),
|
||||
},
|
||||
forceSnapshot: force || undefined,
|
||||
});
|
||||
trpc.dashboard.getContextBundleDelta.query(
|
||||
{
|
||||
include,
|
||||
known: force
|
||||
? undefined
|
||||
: {
|
||||
...(contextRevision ? { context: contextRevision } : {}),
|
||||
...(commandTableRevision ? { commandTable: commandTableRevision } : {}),
|
||||
...(boardAccessRevision ? { boardAccess: boardAccessRevision } : {}),
|
||||
},
|
||||
knownSource: force
|
||||
? undefined
|
||||
: {
|
||||
...(contextSourceRevision ? { context: contextSourceRevision } : {}),
|
||||
...(commandTableSourceRevision ? { commandTable: commandTableSourceRevision } : {}),
|
||||
...(boardAccessSourceRevision ? { boardAccess: boardAccessSourceRevision } : {}),
|
||||
},
|
||||
forceSnapshot: force || undefined,
|
||||
},
|
||||
queryOptions
|
||||
);
|
||||
|
||||
return resolveWithReadModelSnapshotFallback({
|
||||
request,
|
||||
@@ -674,7 +680,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
}
|
||||
);
|
||||
|
||||
const refreshChangedReadModels = async (plan: RealtimeReadModelInvalidation) => {
|
||||
const refreshChangedReadModels = async (plan: RealtimeReadModelInvalidation, refreshGrant: string) => {
|
||||
const id = generalId.value;
|
||||
if (!id) {
|
||||
return;
|
||||
@@ -688,37 +694,44 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
if (plan.records) recordsError.value = null;
|
||||
if (plan.frontStatus) frontStatusError.value = null;
|
||||
try {
|
||||
const queryOptions = createRealtimeRequestOptions(refreshGrant);
|
||||
// Every automatic refresh crosses this access-limit gate before
|
||||
// any selected follow-up query starts. An all-false bundle is an
|
||||
// access-only check and does not project general context.
|
||||
const contextPatch = await fetchContextBundlePatch(resolveDashboardContextBundleInclude(plan));
|
||||
const contextPatch = await fetchContextBundlePatch(
|
||||
resolveDashboardContextBundleInclude(plan),
|
||||
false,
|
||||
refreshGrant
|
||||
);
|
||||
accessLimited.value = false;
|
||||
const lobbyPromise = plan.lobby ? trpc.lobby.info.query() : Promise.resolve(undefined);
|
||||
const lobbyPromise = plan.lobby
|
||||
? trpc.lobby.info.query(undefined, queryOptions)
|
||||
: Promise.resolve(undefined);
|
||||
const mapPromise = plan.map
|
||||
? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true })
|
||||
? trpc.world.getMap.query({ generalId: id, showMe: true, useCache: true }, queryOptions)
|
||||
: Promise.resolve(undefined);
|
||||
const contactsPromise = plan.contacts
|
||||
? trpc.messages.getContacts.query({ generalId: id })
|
||||
? trpc.messages.getContacts.query({ generalId: id }, queryOptions)
|
||||
: Promise.resolve(undefined);
|
||||
const reservedPromise = plan.reservedTurns
|
||||
? trpc.turns.reserved.getGeneral.query({ generalId: id })
|
||||
? trpc.turns.reserved.getGeneral.query({ generalId: id }, queryOptions)
|
||||
: Promise.resolve(undefined);
|
||||
const recordsPromise = plan.records
|
||||
? trpc.general.getRecentRecords
|
||||
.query({ lastGeneralRecordId, lastWorldHistoryId })
|
||||
.query({ lastGeneralRecordId, lastWorldHistoryId }, queryOptions)
|
||||
.catch((err: unknown) => {
|
||||
recordsError.value = resolveErrorMessage(err);
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(undefined);
|
||||
const frontPromise = plan.frontStatus
|
||||
? trpc.general.getFrontStatus.query().catch((err: unknown) => {
|
||||
? trpc.general.getFrontStatus.query(undefined, queryOptions).catch((err: unknown) => {
|
||||
frontStatusError.value = resolveErrorMessage(err);
|
||||
return null;
|
||||
})
|
||||
: Promise.resolve(undefined);
|
||||
const tournamentPromise: Promise<TournamentState | undefined> = plan.tournament
|
||||
? trpc.tournament.getState.query().catch(() => undefined)
|
||||
? trpc.tournament.getState.query(undefined, queryOptions).catch(() => undefined)
|
||||
: Promise.resolve(undefined);
|
||||
|
||||
const [lobby, map, contacts, generalTurns, records, nextFrontStatus, tournamentState] = await Promise.all([
|
||||
@@ -761,13 +774,16 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
|
||||
const readModelRefreshQueue = createMergedReadModelRefreshQueue(refreshChangedReadModels);
|
||||
|
||||
const refreshMessages = async () => {
|
||||
const refreshMessages = async (refreshGrant?: string) => {
|
||||
const id = generalId.value;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nextMessages = await trpc.messages.getRecent.query({ generalId: id });
|
||||
const nextMessages = await trpc.messages.getRecent.query(
|
||||
{ generalId: id },
|
||||
createRealtimeRequestOptions(refreshGrant)
|
||||
);
|
||||
const patch = { messages: nextMessages } satisfies DashboardReadModelPatch;
|
||||
applyDashboardPatch(patch);
|
||||
publishDashboardPatch(patch);
|
||||
@@ -1145,7 +1161,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
if (!payload || payload.type !== 'readModelInvalidated') {
|
||||
return;
|
||||
}
|
||||
readModelRefreshQueue.request(payload.invalidation);
|
||||
readModelRefreshQueue.request(payload.invalidation, payload.refreshGrant);
|
||||
});
|
||||
source.addEventListener('messagesInvalidated', (event) => {
|
||||
if (realtimeCoordinator !== null && !realtimeCoordinator.isLeader()) return;
|
||||
@@ -1153,7 +1169,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => {
|
||||
if (!payload || payload.type !== 'messagesInvalidated') {
|
||||
return;
|
||||
}
|
||||
void refreshMessages();
|
||||
void refreshMessages(payload.refreshGrant);
|
||||
});
|
||||
|
||||
// Rolling deployment fallback: an older API may still expose internal
|
||||
|
||||
@@ -20,9 +20,7 @@ export const resolveDashboardRefreshPlan = (
|
||||
identity: DashboardReadModelIdentity
|
||||
): DashboardRefreshPlan => resolveRealtimeReadModelInvalidation(changes, identity);
|
||||
|
||||
export const resolveDashboardContextBundleInclude = (
|
||||
plan: DashboardRefreshPlan
|
||||
): DashboardContextBundleInclude => ({
|
||||
export const resolveDashboardContextBundleInclude = (plan: DashboardRefreshPlan): DashboardContextBundleInclude => ({
|
||||
context: plan.context,
|
||||
commandTable: plan.commands,
|
||||
boardAccess: plan.boardAccess,
|
||||
@@ -31,12 +29,12 @@ export const resolveDashboardContextBundleInclude = (
|
||||
type TimerHandle = ReturnType<typeof setTimeout>;
|
||||
|
||||
export interface MergedReadModelRefreshQueue {
|
||||
request(invalidation: RealtimeReadModelInvalidation): void;
|
||||
request(invalidation: RealtimeReadModelInvalidation, refreshGrant: string): void;
|
||||
cancelPending(): void;
|
||||
}
|
||||
|
||||
export const createMergedReadModelRefreshQueue = (
|
||||
refresh: (invalidation: RealtimeReadModelInvalidation) => Promise<void>,
|
||||
refresh: (invalidation: RealtimeReadModelInvalidation, refreshGrant: string) => Promise<void>,
|
||||
options: {
|
||||
minIntervalMs?: number;
|
||||
now?: () => number;
|
||||
@@ -49,6 +47,7 @@ export const createMergedReadModelRefreshQueue = (
|
||||
const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
||||
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
||||
let pending = createEmptyRealtimeReadModelInvalidation();
|
||||
let pendingRefreshGrant = '';
|
||||
let hasPending = false;
|
||||
let running = false;
|
||||
let timer: TimerHandle | null = null;
|
||||
@@ -65,11 +64,13 @@ export const createMergedReadModelRefreshQueue = (
|
||||
return;
|
||||
}
|
||||
const next = pending;
|
||||
const nextRefreshGrant = pendingRefreshGrant;
|
||||
pending = createEmptyRealtimeReadModelInvalidation();
|
||||
pendingRefreshGrant = '';
|
||||
hasPending = false;
|
||||
running = true;
|
||||
lastStartedAt = now();
|
||||
void refresh(next).finally(() => {
|
||||
void refresh(next, nextRefreshGrant).finally(() => {
|
||||
running = false;
|
||||
schedule();
|
||||
});
|
||||
@@ -77,14 +78,16 @@ export const createMergedReadModelRefreshQueue = (
|
||||
};
|
||||
|
||||
return {
|
||||
request: (invalidation) => {
|
||||
request: (invalidation, refreshGrant) => {
|
||||
pending = hasPending ? mergeRealtimeReadModelInvalidations(pending, invalidation) : invalidation;
|
||||
pendingRefreshGrant = refreshGrant;
|
||||
hasPending = true;
|
||||
schedule();
|
||||
},
|
||||
cancelPending: () => {
|
||||
hasPending = false;
|
||||
pending = createEmptyRealtimeReadModelInvalidation();
|
||||
pendingRefreshGrant = '';
|
||||
if (timer !== null) {
|
||||
clearTimer(timer);
|
||||
timer = null;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export const REALTIME_ACCESS_GRANT_CONTEXT_KEY = 'realtimeAccessGrant';
|
||||
|
||||
export const createRealtimeRequestOptions = (refreshGrant: string | null | undefined) =>
|
||||
refreshGrant
|
||||
? {
|
||||
context: {
|
||||
[REALTIME_ACCESS_GRANT_CONTEXT_KEY]: refreshGrant,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
export const resolveBatchRealtimeAccessGrant = (
|
||||
operations: ReadonlyArray<{ context: Record<string, unknown> }>
|
||||
): string | undefined => {
|
||||
if (operations.length === 0) return undefined;
|
||||
const first = operations[0]?.context[REALTIME_ACCESS_GRANT_CONTEXT_KEY];
|
||||
if (typeof first !== 'string' || first.length === 0) return undefined;
|
||||
return operations.every((operation) => operation.context[REALTIME_ACCESS_GRANT_CONTEXT_KEY] === first)
|
||||
? first
|
||||
: undefined;
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createTRPCProxyClient, httpBatchLink } from '@trpc/client';
|
||||
import type { AppRouter } from '@sammo-ts/game-api';
|
||||
import { REALTIME_ACCESS_GRANT_HEADER } from '@sammo-ts/common';
|
||||
import { resolveBatchRealtimeAccessGrant } from './realtimeAccessGrant';
|
||||
|
||||
const getGameToken = (): string | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -13,9 +15,13 @@ export const trpc = createTRPCProxyClient<AppRouter>({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: import.meta.env.VITE_GAME_API_URL ?? '/api/trpc',
|
||||
headers() {
|
||||
headers({ opList }) {
|
||||
const token = getGameToken();
|
||||
return token ? { authorization: `Bearer ${token}` } : {};
|
||||
const refreshGrant = resolveBatchRealtimeAccessGrant(opList);
|
||||
return {
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...(refreshGrant ? { [REALTIME_ACCESS_GRANT_HEADER]: refreshGrant } : {}),
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -207,10 +207,10 @@ void test('merges browser-safe boolean invalidations and starts at most once per
|
||||
let nowMs = 0;
|
||||
let nextTimerId = 1;
|
||||
const timers = new Map<number, { callback: () => void; at: number }>();
|
||||
const observed: Array<{ context: boolean; records: boolean }> = [];
|
||||
const observed: Array<{ context: boolean; records: boolean; refreshGrant: string }> = [];
|
||||
const queue = createMergedReadModelRefreshQueue(
|
||||
async (invalidation) => {
|
||||
observed.push({ context: invalidation.context, records: invalidation.records });
|
||||
async (invalidation, refreshGrant) => {
|
||||
observed.push({ context: invalidation.context, records: invalidation.records, refreshGrant });
|
||||
},
|
||||
{
|
||||
minIntervalMs: 1_000,
|
||||
@@ -232,13 +232,13 @@ void test('merges browser-safe boolean invalidations and starts at most once per
|
||||
}
|
||||
};
|
||||
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true });
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true }, 'grant-a');
|
||||
runDueTimers();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(observed, [{ context: true, records: false }]);
|
||||
assert.deepEqual(observed, [{ context: true, records: false, refreshGrant: 'grant-a' }]);
|
||||
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true });
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), records: true });
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), context: true }, 'grant-b');
|
||||
queue.request({ ...createEmptyRealtimeReadModelInvalidation(), records: true }, 'grant-c');
|
||||
nowMs = 999;
|
||||
runDueTimers();
|
||||
assert.equal(observed.length, 1);
|
||||
@@ -246,7 +246,7 @@ void test('merges browser-safe boolean invalidations and starts at most once per
|
||||
runDueTimers();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(observed, [
|
||||
{ context: true, records: false },
|
||||
{ context: true, records: true },
|
||||
{ context: true, records: false, refreshGrant: 'grant-a' },
|
||||
{ context: true, records: true, refreshGrant: 'grant-c' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
createRealtimeRequestOptions,
|
||||
REALTIME_ACCESS_GRANT_CONTEXT_KEY,
|
||||
resolveBatchRealtimeAccessGrant,
|
||||
} from '../src/utils/realtimeAccessGrant.ts';
|
||||
|
||||
void test('adds a realtime grant only to server-signaled request options', () => {
|
||||
assert.deepEqual(createRealtimeRequestOptions('grant-a'), {
|
||||
context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' },
|
||||
});
|
||||
assert.equal(createRealtimeRequestOptions(undefined), undefined);
|
||||
});
|
||||
|
||||
void test('sets a batch grant only when every operation carries the same proof', () => {
|
||||
assert.equal(
|
||||
resolveBatchRealtimeAccessGrant([
|
||||
{ context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' } },
|
||||
{ context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' } },
|
||||
]),
|
||||
'grant-a'
|
||||
);
|
||||
assert.equal(
|
||||
resolveBatchRealtimeAccessGrant([
|
||||
{ context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' } },
|
||||
{ context: {} },
|
||||
]),
|
||||
undefined
|
||||
);
|
||||
assert.equal(
|
||||
resolveBatchRealtimeAccessGrant([
|
||||
{ context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-a' } },
|
||||
{ context: { [REALTIME_ACCESS_GRANT_CONTEXT_KEY]: 'grant-b' } },
|
||||
]),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user