@smuzi/logger-ui 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build/auth.d.ts +17 -0
- package/build/auth.js +104 -0
- package/build/commands.d.ts +7 -0
- package/build/commands.js +67 -0
- package/build/handlers.d.ts +8 -2
- package/build/handlers.js +108 -10
- package/build/index.d.ts +4 -0
- package/build/index.js +4 -0
- package/build/password.d.ts +4 -0
- package/build/password.js +33 -0
- package/build/query.js +6 -0
- package/build/routes.d.ts +7 -2
- package/build/routes.js +16 -6
- package/build/server.d.ts +3 -2
- package/build/server.js +4 -4
- package/build/ui/app.css +1 -1
- package/build/ui/app.js +2 -2
- package/build/ui/index.html +126 -15
- package/build/ui/login.html +37 -0
- package/build/users.d.ts +19 -0
- package/build/users.js +68 -0
- package/package.json +10 -4
package/build/auth.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { HttpRequest, Option, Result, StdError } from "@smuzi/std";
|
|
2
|
+
import { LoggerUiUser, LoggerUiUsersRepository } from "./users.js";
|
|
3
|
+
export declare const LOGGER_UI_SESSION_COOKIE = "logger_ui_session";
|
|
4
|
+
export declare const LOGGER_UI_SESSION_DEFAULT_TTL_SECONDS: number;
|
|
5
|
+
export type LoggerUiAuthOptions = {
|
|
6
|
+
session_secret?: string;
|
|
7
|
+
session_ttl_seconds?: number;
|
|
8
|
+
secure_cookie?: boolean;
|
|
9
|
+
};
|
|
10
|
+
export declare class LoggerUiAuth {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(users: LoggerUiUsersRepository, { session_secret, session_ttl_seconds, secure_cookie }?: LoggerUiAuthOptions);
|
|
13
|
+
login(email: string, password: string): Promise<Result<Option<string>, StdError>>;
|
|
14
|
+
authenticate(request: HttpRequest): Promise<Result<Option<LoggerUiUser>, StdError>>;
|
|
15
|
+
sessionCookie(token: string, path: string): string;
|
|
16
|
+
clearSessionCookie(path: string): string;
|
|
17
|
+
}
|
package/build/auth.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { Err, None, Ok, Some } from "@smuzi/std";
|
|
3
|
+
import { generatePassword, hashPassword, verifyPassword } from "./password.js";
|
|
4
|
+
export const LOGGER_UI_SESSION_COOKIE = "logger_ui_session";
|
|
5
|
+
export const LOGGER_UI_SESSION_DEFAULT_TTL_SECONDS = 60 * 60 * 12;
|
|
6
|
+
function readCookie(request, name) {
|
|
7
|
+
const header = request.headers.get("cookie").someOr("");
|
|
8
|
+
for (const part of header.split(";")) {
|
|
9
|
+
const separator = part.indexOf("=");
|
|
10
|
+
if (separator !== -1 && part.slice(0, separator).trim() === name) {
|
|
11
|
+
return Some(decodeURIComponent(part.slice(separator + 1).trim()));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return None();
|
|
15
|
+
}
|
|
16
|
+
function safeEqual(left, right) {
|
|
17
|
+
const left_buffer = Buffer.from(left);
|
|
18
|
+
const right_buffer = Buffer.from(right);
|
|
19
|
+
return left_buffer.length === right_buffer.length && timingSafeEqual(left_buffer, right_buffer);
|
|
20
|
+
}
|
|
21
|
+
export class LoggerUiAuth {
|
|
22
|
+
#users;
|
|
23
|
+
#secret;
|
|
24
|
+
#ttl_seconds;
|
|
25
|
+
#secure_cookie;
|
|
26
|
+
#dummy_hash = null;
|
|
27
|
+
constructor(users, { session_secret, session_ttl_seconds = LOGGER_UI_SESSION_DEFAULT_TTL_SECONDS, secure_cookie = false } = {}) {
|
|
28
|
+
this.#users = users;
|
|
29
|
+
this.#secret = session_secret !== undefined && session_secret !== ""
|
|
30
|
+
? Buffer.from(session_secret)
|
|
31
|
+
: randomBytes(32);
|
|
32
|
+
this.#ttl_seconds = session_ttl_seconds;
|
|
33
|
+
this.#secure_cookie = secure_cookie;
|
|
34
|
+
}
|
|
35
|
+
// The password hash is part of the signature, so a changed password invalidates old sessions.
|
|
36
|
+
#sign(payload, user) {
|
|
37
|
+
return createHmac("sha256", this.#secret)
|
|
38
|
+
.update(payload + "." + user.password_hash)
|
|
39
|
+
.digest("base64url");
|
|
40
|
+
}
|
|
41
|
+
#createToken(user) {
|
|
42
|
+
const expires_at = Math.floor(Date.now() / 1000) + this.#ttl_seconds;
|
|
43
|
+
const payload = user.id + "." + expires_at;
|
|
44
|
+
return payload + "." + this.#sign(payload, user);
|
|
45
|
+
}
|
|
46
|
+
// Hashing against a throwaway hash keeps response time the same for unknown emails.
|
|
47
|
+
#dummyHash() {
|
|
48
|
+
this.#dummy_hash ??= hashPassword(generatePassword());
|
|
49
|
+
return this.#dummy_hash;
|
|
50
|
+
}
|
|
51
|
+
// Returns the session token on valid credentials.
|
|
52
|
+
async login(email, password) {
|
|
53
|
+
const found = await this.#users.findByEmail(email);
|
|
54
|
+
if (found.isErr()) {
|
|
55
|
+
return Err(found.unsafeSource());
|
|
56
|
+
}
|
|
57
|
+
const user = found.unwrap();
|
|
58
|
+
if (user.isNone()) {
|
|
59
|
+
await verifyPassword(password, await this.#dummyHash());
|
|
60
|
+
return Ok(None());
|
|
61
|
+
}
|
|
62
|
+
const valid = await verifyPassword(password, user.unwrap().password_hash);
|
|
63
|
+
return Ok(valid ? Some(this.#createToken(user.unwrap())) : None());
|
|
64
|
+
}
|
|
65
|
+
async authenticate(request) {
|
|
66
|
+
const token = readCookie(request, LOGGER_UI_SESSION_COOKIE);
|
|
67
|
+
if (token.isNone()) {
|
|
68
|
+
return Ok(None());
|
|
69
|
+
}
|
|
70
|
+
const [id, expires_at, signature] = token.unwrap().split(".");
|
|
71
|
+
const user_id = Number(id);
|
|
72
|
+
if (!Number.isInteger(user_id) || signature === undefined || Number(expires_at) < Date.now() / 1000) {
|
|
73
|
+
return Ok(None());
|
|
74
|
+
}
|
|
75
|
+
const found = await this.#users.findById(user_id);
|
|
76
|
+
if (found.isErr()) {
|
|
77
|
+
return found;
|
|
78
|
+
}
|
|
79
|
+
const user = found.unwrap();
|
|
80
|
+
if (user.isNone() || !safeEqual(signature, this.#sign(id + "." + expires_at, user.unwrap()))) {
|
|
81
|
+
return Ok(None());
|
|
82
|
+
}
|
|
83
|
+
return Ok(user);
|
|
84
|
+
}
|
|
85
|
+
#cookie(value, path, max_age) {
|
|
86
|
+
const attributes = [
|
|
87
|
+
`${LOGGER_UI_SESSION_COOKIE}=${encodeURIComponent(value)}`,
|
|
88
|
+
`Path=${path === "" ? "/" : path}`,
|
|
89
|
+
`Max-Age=${max_age}`,
|
|
90
|
+
"HttpOnly",
|
|
91
|
+
"SameSite=Strict",
|
|
92
|
+
];
|
|
93
|
+
if (this.#secure_cookie) {
|
|
94
|
+
attributes.push("Secure");
|
|
95
|
+
}
|
|
96
|
+
return attributes.join("; ");
|
|
97
|
+
}
|
|
98
|
+
sessionCookie(token, path) {
|
|
99
|
+
return this.#cookie(token, path, this.#ttl_seconds);
|
|
100
|
+
}
|
|
101
|
+
clearSessionCookie(path) {
|
|
102
|
+
return this.#cookie("", path, 0);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { TInputParams, TOutputConsole } from "@smuzi/console";
|
|
2
|
+
import { Result } from "@smuzi/std";
|
|
3
|
+
import { LoggerUiUsersRepository } from "./users.js";
|
|
4
|
+
export declare function parseUserEmail(value: string): Result<string, string>;
|
|
5
|
+
export declare function createUserCommand(users: LoggerUiUsersRepository): (output: TOutputConsole, params: TInputParams) => Promise<void>;
|
|
6
|
+
export declare function resetPasswordCommand(users: LoggerUiUsersRepository): (output: TOutputConsole, params: TInputParams) => Promise<void>;
|
|
7
|
+
export declare const loggerUiConsole: (users: LoggerUiUsersRepository, prefix_command?: string) => import("@smuzi/console").ConsoleRouter;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { CreateConsoleRouter } from "@smuzi/console";
|
|
2
|
+
import { Err, Ok, Some } from "@smuzi/std";
|
|
3
|
+
import { generatePassword, hashPassword } from "./password.js";
|
|
4
|
+
import { normalizeEmail } from "./users.js";
|
|
5
|
+
export function parseUserEmail(value) {
|
|
6
|
+
const email = normalizeEmail(value);
|
|
7
|
+
if (email === "") {
|
|
8
|
+
return Err("Argument --email is required, e.g. --email=admin@example.com");
|
|
9
|
+
}
|
|
10
|
+
const at = email.indexOf("@");
|
|
11
|
+
if (at <= 0 || at === email.length - 1) {
|
|
12
|
+
return Err(`'${email}' is not an email: expected <name>@<domain>`);
|
|
13
|
+
}
|
|
14
|
+
return Ok(email);
|
|
15
|
+
}
|
|
16
|
+
export function createUserCommand(users) {
|
|
17
|
+
return async (output, params) => {
|
|
18
|
+
const email = parseUserEmail(params.get("email").someOr(""));
|
|
19
|
+
if (email.isErr()) {
|
|
20
|
+
output.error(email.unsafeSource());
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const existing = (await users.findByEmail(email.unwrap())).unwrap();
|
|
25
|
+
if (existing.isSome()) {
|
|
26
|
+
output.error(`User '${email.unwrap()}' already exists`);
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const password = generatePassword();
|
|
31
|
+
const user = (await users.create(email.unwrap(), await hashPassword(password))).unwrap();
|
|
32
|
+
output.success(`Logger UI user created: ${user.email}`);
|
|
33
|
+
output.info("Password (shown only once):");
|
|
34
|
+
output.bold(password);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function resetPasswordCommand(users) {
|
|
38
|
+
return async (output, params) => {
|
|
39
|
+
const email = parseUserEmail(params.get("email").someOr(""));
|
|
40
|
+
if (email.isErr()) {
|
|
41
|
+
output.error(email.unsafeSource());
|
|
42
|
+
process.exitCode = 1;
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const password = generatePassword();
|
|
46
|
+
// Updates only this one row by email; never affects any other user.
|
|
47
|
+
const updated = (await users.updatePasswordByEmail(email.unwrap(), await hashPassword(password))).unwrap();
|
|
48
|
+
if (updated.isNone()) {
|
|
49
|
+
output.error(`User '${email.unwrap()}' does not exist`);
|
|
50
|
+
process.exitCode = 1;
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
output.success(`Logger UI user password reset: ${updated.unwrap().email}`);
|
|
54
|
+
output.info("New password (shown only once):");
|
|
55
|
+
output.bold(password);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// Usage: <prefix>users:create --email=admin@example.com
|
|
59
|
+
// Usage: <prefix>users:reset-password --email=admin@example.com
|
|
60
|
+
export const loggerUiConsole = (users, prefix_command = "logger-ui:") => {
|
|
61
|
+
const router = CreateConsoleRouter(prefix_command);
|
|
62
|
+
const router_users = CreateConsoleRouter("users:");
|
|
63
|
+
router_users.add({ path: "create", description: Some("Create logger UI user and print generated password: --email=<email>") }, createUserCommand(users));
|
|
64
|
+
router_users.add({ path: "reset-password", description: Some("Generate and set a new password for an existing user, print it: --email=<email>") }, resetPasswordCommand(users));
|
|
65
|
+
router.group(router_users);
|
|
66
|
+
return router;
|
|
67
|
+
};
|
package/build/handlers.d.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { ServerResponse } from "node:http";
|
|
2
2
|
import { Action } from "@smuzi/http-server";
|
|
3
3
|
import { LogsReader } from "@smuzi/logger";
|
|
4
|
+
import { HttpClient } from "@smuzi/http-client";
|
|
5
|
+
import { LoggerUiAuth } from "./auth.js";
|
|
4
6
|
export declare enum UiAsset {
|
|
5
7
|
script = "app.js",
|
|
6
8
|
style = "app.css"
|
|
7
9
|
}
|
|
8
|
-
export declare function uiPageAction(base_path: string): Action<ServerResponse>;
|
|
10
|
+
export declare function uiPageAction(auth: LoggerUiAuth, base_path: string): Action<ServerResponse>;
|
|
11
|
+
export declare function loginPageAction(auth: LoggerUiAuth, base_path: string): Action<ServerResponse>;
|
|
12
|
+
export declare function loginAction(auth: LoggerUiAuth, base_path: string): Action<ServerResponse>;
|
|
13
|
+
export declare function logoutAction(auth: LoggerUiAuth, base_path: string): Action<ServerResponse>;
|
|
9
14
|
export declare function uiAssetAction(asset: UiAsset): Action<ServerResponse>;
|
|
10
|
-
export declare function logsQueryAction(reader: LogsReader): Action<ServerResponse>;
|
|
15
|
+
export declare function logsQueryAction(auth: LoggerUiAuth, reader: LogsReader): Action<ServerResponse>;
|
|
16
|
+
export declare function logRetryAction(auth: LoggerUiAuth, reader: LogsReader, http_client: HttpClient): Action<ServerResponse>;
|
package/build/handlers.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { HttpResponse, ResponseHttpHeaders, Some } from "@smuzi/std";
|
|
2
|
+
import { asObject, asString, HttpResponse, ResponseHttpHeaders, Some } from "@smuzi/std";
|
|
3
3
|
import { parseLogsQuery } from "./query.js";
|
|
4
4
|
// Resolves to <package>/build/ui both from src/ (workspace) and from build/ (published).
|
|
5
5
|
const UI_DIR = new URL("../build/ui/", import.meta.url);
|
|
@@ -37,15 +37,67 @@ function escapeHtmlAttribute(value) {
|
|
|
37
37
|
.replace(/</g, "<")
|
|
38
38
|
.replace(/>/g, ">");
|
|
39
39
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
async function htmlPageResponse(file, base_path) {
|
|
41
|
+
const html = await readUiFile(file);
|
|
42
|
+
if (html === null) {
|
|
43
|
+
return assetsNotBuiltResponse();
|
|
44
|
+
}
|
|
45
|
+
return textResponse(html.replaceAll("{{base}}", escapeHtmlAttribute(base_path)), "text/html; charset=utf-8");
|
|
46
|
+
}
|
|
47
|
+
function authErrorResponse(message) {
|
|
48
|
+
return HttpResponse.asJson({ error: message }, 500);
|
|
49
|
+
}
|
|
50
|
+
// Runs the action only for a logged-in user; guests get `onGuest`.
|
|
51
|
+
function authenticated(auth, action, onGuest) {
|
|
52
|
+
return async (context) => {
|
|
53
|
+
const user = await auth.authenticate(context.request);
|
|
54
|
+
if (user.isErr()) {
|
|
55
|
+
return authErrorResponse(user.unsafeSource().message);
|
|
56
|
+
}
|
|
57
|
+
return user.unwrap().isSome() ? action(context) : onGuest(context);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function uiPageAction(auth, base_path) {
|
|
61
|
+
return authenticated(auth, () => htmlPageResponse("index.html", base_path), () => HttpResponse.asRedirect(base_path + "/login"));
|
|
62
|
+
}
|
|
63
|
+
export function loginPageAction(auth, base_path) {
|
|
64
|
+
return authenticated(auth, () => HttpResponse.asRedirect(base_path === "" ? "/" : base_path), () => htmlPageResponse("login.html", base_path));
|
|
65
|
+
}
|
|
66
|
+
export function loginAction(auth, base_path) {
|
|
67
|
+
return async (context) => {
|
|
68
|
+
const body = await context.request.body();
|
|
69
|
+
if (body.isErr()) {
|
|
70
|
+
return HttpResponse.asJson({ error: "Unable to read request body" }, 400);
|
|
71
|
+
}
|
|
72
|
+
let input;
|
|
73
|
+
try {
|
|
74
|
+
input = JSON.parse(body.unwrap());
|
|
45
75
|
}
|
|
46
|
-
|
|
76
|
+
catch {
|
|
77
|
+
return HttpResponse.asJson({ error: "Request body must be valid JSON" }, 400);
|
|
78
|
+
}
|
|
79
|
+
if (!asObject(input) || !asString(input.email) || !asString(input.password)) {
|
|
80
|
+
return HttpResponse.asJson({ error: "'email' and 'password' must be strings" }, 422);
|
|
81
|
+
}
|
|
82
|
+
const token = await auth.login(input.email, input.password);
|
|
83
|
+
if (token.isErr()) {
|
|
84
|
+
return authErrorResponse(token.unsafeSource().message);
|
|
85
|
+
}
|
|
86
|
+
return token.unwrap().match({
|
|
87
|
+
Some: (value) => HttpResponse.asJson({ ok: true }).mapOk((response) => {
|
|
88
|
+
response.headers.set("set-cookie", auth.sessionCookie(value, base_path));
|
|
89
|
+
return response;
|
|
90
|
+
}),
|
|
91
|
+
None: () => HttpResponse.asJson({ error: "Invalid email or password" }, 401),
|
|
92
|
+
});
|
|
47
93
|
};
|
|
48
94
|
}
|
|
95
|
+
export function logoutAction(auth, base_path) {
|
|
96
|
+
return () => HttpResponse.asJson({ ok: true }).mapOk((response) => {
|
|
97
|
+
response.headers.set("set-cookie", auth.clearSessionCookie(base_path));
|
|
98
|
+
return response;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
49
101
|
export function uiAssetAction(asset) {
|
|
50
102
|
return async () => {
|
|
51
103
|
const content = await readUiFile(asset);
|
|
@@ -55,8 +107,8 @@ export function uiAssetAction(asset) {
|
|
|
55
107
|
return textResponse(content, ASSET_CONTENT_TYPES[asset]);
|
|
56
108
|
};
|
|
57
109
|
}
|
|
58
|
-
export function logsQueryAction(reader) {
|
|
59
|
-
return async (context) => {
|
|
110
|
+
export function logsQueryAction(auth, reader) {
|
|
111
|
+
return authenticated(auth, async (context) => {
|
|
60
112
|
const body = await context.request.body();
|
|
61
113
|
if (body.isErr()) {
|
|
62
114
|
return HttpResponse.asJson({ error: "Unable to read request body" }, 400);
|
|
@@ -70,5 +122,51 @@ export function logsQueryAction(reader) {
|
|
|
70
122
|
Ok: (value) => HttpResponse.asJson(value),
|
|
71
123
|
Err: (error) => HttpResponse.asJson({ error: error.message }, 500),
|
|
72
124
|
});
|
|
73
|
-
};
|
|
125
|
+
}, () => HttpResponse.asJson({ error: "Unauthorized" }, 401));
|
|
126
|
+
}
|
|
127
|
+
function pathParam(context, name) {
|
|
128
|
+
return context.pathParams.get(name);
|
|
129
|
+
}
|
|
130
|
+
export function logRetryAction(auth, reader, http_client) {
|
|
131
|
+
return authenticated(auth, async (context) => {
|
|
132
|
+
const id = Number(pathParam(context, "id").someOr(""));
|
|
133
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
134
|
+
return HttpResponse.asJson({ error: "Invalid log id" }, 400);
|
|
135
|
+
}
|
|
136
|
+
const found = await reader.getById(id);
|
|
137
|
+
if (found.isErr()) {
|
|
138
|
+
return HttpResponse.asJson({ error: found.unsafeSource().message }, 500);
|
|
139
|
+
}
|
|
140
|
+
const log = found.unwrap();
|
|
141
|
+
if (log.isNone()) {
|
|
142
|
+
return HttpResponse.asJson({ error: "Log not found" }, 404);
|
|
143
|
+
}
|
|
144
|
+
const retry = log.unwrap().retry;
|
|
145
|
+
if (retry === null) {
|
|
146
|
+
return HttpResponse.asJson({ error: "Log has no retry configuration" }, 422);
|
|
147
|
+
}
|
|
148
|
+
// Resends the original message as JSON; the http-client sets the correct
|
|
149
|
+
// "application/json" content-type itself for an object body. Uses plain
|
|
150
|
+
// fetch, so this goes out over HTTP/1 by default (no HTTP/2 dispatcher configured).
|
|
151
|
+
const response = await http_client.post(retry.url, {
|
|
152
|
+
body: Some({ message: log.unwrap().message }),
|
|
153
|
+
rawResponse: true,
|
|
154
|
+
});
|
|
155
|
+
const retries_count = await reader.incrementRetryCount(id);
|
|
156
|
+
const retries_count_value = retries_count.okOr(log.unwrap().retries_count);
|
|
157
|
+
return response.match({
|
|
158
|
+
Ok: (ok_response) => HttpResponse.asJson({
|
|
159
|
+
status: ok_response.status,
|
|
160
|
+
body: ok_response.body.someOr(""),
|
|
161
|
+
retries_count: retries_count_value,
|
|
162
|
+
}),
|
|
163
|
+
Err: (error) => error instanceof HttpResponse
|
|
164
|
+
? HttpResponse.asJson({
|
|
165
|
+
status: error.status,
|
|
166
|
+
body: error.body.someOr(""),
|
|
167
|
+
retries_count: retries_count_value,
|
|
168
|
+
})
|
|
169
|
+
: HttpResponse.asJson({ error: "Retry request failed: " + String(error) }, 502),
|
|
170
|
+
});
|
|
171
|
+
}, () => HttpResponse.asJson({ error: "Unauthorized" }, 401));
|
|
74
172
|
}
|
package/build/index.d.ts
CHANGED
package/build/index.js
CHANGED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const GENERATED_PASSWORD_LENGTH = 24;
|
|
2
|
+
export declare function generatePassword(length?: number): string;
|
|
3
|
+
export declare function hashPassword(password: string): Promise<string>;
|
|
4
|
+
export declare function verifyPassword(password: string, password_hash: string): Promise<boolean>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { randomBytes, randomInt, scrypt, timingSafeEqual } from "node:crypto";
|
|
2
|
+
const SCRYPT_KEY_LENGTH = 64;
|
|
3
|
+
const SCRYPT_SALT_LENGTH = 16;
|
|
4
|
+
const PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
|
|
5
|
+
export const GENERATED_PASSWORD_LENGTH = 24;
|
|
6
|
+
function scryptKey(password, salt) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
scrypt(password, salt, SCRYPT_KEY_LENGTH, (error, key) => error ? reject(error) : resolve(key));
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
// Alphanumeric without look-alike characters, so it is easy to copy from the terminal.
|
|
12
|
+
export function generatePassword(length = GENERATED_PASSWORD_LENGTH) {
|
|
13
|
+
let password = "";
|
|
14
|
+
for (let index = 0; index < length; index++) {
|
|
15
|
+
password += PASSWORD_ALPHABET[randomInt(PASSWORD_ALPHABET.length)];
|
|
16
|
+
}
|
|
17
|
+
return password;
|
|
18
|
+
}
|
|
19
|
+
// Stored as "scrypt$<salt>$<key>", both base64url.
|
|
20
|
+
export async function hashPassword(password) {
|
|
21
|
+
const salt = randomBytes(SCRYPT_SALT_LENGTH);
|
|
22
|
+
const key = await scryptKey(password, salt);
|
|
23
|
+
return ["scrypt", salt.toString("base64url"), key.toString("base64url")].join("$");
|
|
24
|
+
}
|
|
25
|
+
export async function verifyPassword(password, password_hash) {
|
|
26
|
+
const [algorithm, salt, key] = password_hash.split("$");
|
|
27
|
+
if (algorithm !== "scrypt" || salt === undefined || key === undefined) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
const expected = Buffer.from(key, "base64url");
|
|
31
|
+
const actual = await scryptKey(password, Buffer.from(salt, "base64url"));
|
|
32
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
33
|
+
}
|
package/build/query.js
CHANGED
|
@@ -39,6 +39,12 @@ export function parseLogsQuery(body) {
|
|
|
39
39
|
}
|
|
40
40
|
query.trace_id = input.trace_id;
|
|
41
41
|
}
|
|
42
|
+
if (input.message !== undefined && input.message !== null) {
|
|
43
|
+
if (!asString(input.message)) {
|
|
44
|
+
return Err("'message' must be a string");
|
|
45
|
+
}
|
|
46
|
+
query.message = input.message;
|
|
47
|
+
}
|
|
42
48
|
if (input.tags !== undefined && input.tags !== null) {
|
|
43
49
|
if (!asArray(input.tags)) {
|
|
44
50
|
return Err("'tags' must be an array of {key, value}");
|
package/build/routes.d.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { Http1Router } from "@smuzi/http-server";
|
|
2
2
|
import { LogsReader } from "@smuzi/logger";
|
|
3
|
+
import { LoggerUiAuth } from "./auth.js";
|
|
3
4
|
export type LoggerUiRoutesOptions = {
|
|
4
5
|
prefix?: string;
|
|
5
6
|
};
|
|
6
7
|
export declare const LOGGER_UI_PATHS: {
|
|
7
8
|
readonly page: "";
|
|
9
|
+
readonly login_page: "login";
|
|
8
10
|
readonly script: string;
|
|
9
11
|
readonly style: string;
|
|
10
12
|
readonly query: "api/logs";
|
|
13
|
+
readonly retry: "api/logs/{id}/retry";
|
|
14
|
+
readonly login: "api/login";
|
|
15
|
+
readonly logout: "api/logout";
|
|
11
16
|
};
|
|
12
|
-
export declare function registerLoggerUiRoutes(router: Http1Router, reader: LogsReader, { prefix }?: LoggerUiRoutesOptions): Http1Router;
|
|
13
|
-
export declare function createLoggerUiRouter(reader: LogsReader, options?: LoggerUiRoutesOptions): Http1Router;
|
|
17
|
+
export declare function registerLoggerUiRoutes(router: Http1Router, reader: LogsReader, auth: LoggerUiAuth, { prefix }?: LoggerUiRoutesOptions): Http1Router;
|
|
18
|
+
export declare function createLoggerUiRouter(reader: LogsReader, auth: LoggerUiAuth, options?: LoggerUiRoutesOptions): Http1Router;
|
package/build/routes.js
CHANGED
|
@@ -1,24 +1,34 @@
|
|
|
1
1
|
import { CreateHttp1Router } from "@smuzi/http-server";
|
|
2
|
-
import {
|
|
2
|
+
import { buildHttpClient } from "@smuzi/http-client";
|
|
3
|
+
import { loginAction, loginPageAction, logoutAction, logRetryAction, logsQueryAction, UiAsset, uiAssetAction, uiPageAction, } from "./handlers.js";
|
|
3
4
|
export const LOGGER_UI_PATHS = {
|
|
4
5
|
page: "",
|
|
6
|
+
login_page: "login",
|
|
5
7
|
script: "assets/" + UiAsset.script,
|
|
6
8
|
style: "assets/" + UiAsset.style,
|
|
7
9
|
query: "api/logs",
|
|
10
|
+
retry: "api/logs/{id}/retry",
|
|
11
|
+
login: "api/login",
|
|
12
|
+
logout: "api/logout",
|
|
8
13
|
};
|
|
9
14
|
function normalizePrefix(prefix) {
|
|
10
15
|
return prefix.replace(/^\/+|\/+$/g, "");
|
|
11
16
|
}
|
|
12
|
-
export function registerLoggerUiRoutes(router, reader, { prefix = "" } = {}) {
|
|
17
|
+
export function registerLoggerUiRoutes(router, reader, auth, { prefix = "" } = {}) {
|
|
13
18
|
const base = normalizePrefix(prefix);
|
|
14
19
|
const withBase = (path) => [base, path].filter(part => part !== "").join("/");
|
|
15
20
|
const base_path = base === "" ? "" : "/" + base;
|
|
16
|
-
|
|
21
|
+
const http_client = buildHttpClient({});
|
|
22
|
+
router.get(withBase(LOGGER_UI_PATHS.page), uiPageAction(auth, base_path));
|
|
23
|
+
router.get(withBase(LOGGER_UI_PATHS.login_page), loginPageAction(auth, base_path));
|
|
17
24
|
router.get(withBase(LOGGER_UI_PATHS.script), uiAssetAction(UiAsset.script));
|
|
18
25
|
router.get(withBase(LOGGER_UI_PATHS.style), uiAssetAction(UiAsset.style));
|
|
19
|
-
router.post(withBase(LOGGER_UI_PATHS.query), logsQueryAction(reader));
|
|
26
|
+
router.post(withBase(LOGGER_UI_PATHS.query), logsQueryAction(auth, reader));
|
|
27
|
+
router.post(withBase(LOGGER_UI_PATHS.retry), logRetryAction(auth, reader, http_client));
|
|
28
|
+
router.post(withBase(LOGGER_UI_PATHS.login), loginAction(auth, base_path));
|
|
29
|
+
router.post(withBase(LOGGER_UI_PATHS.logout), logoutAction(auth, base_path));
|
|
20
30
|
return router;
|
|
21
31
|
}
|
|
22
|
-
export function createLoggerUiRouter(reader, options = {}) {
|
|
23
|
-
return registerLoggerUiRoutes(CreateHttp1Router({ path: "" }), reader, options);
|
|
32
|
+
export function createLoggerUiRouter(reader, auth, options = {}) {
|
|
33
|
+
return registerLoggerUiRoutes(CreateHttp1Router({ path: "" }), reader, auth, options);
|
|
24
34
|
}
|
package/build/server.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { LogsReader } from "@smuzi/logger";
|
|
2
2
|
import { LoggerUiRoutesOptions } from "./routes.js";
|
|
3
|
+
import { LoggerUiAuth } from "./auth.js";
|
|
3
4
|
export declare const LOGGER_UI_DEFAULT_PORT = 8090;
|
|
4
5
|
export type LoggerUiServerOptions = LoggerUiRoutesOptions & {
|
|
5
6
|
host?: string;
|
|
6
7
|
port?: number;
|
|
7
8
|
};
|
|
8
|
-
export declare function buildLoggerUiServerConfig(reader: LogsReader, { host, port, prefix }?: LoggerUiServerOptions): import("@smuzi/http-server").Http1ServerConfig;
|
|
9
|
-
export declare function runLoggerUi(reader: LogsReader, options?: LoggerUiServerOptions): Promise<import("@smuzi/std").Result<import("@smuzi/http-server").StdHttp1Server, import("@smuzi/http-server").HttpServerRunError>>;
|
|
9
|
+
export declare function buildLoggerUiServerConfig(reader: LogsReader, auth: LoggerUiAuth, { host, port, prefix }?: LoggerUiServerOptions): import("@smuzi/http-server").Http1ServerConfig;
|
|
10
|
+
export declare function runLoggerUi(reader: LogsReader, auth: LoggerUiAuth, options?: LoggerUiServerOptions): Promise<import("@smuzi/std").Result<import("@smuzi/http-server").StdHttp1Server, import("@smuzi/http-server").HttpServerRunError>>;
|
package/build/server.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { buildHttp1ServerConfig, http1ServerRun } from "@smuzi/http-server";
|
|
2
2
|
import { createLoggerUiRouter } from "./routes.js";
|
|
3
3
|
export const LOGGER_UI_DEFAULT_PORT = 8090;
|
|
4
|
-
export function buildLoggerUiServerConfig(reader, { host = "localhost", port = LOGGER_UI_DEFAULT_PORT, prefix } = {}) {
|
|
4
|
+
export function buildLoggerUiServerConfig(reader, auth, { host = "localhost", port = LOGGER_UI_DEFAULT_PORT, prefix } = {}) {
|
|
5
5
|
return buildHttp1ServerConfig({
|
|
6
6
|
host,
|
|
7
7
|
port,
|
|
8
|
-
router: createLoggerUiRouter(reader, { prefix }),
|
|
8
|
+
router: createLoggerUiRouter(reader, auth, { prefix }),
|
|
9
9
|
});
|
|
10
10
|
}
|
|
11
|
-
export function runLoggerUi(reader, options = {}) {
|
|
12
|
-
return http1ServerRun(buildLoggerUiServerConfig(reader, options));
|
|
11
|
+
export function runLoggerUi(reader, auth, options = {}) {
|
|
12
|
+
return http1ServerRun(buildLoggerUiServerConfig(reader, auth, options));
|
|
13
13
|
}
|
package/build/ui/app.css
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */
|
|
2
|
-
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-200:oklch(89.4% .057 293.283);--color-violet-700:oklch(49.1% .27 292.581);--color-violet-800:oklch(43.2% .232 292.759);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-800:oklch(45.5% .188 13.697);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-white:#fff;--spacing:.25rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--font-weight-medium:500;--font-weight-semibold:600;--radius-md:.375rem;--radius-lg:.5rem;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.mx-auto{margin-inline:auto}.ml-auto{margin-left:auto}.flex{display:flex}.grid{display:grid}.inline-flex{display:inline-flex}.h-5{height:calc(var(--spacing) * 5)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-5{width:calc(var(--spacing) * 5)}.w-10{width:calc(var(--spacing) * 10)}.w-48{width:calc(var(--spacing) * 48)}.max-w-6xl{max-width:var(--container-6xl)}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.grid-cols-\[6\.5rem_3\.5rem_1fr\]{grid-template-columns:6.5rem 3.5rem 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-rose-200{border-color:var(--color-rose-200)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.bg-rose-50{background-color:var(--color-rose-50)}.bg-rose-100{background-color:var(--color-rose-100)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-100{background-color:var(--color-slate-100)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-white{background-color:var(--color-white)}.p-4{padding:calc(var(--spacing) * 4)}.p-8{padding:calc(var(--spacing) * 8)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pr-1{padding-right:var(--spacing)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-10{padding-left:calc(var(--spacing) * 10)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.break-words{overflow-wrap:break-word}.whitespace-pre-wrap{white-space:pre-wrap}.text-rose-800{color:var(--color-rose-800)}.text-sky-800{color:var(--color-sky-800)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-violet-700{color:var(--color-violet-700)}.text-violet-800{color:var(--color-violet-800)}.text-white{color:var(--color-white)}.italic{font-style:italic}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-rose-200{--tw-ring-color:var(--color-rose-200)}.ring-sky-200{--tw-ring-color:var(--color-sky-200)}.ring-slate-200{--tw-ring-color:var(--color-slate-200)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.hover\:bg-sky-100:hover{background-color:var(--color-sky-100)}.hover\:bg-sky-200:hover{background-color:var(--color-sky-200)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-violet-200:hover{background-color:var(--color-violet-200)}.hover\:text-sky-800:hover{color:var(--color-sky-800)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-sky-500:focus{border-color:var(--color-sky-500)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:opacity-40:disabled{opacity:.4}}[x-cloak]{display:none!important}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
|
|
2
|
+
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-800:oklch(43.2% .095 166.913);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-sky-800:oklch(44.3% .11 240.79);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-200:oklch(89.4% .057 293.283);--color-violet-700:oklch(49.1% .27 292.581);--color-violet-800:oklch(43.2% .232 292.759);--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-800:oklch(45.5% .188 13.697);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--container-lg:32rem;--container-2xl:42rem;--container-6xl:72rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--font-weight-medium:500;--font-weight-semibold:600;--radius-md:.375rem;--radius-lg:.5rem;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.fixed{position:fixed}.inset-0{inset:0}.z-50{z-index:50}.mx-auto{margin-inline:auto}.mt-1{margin-top:var(--spacing)}.ml-auto{margin-left:auto}.flex{display:flex}.grid{display:grid}.inline-flex{display:inline-flex}.h-5{height:calc(var(--spacing) * 5)}.max-h-96{max-height:calc(var(--spacing) * 96)}.min-h-screen{min-height:100vh}.w-3{width:calc(var(--spacing) * 3)}.w-5{width:calc(var(--spacing) * 5)}.w-10{width:calc(var(--spacing) * 10)}.w-40{width:calc(var(--spacing) * 40)}.w-fit{width:fit-content}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-6xl{max-width:var(--container-6xl)}.max-w-lg{max-width:var(--container-lg)}.min-w-0{min-width:0}.min-w-\[12rem\]{min-width:12rem}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.grid-cols-\[6\.5rem_3\.5rem_1fr\]{grid-template-columns:6.5rem 3.5rem 1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-rose-200{border-color:var(--color-rose-200)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-rose-50{background-color:var(--color-rose-50)}.bg-rose-100{background-color:var(--color-rose-100)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-900\/40{background-color:#0f172b66}@supports (color:color-mix(in lab, red, red)){.bg-slate-900\/40{background-color:color-mix(in oklab, var(--color-slate-900) 40%, transparent)}}.bg-violet-100{background-color:var(--color-violet-100)}.bg-white{background-color:var(--color-white)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-8{padding:calc(var(--spacing) * 8)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:var(--spacing)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-3{padding-top:calc(var(--spacing) * 3)}.pr-1{padding-right:var(--spacing)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-10{padding-left:calc(var(--spacing) * 10)}.text-center{text-align:center}.font-mono{font-family:var(--font-mono)}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[11px\]{font-size:11px}.leading-none{--tw-leading:1;line-height:1}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.break-words{overflow-wrap:break-word}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber-800{color:var(--color-amber-800)}.text-emerald-800{color:var(--color-emerald-800)}.text-rose-800{color:var(--color-rose-800)}.text-sky-700{color:var(--color-sky-700)}.text-sky-800{color:var(--color-sky-800)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-violet-700{color:var(--color-violet-700)}.text-violet-800{color:var(--color-violet-800)}.text-white{color:var(--color-white)}.italic{font-style:italic}.underline{text-decoration-line:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-200{--tw-ring-color:var(--color-amber-200)}.ring-emerald-200{--tw-ring-color:var(--color-emerald-200)}.ring-rose-200{--tw-ring-color:var(--color-rose-200)}.ring-sky-200{--tw-ring-color:var(--color-sky-200)}.ring-slate-200{--tw-ring-color:var(--color-slate-200)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.hover\:bg-amber-200:hover{background-color:var(--color-amber-200)}.hover\:bg-sky-100:hover{background-color:var(--color-sky-100)}.hover\:bg-sky-200:hover{background-color:var(--color-sky-200)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-violet-200:hover{background-color:var(--color-violet-200)}.hover\:text-sky-800:hover{color:var(--color-sky-800)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-sky-500:focus{border-color:var(--color-sky-500)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:opacity-40:disabled{opacity:.4}}[x-cloak]{display:none!important}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}
|