From 5dfeff2cd21e9ad9078d3bba0c266c604c1c2a21 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Tue, 3 Sep 2024 23:46:32 -0700 Subject: [PATCH] chore: add docs to core modules --- modules/rate_limit/README.md | 172 ++++++++++++++++ modules/tokens/README.md | 381 ++++++++++++++++++++++++++++++++++- modules/uploads/README.md | 311 ++++++++++++++++++++++++++++ tests/basic/deno.lock | 36 +++- 4 files changed, 898 insertions(+), 2 deletions(-) create mode 100644 modules/rate_limit/README.md create mode 100644 modules/uploads/README.md diff --git a/modules/rate_limit/README.md b/modules/rate_limit/README.md new file mode 100644 index 00000000..42304508 --- /dev/null +++ b/modules/rate_limit/README.md @@ -0,0 +1,172 @@ +# Rate Limit + +Rate limiting is a crucial aspect of managing your game's backend to prevent abuse and ensure fair usage of your resources. The `rate_limit` module in Open Game Backend provides an easy way to implement rate limiting for your scripts. + +## What is Rate Limiting? + +Rate limiting restricts the number of requests a client can make to your backend within a specified time period. This helps to: + +1. Prevent abuse and potential DoS attacks +2. Ensure fair usage of resources among all users +3. Manage server load and maintain performance + +## Using the Rate Limit Module + +The `rate_limit` module provides two main scripts: + +1. `throttle`: For general-purpose rate limiting +2. `throttle_public`: Specifically designed for rate limiting public scripts + +Let's focus on `throttle_public` as it's the recommended method for rate limiting public scripts. + +### Throttling Public Scripts + +To rate limit a public script, you can use the `throttle_public` script at the beginning of your script. Here's how to use it: + + +```typescript scripts/my_public_script.ts +import { ScriptContext } from "../module.gen.ts"; + +export interface Request { + // Your request parameters here +} + +export interface Response { + // Your response type here +} + +export async function run( + ctx: ScriptContext, + req: Request +): Promise { + // Apply rate limiting + await ctx.modules.rateLimit.throttlePublic({}); + + // Your script logic here + // ... + + return { + // Your response here + }; +} +``` + + +The `throttlePublic` script automatically uses the client's IP address for rate limiting. By default, it allows 20 requests per 5 minutes (300 seconds) for each IP address. + +### Customizing Rate Limits + +You can customize the rate limit by passing parameters to `throttlePublic`: + + +```typescript scripts/my_custom_rate_limited_script.ts +export async function run( + ctx: ScriptContext, + req: Request +): Promise { + // Custom rate limit: 5 requests per 1 minute + await ctx.modules.rateLimit.throttlePublic({ + requests: 5, + period: 60 + }); + + // Your script logic here + // ... +} +``` + + +## Best Practices for Rate Limiting + +1. **Only Rate Limit Public Scripts**: Apply rate limiting to public scripts that are directly accessible by clients. Internal scripts called by other modules typically don't need rate limiting. + +2. **Choose Appropriate Limits**: Set rate limits that balance protection against abuse with legitimate usage patterns. Consider your game's requirements and user behavior. + +3. **Use `throttle_public` for Client-Facing Scripts**: The `throttle_public` script is designed to work with client IP addresses automatically, making it ideal for public-facing endpoints. + +4. **Consistent Application**: Apply rate limiting consistently across all public endpoints to prevent attackers from finding unprotected routes. + +5. **Monitor and Adjust**: Regularly review your rate limits and adjust them based on real-world usage and any issues that arise. + +6. **Inform Users**: When a rate limit is exceeded, return a clear error message to the client so they understand why their request was blocked. + +7. **Consider Different Limits for Different Actions**: Some actions might require stricter rate limits than others. For example: + + + ```typescript scripts/login.ts + export async function run(ctx: ScriptContext, req: Request): Promise { + // Stricter rate limit for login attempts + await ctx.modules.rateLimit.throttlePublic({ + requests: 5, + period: 300 // 5 minutes + }); + + // Login logic here + // ... + } + ``` + + ```typescript scripts/fetch_game_state.ts + export async function run(ctx: ScriptContext, req: Request): Promise { + // More lenient rate limit for fetching game state + await ctx.modules.rateLimit.throttlePublic({ + requests: 60, + period: 60 // 1 minute + }); + + // Fetch game state logic here + // ... + } + ``` + + +8. **Handle Rate Limit Errors Gracefully**: In your client-side code, be prepared to handle rate limit errors and potentially implement exponential backoff for retries. + +## Example: Rate Limited Leaderboard Fetch + +Here's an example of how you might implement a rate-limited public script for fetching a game leaderboard: + + +```typescript scripts/fetch_leaderboard.ts +import { ScriptContext } from "../module.gen.ts"; + +export interface Request { + gameMode: string; + limit: number; +} + +export interface Response { + leaderboard: LeaderboardEntry[]; +} + +interface LeaderboardEntry { + username: string; + score: number; +} + +export async function run( + ctx: ScriptContext, + req: Request +): Promise { + // Apply rate limiting: 10 requests per minute + await ctx.modules.rateLimit.throttlePublic({ + requests: 10, + period: 60 + }); + + // Validate request + if (req.limit > 100) { + throw new Error("Limit cannot exceed 100"); + } + + // Fetch leaderboard logic here (example) + const leaderboard = await ctx.modules.gameData.getLeaderboard(req.gameMode, req.limit); + + return { + leaderboard + }; +} +``` + + +In this example, we've applied a rate limit of 10 requests per minute to the leaderboard fetch script. This allows frequent updates for players while still protecting the backend from excessive requests. diff --git a/modules/tokens/README.md b/modules/tokens/README.md index bd567f2d..b6874a73 100644 --- a/modules/tokens/README.md +++ b/modules/tokens/README.md @@ -1,4 +1,383 @@ -# tokens +# Tokens + +The token module allows you to create, validate, fetch, and revoke tokens. These tokens are typically used for authentication and authorization purposes in your game backend. + +## Common Use Cases + +1. **User Authentication**: Create tokens when users log in and validate them on subsequent requests. +2. **Session Management**: Use tokens to maintain user sessions across multiple requests. +3. **API Access**: Generate tokens for third-party applications to access your game's API. +4. **Password Reset**: Create short-lived tokens for password reset functionality. +5. **Email Verification**: Generate tokens to verify user email addresses. + +## Token Parameters + +When creating or working with tokens, you'll encounter the following parameters: + +1. `type` (string): Identifies the purpose of the token (e.g., "user", "api", "reset_password"). +2. `meta` (object): Additional metadata associated with the token. +3. `expireAt` (string, optional): ISO 8601 timestamp for when the token should expire. +4. `token` (string): The actual token string used for authentication. +5. `id` (string): A unique identifier for the token. +6. `createdAt` (string): ISO 8601 timestamp of when the token was created. +7. `revokedAt` (string, optional): ISO 8601 timestamp of when the token was revoked, if applicable. + +## Functionality + +The token module provides the following core functions: + +### 1. Create Token + +Creates a new token with the specified parameters. + + +```typescript scripts/create.ts +export interface Request { + type: string; + meta: Record; + expireAt?: string; +} + +export interface Response { + token: TokenWithSecret; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + const tokenStr = generateToken(req.type); + + const rows = await ctx.db.insert(Database.tokens) + .values({ + token: tokenStr, + type: req.type, + meta: req.meta, + trace: ctx.trace, + expireAt: req.expireAt ? new Date(req.expireAt) : undefined, + }) + .returning(); + + return { + token: tokenFromRow(rows[0]!), + }; +} +``` + + +### 2. Validate Token + +Validates a given token, checking if it exists and hasn't expired or been revoked. + + +```typescript scripts/validate.ts +export interface Request { + token: string; +} + +export interface Response { + token: Token; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + const { tokens } = await ctx.modules.tokens.fetchByToken({ + tokens: [req.token], + }); + const token = tokens[0]; + + if (!token) throw new RuntimeError("token_not_found"); + + if (token.revokedAt) throw new RuntimeError("token_revoked"); + + if (token.expireAt) { + const expireAt = new Date(token.expireAt); + const now = new Date(); + if (expireAt < now) { + throw new RuntimeError("token_expired"); + } + } + + return { token }; +} +``` + + +### 3. Fetch Tokens + +Retrieves token information based on token IDs. + + +```typescript scripts/fetch.ts +export interface Request { + tokenIds: string[]; +} + +export interface Response { + tokens: Token[]; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + const rows = await ctx.db.query.tokens.findMany({ + where: Query.inArray(Database.tokens.id, req.tokenIds), + orderBy: [Query.desc(Database.tokens.createdAt)] + }); + + const tokens = rows.map(tokenFromRow); + + return { tokens }; +} +``` + + +### 4. Fetch by Token + +Retrieves token information based on the token strings. + + +```typescript scripts/fetch_by_token.ts +export interface Request { + tokens: string[]; +} + +export interface Response { + tokens: Token[]; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + const rows = await ctx.db.query.tokens.findMany({ + where: Query.inArray(Database.tokens.token, req.tokens), + orderBy: [Query.desc(Database.tokens.createdAt)] + }); + + const tokens = rows.map(tokenFromRow); + + return { tokens }; +} +``` + + +### 5. Revoke Tokens + +Revokes one or more tokens, preventing their further use. + + +```typescript scripts/revoke.ts +export interface Request { + tokenIds: string[]; +} + +export interface Response { + updates: { [key: string]: TokenUpdate }; +} + +export enum TokenUpdate { + Revoked = "revoked", + AlreadyRevoked = "already_revoked", + NotFound = "not_found", +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + interface TokenRow { + id: string; + already_revoked: boolean; + } + + // Sets revokedAt on all tokens that have not already been revoked. Returns + // whether or not each token was revoked. + const { rows }: { rows: TokenRow[] } = await ctx.db.execute(Query.sql` + WITH pre_update AS ( + SELECT + ${Database.tokens.id} AS id, + ${Database.tokens.revokedAt} AS revoked_at + FROM ${Database.tokens} + WHERE ${Database.tokens.id} IN ${req.tokenIds} + ) + UPDATE ${Database.tokens} + SET revoked_at = COALESCE(${Database.tokens.revokedAt}, current_timestamp) + FROM pre_update + WHERE ${Database.tokens.id} = pre_update.id + RETURNING + ${Database.tokens.id} AS id, + pre_update.revoked_at IS NOT NULL AS already_revoked + `); + + const updates: Record = {}; + for (const tokenId of req.tokenIds) { + const tokenRow = rows.find((row) => row.id === tokenId); + if (tokenRow) { + updates[tokenId] = tokenRow.already_revoked + ? TokenUpdate.AlreadyRevoked + : TokenUpdate.Revoked; + } else { + updates[tokenId] = TokenUpdate.NotFound; + } + } + + return { updates }; +} +``` + + +### 6. Extend Token + +Extends the expiration date of a token. + + +```typescript scripts/extend.ts +export interface Request { + token: string; + newExpiration: string | null; +} + +export interface Response { + token: TokenWithSecret; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Ensure the token hasn't expired or been revoked yet + const { token } = await ctx.modules.tokens.validate({ + token: req.token, + }); + + // Update the token's expiration date + const rows = await ctx.db.update(Database.tokens) + .set({ expireAt: req.newExpiration ? new Date(req.newExpiration) : null }) + .where(Query.eq(Database.tokens.id, token.id)) + .returning(); + + // Return the updated token + return { + token: tokenFromRow(rows[0]!), + }; +} +``` + + +## Errors + +The token module can throw the following errors: + +1. `token_not_found`: The specified token does not exist. +2. `token_revoked`: The token has been revoked and is no longer valid. +3. `token_expired`: The token has expired and is no longer valid. + +## Common Patterns + +### 1. Creating and Using User Tokens + +When a user logs in, create a token and return it to the client: + +```typescript +const { token } = await ctx.modules.tokens.create({ + type: "user", + meta: { userId: user.id }, + expireAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), // 30 days +}); + +// Return token to client +return { userToken: token.token }; +``` + +On subsequent requests, validate the token: + +```typescript +const { token } = await ctx.modules.tokens.validate({ + token: req.userToken, +}); + +// Use token.meta.userId to identify the user +const userId = token.meta.userId; +``` + +### 2. Passing Tokens in Requests + +Tokens are typically passed in the `Authorization` header of HTTP requests: + +```http +Authorization: Bearer +``` + +In your route handlers, you can extract and validate the token: + +```typescript +export async function run(ctx: RouteContext, req: Request): Promise { + const authHeader = req.headers.get("Authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + throw new RuntimeError("invalid_auth_header"); + } + + const token = authHeader.slice(7); // Remove "Bearer " prefix + + try { + const { token: validatedToken } = await ctx.modules.tokens.validate({ token }); + // Use validatedToken.meta to access token metadata + } catch (error) { + if (error instanceof RuntimeError && error.code === "token_not_found") { + throw new RuntimeError("unauthorized"); + } + throw error; + } + + // Proceed with the authenticated request +} +``` + +### 3. Revoking Tokens on Logout + +When a user logs out, revoke their token: + +```typescript +await ctx.modules.tokens.revoke({ + tokenIds: [userToken.id], +}); +``` + +### 4. Using Short-lived Tokens for Password Reset + +Create a short-lived token for password reset: + +```typescript +const { token } = await ctx.modules.tokens.create({ + type: "password_reset", + meta: { userId: user.id }, + expireAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour +}); + +// Send token to user's email +await sendPasswordResetEmail(user.email, token.token); +``` + +When the user clicks the reset link, validate the token: + +```typescript +const { token } = await ctx.modules.tokens.validate({ + token: req.resetToken, +}); + +// Ensure token type is correct +if (token.type !== "password_reset") { + throw new RuntimeError("invalid_token_type"); +} + +// Allow password reset for token.meta.userId +``` + +By following these patterns and utilizing the token module's functionality, you can implement secure authentication and authorization in your Open Game Backend projects. ## FAQ diff --git a/modules/uploads/README.md b/modules/uploads/README.md new file mode 100644 index 00000000..04c268d0 --- /dev/null +++ b/modules/uploads/README.md @@ -0,0 +1,311 @@ +# Uploads + +The uploads module in Open Game Backend provides a robust system for handling file uploads in your game. It allows you to securely store and manage various types of files, such as user-generated content, game assets, or any other binary data your game might need to handle. + +## Overview + +The uploads module offers the following key features: + +1. Secure file uploading +2. Support for both single-part and multi-part uploads +3. File metadata management +4. Presigned URLs for direct client-side uploads +5. File retrieval and deletion + +## Getting Started + +To use the uploads module in your Open Game Backend project, you'll need to ensure it's properly configured in your `backend.json` file. Here's an example configuration: + + +```json backend.json +{ + "modules": { + "uploads": { + "config": { + "maxUploadSize": "30mib", + "maxMultipartUploadSize": "10gib", + "maxFilesPerUpload": 10, + "defaultMultipartChunkSize": "10mib" + } + } + } +} +``` + + +Let's break down these configuration options: + +- `maxUploadSize`: The maximum size for a single-part upload (default: 30 MiB) +- `maxMultipartUploadSize`: The maximum size for a multi-part upload (default: 10 GiB) +- `maxFilesPerUpload`: The maximum number of files allowed in a single upload request (default: 10) +- `defaultMultipartChunkSize`: The default chunk size for multi-part uploads (default: 10 MiB) + +## Core Concepts + +### Upload Process + +The upload process in the uploads module consists of three main steps: + +1. **Prepare**: Initialize the upload and get presigned URLs for uploading. +2. **Upload**: Use the presigned URLs to upload file data directly to storage. +3. **Complete**: Finalize the upload process. + +### Single-part vs Multi-part Uploads + +- **Single-part uploads** are suitable for smaller files (up to 30 MiB by default). +- **Multi-part uploads** are used for larger files, allowing them to be uploaded in chunks. + +## API Reference + +### Prepare Upload + +Prepares an upload by creating metadata and generating presigned URLs for file uploads. + + +```typescript +async function prepareUpload(ctx: ScriptContext, req: { + files: { + path: string; + contentLength: string; + mime: string; + multipart: boolean; + }[]; + metadata?: unknown; +}): Promise<{ + upload: PresignedUpload; +}> +``` + + +#### Parameters: + +- `files`: An array of file objects containing: + - `path`: The desired path/name for the file + - `contentLength`: The size of the file in bytes (as a string) + - `mime`: The MIME type of the file + - `multipart`: Whether to use multi-part upload (for large files) +- `metadata`: Optional metadata to associate with the upload + +#### Returns: + +- `upload`: A `PresignedUpload` object containing upload details and presigned URLs for each file + +### Complete Upload + +Finalizes an upload after all files have been uploaded. + + +```typescript +async function completeUpload(ctx: ScriptContext, req: { + uploadId: string; +}): Promise<{ + upload: UploadWithoutFiles; +}> +``` + + +#### Parameters: + +- `uploadId`: The ID of the upload to complete + +#### Returns: + +- `upload`: An `UploadWithoutFiles` object containing upload details + +### Fetch Upload Metadata + +Retrieves metadata for one or more uploads. + + +```typescript +async function fetchUploadMetadata(ctx: ScriptContext, req: { + uploadIds: string[]; + includeFiles?: boolean; +}): Promise<{ + uploads: UploadWithOptionalFiles[]; +}> +``` + + +#### Parameters: + +- `uploadIds`: An array of upload IDs to fetch +- `includeFiles`: Whether to include file details in the response (optional) + +#### Returns: + +- `uploads`: An array of `UploadWithOptionalFiles` objects containing upload details + +### Fetch Public File URLs + +Generates public URLs for accessing uploaded files. + + +```typescript +async function fetchPublicFileUrls(ctx: ScriptContext, req: { + uploadId: string; + filePaths: string[]; + expirySeconds?: number; +}): Promise<{ + files: DownloadableFile[]; +}> +``` + + +#### Parameters: + +- `uploadId`: The ID of the upload containing the files +- `filePaths`: An array of file paths to generate URLs for +- `expirySeconds`: The number of seconds the URLs should remain valid (optional) + +#### Returns: + +- `files`: An array of `DownloadableFile` objects containing file details and public URLs + +### Delete Upload + +Removes an upload and its associated files from storage. + + +```typescript +async function deleteUpload(ctx: ScriptContext, req: { + uploadId: string; +}): Promise<{ + bytesDeleted: string; +}> +``` + + +#### Parameters: + +- `uploadId`: The ID of the upload to delete + +#### Returns: + +- `bytesDeleted`: The number of bytes deleted (as a string) + +## Usage Examples + +### Preparing and Completing a Single-part Upload + +Here's an example of how to prepare and complete a single-part upload: + + +```typescript +import { ScriptContext } from "opengb"; + +export async function uploadProfilePicture(ctx: ScriptContext, req: { + userId: string; + fileName: string; + fileSize: number; + mimeType: string; +}) { + // Prepare the upload + const { upload } = await ctx.modules.uploads.prepare({ + files: [{ + path: `users/${req.userId}/profile-picture/${req.fileName}`, + contentLength: req.fileSize.toString(), + mime: req.mimeType, + multipart: false + }], + metadata: { userId: req.userId } + }); + + // Return the presigned URL to the client + const presignedUrl = upload.files[0].presignedChunks[0].url; + + // The client would use this URL to upload the file directly + + // After the client has uploaded the file, complete the upload + await ctx.modules.uploads.complete({ uploadId: upload.id }); + + return { message: "Profile picture uploaded successfully" }; +} +``` + + +### Handling a Multi-part Upload + +For larger files, you'll want to use multi-part uploads. Here's an example: + + +```typescript +import { ScriptContext } from "opengb"; + +export async function uploadLargeFile(ctx: ScriptContext, req: { + userId: string; + fileName: string; + fileSize: number; + mimeType: string; +}) { + // Prepare the multi-part upload + const { upload } = await ctx.modules.uploads.prepare({ + files: [{ + path: `users/${req.userId}/large-files/${req.fileName}`, + contentLength: req.fileSize.toString(), + mime: req.mimeType, + multipart: true + }], + metadata: { userId: req.userId } + }); + + // Return the presigned URLs for each chunk to the client + const presignedChunks = upload.files[0].presignedChunks; + + // The client would use these URLs to upload each chunk of the file + + // After the client has uploaded all chunks, complete the upload + await ctx.modules.uploads.complete({ uploadId: upload.id }); + + return { message: "Large file uploaded successfully" }; +} +``` + + +### Retrieving and Using Uploaded Files + +Here's how you can fetch and use the URLs for uploaded files: + + +```typescript +import { ScriptContext } from "opengb"; + +export async function getProfilePictureUrl(ctx: ScriptContext, req: { + userId: string; + uploadId: string; +}) { + const { files } = await ctx.modules.uploads.fetchPublicFileUrls({ + uploadId: req.uploadId, + filePaths: [`users/${req.userId}/profile-picture`], + expirySeconds: 3600 // URL will be valid for 1 hour + }); + + if (files.length === 0) { + throw new Error("Profile picture not found"); + } + + return { profilePictureUrl: files[0].url }; +} +``` + + +## Best Practices + +1. **Use multi-part uploads for large files**: This improves reliability and allows for resumable uploads. +2. **Set appropriate expiry times for public URLs**: Balance security with usability when generating public file URLs. +3. **Validate file types and sizes**: Implement checks on the client-side and server-side to ensure only allowed file types and sizes are uploaded. +4. **Use meaningful file paths**: Organize your uploads with a clear directory structure (e.g., `users/{userId}/profile-pictures/{fileName}`). +5. **Clean up unused uploads**: Implement a system to periodically delete unused or temporary uploads to manage storage efficiently. + +## Error Handling + +The uploads module can throw various errors. Here are some common ones to handle: + +- `no_files`: Thrown when trying to create an upload with no files. +- `too_many_files`: Thrown when the number of files exceeds `maxFilesPerUpload`. +- `duplicate_paths`: Thrown when two files in the same upload have the same path. +- `size_limit_exceeded`: Thrown when the total upload size exceeds the configured limit. +- `upload_not_found`: Thrown when trying to operate on a non-existent upload. +- `upload_already_completed`: Thrown when trying to complete an already completed upload. + +Always wrap your upload operations in try-catch blocks and handle these errors gracefully in your game logic. diff --git a/tests/basic/deno.lock b/tests/basic/deno.lock index 5af0b04c..969c62be 100644 --- a/tests/basic/deno.lock +++ b/tests/basic/deno.lock @@ -9,6 +9,7 @@ "npm:@types/node": "npm:@types/node@18.16.19", "npm:drizzle-orm@0.33.0": "npm:drizzle-orm@0.33.0_pg@8.12.0", "npm:pg@8.11.3": "npm:pg@8.11.3", + "npm:pg@8.12.0": "npm:pg@8.12.0", "npm:pg@^8.11.3": "npm:pg@8.12.0", "npm:tsup": "npm:tsup@8.2.4_typescript@5.5.4_esbuild@0.23.1", "npm:typescript": "npm:typescript@5.5.4", @@ -1166,11 +1167,42 @@ "https://deno.land/std@0.222.1/encoding/base64url.ts": "ef40e0f18315ab539f17cebcc32839779e018d86dea9df39d94d302f342a1713", "https://deno.land/std@0.222.1/encoding/hex.ts": "6270f25e5d85f99fcf315278670ba012b04b7c94b67715b53f30d03249687c07", "https://deno.land/std@0.222.1/fmt/colors.ts": "d239d84620b921ea520125d778947881f62c50e78deef2657073840b8af9559a", + "https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975", "https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293", + "https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7", + "https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74", + "https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff", + "https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46", + "https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b", + "https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c", + "https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491", + "https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68", + "https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3", + "https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7", + "https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29", + "https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a", + "https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a", + "https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8", + "https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693", + "https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31", + "https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5", + "https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8", + "https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb", "https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47", + "https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68", + "https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3", + "https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73", + "https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19", "https://deno.land/std@0.224.0/collections/_utils.ts": "b2ec8ada31b5a72ebb1d99774b849b4c09fe4b3a38d07794bd010bd218a16e0b", "https://deno.land/std@0.224.0/collections/deep_merge.ts": "04f8d2a6cfa15c7580e788689bcb5e162512b9ccb18bab1241824b432a78551e", "https://deno.land/std@0.224.0/crypto/timing_safe_equal.ts": "bc3622b5aec05e2d8b735bf60633425c34333c06cfb6c4a9f102e4a0f3931ced", + "https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5", + "https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6", + "https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2", + "https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e", "https://deno.land/x/argontwo@0.2.0/mod.ts": "076395861fc86d5b475ed49ecf10a35df47db55a506469da054a92969d7cdf66", "https://deno.land/x/argontwo@0.2.0/wasm/mod.ts": "588486da70ccd43760b71fb3d236ff37d1790d3419cbb764c35fc05a550a2881", "https://deno.land/x/argontwo@0.2.0/wasm/wasm.js": "c5fdbe828b5984d9a434247ca2cf6cd44e8f7560490813cdd92be785ec57d3fa", @@ -2501,6 +2533,8 @@ "https://esm.sh/v135/strnum@1.0.5/denonext/strnum.mjs": "1ffef4adec2f74139e36a2bfed8381880541396fe1c315779fb22e081b17468b", "https://esm.sh/v135/tslib@2.6.2/denonext/tslib.mjs": "29782bcd3139f77ec063dc5a9385c0fff4a8d0a23b6765c73d9edeb169a04bf1", "https://esm.sh/v135/tslib@2.6.3/denonext/tslib.mjs": "0834c22e9fbf95f6a5659cc2017543f7d41aa880f24ab84cb11d24e6bee99303", - "https://esm.sh/v135/uuid@9.0.1/denonext/uuid.mjs": "7d7d3aa57fa136e2540886654c416d9da10d8cfebe408bae47fd47070f0bfb2a" + "https://esm.sh/v135/uuid@9.0.1/denonext/uuid.mjs": "7d7d3aa57fa136e2540886654c416d9da10d8cfebe408bae47fd47070f0bfb2a", + "https://esm.sh/zod-validation-error@3.3.0": "d8825ca67952b6adff6b35026dc465f9638d4923dbd54fe9e8e81fbfddca9630", + "https://esm.sh/zod@3.23.8": "728819c1f651800179a5a80daf24b3e54b2ddea87828bd10e63875a604bcb94e" } }