Add complimentary OOBE polish and configurable admin mTLS
Allow one server-audited onboarding polish request without credits and make the certificate gate temporarily reversible while preserving application authentication.
This commit is contained in:
@@ -17,6 +17,7 @@ class AppConfigTest : FunSpec({
|
||||
config.credits.signupTrial shouldBe 1_000
|
||||
config.credits.referralInviter shouldBe 1_000
|
||||
config.credits.referralInvitee shouldBe 1_000
|
||||
config.admin.mtlsRequired shouldBe true
|
||||
}
|
||||
|
||||
test("production rejects placeholder secrets") {
|
||||
@@ -68,6 +69,14 @@ class AppConfigTest : FunSpec({
|
||||
admin.bootstrapTotpSecretBase32 shouldBe null
|
||||
}
|
||||
|
||||
test("administrator mTLS can be disabled explicitly") {
|
||||
val config = validConfig("test").apply {
|
||||
put("app.admin.mtlsRequired", "false")
|
||||
}
|
||||
|
||||
AppConfig.from(config).admin.mtlsRequired shouldBe false
|
||||
}
|
||||
|
||||
test("admin bootstrap cannot be enabled while admin routes are disabled") {
|
||||
val config = validProductionConfig().apply {
|
||||
put("app.admin.enabled", "false")
|
||||
|
||||
@@ -169,6 +169,7 @@ class DeploymentConsistencyTest : FunSpec({
|
||||
val smokePrivileges = root.read("deploy/smoke/runtime-grants.sql")
|
||||
|
||||
compose shouldContain "ADMIN_BOOTSTRAP_ENABLED: \${ADMIN_BOOTSTRAP_ENABLED:-false}"
|
||||
compose shouldContain "ADMIN_MTLS_REQUIRED: \${ADMIN_MTLS_REQUIRED:-true}"
|
||||
privileges shouldContain "GRANT SELECT ON osg_account.admin_operators"
|
||||
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.admin_operators"
|
||||
privileges shouldContain "GRANT SELECT ON osg_account.admin_sessions"
|
||||
@@ -177,6 +178,9 @@ class DeploymentConsistencyTest : FunSpec({
|
||||
privileges shouldContain "GRANT INSERT ON osg_account.gateway_grant_scopes"
|
||||
privileges shouldContain "GRANT SELECT ON osg_account.gateway_refresh_tokens"
|
||||
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.gateway_refresh_tokens"
|
||||
privileges shouldContain "GRANT SELECT ON osg_account.gateway_complimentary_requests"
|
||||
privileges shouldContain
|
||||
"GRANT INSERT, UPDATE, DELETE ON osg_account.gateway_complimentary_requests"
|
||||
privileges shouldContain "GRANT SELECT ON osg_account.account_profiles"
|
||||
privileges shouldContain "GRANT INSERT, UPDATE ON osg_account.account_profiles"
|
||||
privileges shouldContain "GRANT INSERT ON osg_account.admin_audit_log"
|
||||
@@ -206,6 +210,8 @@ class DeploymentConsistencyTest : FunSpec({
|
||||
test("OpenResty proxies HTTP WebSocket invitations and both AASA paths safely") {
|
||||
val openResty = root.read("deploy/openresty-account.conf")
|
||||
|
||||
openResty shouldContain "proxy_set_header X-OSG-mTLS-Verified \$ssl_client_verify;"
|
||||
openResty shouldNotContain "proxy_set_header X-OSG-mTLS-Verified \"SUCCESS\";"
|
||||
openResty shouldContain "proxy_set_header Upgrade \$http_upgrade;"
|
||||
openResty shouldContain "proxy_set_header Connection \$connection_upgrade;"
|
||||
openResty shouldContain "location = /.well-known/apple-app-site-association"
|
||||
|
||||
@@ -37,7 +37,7 @@ class SmokeDeploymentTest : FunSpec({
|
||||
runner shouldContain "APPLE_JWKS_URL=http://127.0.0.1:9/"
|
||||
runner shouldContain "VOLCENGINE_ASR_ENDPOINT=ws://127.0.0.1:9/"
|
||||
runner shouldContain "DEEPSEEK_ENDPOINT=http://127.0.0.1:9/"
|
||||
runner shouldContain "Flyway history was not exactly successful V1-V12"
|
||||
runner shouldContain "Flyway history was not exactly successful V1-V17"
|
||||
runner shouldContain "default referral rewards were not 1000 credits for both accounts"
|
||||
runner shouldContain "active smaller credit rates did not match the V10 contract"
|
||||
runner shouldContain "first ledger page omitted nextCursor"
|
||||
@@ -51,7 +51,7 @@ class SmokeDeploymentTest : FunSpec({
|
||||
|
||||
test("runtime grants cover every migrated table without mutable history privileges") {
|
||||
val grants = root.read("deploy/smoke/runtime-grants.sql")
|
||||
val migrationTables = (1..16)
|
||||
val migrationTables = (1..17)
|
||||
.flatMap { version ->
|
||||
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
|
||||
paths.filter { it.fileName.toString().startsWith("V${version}__") }
|
||||
@@ -69,6 +69,8 @@ class SmokeDeploymentTest : FunSpec({
|
||||
|
||||
grantedTables.sorted() shouldContainExactly migrationTables.sorted()
|
||||
grants shouldContain "GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.admin_sessions"
|
||||
grants shouldContain
|
||||
"GRANT INSERT, UPDATE, DELETE ON osg_account_smoke.gateway_complimentary_requests"
|
||||
grants shouldNotContain "UPDATE ON osg_account_smoke.credit_ledger"
|
||||
grants shouldNotContain "DELETE ON osg_account_smoke.credit_ledger"
|
||||
grants shouldNotContain "UPDATE ON osg_account_smoke.admin_audit_log"
|
||||
|
||||
@@ -68,6 +68,16 @@ class AdminRoutesTest {
|
||||
response.bodyAsText() shouldContain """"authenticated":false"""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled mTLS allows anonymous session check without edge header`() = testApplication {
|
||||
application { installAdminTestRoutes(mtlsRequired = false) }
|
||||
|
||||
val response = client.get("/v1/admin/auth/session")
|
||||
|
||||
assertEquals(HttpStatusCode.OK, response.status)
|
||||
response.bodyAsText() shouldContain """"authenticated":false"""
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authenticated session exposes role for client-side capability navigation`() = testApplication {
|
||||
application {
|
||||
@@ -103,9 +113,20 @@ class AdminRoutesTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `admin web resources are embedded`() = testApplication {
|
||||
fun `admin web resources are hidden when mTLS is required`() = testApplication {
|
||||
application {
|
||||
routing { adminWebRoutes() }
|
||||
routing { adminWebRoutes(adminTestConfig()) }
|
||||
}
|
||||
|
||||
val response = client.get("/admin/")
|
||||
|
||||
assertEquals(HttpStatusCode.NotFound, response.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `admin web resources are embedded when mTLS is disabled`() = testApplication {
|
||||
application {
|
||||
routing { adminWebRoutes(adminTestConfig(mtlsRequired = false)) }
|
||||
}
|
||||
|
||||
val response = client.get("/admin/")
|
||||
@@ -296,6 +317,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
|
||||
operatorService: AdminOperatorService = mockk(relaxed = true),
|
||||
auditService: AdminAuditService = mockk(relaxed = true),
|
||||
usersService: AdminUsersService = mockk(relaxed = true),
|
||||
mtlsRequired: Boolean = true,
|
||||
) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { explicitNulls = false })
|
||||
@@ -305,11 +327,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
|
||||
rateLimiter(limit = 20, refillPeriod = 1.minutes)
|
||||
}
|
||||
}
|
||||
val config = mockk<AppConfig> {
|
||||
every { publicBaseUrl } returns "https://account.osglab.com"
|
||||
every { isProduction } returns false
|
||||
every { admin } returns AdminConfig()
|
||||
}
|
||||
val config = adminTestConfig(mtlsRequired)
|
||||
routing {
|
||||
adminApiRoutes(
|
||||
config = config,
|
||||
@@ -325,6 +343,12 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
private fun adminTestConfig(mtlsRequired: Boolean = true) = mockk<AppConfig> {
|
||||
every { publicBaseUrl } returns "https://account.osglab.com"
|
||||
every { isProduction } returns false
|
||||
every { admin } returns AdminConfig(mtlsRequired = mtlsRequired)
|
||||
}
|
||||
|
||||
private fun grantRouteFixture(
|
||||
failure: RuntimeException,
|
||||
): Pair<AdminSessionService, AdminGrantService> {
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package com.osglab.account.features.gateway.repositories
|
||||
|
||||
import com.osglab.account.config.DatabaseConfig
|
||||
import com.osglab.account.config.DatabaseFactory
|
||||
import com.osglab.account.features.gateway.models.GatewayCapability
|
||||
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
|
||||
import io.kotest.core.spec.style.FunSpec
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.shouldNotBe
|
||||
import java.sql.DriverManager
|
||||
import java.time.Clock
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.opentest4j.TestAbortedException
|
||||
import org.testcontainers.DockerClientFactory
|
||||
import org.testcontainers.containers.MySQLContainer
|
||||
|
||||
class GatewayComplimentaryRepositoryIntegrationTest : FunSpec({
|
||||
test("complimentary claims are atomic consumed once and releasable before success") {
|
||||
withGatewayDatabase { config, databaseFactory ->
|
||||
val now = Instant.parse("2026-08-20T08:00:00Z")
|
||||
val consumedAccount = UUID.randomUUID()
|
||||
val releasedAccount = UUID.randomUUID()
|
||||
insertAccounts(config, listOf(consumedAccount, releasedAccount), now)
|
||||
val repository = ExposedGatewayRepository(
|
||||
databaseFactory,
|
||||
Clock.fixed(now, ZoneOffset.UTC),
|
||||
)
|
||||
|
||||
val concurrentClaims = coroutineScope {
|
||||
(1..8).map { index ->
|
||||
async(Dispatchers.Default) {
|
||||
repository.claim(
|
||||
accountId = consumedAccount.toString(),
|
||||
purpose = GatewayRequestPurpose.OOBE,
|
||||
capability = GatewayCapability.POLISH,
|
||||
requestId = "concurrent-oobe-$index",
|
||||
)
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
val winningClaim = concurrentClaims.filterNotNull().single()
|
||||
|
||||
repository.consume(winningClaim)
|
||||
repository.release(winningClaim)
|
||||
repository.claim(
|
||||
accountId = consumedAccount.toString(),
|
||||
purpose = GatewayRequestPurpose.OOBE,
|
||||
capability = GatewayCapability.POLISH,
|
||||
requestId = "consumed-replay",
|
||||
) shouldBe null
|
||||
|
||||
val releasedClaim = repository.claim(
|
||||
accountId = releasedAccount.toString(),
|
||||
purpose = GatewayRequestPurpose.OOBE,
|
||||
capability = GatewayCapability.POLISH,
|
||||
requestId = "released-first-attempt",
|
||||
)
|
||||
releasedClaim shouldNotBe null
|
||||
repository.release(requireNotNull(releasedClaim))
|
||||
repository.claim(
|
||||
accountId = releasedAccount.toString(),
|
||||
purpose = GatewayRequestPurpose.OOBE,
|
||||
capability = GatewayCapability.POLISH,
|
||||
requestId = "released-retry",
|
||||
) shouldNotBe null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private suspend fun withGatewayDatabase(
|
||||
block: suspend (DatabaseConfig, DatabaseFactory) -> Unit,
|
||||
) {
|
||||
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
|
||||
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
|
||||
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
|
||||
}
|
||||
val mysql = if (externalJdbcUrl == null) {
|
||||
GatewayMySqlContainer("mysql:8.4")
|
||||
.withDatabaseName("osg_gateway_complimentary_test")
|
||||
.withUsername("test")
|
||||
.withPassword("test")
|
||||
.also(GatewayMySqlContainer::start)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val config = DatabaseConfig(
|
||||
jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl,
|
||||
username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
|
||||
?: mysql?.username
|
||||
?: "root",
|
||||
password = System.getenv("TEST_MYSQL_PASSWORD") ?: mysql?.password ?: "",
|
||||
maximumPoolSize = 8,
|
||||
)
|
||||
val databaseFactory = DatabaseFactory(config)
|
||||
try {
|
||||
databaseFactory.database
|
||||
block(config, databaseFactory)
|
||||
} finally {
|
||||
databaseFactory.close()
|
||||
mysql?.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertAccounts(
|
||||
config: DatabaseConfig,
|
||||
accountIds: List<UUID>,
|
||||
now: Instant,
|
||||
) {
|
||||
DriverManager.getConnection(config.jdbcUrl, config.username, config.password).use { connection ->
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
).use { statement ->
|
||||
accountIds.forEach { accountId ->
|
||||
statement.setString(1, accountId.toString())
|
||||
statement.setString(2, "gateway-test-$accountId")
|
||||
statement.setTimestamp(3, java.sql.Timestamp.from(now))
|
||||
statement.setTimestamp(4, java.sql.Timestamp.from(now))
|
||||
statement.addBatch()
|
||||
}
|
||||
statement.executeBatch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class GatewayMySqlContainer(image: String) :
|
||||
MySQLContainer<GatewayMySqlContainer>(image)
|
||||
+122
-1
@@ -2,6 +2,7 @@ package com.osglab.account.features.gateway.services
|
||||
|
||||
import com.osglab.account.features.gateway.models.GatewayCapability
|
||||
import com.osglab.account.features.gateway.models.GatewayPrincipal
|
||||
import com.osglab.account.features.gateway.models.GatewayRequestPurpose
|
||||
import com.osglab.account.features.gateway.models.GatewayRequestSource
|
||||
import com.osglab.account.features.gateway.models.ProviderDescriptor
|
||||
import com.osglab.account.features.gateway.models.ProviderOutput
|
||||
@@ -11,9 +12,13 @@ import com.osglab.account.features.gateway.models.TextProviderRequest
|
||||
import com.osglab.account.features.gateway.models.UsageMeter
|
||||
import com.osglab.account.features.gateway.ports.CreditMeterPort
|
||||
import com.osglab.account.features.gateway.ports.CreditReservation
|
||||
import com.osglab.account.features.gateway.ports.ComplimentaryRequestClaim
|
||||
import com.osglab.account.features.gateway.ports.ComplimentaryRequestPort
|
||||
import com.osglab.account.features.gateway.ports.GatewayRequestAlreadyClaimedException
|
||||
import com.osglab.account.features.gateway.ports.GatewayUsagePort
|
||||
import com.osglab.account.features.gateway.ports.PendingSettlement
|
||||
import com.osglab.account.features.gateway.ports.ProviderRequestMetadata
|
||||
import com.osglab.account.features.gateway.ports.ProviderRequestState
|
||||
import com.osglab.account.features.gateway.ports.ProviderUsageEstimate
|
||||
import com.osglab.account.features.gateway.providers.GatewayProvider
|
||||
import com.osglab.account.features.gateway.providers.ProviderCatalog
|
||||
@@ -80,6 +85,75 @@ class GatewayServiceBillingTest : StringSpec({
|
||||
usageRecords.lastClaim?.requestSource shouldBe GatewayRequestSource.HOTWORD
|
||||
}
|
||||
|
||||
"executes one OOBE dictation polish without reserving or settling credits" {
|
||||
val credits = FakeCredits()
|
||||
val complimentary = FakeComplimentaryRequests()
|
||||
val usageRecords = FakeUsageRecords()
|
||||
val service = service(
|
||||
credits = credits,
|
||||
provider = FakeProvider(capability = GatewayCapability.POLISH),
|
||||
usageRecords = usageRecords,
|
||||
complimentaryRequests = complimentary,
|
||||
)
|
||||
|
||||
service.execute(
|
||||
PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)),
|
||||
oobeRequest(),
|
||||
DISCARD_OUTPUT,
|
||||
)
|
||||
|
||||
credits.reserveCalls shouldBe 0
|
||||
credits.settled shouldBe emptyList()
|
||||
credits.released shouldBe emptyList()
|
||||
complimentary.consumed.size shouldBe 1
|
||||
complimentary.released shouldBe emptyList()
|
||||
usageRecords.lastClaim?.reservationId shouldBe null
|
||||
usageRecords.lastClaim?.requestPurpose shouldBe GatewayRequestPurpose.OOBE
|
||||
}
|
||||
|
||||
"rejects a second OOBE polish without falling through to paid billing" {
|
||||
val credits = FakeCredits()
|
||||
val complimentary = FakeComplimentaryRequests(available = false)
|
||||
val service = service(
|
||||
credits = credits,
|
||||
provider = FakeProvider(capability = GatewayCapability.POLISH),
|
||||
complimentaryRequests = complimentary,
|
||||
)
|
||||
|
||||
shouldThrow<ComplimentaryRequestUnavailableException> {
|
||||
service.execute(
|
||||
PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)),
|
||||
oobeRequest(),
|
||||
DISCARD_OUTPUT,
|
||||
)
|
||||
}
|
||||
|
||||
credits.reserveCalls shouldBe 0
|
||||
}
|
||||
|
||||
"releases a newly acquired OOBE claim when the request id is a replay" {
|
||||
val complimentary = FakeComplimentaryRequests()
|
||||
val service = service(
|
||||
credits = FakeCredits(),
|
||||
provider = FakeProvider(capability = GatewayCapability.POLISH),
|
||||
usageRecords = FakeUsageRecords(
|
||||
claimFailure = GatewayRequestAlreadyClaimedException(ProviderRequestState.RELEASED),
|
||||
),
|
||||
complimentaryRequests = complimentary,
|
||||
)
|
||||
|
||||
shouldThrow<GatewayRequestAlreadyClaimedException> {
|
||||
service.execute(
|
||||
PRINCIPAL.copy(scopes = setOf(GatewayCapability.POLISH)),
|
||||
oobeRequest(),
|
||||
DISCARD_OUTPUT,
|
||||
)
|
||||
}
|
||||
|
||||
complimentary.consumed shouldBe emptyList()
|
||||
complimentary.released.size shouldBe 1
|
||||
}
|
||||
|
||||
"uses one reservation when a buffered DeepSeek empty result succeeds on retry" {
|
||||
val credits = FakeCredits()
|
||||
var attempts = 0
|
||||
@@ -210,11 +284,13 @@ private fun service(
|
||||
credits: CreditMeterPort,
|
||||
provider: GatewayProvider,
|
||||
usageRecords: GatewayUsagePort = FakeUsageRecords(),
|
||||
complimentaryRequests: ComplimentaryRequestPort = FakeComplimentaryRequests(available = false),
|
||||
): GatewayService = GatewayService(
|
||||
catalog = ProviderCatalog(listOf(provider)),
|
||||
credits = credits,
|
||||
grants = { _, _ -> true },
|
||||
usageRecords = usageRecords,
|
||||
complimentaryRequests = complimentaryRequests,
|
||||
)
|
||||
|
||||
private fun request(requestSource: GatewayRequestSource? = null): TextProviderRequest {
|
||||
@@ -236,6 +312,25 @@ private fun request(requestSource: GatewayRequestSource? = null): TextProviderRe
|
||||
)
|
||||
}
|
||||
|
||||
private fun oobeRequest(): TextProviderRequest {
|
||||
val executionPolicy = GatewayTaskPolicyResolver().resolve(
|
||||
GatewayCapability.POLISH,
|
||||
requestedTaskKind = null,
|
||||
requestedMaxOutputTokens = 32,
|
||||
)
|
||||
return TextProviderRequest(
|
||||
requestId = "oobe-request-123",
|
||||
capability = GatewayCapability.POLISH,
|
||||
executionPolicy = executionPolicy,
|
||||
input = "hello",
|
||||
context = null,
|
||||
maxOutputTokens = executionPolicy.maxOutputTokens,
|
||||
temperature = 0.2,
|
||||
stream = false,
|
||||
requestPurpose = GatewayRequestPurpose.OOBE,
|
||||
)
|
||||
}
|
||||
|
||||
private class FakeCredits(
|
||||
private val failSettle: Boolean = false,
|
||||
private val idempotent: Boolean = false,
|
||||
@@ -286,10 +381,11 @@ private class FakeCredits(
|
||||
|
||||
private class FakeProvider(
|
||||
private val fail: Boolean = false,
|
||||
capability: GatewayCapability = GatewayCapability.AI,
|
||||
) : GatewayProvider {
|
||||
override val descriptor = ProviderDescriptor(
|
||||
id = "mock-deepseek",
|
||||
capabilities = setOf(GatewayCapability.AI),
|
||||
capabilities = setOf(capability),
|
||||
streaming = true,
|
||||
usageMeter = UsageMeter.LLM_TOKEN,
|
||||
)
|
||||
@@ -300,6 +396,29 @@ private class FakeProvider(
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeComplimentaryRequests(
|
||||
private val available: Boolean = true,
|
||||
) : ComplimentaryRequestPort {
|
||||
val consumed = mutableListOf<ComplimentaryRequestClaim>()
|
||||
val released = mutableListOf<ComplimentaryRequestClaim>()
|
||||
|
||||
override suspend fun claim(
|
||||
accountId: String,
|
||||
purpose: GatewayRequestPurpose,
|
||||
capability: GatewayCapability,
|
||||
requestId: String,
|
||||
): ComplimentaryRequestClaim? =
|
||||
if (available) ComplimentaryRequestClaim(accountId, purpose, capability, requestId) else null
|
||||
|
||||
override suspend fun consume(claim: ComplimentaryRequestClaim) {
|
||||
consumed += claim
|
||||
}
|
||||
|
||||
override suspend fun release(claim: ComplimentaryRequestClaim) {
|
||||
released += claim
|
||||
}
|
||||
}
|
||||
|
||||
private class EmptyResultProvider : GatewayProvider {
|
||||
override val descriptor = ProviderDescriptor(
|
||||
id = "empty-provider",
|
||||
@@ -315,10 +434,12 @@ private class EmptyResultProvider : GatewayProvider {
|
||||
|
||||
private class FakeUsageRecords(
|
||||
private val pending: MutableList<PendingSettlement> = mutableListOf(),
|
||||
private val claimFailure: RuntimeException? = null,
|
||||
) : GatewayUsagePort {
|
||||
var lastClaim: ProviderRequestMetadata? = null
|
||||
|
||||
override suspend fun claim(metadata: ProviderRequestMetadata) {
|
||||
claimFailure?.let { throw it }
|
||||
lastClaim = metadata
|
||||
}
|
||||
override suspend fun markStarted(accountId: String, requestId: String) = Unit
|
||||
|
||||
Reference in New Issue
Block a user