fix: chunk OAuth credentials for platform keyrings
This commit is contained in:
Vendored
+86
-11
@@ -737,7 +737,7 @@ async function serveBridge(options, tokenStore, discoveryStore, serviceToken = p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function createProxyServer(upstream) {
|
function createProxyServer(upstream) {
|
||||||
const server = new Server({ name: "crafttable-mcp-stdio-bridge", version: "0.1.1" }, {
|
const server = new Server({ name: "crafttable-mcp-stdio-bridge", version: "0.1.2" }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
resources: {}
|
resources: {}
|
||||||
@@ -752,44 +752,119 @@ function createProxyServer(upstream) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// src/credentials.ts
|
// src/credentials.ts
|
||||||
|
import { randomUUID as randomUUID2 } from "node:crypto";
|
||||||
var KEYRING_SERVICE = "CraftTable MCP";
|
var KEYRING_SERVICE = "CraftTable MCP";
|
||||||
|
var KEYRING_CHUNK_SIZE = 1e3;
|
||||||
var KeyringTokenStore = class {
|
var KeyringTokenStore = class {
|
||||||
|
constructor(entryFactory = keyringEntry) {
|
||||||
|
this.entryFactory = entryFactory;
|
||||||
|
}
|
||||||
|
entryFactory;
|
||||||
async get(account) {
|
async get(account) {
|
||||||
let serialized;
|
let serialized;
|
||||||
try {
|
try {
|
||||||
const entry = await keyringEntry(account);
|
serialized = await this.readPassword(account);
|
||||||
serialized = await entry.getPassword();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw keyringError(error);
|
throw keyringError(error);
|
||||||
}
|
}
|
||||||
if (!serialized) return void 0;
|
if (!serialized) return void 0;
|
||||||
|
const manifest = parseChunkManifest(serialized);
|
||||||
|
if (manifest) {
|
||||||
|
try {
|
||||||
|
const chunks = await Promise.all(Array.from({ length: manifest.chunks }, (_, index) => this.readPassword(chunkAccount(account, manifest.generation, index))));
|
||||||
|
if (chunks.some((chunk) => chunk === void 0)) throw new Error("missing credential chunk");
|
||||||
|
serialized = Buffer.from(chunks.join(""), "base64").toString("utf8");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "missing credential chunk") throw invalidCredentialError();
|
||||||
|
throw keyringError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(serialized);
|
const value = JSON.parse(serialized);
|
||||||
if (!value.access_token || !value.token_type) throw new Error("missing token fields");
|
if (!value.access_token || !value.token_type) throw new Error("missing token fields");
|
||||||
return value;
|
return value;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("The saved CraftTable MCP credential is invalid; run `crafttable-mcp logout --local-only` and log in again");
|
throw invalidCredentialError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async set(account, tokens) {
|
async set(account, tokens) {
|
||||||
|
const encoded = Buffer.from(JSON.stringify(tokens), "utf8").toString("base64");
|
||||||
|
const chunks = splitCredential(encoded);
|
||||||
|
const generation = randomUUID2().replaceAll("-", "");
|
||||||
|
const manifest = { version: 2, generation, chunks: chunks.length };
|
||||||
|
let previous;
|
||||||
|
const writtenAccounts = [];
|
||||||
try {
|
try {
|
||||||
const entry = await keyringEntry(account);
|
previous = await this.readPassword(account);
|
||||||
await entry.setPassword(JSON.stringify(tokens));
|
for (const [index, chunk] of chunks.entries()) {
|
||||||
|
const partAccount = chunkAccount(account, generation, index);
|
||||||
|
await (await this.entryFactory(partAccount)).setPassword(chunk);
|
||||||
|
writtenAccounts.push(partAccount);
|
||||||
|
}
|
||||||
|
await (await this.entryFactory(account)).setPassword(JSON.stringify(manifest));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await Promise.allSettled(writtenAccounts.map((partAccount) => this.deletePassword(partAccount)));
|
||||||
throw keyringError(error);
|
throw keyringError(error);
|
||||||
}
|
}
|
||||||
|
const previousManifest = previous && parseChunkManifest(previous);
|
||||||
|
if (previousManifest) {
|
||||||
|
await Promise.allSettled(Array.from({ length: previousManifest.chunks }, (_, index) => this.deletePassword(chunkAccount(account, previousManifest.generation, index))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async delete(account) {
|
async delete(account) {
|
||||||
try {
|
try {
|
||||||
const entry = await keyringEntry(account);
|
const serialized = await this.readPassword(account);
|
||||||
await entry.deleteCredential();
|
const manifest = serialized ? parseChunkManifest(serialized) : void 0;
|
||||||
|
await this.deletePassword(account);
|
||||||
|
if (manifest) {
|
||||||
|
await Promise.all(Array.from({ length: manifest.chunks }, (_, index) => this.deletePassword(chunkAccount(account, manifest.generation, index))));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = String(error?.message ?? error).toLowerCase();
|
|
||||||
if (message.includes("no entry") || message.includes("not found")) return;
|
|
||||||
throw keyringError(error);
|
throw keyringError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async readPassword(account) {
|
||||||
|
try {
|
||||||
|
return await (await this.entryFactory(account)).getPassword();
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingCredential(error)) return void 0;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async deletePassword(account) {
|
||||||
|
try {
|
||||||
|
await (await this.entryFactory(account)).deleteCredential();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMissingCredential(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
function splitCredential(value) {
|
||||||
|
const chunks = [];
|
||||||
|
for (let offset = 0; offset < value.length; offset += KEYRING_CHUNK_SIZE) {
|
||||||
|
chunks.push(value.slice(offset, offset + KEYRING_CHUNK_SIZE));
|
||||||
|
}
|
||||||
|
return chunks.length ? chunks : [""];
|
||||||
|
}
|
||||||
|
function chunkAccount(account, generation, index) {
|
||||||
|
return `${account}:v2:${generation}:${index}`;
|
||||||
|
}
|
||||||
|
function parseChunkManifest(value) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
if (parsed.version !== 2 || typeof parsed.generation !== "string" || !/^[0-9a-f]{32}$/.test(parsed.generation) || !Number.isInteger(parsed.chunks) || (parsed.chunks ?? 0) < 1 || (parsed.chunks ?? 0) > 100) return void 0;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return void 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function isMissingCredential(error) {
|
||||||
|
const message = String(error?.message ?? error).toLowerCase();
|
||||||
|
return message.includes("no entry") || message.includes("not found");
|
||||||
|
}
|
||||||
|
function invalidCredentialError() {
|
||||||
|
return new Error("The saved CraftTable MCP credential is invalid; run `crafttable-mcp logout --local-only` and log in again");
|
||||||
|
}
|
||||||
async function keyringEntry(account) {
|
async function keyringEntry(account) {
|
||||||
try {
|
try {
|
||||||
const { AsyncEntry } = await import("@napi-rs/keyring");
|
const { AsyncEntry } = await import("@napi-rs/keyring");
|
||||||
@@ -805,7 +880,7 @@ function keyringError(error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// src/cli.ts
|
// src/cli.ts
|
||||||
var program = new Command().name("crafttable-mcp").description("OAuth client and stdio bridge for CraftTable MCP").version("0.1.1");
|
var program = new Command().name("crafttable-mcp").description("OAuth client and stdio bridge for CraftTable MCP").version("0.1.2");
|
||||||
withConnection(program.command("login").description("Log in through the system browser")).option("--no-browser", "print the authorization URL instead of opening it").option("--timeout <milliseconds>", "OAuth callback timeout", "600000").action(async (flags) => {
|
withConnection(program.command("login").description("Log in through the system browser")).option("--no-browser", "print the authorization URL instead of opening it").option("--timeout <milliseconds>", "OAuth callback timeout", "600000").action(async (flags) => {
|
||||||
const options = connectionOptions(flags);
|
const options = connectionOptions(flags);
|
||||||
const timeoutMs = positiveInteger(flags.timeout, "OAuth callback timeout");
|
const timeoutMs = positiveInteger(flags.timeout, "OAuth callback timeout");
|
||||||
|
|||||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@game-crafttable/mcp-client",
|
"name": "@game-crafttable/mcp-client",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"description": "OAuth CLI and stdio bridge for the CraftTable MCP server",
|
"description": "OAuth CLI and stdio bridge for the CraftTable MCP server",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
+1
-1
@@ -45,7 +45,7 @@ export function createProxyServer(upstream: {
|
|||||||
listResourceTemplates: (params?: { cursor?: string }) => Promise<unknown>;
|
listResourceTemplates: (params?: { cursor?: string }) => Promise<unknown>;
|
||||||
readResource: (params: { uri: string }) => Promise<unknown>;
|
readResource: (params: { uri: string }) => Promise<unknown>;
|
||||||
}): Server {
|
}): Server {
|
||||||
const server = new Server({ name: "crafttable-mcp-stdio-bridge", version: "0.1.1" }, {
|
const server = new Server({ name: "crafttable-mcp-stdio-bridge", version: "0.1.2" }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
resources: {},
|
resources: {},
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ type ConnectionFlags = { url?: string; clientId?: string; callbackPort?: string
|
|||||||
const program = new Command()
|
const program = new Command()
|
||||||
.name("crafttable-mcp")
|
.name("crafttable-mcp")
|
||||||
.description("OAuth client and stdio bridge for CraftTable MCP")
|
.description("OAuth client and stdio bridge for CraftTable MCP")
|
||||||
.version("0.1.1");
|
.version("0.1.2");
|
||||||
|
|
||||||
withConnection(program.command("login").description("Log in through the system browser"))
|
withConnection(program.command("login").description("Log in through the system browser"))
|
||||||
.option("--no-browser", "print the authorization URL instead of opening it")
|
.option("--no-browser", "print the authorization URL instead of opening it")
|
||||||
|
|||||||
+111
-9
@@ -1,6 +1,22 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
|
||||||
export const KEYRING_SERVICE = "CraftTable MCP";
|
export const KEYRING_SERVICE = "CraftTable MCP";
|
||||||
|
const KEYRING_CHUNK_SIZE = 1000;
|
||||||
|
|
||||||
|
type CredentialEntry = {
|
||||||
|
getPassword(): Promise<string | undefined>;
|
||||||
|
setPassword(password: string): Promise<void>;
|
||||||
|
deleteCredential(): Promise<boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CredentialEntryFactory = (account: string) => Promise<CredentialEntry>;
|
||||||
|
|
||||||
|
type ChunkManifest = {
|
||||||
|
version: 2;
|
||||||
|
generation: string;
|
||||||
|
chunks: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface TokenStore {
|
export interface TokenStore {
|
||||||
get(account: string): Promise<OAuthTokens | undefined>;
|
get(account: string): Promise<OAuthTokens | undefined>;
|
||||||
@@ -9,43 +25,129 @@ export interface TokenStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class KeyringTokenStore implements TokenStore {
|
export class KeyringTokenStore implements TokenStore {
|
||||||
|
constructor(private readonly entryFactory: CredentialEntryFactory = keyringEntry) {}
|
||||||
|
|
||||||
async get(account: string): Promise<OAuthTokens | undefined> {
|
async get(account: string): Promise<OAuthTokens | undefined> {
|
||||||
let serialized: string | undefined;
|
let serialized: string | undefined;
|
||||||
try {
|
try {
|
||||||
const entry = await keyringEntry(account);
|
serialized = await this.readPassword(account);
|
||||||
serialized = await entry.getPassword();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw keyringError(error);
|
throw keyringError(error);
|
||||||
}
|
}
|
||||||
if (!serialized) return undefined;
|
if (!serialized) return undefined;
|
||||||
|
|
||||||
|
const manifest = parseChunkManifest(serialized);
|
||||||
|
if (manifest) {
|
||||||
|
try {
|
||||||
|
const chunks = await Promise.all(Array.from({ length: manifest.chunks }, (_, index) =>
|
||||||
|
this.readPassword(chunkAccount(account, manifest.generation, index))));
|
||||||
|
if (chunks.some((chunk) => chunk === undefined)) throw new Error("missing credential chunk");
|
||||||
|
serialized = Buffer.from(chunks.join(""), "base64").toString("utf8");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === "missing credential chunk") throw invalidCredentialError();
|
||||||
|
throw keyringError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(serialized) as OAuthTokens;
|
const value = JSON.parse(serialized) as OAuthTokens;
|
||||||
if (!value.access_token || !value.token_type) throw new Error("missing token fields");
|
if (!value.access_token || !value.token_type) throw new Error("missing token fields");
|
||||||
return value;
|
return value;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error("The saved CraftTable MCP credential is invalid; run `crafttable-mcp logout --local-only` and log in again");
|
throw invalidCredentialError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(account: string, tokens: OAuthTokens): Promise<void> {
|
async set(account: string, tokens: OAuthTokens): Promise<void> {
|
||||||
|
const encoded = Buffer.from(JSON.stringify(tokens), "utf8").toString("base64");
|
||||||
|
const chunks = splitCredential(encoded);
|
||||||
|
const generation = randomUUID().replaceAll("-", "");
|
||||||
|
const manifest: ChunkManifest = { version: 2, generation, chunks: chunks.length };
|
||||||
|
let previous: string | undefined;
|
||||||
|
const writtenAccounts: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entry = await keyringEntry(account);
|
previous = await this.readPassword(account);
|
||||||
await entry.setPassword(JSON.stringify(tokens));
|
for (const [index, chunk] of chunks.entries()) {
|
||||||
|
const partAccount = chunkAccount(account, generation, index);
|
||||||
|
await (await this.entryFactory(partAccount)).setPassword(chunk);
|
||||||
|
writtenAccounts.push(partAccount);
|
||||||
|
}
|
||||||
|
await (await this.entryFactory(account)).setPassword(JSON.stringify(manifest));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await Promise.allSettled(writtenAccounts.map((partAccount) => this.deletePassword(partAccount)));
|
||||||
throw keyringError(error);
|
throw keyringError(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previousManifest = previous && parseChunkManifest(previous);
|
||||||
|
if (previousManifest) {
|
||||||
|
await Promise.allSettled(Array.from({ length: previousManifest.chunks }, (_, index) =>
|
||||||
|
this.deletePassword(chunkAccount(account, previousManifest.generation, index))));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(account: string): Promise<void> {
|
async delete(account: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const entry = await keyringEntry(account);
|
const serialized = await this.readPassword(account);
|
||||||
await entry.deleteCredential();
|
const manifest = serialized ? parseChunkManifest(serialized) : undefined;
|
||||||
|
await this.deletePassword(account);
|
||||||
|
if (manifest) {
|
||||||
|
await Promise.all(Array.from({ length: manifest.chunks }, (_, index) =>
|
||||||
|
this.deletePassword(chunkAccount(account, manifest.generation, index))));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = String((error as Error)?.message ?? error).toLowerCase();
|
|
||||||
if (message.includes("no entry") || message.includes("not found")) return;
|
|
||||||
throw keyringError(error);
|
throw keyringError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async readPassword(account: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
return await (await this.entryFactory(account)).getPassword();
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingCredential(error)) return undefined;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deletePassword(account: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await (await this.entryFactory(account)).deleteCredential();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMissingCredential(error)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitCredential(value: string): string[] {
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let offset = 0; offset < value.length; offset += KEYRING_CHUNK_SIZE) {
|
||||||
|
chunks.push(value.slice(offset, offset + KEYRING_CHUNK_SIZE));
|
||||||
|
}
|
||||||
|
return chunks.length ? chunks : [""];
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunkAccount(account: string, generation: string, index: number): string {
|
||||||
|
return `${account}:v2:${generation}:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChunkManifest(value: string): ChunkManifest | undefined {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as Partial<ChunkManifest>;
|
||||||
|
if (parsed.version !== 2 || typeof parsed.generation !== "string" || !/^[0-9a-f]{32}$/.test(parsed.generation)
|
||||||
|
|| !Number.isInteger(parsed.chunks) || (parsed.chunks ?? 0) < 1 || (parsed.chunks ?? 0) > 100) return undefined;
|
||||||
|
return parsed as ChunkManifest;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingCredential(error: unknown): boolean {
|
||||||
|
const message = String((error as Error)?.message ?? error).toLowerCase();
|
||||||
|
return message.includes("no entry") || message.includes("not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidCredentialError(): Error {
|
||||||
|
return new Error("The saved CraftTable MCP credential is invalid; run `crafttable-mcp logout --local-only` and log in again");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function keyringEntry(account: string): Promise<import("@napi-rs/keyring").AsyncEntry> {
|
async function keyringEntry(account: string): Promise<import("@napi-rs/keyring").AsyncEntry> {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
|
import { KeyringTokenStore } from "../src/credentials.js";
|
||||||
|
|
||||||
|
class MemoryEntry {
|
||||||
|
constructor(private readonly values: Map<string, string>, private readonly account: string) {}
|
||||||
|
|
||||||
|
async getPassword(): Promise<string | undefined> {
|
||||||
|
return this.values.get(this.account);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPassword(password: string): Promise<void> {
|
||||||
|
if (password.length > 1000) throw new Error("credential too large");
|
||||||
|
this.values.set(this.account, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCredential(): Promise<boolean> {
|
||||||
|
return this.values.delete(this.account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryStore(values = new Map<string, string>()): KeyringTokenStore {
|
||||||
|
return new KeyringTokenStore(async (account) => new MemoryEntry(values, account));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("keyring token store chunks large OAuth credentials and removes every chunk", async () => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const store = memoryStore(values);
|
||||||
|
const tokens: OAuthTokens = {
|
||||||
|
access_token: "a".repeat(3400),
|
||||||
|
refresh_token: "r".repeat(2400),
|
||||||
|
token_type: "Bearer",
|
||||||
|
};
|
||||||
|
|
||||||
|
await store.set("account", tokens);
|
||||||
|
|
||||||
|
assert.ok(values.size > 2);
|
||||||
|
assert.deepEqual(await store.get("account"), tokens);
|
||||||
|
assert.ok([...values.values()].every((value) => value.length <= 1000));
|
||||||
|
|
||||||
|
await store.delete("account");
|
||||||
|
assert.equal(values.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keyring token store replaces rotated credentials and cleans old chunks", async () => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const store = memoryStore(values);
|
||||||
|
await store.set("account", { access_token: "a".repeat(3000), refresh_token: "old", token_type: "Bearer" });
|
||||||
|
const oldAccounts = [...values.keys()].filter((account) => account !== "account");
|
||||||
|
|
||||||
|
await store.set("account", { access_token: "b".repeat(3000), refresh_token: "new", token_type: "Bearer" });
|
||||||
|
|
||||||
|
assert.equal((await store.get("account"))?.refresh_token, "new");
|
||||||
|
assert.ok(oldAccounts.every((account) => !values.has(account)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keyring token store still reads and removes legacy single-entry credentials", async () => {
|
||||||
|
const tokens: OAuthTokens = { access_token: "legacy", token_type: "Bearer" };
|
||||||
|
const values = new Map([["account", JSON.stringify(tokens)]]);
|
||||||
|
const store = memoryStore(values);
|
||||||
|
|
||||||
|
assert.deepEqual(await store.get("account"), tokens);
|
||||||
|
await store.delete("account");
|
||||||
|
assert.equal(values.size, 0);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user