refactor(fingerprint): start taking fingerprint snapshots in new table

This commit is contained in:
Varun Narravula
2026-01-20 06:48:40 -08:00
committed by Owen Schwartz
parent adf3d0347b
commit 1f077d7ec2
11 changed files with 307 additions and 131 deletions

View File

@@ -780,7 +780,7 @@ export const olms = pgTable("olms", {
archived: boolean("archived").notNull().default(false)
});
export const fingerprints = pgTable("fingerprints", {
export const currentFingerprint = pgTable("currentFingerprint", {
fingerprintId: serial("id").primaryKey(),
olmId: text("olmId")
@@ -792,7 +792,7 @@ export const fingerprints = pgTable("fingerprints", {
username: text("username"),
hostname: text("hostname"),
platform: text("platform"), // macos | windows | linux | ios | android | unknown
platform: text("platform"),
osVersion: text("osVersion"),
kernelVersion: text("kernelVersion"),
arch: text("arch"),
@@ -801,6 +801,29 @@ export const fingerprints = pgTable("fingerprints", {
platformFingerprint: varchar("platformFingerprint")
});
export const fingerprintSnapshots = pgTable("fingerprintSnapshots", {
snapshotId: serial("id").primaryKey(),
fingerprintId: integer("fingerprintId")
.references(() => currentFingerprint.fingerprintId, {
onDelete: "cascade"
})
.notNull(),
username: text("username"),
hostname: text("hostname"),
platform: text("platform"),
osVersion: text("osVersion"),
kernelVersion: text("kernelVersion"),
arch: text("arch"),
deviceModel: text("deviceModel"),
serialNumber: text("serialNumber"),
platformFingerprint: varchar("platformFingerprint"),
hash: text("hash").notNull(),
collectedAt: integer("collectedAt").notNull()
});
export const olmSessions = pgTable("clientSession", {
sessionId: varchar("id").primaryKey(),
olmId: varchar("olmId")

View File

@@ -497,7 +497,7 @@ export const olms = sqliteTable("olms", {
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
});
export const fingerprints = sqliteTable("fingerprints", {
export const currentFingerprint = sqliteTable("currentFingerprint", {
fingerprintId: integer("id").primaryKey({ autoIncrement: true }),
olmId: text("olmId")
@@ -509,7 +509,7 @@ export const fingerprints = sqliteTable("fingerprints", {
username: text("username"),
hostname: text("hostname"),
platform: text("platform"), // macos | windows | linux | ios | android | unknown
platform: text("platform"),
osVersion: text("osVersion"),
kernelVersion: text("kernelVersion"),
arch: text("arch"),
@@ -518,6 +518,29 @@ export const fingerprints = sqliteTable("fingerprints", {
platformFingerprint: text("platformFingerprint")
});
export const fingerprintSnapshots = sqliteTable("fingerprintSnapshots", {
snapshotId: integer("id").primaryKey({ autoIncrement: true }),
fingerprintId: integer("fingerprintId")
.references(() => currentFingerprint.fingerprintId, {
onDelete: "cascade"
})
.notNull(),
username: text("username"),
hostname: text("hostname"),
platform: text("platform"),
osVersion: text("osVersion"),
kernelVersion: text("kernelVersion"),
arch: text("arch"),
deviceModel: text("deviceModel"),
serialNumber: text("serialNumber"),
platformFingerprint: text("platformFingerprint"),
hash: text("hash").notNull(),
collectedAt: integer("collectedAt").notNull()
});
export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", {
codeId: integer("id").primaryKey({ autoIncrement: true }),
userId: text("userId")

View File

@@ -21,7 +21,15 @@ import type { Request, Response, NextFunction } from "express";
import { build } from "@server/build";
import { getOrgTierData } from "@server/lib/billing";
import { TierId } from "@server/lib/billing/tiers";
import { approvals, clients, db, users, olms, fingerprints, type Approval } from "@server/db";
import {
approvals,
clients,
db,
users,
olms,
currentFingerprint,
type Approval
} from "@server/db";
import { eq, isNull, sql, not, and, desc } from "drizzle-orm";
import response from "@server/lib/response";
import { getUserDeviceName } from "@server/db/names";
@@ -92,14 +100,14 @@ async function queryApprovals(
},
clientName: clients.name,
niceId: clients.niceId,
deviceModel: fingerprints.deviceModel,
fingerprintPlatform: fingerprints.platform,
fingerprintOsVersion: fingerprints.osVersion,
fingerprintKernelVersion: fingerprints.kernelVersion,
fingerprintArch: fingerprints.arch,
fingerprintSerialNumber: fingerprints.serialNumber,
fingerprintUsername: fingerprints.username,
fingerprintHostname: fingerprints.hostname
deviceModel: currentFingerprint.deviceModel,
fingerprintPlatform: currentFingerprint.platform,
fingerprintOsVersion: currentFingerprint.osVersion,
fingerprintKernelVersion: currentFingerprint.kernelVersion,
fingerprintArch: currentFingerprint.arch,
fingerprintSerialNumber: currentFingerprint.serialNumber,
fingerprintUsername: currentFingerprint.username,
fingerprintHostname: currentFingerprint.hostname
})
.from(approvals)
.innerJoin(users, and(eq(approvals.userId, users.userId)))
@@ -111,7 +119,7 @@ async function queryApprovals(
)
)
.leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
.where(
and(
eq(approvals.orgId, orgId),
@@ -125,14 +133,14 @@ async function queryApprovals(
)
.limit(limit)
.offset(offset);
// Process results to format device names and build fingerprint objects
return res.map((approval) => {
const model = approval.deviceModel || null;
const deviceName = approval.clientName
const deviceName = approval.clientName
? getUserDeviceName(model, approval.clientName)
: null;
// Build fingerprint object if any fingerprint data exists
const hasFingerprintData =
approval.fingerprintPlatform ||
@@ -143,20 +151,20 @@ async function queryApprovals(
approval.fingerprintUsername ||
approval.fingerprintHostname ||
approval.deviceModel;
const fingerprint = hasFingerprintData
? {
platform: approval.fingerprintPlatform || null,
osVersion: approval.fingerprintOsVersion || null,
kernelVersion: approval.fingerprintKernelVersion || null,
arch: approval.fingerprintArch || null,
deviceModel: approval.deviceModel || null,
serialNumber: approval.fingerprintSerialNumber || null,
username: approval.fingerprintUsername || null,
hostname: approval.fingerprintHostname || null
}
platform: approval.fingerprintPlatform || null,
osVersion: approval.fingerprintOsVersion || null,
kernelVersion: approval.fingerprintKernelVersion || null,
arch: approval.fingerprintArch || null,
deviceModel: approval.deviceModel || null,
serialNumber: approval.fingerprintSerialNumber || null,
username: approval.fingerprintUsername || null,
hostname: approval.fingerprintHostname || null
}
: null;
const {
clientName,
deviceModel,
@@ -169,7 +177,7 @@ async function queryApprovals(
fingerprintHostname,
...rest
} = approval;
return {
...rest,
deviceName,

View File

@@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, olms } from "@server/db";
import { clients, fingerprints } from "@server/db";
import { clients, currentFingerprint } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -30,7 +30,10 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
.from(clients)
.where(eq(clients.clientId, clientId))
.leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
.leftJoin(
currentFingerprint,
eq(olms.olmId, currentFingerprint.olmId)
)
.limit(1);
return res;
} else if (niceId && orgId) {
@@ -39,7 +42,10 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
.from(clients)
.where(and(eq(clients.niceId, niceId), eq(clients.orgId, orgId)))
.leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
.leftJoin(
currentFingerprint,
eq(olms.olmId, currentFingerprint.olmId)
)
.limit(1);
return res;
}
@@ -125,24 +131,25 @@ export async function getClient(
// Replace name with device name if OLM exists
let clientName = client.clients.name;
if (client.olms) {
const model = client.fingerprints?.deviceModel || null;
const model = client.currentFingerprint?.deviceModel || null;
clientName = getUserDeviceName(model, client.clients.name);
}
// Build fingerprint data if available
const fingerprintData = client.fingerprints
const fingerprintData = client.currentFingerprint
? {
username: client.fingerprints.username || null,
hostname: client.fingerprints.hostname || null,
platform: client.fingerprints.platform || null,
osVersion: client.fingerprints.osVersion || null,
kernelVersion: client.fingerprints.kernelVersion || null,
arch: client.fingerprints.arch || null,
deviceModel: client.fingerprints.deviceModel || null,
serialNumber: client.fingerprints.serialNumber || null,
firstSeen: client.fingerprints.firstSeen || null,
lastSeen: client.fingerprints.lastSeen || null
}
username: client.currentFingerprint.username || null,
hostname: client.currentFingerprint.hostname || null,
platform: client.currentFingerprint.platform || null,
osVersion: client.currentFingerprint.osVersion || null,
kernelVersion:
client.currentFingerprint.kernelVersion || null,
arch: client.currentFingerprint.arch || null,
deviceModel: client.currentFingerprint.deviceModel || null,
serialNumber: client.currentFingerprint.serialNumber || null,
firstSeen: client.currentFingerprint.firstSeen || null,
lastSeen: client.currentFingerprint.lastSeen || null
}
: null;
const data: GetClientResponse = {

View File

@@ -6,7 +6,7 @@ import {
sites,
userClients,
clientSitesAssociationsCache,
fingerprints
currentFingerprint
} from "@server/db";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
@@ -143,20 +143,20 @@ function queryClients(
olmArchived: olms.archived,
archived: clients.archived,
blocked: clients.blocked,
deviceModel: fingerprints.deviceModel,
fingerprintPlatform: fingerprints.platform,
fingerprintOsVersion: fingerprints.osVersion,
fingerprintKernelVersion: fingerprints.kernelVersion,
fingerprintArch: fingerprints.arch,
fingerprintSerialNumber: fingerprints.serialNumber,
fingerprintUsername: fingerprints.username,
fingerprintHostname: fingerprints.hostname
deviceModel: currentFingerprint.deviceModel,
fingerprintPlatform: currentFingerprint.platform,
fingerprintOsVersion: currentFingerprint.osVersion,
fingerprintKernelVersion: currentFingerprint.kernelVersion,
fingerprintArch: currentFingerprint.arch,
fingerprintSerialNumber: currentFingerprint.serialNumber,
fingerprintUsername: currentFingerprint.username,
fingerprintHostname: currentFingerprint.hostname
})
.from(clients)
.leftJoin(orgs, eq(clients.orgId, orgs.orgId))
.leftJoin(olms, eq(clients.clientId, olms.clientId))
.leftJoin(users, eq(clients.userId, users.userId))
.leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
.leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId))
.where(and(...conditions));
}

View File

@@ -0,0 +1,132 @@
import { sha256 } from "@oslojs/crypto/sha2";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { currentFingerprint, db, fingerprintSnapshots, Olm } from "@server/db";
import { desc, eq } from "drizzle-orm";
function fingerprintHash(fp: any): string {
const canonical = {
username: fp.username ?? null,
hostname: fp.hostname ?? null,
platform: fp.platform ?? null,
osVersion: fp.osVersion ?? null,
kernelVersion: fp.kernelVersion ?? null,
arch: fp.arch ?? null,
deviceModel: fp.deviceModel ?? null,
serialNumber: fp.serialNumber ?? null,
platformFingerprint: fp.platformFingerprint ?? null
};
return encodeHexLowerCase(
sha256(new TextEncoder().encode(JSON.stringify(canonical)))
);
}
export async function handleFingerprintInsertion(olm: Olm, fingerprint: any) {
if (!fingerprint || !olm.olmId || Object.keys(fingerprint).length < 1) {
return;
}
const hash = fingerprintHash(fingerprint);
const now = Math.floor(Date.now() / 1000);
const [current] = await db
.select()
.from(currentFingerprint)
.where(eq(currentFingerprint.olmId, olm.olmId))
.limit(1);
if (!current) {
const [inserted] = await db
.insert(currentFingerprint)
.values({
olmId: olm.olmId,
firstSeen: now,
lastSeen: now,
username: fingerprint.username,
hostname: fingerprint.hostname,
platform: fingerprint.platform,
osVersion: fingerprint.osVersion,
kernelVersion: fingerprint.kernelVersion,
arch: fingerprint.arch,
deviceModel: fingerprint.deviceModel,
serialNumber: fingerprint.serialNumber,
platformFingerprint: fingerprint.platformFingerprint
})
.returning();
await db.insert(fingerprintSnapshots).values({
fingerprintId: inserted.fingerprintId,
username: fingerprint.username,
hostname: fingerprint.hostname,
platform: fingerprint.platform,
osVersion: fingerprint.osVersion,
kernelVersion: fingerprint.kernelVersion,
arch: fingerprint.arch,
deviceModel: fingerprint.deviceModel,
serialNumber: fingerprint.serialNumber,
platformFingerprint: fingerprint.platformFingerprint,
hash,
collectedAt: now
});
return;
}
// Get most recent snapshot hash
const [latestSnapshot] = await db
.select({ hash: fingerprintSnapshots.hash })
.from(fingerprintSnapshots)
.where(eq(fingerprintSnapshots.fingerprintId, current.fingerprintId))
.orderBy(desc(fingerprintSnapshots.collectedAt))
.limit(1);
const changed = !latestSnapshot || latestSnapshot.hash !== hash;
if (changed) {
// Insert snapshot if it has changed
await db.insert(fingerprintSnapshots).values({
fingerprintId: current.fingerprintId,
username: fingerprint.username,
hostname: fingerprint.hostname,
platform: fingerprint.platform,
osVersion: fingerprint.osVersion,
kernelVersion: fingerprint.kernelVersion,
arch: fingerprint.arch,
deviceModel: fingerprint.deviceModel,
serialNumber: fingerprint.serialNumber,
platformFingerprint: fingerprint.platformFingerprint,
hash,
collectedAt: now
});
// Update current fingerprint fully
await db
.update(currentFingerprint)
.set({
lastSeen: now,
username: fingerprint.username,
hostname: fingerprint.hostname,
platform: fingerprint.platform,
osVersion: fingerprint.osVersion,
kernelVersion: fingerprint.kernelVersion,
arch: fingerprint.arch,
deviceModel: fingerprint.deviceModel,
serialNumber: fingerprint.serialNumber,
platformFingerprint: fingerprint.platformFingerprint
})
.where(eq(currentFingerprint.fingerprintId, current.fingerprintId));
} else {
// No change, so only bump lastSeen
await db
.update(currentFingerprint)
.set({ lastSeen: now })
.where(eq(currentFingerprint.fingerprintId, current.fingerprintId));
}
}

View File

@@ -1,6 +1,6 @@
import { NextFunction, Request, Response } from "express";
import { db } from "@server/db";
import { olms, clients, fingerprints } from "@server/db";
import { olms, clients, currentFingerprint } from "@server/db";
import { eq, and } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -66,16 +66,14 @@ export async function getUserOlm(
.select()
.from(olms)
.where(and(eq(olms.userId, userId), eq(olms.olmId, olmId)))
.leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
.leftJoin(
currentFingerprint,
eq(olms.olmId, currentFingerprint.olmId)
)
.limit(1);
if (!result || !result.olms) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Olm not found"
)
);
return next(createHttpError(HttpCode.NOT_FOUND, "Olm not found"));
}
const olm = result.olms;
@@ -98,12 +96,13 @@ export async function getUserOlm(
}
// Replace name with device name
const model = result.fingerprints?.deviceModel || null;
const model = result.currentFingerprint?.deviceModel || null;
const newName = getUserDeviceName(model, olm.name);
const responseData = blocked !== undefined
? { ...olm, name: newName, blocked }
: { ...olm, name: newName };
const responseData =
blocked !== undefined
? { ...olm, name: newName, blocked }
: { ...olm, name: newName };
return response(res, {
data: responseData,

View File

@@ -1,5 +1,5 @@
import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws";
import { clientPostureSnapshots, db, fingerprints } from "@server/db";
import { clientPostureSnapshots, db, currentFingerprint } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { clients, olms, Olm } from "@server/db";
import { eq, lt, isNull, and, or } from "drizzle-orm";
@@ -11,6 +11,7 @@ import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { sendOlmSyncMessage } from "./sync";
import { OlmErrorCodes } from "./error";
import { handleFingerprintInsertion } from "./fingerprintingUtils";
// Track if the offline checker interval is running
let offlineCheckerInterval: NodeJS.Timeout | null = null;
@@ -173,15 +174,25 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
}
// get the version
logger.debug(`handleOlmPingMessage: About to get config version for olmId: ${olm.olmId}`);
logger.debug(
`handleOlmPingMessage: About to get config version for olmId: ${olm.olmId}`
);
const configVersion = await getClientConfigVersion(olm.olmId);
logger.debug(`handleOlmPingMessage: Got config version: ${configVersion} (type: ${typeof configVersion})`);
logger.debug(
`handleOlmPingMessage: Got config version: ${configVersion} (type: ${typeof configVersion})`
);
if (configVersion == null || configVersion === undefined) {
logger.debug(`handleOlmPingMessage: could not get config version from server for olmId: ${olm.olmId}`);
logger.debug(
`handleOlmPingMessage: could not get config version from server for olmId: ${olm.olmId}`
);
}
if (message.configVersion != null && configVersion != null && configVersion != message.configVersion) {
if (
message.configVersion != null &&
configVersion != null &&
configVersion != message.configVersion
) {
logger.debug(
`handleOlmPingMessage: Olm ping with outdated config version: ${message.configVersion} (current: ${configVersion})`
);
@@ -204,55 +215,14 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
.set({ archived: false })
.where(eq(olms.olmId, olm.olmId));
}
await handleFingerprintInsertion(olm, fingerprint);
} catch (error) {
logger.error("Error handling ping message", { error });
}
const now = Math.floor(Date.now() / 1000);
if (fingerprint && olm.olmId) {
const [existingFingerprint] = await db
.select()
.from(fingerprints)
.where(eq(fingerprints.olmId, olm.olmId))
.limit(1);
if (!existingFingerprint) {
await db.insert(fingerprints).values({
olmId: olm.olmId,
firstSeen: now,
lastSeen: now,
username: fingerprint.username,
hostname: fingerprint.hostname,
platform: fingerprint.platform,
osVersion: fingerprint.osVersion,
kernelVersion: fingerprint.kernelVersion,
arch: fingerprint.arch,
deviceModel: fingerprint.deviceModel,
serialNumber: fingerprint.serialNumber,
platformFingerprint: fingerprint.platformFingerprint
});
} else {
await db
.update(fingerprints)
.set({
lastSeen: now,
username: fingerprint.username,
hostname: fingerprint.hostname,
platform: fingerprint.platform,
osVersion: fingerprint.osVersion,
kernelVersion: fingerprint.kernelVersion,
arch: fingerprint.arch,
deviceModel: fingerprint.deviceModel,
serialNumber: fingerprint.serialNumber,
platformFingerprint: fingerprint.platformFingerprint
})
.where(eq(fingerprints.olmId, olm.olmId));
}
}
if (postures && olm.clientId) {
await db.insert(clientPostureSnapshots).values({
clientId: olm.clientId,

View File

@@ -1,4 +1,9 @@
import { clientPostureSnapshots, db, fingerprints, orgs } from "@server/db";
import {
clientPostureSnapshots,
db,
currentFingerprint,
orgs
} from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import {
clients,
@@ -48,12 +53,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (fingerprint) {
const [existingFingerprint] = await db
.select()
.from(fingerprints)
.where(eq(fingerprints.olmId, olm.olmId))
.from(currentFingerprint)
.where(eq(currentFingerprint.olmId, olm.olmId))
.limit(1);
if (!existingFingerprint) {
await db.insert(fingerprints).values({
await db.insert(currentFingerprint).values({
olmId: olm.olmId,
firstSeen: now,
lastSeen: now,
@@ -75,16 +80,16 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
existingFingerprint.platform !== fingerprint.platform ||
existingFingerprint.osVersion !== fingerprint.osVersion ||
existingFingerprint.kernelVersion !==
fingerprint.kernelVersion ||
fingerprint.kernelVersion ||
existingFingerprint.arch !== fingerprint.arch ||
existingFingerprint.deviceModel !== fingerprint.deviceModel ||
existingFingerprint.serialNumber !== fingerprint.serialNumber ||
existingFingerprint.platformFingerprint !==
fingerprint.platformFingerprint;
fingerprint.platformFingerprint;
if (hasChanges) {
await db
.update(fingerprints)
.update(currentFingerprint)
.set({
lastSeen: now,
username: fingerprint.username,
@@ -97,7 +102,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
serialNumber: fingerprint.serialNumber,
platformFingerprint: fingerprint.platformFingerprint
})
.where(eq(fingerprints.olmId, olm.olmId));
.where(eq(currentFingerprint.olmId, olm.olmId));
}
}
}

View File

@@ -1,5 +1,5 @@
import { NextFunction, Request, Response } from "express";
import { db, fingerprints } from "@server/db";
import { db, currentFingerprint } from "@server/db";
import { olms } from "@server/db";
import { eq, count, desc } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
@@ -104,13 +104,16 @@ export async function listUserOlms(
.select()
.from(olms)
.where(eq(olms.userId, userId))
.leftJoin(fingerprints, eq(olms.olmId, fingerprints.olmId))
.leftJoin(
currentFingerprint,
eq(olms.olmId, currentFingerprint.olmId)
)
.orderBy(desc(olms.dateCreated))
.limit(limit)
.offset(offset);
const userOlms = list.map((item) => {
const model = item.fingerprints?.deviceModel || null;
const model = item.currentFingerprint?.deviceModel || null;
const newName = getUserDeviceName(model, item.olms.name);
return {

View File

@@ -1,4 +1,4 @@
import { db, fingerprints, olms } from "@server/db";
import { db, currentFingerprint, olms } from "@server/db";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
@@ -55,18 +55,24 @@ export async function recoverOlmWithFingerprint(
const result = await db
.select({
olm: olms,
fingerprint: fingerprints
fingerprint: currentFingerprint
})
.from(olms)
.innerJoin(fingerprints, eq(fingerprints.olmId, olms.olmId))
.innerJoin(
currentFingerprint,
eq(currentFingerprint.olmId, olms.olmId)
)
.where(
and(
eq(olms.userId, userId),
eq(olms.archived, false),
eq(fingerprints.platformFingerprint, platformFingerprint)
eq(
currentFingerprint.platformFingerprint,
platformFingerprint
)
)
)
.orderBy(fingerprints.lastSeen);
.orderBy(currentFingerprint.lastSeen);
if (!result || result.length == 0) {
return next(