Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 17 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@

![TLS Enabled](https://img.shields.io/badge/TLS-enabled-brightgreen?style=flat-square&logo=letsencrypt&logoColor=white)

###### (a) Current Development

This project focuses on exploring and implementing various puzzle-solving algorithms, with a primary emphasis on Sudoku puzzles. The backtracking algorithm is one of the key implementations, and recent refactors have made all compute solutions accessible as a service.
###### (a) Introduction: From Puzzle Solving to Platform — A Functional and Secure Compute Layer

`play4s-service` and its supporting modules are built atop [cats-effect](https://github.com/typelevel/cats-effect) and
[fs2](https://github.com/functional-streams-for-scala/fs2), fully embracing the foundational principles of functional programming. This design emphasizes purity, composability, and effect management—enabling precise control over resource lifecycles, concurrency, and asynchronous computations in a principled, type-safe manner.

What began as an exploration into puzzle-solving—specifically Sudoku—has progressively transformed into a more expansive and versatile compute platform. Core algorithms have been rearchitected through a functional-first lens, enabling advanced features such as parallel solver execution during compute requests. A prime example is the constraint propagation algorithm, which drastically reduces the search space to accelerate resolution. Leveraging the concurrency model offered by Cats Effect, we can benchmark this solver by racing it against an alternative implementation adhering to the same trait—automatically short-circuiting as soon as the quicker solution emerges. This not only demonstrates tangible performance improvements but also guarantees timely and accurate results.

This technical evolution represents a key inflection point: solver logic has been modularized into composable, service-oriented units, forming a scalable foundation for future expansion. These services now underpin a robust, high-performance computation layer that is both adaptable and maintainable.

Complementing this architecture is a hardened authentication stack that blends API key and OTP-based verification, JWT token lifecycle governance, and secure credential storage. Together, these safeguards ensure an uncompromising streamlined, secure interaction model—for both consumers of the service and the developers who extend it.


###### (b) Performance Progress and Example Analytics

Expand Down Expand Up @@ -109,11 +118,13 @@ To retrieve the latest Load Balancer endpoint, re-trigger the GitHub Actions wor

| Endpoint Path | Description |
| ------------------------------------- | ------------------------------- |
| `/internal/auth/token` | JWT generation endpoint |
| `/internal/meta/health` | Health check endpoint |
| `/internal/meta/version` | Runtime build details endpoint |
| `/internal/auth/token` | JWT generation endpoint |
| `/internal/auth/otp/initiate` | OTP initiation endpoint |
| `/internal/auth/otp/authorize` | OTP authorization endpoint |
| `/internal/meta/health` | Health check endpoint |
| `/internal/meta/version` | Runtime build details endpoint |
| `/internal/game/sudoku/hints` | Generate playable cell hints |
| `/internal/game/sudoku/metrics` | Retrieve metrics about computations |
| `/internal/game/sudoku/metrics` | Retrieve metrics about computations |
| `/internal/game/sudoku/solve` | Developer endpoint |
| `/public/game/sudoku/solve` | Sudoku puzzle-solving endpoint |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,12 @@ structure InvalidInputError {
description: String
}

@error("client")
@httpError(401)
structure AuthError {
@required
description: String
}
@error("client")
@httpError(403)
structure ForbiddenError {
description: String
}


@error("client")
@httpError(422)
structure NoSolutionFoundError {
Expand All @@ -43,13 +36,6 @@ structure InitialStateSettingError {
description: String
}

@error("server")
@httpError(500)
structure DecodeFailureError {
@required
description: String
}

@error("server")
@httpError(500)
structure InternalServerError {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
$version: "2"

namespace com.theproductcollectiveco.play4s.internal.auth

@error("client")
@httpError(401)
structure AuthError {
@required
description: String
}

@error("client")
@httpError(429)
structure AuthEligibilityError {
@required
description: String
}

@error("server")
@httpError(500)
structure DecodeFailureError {
@required
description: String
}

@error("server")
@httpError(500)
structure AuthProcessingError {
@required
description: String
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ $version: "2"

namespace com.theproductcollectiveco.play4s.internal.auth

structure Alias {
@required
value: String
}

structure Token {
@required
value: String
Expand Down Expand Up @@ -30,17 +35,19 @@ structure Payload {
issuedAt: Long
@required
roles: RolesList
@pattern("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
@required
tokenId: String
metadata: MetadataList
}

union GenericHandle {
emailAddress: EmailAddress
contact: Contact
username: Username
}

structure EmailAddress {
@pattern("^(?=.{1,320}$)(?=.{1,64}@.{1,255}$)([A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[A-Za-z0-9!#$%&'*+/=?^_`{|}~-]+)*@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+)$")
@required
value: String
}
Expand All @@ -50,6 +57,48 @@ structure Username {
value: String
}

structure PhoneNumber {
@pattern("^\\+?[0-9]{7,15}$")
@required
value: String
}

structure OtpSession {
@required
otp: Otp
otpContext: OtpContext
@required
expiresAt: Timestamp
@default(0)
@required
initiateAttempts: Integer
@default(0)
@required
validateAttempts: Integer
@default(false)
@required
isRedeemed: Boolean
@required
handle: GenericHandle
}


structure Otp {
@required
@pattern("^[A-Za-z0-9]{6}$")
value: String
}

structure OtpContext {
@required
value: String
}

union Contact {
emailAddress: EmailAddress
phoneNumber: PhoneNumber
}

list RolesList {
member: String
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,25 @@ $version: "2"

namespace com.theproductcollectiveco.play4s.internal.auth

use com.theproductcollectiveco.play4s.internal.auth#Otp
use com.theproductcollectiveco.play4s.internal.auth#Token
use com.theproductcollectiveco.play4s.internal.auth#GenericHandle
use com.theproductcollectiveco.play4s.internal.auth#AuthError
use com.theproductcollectiveco.play4s.internal.auth#AuthEligibilityError
use com.theproductcollectiveco.play4s.internal.auth#AuthProcessingError
use com.theproductcollectiveco.play4s.game.sudoku#InvalidInputError

use alloy#simpleRestJson


@simpleRestJson
service ServiceAuthApi {
operations: [
RequestToken
]
RequestToken,
InitiateOtp,
RedeemOtp
],
errors: [AuthError, AuthEligibilityError, AuthProcessingError, InvalidInputError]
}

@readonly
Expand All @@ -23,3 +32,24 @@ operation RequestToken {
}
output: Token
}

@readonly
@http(method: "POST", uri: "/internal/auth/otp/initiate", code: 201)
operation InitiateOtp {
input := {
@required
requester: GenericHandle
}
}

@readonly
@http(method: "POST", uri: "/internal/auth/otp/authorize")
operation RedeemOtp {
input := {
@required
requester: GenericHandle
@required
otp: Otp
}
output: Token
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ use alloy#simpleRestJson
use smithy.api#httpApiKeyAuth
use smithy.api#auth

use com.theproductcollectiveco.play4s.game.sudoku#AuthError
use com.theproductcollectiveco.play4s.internal.auth#AuthError
use com.theproductcollectiveco.play4s.game.sudoku#ForbiddenError
use com.theproductcollectiveco.play4s.game.sudoku#InvalidInputError
use com.theproductcollectiveco.play4s.game.sudoku#NoSolutionFoundError
use com.theproductcollectiveco.play4s.game.sudoku#BoardNotCreatedError
use com.theproductcollectiveco.play4s.game.sudoku#InitialStateSettingError
use com.theproductcollectiveco.play4s.game.sudoku#InternalServerError
use com.theproductcollectiveco.play4s.game.sudoku#DecodeFailureError
use com.theproductcollectiveco.play4s.internal.auth#DecodeFailureError
use com.theproductcollectiveco.play4s.game.sudoku.public#ComputeSudoku
use com.theproductcollectiveco.play4s.game.sudoku.internal#ComputeSudokuDeveloperMode
use com.theproductcollectiveco.play4s.game.sudoku.internal#GetSudokuHints
Expand Down
18 changes: 14 additions & 4 deletions app/src/main/scala/com/theproductcollectiveco/play4s/MainApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,25 @@ package com.theproductcollectiveco.play4s
import cats.effect.{Clock, IO, Resource, ResourceApp}
import cats.effect.IO.asyncForIO
import cats.effect.implicits.*
import cats.effect.std.{SecureRandom, UUIDGen}
import cats.effect.std.{SecureRandom, Supervisor, UUIDGen}
import cats.syntax.all.*
import com.theproductcollectiveco.play4s.Middleware.{routes, secureRoutes}
import com.theproductcollectiveco.play4s.Play4sApi
import com.theproductcollectiveco.play4s.api.{AuthService, HealthService, Play4sService}
import com.theproductcollectiveco.play4s.auth.{AuthProvider, DefaultAuthProvider, DefaultJwtProvider, DefaultKeyStoreBackend, JwtProvider}
import com.theproductcollectiveco.play4s.auth.{
AuthProvider,
DefaultAuthProvider,
DefaultJwtProvider,
DefaultKeyStoreBackend,
DefaultOtpProvider,
JwtProvider,
OtpProvider,
}
import com.theproductcollectiveco.play4s.auth.DefaultJwtProvider.*
import com.theproductcollectiveco.play4s.config.{ApiKeyStoreConfig, AppConfig, AuthConfig}
import com.theproductcollectiveco.play4s.game.sudoku.core.{BacktrackingAlgorithm, ConstraintPropagationAlgorithm, Orchestrator}
import com.theproductcollectiveco.play4s.game.sudoku.parser.{GoogleCloudClient, TraceClient}
import com.theproductcollectiveco.play4s.internal.auth.ServiceAuthApi
import com.theproductcollectiveco.play4s.internal.auth.{Alias, ServiceAuthApi}
import com.theproductcollectiveco.play4s.internal.meta.health.ServiceMetaApi
import fs2.io.file.Files
import org.http4s.ember.server.EmberServerBuilder
Expand All @@ -37,8 +45,10 @@ object MainApp extends ResourceApp.Forever {
_ <- authProvider.storeCredentials(googleCloudApiKey, googlePath.mkString)
_ <- authProvider.storeCredentials(keystore, keystorePath.mkString)
tlsContext <- authProvider.tlsContextResource(summon[AppConfig].apiKeyStore.keyStoreManagement)
_ <- authProvider.initializeSecret(alias = "jwtSigningSecret", summon[AppConfig].apiKeyStore.keyStoreManagement).toResource
_ <- authProvider.initializeSecret(alias = Alias("jwtSigningSecret"), summon[AppConfig].apiKeyStore.keyStoreManagement).toResource
given AuthProvider[IO] = authProvider
given Supervisor[IO] <- Supervisor[IO]
given OtpProvider[IO] = DefaultOtpProvider[IO](summon[AppConfig], authProvider)
given JwtProvider[IO] = DefaultJwtProvider[IO](summon[AppConfig], authProvider)
given Metrics[IO] <- Metrics.make[IO].toResource
given SecureRandom[IO] <- SecureRandom.javaSecuritySecureRandom[IO].toResource
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import cats.data.{Kleisli, OptionT}
import cats.effect.{Async, Resource}
import cats.syntax.all.*
import com.theproductcollectiveco.play4s.auth.JwtProvider
import com.theproductcollectiveco.play4s.game.sudoku.AuthError
import com.theproductcollectiveco.play4s.internal.auth.AuthError
import io.circe.Encoder
import io.circe.generic.auto.*
import org.http4s.*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,22 @@ package com.theproductcollectiveco.play4s.api
import cats.effect.{Async, Clock}
import cats.effect.std.{SecureRandom, UUIDGen}
import cats.implicits.*
import com.theproductcollectiveco.play4s.auth.JwtProvider
import com.theproductcollectiveco.play4s.internal.auth.{GenericHandle, ServiceAuthApi, Token}
import com.theproductcollectiveco.play4s.auth.{JwtProvider, OtpProvider}
import com.theproductcollectiveco.play4s.game.sudoku.InvalidInputError
import com.theproductcollectiveco.play4s.internal.auth.{GenericHandle, Otp, ServiceAuthApi, Token}
import org.typelevel.log4cats.Logger

object AuthService {

def make[F[_]: Async](using provider: JwtProvider[F]): ServiceAuthApi[F] =
def make[F[_]: Async: Logger](using jwtProvider: JwtProvider[F], otpProvider: OtpProvider[F]): ServiceAuthApi[F] =
new ServiceAuthApi[F] {

override def requestToken(requester: GenericHandle): F[Token] =
SecureRandom.javaSecuritySecureRandom[F].flatMap { security =>
given SecureRandom[F] = security
UUIDGen.fromSecureRandom.randomUUID.map(_.toString).flatMap { uuid =>
Clock[F].realTimeInstant.flatMap { now =>
provider
jwtProvider
.generateJwt(
handle = requester,
expiration = now.getEpochSecond + 300,
Expand All @@ -30,6 +32,19 @@ object AuthService {
}
}
}

override def initiateOtp(requester: GenericHandle): F[Unit] =
for {
otp <- otpProvider.initiateOtp(requester)
// todo: send the otp via sms or email client integration
_ <- Logger[F].info(s"Initialized otp: $otp")
} yield ()

override def redeemOtp(requester: GenericHandle, otp: Otp): F[Token] =
otpProvider
.verifyOtpEligibility(requester, otp)
.ifM(requestToken(requester), InvalidInputError("Invalid OTP supplied").raiseError)

}

}
Loading