From 5cea1a52cbf8fbfb6991059abe961cfc66902f0b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 05:36:16 +0100 Subject: [PATCH] feat: build initial clickclack app --- .gitignore | 12 + .oxfmtrc.json | 3 + Dockerfile | 31 + README.md | 31 + SPEC.md | 122 +- apps/api/cmd/clickclack/main.go | 321 +++ apps/api/internal/config/config.go | 58 + apps/api/internal/config/config_test.go | 63 + apps/api/internal/httpapi/auth.go | 33 + apps/api/internal/httpapi/authz_test.go | 296 +++ apps/api/internal/httpapi/features.go | 212 ++ apps/api/internal/httpapi/github.go | 230 +++ apps/api/internal/httpapi/github_test.go | 200 ++ apps/api/internal/httpapi/mutations.go | 105 + apps/api/internal/httpapi/mutations_test.go | 128 ++ apps/api/internal/httpapi/server.go | 412 ++++ apps/api/internal/httpapi/server_test.go | 441 +++++ apps/api/internal/realtime/hub.go | 49 + apps/api/internal/realtime/hub_test.go | 38 + apps/api/internal/store/sqlite/auth.go | 135 ++ apps/api/internal/store/sqlite/chat_test.go | 472 +++++ apps/api/internal/store/sqlite/dms.go | 201 ++ apps/api/internal/store/sqlite/export.go | 67 + apps/api/internal/store/sqlite/fault_test.go | 335 ++++ apps/api/internal/store/sqlite/helpers.go | 198 ++ apps/api/internal/store/sqlite/identity.go | 56 + apps/api/internal/store/sqlite/invites.go | 24 + apps/api/internal/store/sqlite/members.go | 13 + .../store/sqlite/migrations/0001_initial.sql | 150 ++ .../store/sqlite/migrations/0002_auth.sql | 22 + apps/api/internal/store/sqlite/misc_test.go | 156 ++ apps/api/internal/store/sqlite/mutations.go | 120 ++ .../internal/store/sqlite/mutations_test.go | 182 ++ apps/api/internal/store/sqlite/search.go | 75 + apps/api/internal/store/sqlite/sqlite.go | 490 +++++ apps/api/internal/store/sqlite/sqlite_test.go | 232 +++ apps/api/internal/store/sqlite/uploads.go | 106 + apps/api/internal/store/types.go | 243 +++ .../webassets/dist/assets/index-4rv34_La.js | 61 + .../webassets/dist/assets/index-Du33dVG9.css | 1 + apps/api/internal/webassets/dist/index.html | 13 + apps/api/internal/webassets/webassets.go | 8 + apps/web/index.html | 12 + apps/web/package.json | 23 + apps/web/src/App.svelte | 472 +++++ apps/web/src/lib/api.ts | 11 + apps/web/src/lib/format.ts | 12 + apps/web/src/lib/types.ts | 83 + apps/web/src/main.ts | 9 + apps/web/src/styles.css | 490 +++++ apps/web/src/vite-env.d.ts | 1 + apps/web/tsconfig.json | 13 + apps/web/vite.config.ts | 11 + docs/api/overview.md | 23 + docs/architecture/overview.md | 29 + examples/bot-ts/README.md | 13 + examples/bot-ts/package.json | 18 + examples/bot-ts/src/index.ts | 20 + examples/bot-ts/tsconfig.json | 13 + go.mod | 26 + go.sum | 57 + infra/migrations/sqlite/0001_initial.sql | 150 ++ infra/migrations/sqlite/0002_auth.sql | 22 + package.json | 25 + packages/protocol/openapi.yaml | 542 ++++++ packages/protocol/package.json | 15 + packages/sdk-ts/package.json | 16 + packages/sdk-ts/src/generated/openapi.d.ts | 1214 ++++++++++++ packages/sdk-ts/src/index.ts | 323 ++++ packages/sdk-ts/tsconfig.json | 13 + playwright.config.ts | 26 + pnpm-lock.yaml | 1718 +++++++++++++++++ pnpm-workspace.yaml | 6 + tests/e2e/chat.spec.ts | 75 + tsconfig.json | 10 + 75 files changed, 11617 insertions(+), 19 deletions(-) create mode 100644 .gitignore create mode 100644 .oxfmtrc.json create mode 100644 Dockerfile create mode 100644 apps/api/cmd/clickclack/main.go create mode 100644 apps/api/internal/config/config.go create mode 100644 apps/api/internal/config/config_test.go create mode 100644 apps/api/internal/httpapi/auth.go create mode 100644 apps/api/internal/httpapi/authz_test.go create mode 100644 apps/api/internal/httpapi/features.go create mode 100644 apps/api/internal/httpapi/github.go create mode 100644 apps/api/internal/httpapi/github_test.go create mode 100644 apps/api/internal/httpapi/mutations.go create mode 100644 apps/api/internal/httpapi/mutations_test.go create mode 100644 apps/api/internal/httpapi/server.go create mode 100644 apps/api/internal/httpapi/server_test.go create mode 100644 apps/api/internal/realtime/hub.go create mode 100644 apps/api/internal/realtime/hub_test.go create mode 100644 apps/api/internal/store/sqlite/auth.go create mode 100644 apps/api/internal/store/sqlite/chat_test.go create mode 100644 apps/api/internal/store/sqlite/dms.go create mode 100644 apps/api/internal/store/sqlite/export.go create mode 100644 apps/api/internal/store/sqlite/fault_test.go create mode 100644 apps/api/internal/store/sqlite/helpers.go create mode 100644 apps/api/internal/store/sqlite/identity.go create mode 100644 apps/api/internal/store/sqlite/invites.go create mode 100644 apps/api/internal/store/sqlite/members.go create mode 100644 apps/api/internal/store/sqlite/migrations/0001_initial.sql create mode 100644 apps/api/internal/store/sqlite/migrations/0002_auth.sql create mode 100644 apps/api/internal/store/sqlite/misc_test.go create mode 100644 apps/api/internal/store/sqlite/mutations.go create mode 100644 apps/api/internal/store/sqlite/mutations_test.go create mode 100644 apps/api/internal/store/sqlite/search.go create mode 100644 apps/api/internal/store/sqlite/sqlite.go create mode 100644 apps/api/internal/store/sqlite/sqlite_test.go create mode 100644 apps/api/internal/store/sqlite/uploads.go create mode 100644 apps/api/internal/store/types.go create mode 100644 apps/api/internal/webassets/dist/assets/index-4rv34_La.js create mode 100644 apps/api/internal/webassets/dist/assets/index-Du33dVG9.css create mode 100644 apps/api/internal/webassets/dist/index.html create mode 100644 apps/api/internal/webassets/webassets.go create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/App.svelte create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/format.ts create mode 100644 apps/web/src/lib/types.ts create mode 100644 apps/web/src/main.ts create mode 100644 apps/web/src/styles.css create mode 100644 apps/web/src/vite-env.d.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 docs/api/overview.md create mode 100644 docs/architecture/overview.md create mode 100644 examples/bot-ts/README.md create mode 100644 examples/bot-ts/package.json create mode 100644 examples/bot-ts/src/index.ts create mode 100644 examples/bot-ts/tsconfig.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 infra/migrations/sqlite/0001_initial.sql create mode 100644 infra/migrations/sqlite/0002_auth.sql create mode 100644 package.json create mode 100644 packages/protocol/openapi.yaml create mode 100644 packages/protocol/package.json create mode 100644 packages/sdk-ts/package.json create mode 100644 packages/sdk-ts/src/generated/openapi.d.ts create mode 100644 packages/sdk-ts/src/index.ts create mode 100644 packages/sdk-ts/tsconfig.json create mode 100644 playwright.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 tests/e2e/chat.spec.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fa67951 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +apps/web/dist/ +packages/sdk-ts/dist/ +data/ +coverage.out +coverage.txt +test-results/ +playwright-report/ +*.db +*.db-shm +*.db-wal +.DS_Store diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..0dc5214 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,3 @@ +{ + "lineWidth": 120 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9b27a70 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM node:25-alpine AS web +WORKDIR /src +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/web/package.json apps/web/package.json +COPY packages/protocol/package.json packages/protocol/package.json +COPY packages/sdk-ts/package.json packages/sdk-ts/package.json +RUN pnpm install --frozen-lockfile +COPY apps apps +COPY packages packages +RUN pnpm build + +FROM golang:1.26-alpine AS api +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY apps/api apps/api +COPY infra infra +COPY --from=web /src/apps/api/internal/webassets/dist apps/api/internal/webassets/dist +RUN go build -o /out/clickclack ./apps/api/cmd/clickclack + +FROM alpine:3.23 +RUN adduser -D -H clickclack +WORKDIR /app +COPY --from=api /out/clickclack /usr/local/bin/clickclack +RUN mkdir -p /app/data && chown -R clickclack:clickclack /app +USER clickclack +EXPOSE 8080 +VOLUME ["/app/data"] +ENTRYPOINT ["clickclack"] +CMD ["serve", "--addr", ":8080", "--data", "/app/data"] diff --git a/README.md b/README.md index b3570c9..102ad61 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,34 @@ Self-hostable chat with Slack-style threads, Discord-ish warmth, and light crustacean seasoning. See [SPEC.md](SPEC.md) for the initial product and architecture plan. + +## Development + +```sh +pnpm install +pnpm build +go run ./apps/api/cmd/clickclack serve +``` + +Open http://localhost:8080. + +Useful commands: + +```sh +go test ./... +pnpm -r typecheck +pnpm lint +pnpm coverage +pnpm test:e2e +pnpm build +go run ./apps/api/cmd/clickclack admin bootstrap --name "Peter" --email steipete@gmail.com +go run ./apps/api/cmd/clickclack admin magic-link create --email steipete@gmail.com --name "Peter" +go run ./apps/api/cmd/clickclack backup --out ./data/backup.db +go run ./apps/api/cmd/clickclack export --out ./data/export.json +pnpm --filter @clickclack/example-bot start +``` + +TypeScript uses `tsgo` from `@typescript/native-preview`; formatting/linting use `oxfmt` and `oxlint`. +Local auth supports dev fallback, `X-ClickClack-User`, bearer session tokens, and CLI-generated magic-link tokens. +Optional GitHub OAuth is enabled with `CLICKCLACK_PUBLIC_URL`, `CLICKCLACK_GITHUB_CLIENT_ID`, and `CLICKCLACK_GITHUB_CLIENT_SECRET`. +The bot example in `examples/bot-ts` uses the framework-neutral SDK and the same auth headers as the web app. diff --git a/SPEC.md b/SPEC.md index 4f03824..fa2c105 100644 --- a/SPEC.md +++ b/SPEC.md @@ -11,6 +11,18 @@ ClickClack is a self-hostable, API-first chat app for internal testing, small te - Ship a TypeScript SDK for bots, integrations, and community tooling. - Feel playful and memorable without sacrificing dense, practical chat workflows. +## Locked V1 Decisions + +- First implementation target: realtime channel chat plus Slack-style threads, not skeleton-only. +- Auth starts CLI-manageable: local owner/user bootstrap and invite/token management from `clickclack admin ...`. +- GitHub OAuth is optional V1, after local auth is usable. +- Frontend is Svelte 5 + Vite SPA. No SvelteKit server layer. +- API contract is OpenAPI-first, with `packages/protocol/openapi.yaml` as the source of truth. +- IDs use ULID-style sortable text IDs with semantic prefixes such as `usr_`, `wsp_`, `chn_`, `msg_`, `evt_`. +- Message body format starts as Markdown. Clients render a safe Markdown subset. +- Search, uploads, and DMs are V1 product scope, but come after the realtime channel/thread vertical slice is working. +- Monorepo layout is the canonical repo shape. + ## Non-Goals For V1 - Voice/video rooms. @@ -72,15 +84,22 @@ The first useful build should support: - Create/select workspace. - Create/select channel. -- Send text message. +- Send Markdown text message. - Realtime message delivery over WebSocket. - Open message thread in right pane. - Send thread reply. - Persist everything in SQLite. - Reload/reconnect and recover state. -- Basic dev/local auth. +- CLI-manageable local auth/bootstrap. - Embedded web app served by Go. +After that vertical slice is stable, V1 expands to: + +- Direct messages. +- SQLite FTS5 message search. +- Local file uploads and message attachments. +- GitHub OAuth as an optional login path. + ## Architecture ```text @@ -127,7 +146,7 @@ Future hosted runtime: - Postgres later: `pgx`. - Queries: start handwritten or `sqlc` once schema settles. - Migrations: embedded SQL migrations with a tiny internal runner, or `goose` if the runner grows. -- IDs: UUIDv7 or ULID. +- IDs: ULID-style sortable text IDs with type prefixes. ### CLI @@ -140,10 +159,19 @@ clickclack serve clickclack migrate --db sqlite://./data/clickclack.db -clickclack admin invite +clickclack admin bootstrap + --name "Peter" + --email steipete@gmail.com + +clickclack admin user create + --name "Ari" + --email ari@example.com + +clickclack admin invite create + --workspace wsp_... ``` -Default `clickclack serve` should be enough for local use. +Default `clickclack serve` should be enough for local development. Production-like local use should bootstrap an owner through the CLI before exposing the instance. ## Frontend @@ -188,9 +216,12 @@ GET /api/workspaces/{workspace_id} GET /api/workspaces/{workspace_id}/channels POST /api/workspaces/{workspace_id}/channels +PATCH /api/channels/{channel_id} GET /api/channels/{channel_id}/messages?before=&after_seq=&limit= POST /api/channels/{channel_id}/messages +PATCH /api/messages/{message_id} +DELETE /api/messages/{message_id} GET /api/messages/{message_id}/thread POST /api/messages/{message_id}/thread/replies @@ -199,7 +230,18 @@ POST /api/messages/{message_id}/reactions DELETE /api/messages/{message_id}/reactions/{emoji} GET /api/realtime/events?after_cursor= +POST /api/realtime/ephemeral GET /api/realtime/ws + +GET /api/search?workspace_id=&q=&limit= + +POST /api/uploads +GET /api/uploads/{upload_id} + +GET /api/dms +POST /api/dms +GET /api/dms/{conversation_id}/messages?before=&after_seq=&limit= +POST /api/dms/{conversation_id}/messages ``` ## Realtime @@ -337,6 +379,31 @@ events type payload_json created_at + +uploads + id + workspace_id + owner_id + filename + content_type + byte_size + storage_path + created_at + +message_attachments + message_id + upload_id + created_at + +direct_conversations + id + workspace_id + created_at + +direct_conversation_members + conversation_id + user_id + created_at ``` Thread rules: @@ -374,13 +441,17 @@ data/ V0: -- Dev/local auth for quick testing. -- Owner bootstrap on first run. +- CLI owner bootstrap. +- CLI user/invite management. +- Dev/local auth for quick testing, gated to local/dev mode. +- CLI-generated magic-link tokens. +- Bearer session tokens and HTTP-only cookie sessions. V1: -- Magic links. -- GitHub OAuth. +- Magic-link token issuance and consume flow, with CLI/local delivery first. +- GitHub OAuth as optional login, enabled via self-host config. +- SMTP or provider-backed email delivery later, once deployment mail settings are known. - Optional local email/password only if needed for fully offline/self-hosted deployments. Auth principles: @@ -401,7 +472,7 @@ packages/sdk-ts Layering: -- Generated OpenAPI client. +- Generated OpenAPI types. - Friendly wrapper. - WebSocket/event subscription helper. @@ -499,26 +570,39 @@ top: channel title, members, search - Thread pane. - Thread reply counts and last reply state. -### M4: Self-Host Polish +### M4: Search, Uploads, DMs + +- SQLite FTS5 message search. +- Local upload storage. +- Message attachments. +- Direct message conversations. + +### M5: Self-Host Polish - First-run owner setup. +- CLI-generated magic-link auth. - Config file/env. -- Local upload storage. - Docker image. - Backups/export. -### M5: SDK +### M6: SDK And Integrations - OpenAPI generation. - TypeScript SDK. - Incoming webhooks. - Basic bot example. +## Answered Questions + +- Setup starts with CLI owner bootstrap. A setup UI can be added later. +- Markdown is the initial rich text format. +- DMs are V1 scope, after channel chat and threads. +- Search starts with SQLite FTS5. +- Uploads are V1 scope, after core chat is solid. +- OpenAPI remains source of truth from the first scaffold. +- TypeScript compilation uses `tsgo`; lint/format use `oxlint` and `oxfmt`. +- GitHub OAuth ships in V1 as an optional configured auth provider. + ## Open Questions -- Should `clickclack serve` expose setup UI on first run or require CLI owner bootstrap? -- How much markdown/rich text in V1? -- Do we need DMs in V1, or only channels and threads? -- Should search start with SQLite FTS5? -- Should uploads exist in V1 or wait until core chat is solid? -- Which generated OpenAPI toolchain is least annoying for Go plus TypeScript? +- Whether to add generated Go request/response validation from OpenAPI in V1 or keep the first backend on hand-written handlers. diff --git a/apps/api/cmd/clickclack/main.go b/apps/api/cmd/clickclack/main.go new file mode 100644 index 0000000..0e5c65a --- /dev/null +++ b/apps/api/cmd/clickclack/main.go @@ -0,0 +1,321 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "path/filepath" + "strings" + + "github.com/openclaw/clickclack/apps/api/internal/config" + "github.com/openclaw/clickclack/apps/api/internal/httpapi" + "github.com/openclaw/clickclack/apps/api/internal/realtime" + "github.com/openclaw/clickclack/apps/api/internal/store" + sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite" +) + +func main() { + if err := run(); err != nil { + log.Fatal(err) + } +} + +func run() error { + cmd := "serve" + if len(os.Args) > 1 { + cmd = os.Args[1] + } + switch cmd { + case "serve": + return serve(os.Args[2:]) + case "migrate": + return migrate(os.Args[2:]) + case "admin": + return admin(os.Args[2:]) + case "backup": + return backup(os.Args[2:]) + case "export": + return exportData(os.Args[2:]) + default: + return fmt.Errorf("unknown command %q", cmd) + } +} + +func serve(args []string) error { + flags := flag.NewFlagSet("serve", flag.ExitOnError) + flags.String("addr", ":8080", "HTTP listen address") + flags.String("data", "./data", "data directory") + flags.String("db", "", "database URL") + configPath := flags.String("config", "", "config file") + devBootstrap := flags.Bool("dev-bootstrap", true, "create a local owner/workspace/channel if no user exists") + if err := flags.Parse(args); err != nil { + return err + } + cfg, err := config.Load(*configPath) + if err != nil { + return err + } + applyFlagOverrides(flags, &cfg) + url := resolveDB(cfg.Data, cfg.DB) + if err := ensureDirs(cfg.Data); err != nil { + return err + } + st, err := sqlitestore.Open(url) + if err != nil { + return err + } + defer st.Close() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + if err := st.Migrate(ctx); err != nil { + return err + } + if *devBootstrap { + user, err := st.EnsureBootstrap(ctx, "Local Captain", "local@clickclack.chat") + if err != nil { + return err + } + log.Printf("dev auth user: %s (%s)", user.DisplayName, user.ID) + } + log.Printf("ClickClack listening on %s", displayURL(cfg.Addr)) + server := httpapi.New(st, realtime.NewHub(), httpapi.Options{ + UploadDir: filepath.Join(cfg.Data, "uploads"), + GitHubOAuth: httpapi.GitHubOAuthConfig{ + ClientID: cfg.GitHubClientID, + ClientSecret: cfg.GitHubClientSecret, + PublicURL: cfg.PublicURL, + }, + }) + return httpapi.ListenAndServe(ctx, cfg.Addr, server.Handler()) +} + +func migrate(args []string) error { + flags := flag.NewFlagSet("migrate", flag.ExitOnError) + data := flags.String("data", "./data", "data directory") + dbURL := flags.String("db", "", "database URL") + if err := flags.Parse(args); err != nil { + return err + } + if err := ensureDirs(*data); err != nil { + return err + } + st, err := sqlitestore.Open(resolveDB(*data, *dbURL)) + if err != nil { + return err + } + defer st.Close() + return st.Migrate(context.Background()) +} + +func admin(args []string) error { + if len(args) == 0 { + return fmt.Errorf("admin requires a subcommand") + } + switch args[0] { + case "bootstrap": + flags := flag.NewFlagSet("admin bootstrap", flag.ExitOnError) + data := flags.String("data", "./data", "data directory") + dbURL := flags.String("db", "", "database URL") + name := flags.String("name", "Owner", "owner display name") + email := flags.String("email", "", "owner email") + if err := flags.Parse(args[1:]); err != nil { + return err + } + if err := ensureDirs(*data); err != nil { + return err + } + st, err := sqlitestore.Open(resolveDB(*data, *dbURL)) + if err != nil { + return err + } + defer st.Close() + ctx := context.Background() + if err := st.Migrate(ctx); err != nil { + return err + } + user, err := st.EnsureBootstrap(ctx, *name, *email) + if err != nil { + return err + } + fmt.Printf("%s\n", user.ID) + return nil + case "user": + if len(args) < 2 || args[1] != "create" { + return fmt.Errorf("usage: clickclack admin user create --name NAME --email EMAIL") + } + flags := flag.NewFlagSet("admin user create", flag.ExitOnError) + data := flags.String("data", "./data", "data directory") + dbURL := flags.String("db", "", "database URL") + name := flags.String("name", "Local User", "display name") + email := flags.String("email", "", "email") + workspaceID := flags.String("workspace", "", "workspace id to join as member") + if err := flags.Parse(args[2:]); err != nil { + return err + } + st, err := sqlitestore.Open(resolveDB(*data, *dbURL)) + if err != nil { + return err + } + defer st.Close() + if err := st.Migrate(context.Background()); err != nil { + return err + } + user, err := st.CreateUser(context.Background(), store.CreateUserInput{DisplayName: *name, Email: *email}) + if err != nil { + return err + } + if *workspaceID != "" { + if err := st.AddWorkspaceMember(context.Background(), *workspaceID, user.ID, "member"); err != nil { + return err + } + } + fmt.Printf("%s\n", user.ID) + return nil + case "invite": + if len(args) < 2 || args[1] != "create" { + return fmt.Errorf("usage: clickclack admin invite create --workspace WORKSPACE_ID") + } + flags := flag.NewFlagSet("admin invite create", flag.ExitOnError) + data := flags.String("data", "./data", "data directory") + dbURL := flags.String("db", "", "database URL") + workspaceID := flags.String("workspace", "", "workspace id") + if err := flags.Parse(args[2:]); err != nil { + return err + } + if *workspaceID == "" { + return fmt.Errorf("--workspace is required") + } + st, err := sqlitestore.Open(resolveDB(*data, *dbURL)) + if err != nil { + return err + } + defer st.Close() + ctx := context.Background() + if err := st.Migrate(ctx); err != nil { + return err + } + user, err := st.FirstUser(ctx) + if err != nil { + return err + } + invite, err := st.CreateInvite(ctx, *workspaceID, user.ID) + if err != nil { + return err + } + fmt.Printf("%s\n", invite.Token) + return nil + case "magic-link": + if len(args) < 2 || args[1] != "create" { + return fmt.Errorf("usage: clickclack admin magic-link create --email EMAIL [--name NAME]") + } + flags := flag.NewFlagSet("admin magic-link create", flag.ExitOnError) + data := flags.String("data", "./data", "data directory") + dbURL := flags.String("db", "", "database URL") + email := flags.String("email", "", "email") + name := flags.String("name", "", "display name") + if err := flags.Parse(args[2:]); err != nil { + return err + } + st, err := sqlitestore.Open(resolveDB(*data, *dbURL)) + if err != nil { + return err + } + defer st.Close() + ctx := context.Background() + if err := st.Migrate(ctx); err != nil { + return err + } + link, err := st.CreateMagicLink(ctx, *email, *name) + if err != nil { + return err + } + fmt.Printf("%s\n", link.Token) + return nil + default: + return fmt.Errorf("unknown admin subcommand %q", args[0]) + } +} + +func backup(args []string) error { + flags := flag.NewFlagSet("backup", flag.ExitOnError) + data := flags.String("data", "./data", "data directory") + dbURL := flags.String("db", "", "database URL") + out := flags.String("out", "", "backup SQLite path") + if err := flags.Parse(args); err != nil { + return err + } + if *out == "" { + return fmt.Errorf("--out is required") + } + st, err := sqlitestore.Open(resolveDB(*data, *dbURL)) + if err != nil { + return err + } + defer st.Close() + return st.Backup(context.Background(), *out) +} + +func exportData(args []string) error { + flags := flag.NewFlagSet("export", flag.ExitOnError) + data := flags.String("data", "./data", "data directory") + dbURL := flags.String("db", "", "database URL") + out := flags.String("out", "-", "JSON output path or '-'") + if err := flags.Parse(args); err != nil { + return err + } + st, err := sqlitestore.Open(resolveDB(*data, *dbURL)) + if err != nil { + return err + } + defer st.Close() + var writer *os.File + if *out == "-" { + writer = os.Stdout + } else { + writer, err = os.Create(*out) + if err != nil { + return err + } + defer writer.Close() + } + return st.ExportJSON(context.Background(), writer) +} + +func resolveDB(data, dbURL string) string { + if dbURL != "" { + return dbURL + } + return "sqlite://" + filepath.Join(data, "clickclack.db") +} + +func applyFlagOverrides(flags *flag.FlagSet, cfg *config.Config) { + flags.Visit(func(f *flag.Flag) { + switch f.Name { + case "addr": + cfg.Addr = f.Value.String() + case "data": + cfg.Data = f.Value.String() + case "db": + cfg.DB = f.Value.String() + } + }) +} + +func ensureDirs(data string) error { + for _, dir := range []string{data, filepath.Join(data, "uploads"), filepath.Join(data, "logs")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + } + return nil +} + +func displayURL(addr string) string { + if strings.HasPrefix(addr, ":") { + return "http://localhost" + addr + } + return "http://" + addr +} diff --git a/apps/api/internal/config/config.go b/apps/api/internal/config/config.go new file mode 100644 index 0000000..d4dd3b0 --- /dev/null +++ b/apps/api/internal/config/config.go @@ -0,0 +1,58 @@ +package config + +import ( + "encoding/json" + "os" +) + +type Config struct { + Addr string `json:"addr"` + Data string `json:"data"` + DB string `json:"db"` + PublicURL string `json:"public_url"` + GitHubClientID string `json:"github_client_id"` + GitHubClientSecret string `json:"github_client_secret"` +} + +func Defaults() Config { + return Config{Addr: ":8080", Data: "./data"} +} + +func Load(path string) (Config, error) { + cfg := Defaults() + if env := os.Getenv("CLICKCLACK_ADDR"); env != "" { + cfg.Addr = env + } + if env := os.Getenv("CLICKCLACK_DATA"); env != "" { + cfg.Data = env + } + if env := os.Getenv("CLICKCLACK_DB"); env != "" { + cfg.DB = env + } + if env := os.Getenv("CLICKCLACK_PUBLIC_URL"); env != "" { + cfg.PublicURL = env + } + if env := os.Getenv("CLICKCLACK_GITHUB_CLIENT_ID"); env != "" { + cfg.GitHubClientID = env + } + if env := os.Getenv("CLICKCLACK_GITHUB_CLIENT_SECRET"); env != "" { + cfg.GitHubClientSecret = env + } + if path == "" { + return cfg, nil + } + body, err := os.ReadFile(path) + if err != nil { + return Config{}, err + } + if err := json.Unmarshal(body, &cfg); err != nil { + return Config{}, err + } + if cfg.Addr == "" { + cfg.Addr = ":8080" + } + if cfg.Data == "" { + cfg.Data = "./data" + } + return cfg, nil +} diff --git a/apps/api/internal/config/config_test.go b/apps/api/internal/config/config_test.go new file mode 100644 index 0000000..f34d7d2 --- /dev/null +++ b/apps/api/internal/config/config_test.go @@ -0,0 +1,63 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadDefaultsEnvAndFile(t *testing.T) { + t.Setenv("CLICKCLACK_ADDR", ":9000") + t.Setenv("CLICKCLACK_DATA", "/tmp/clickclack") + t.Setenv("CLICKCLACK_DB", "sqlite:///tmp/clickclack.db") + t.Setenv("CLICKCLACK_PUBLIC_URL", "https://clickclack.test") + t.Setenv("CLICKCLACK_GITHUB_CLIENT_ID", "client") + t.Setenv("CLICKCLACK_GITHUB_CLIENT_SECRET", "secret") + cfg, err := Load("") + if err != nil { + t.Fatal(err) + } + if cfg.Addr != ":9000" || cfg.Data != "/tmp/clickclack" || cfg.DB != "sqlite:///tmp/clickclack.db" || cfg.PublicURL != "https://clickclack.test" || cfg.GitHubClientID != "client" || cfg.GitHubClientSecret != "secret" { + t.Fatalf("unexpected env config: %#v", cfg) + } + + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(`{"addr":":7000","data":"/data"}`), 0o644); err != nil { + t.Fatal(err) + } + cfg, err = Load(path) + if err != nil { + t.Fatal(err) + } + if cfg.Addr != ":7000" || cfg.Data != "/data" { + t.Fatalf("unexpected file config: %#v", cfg) + } + + t.Setenv("CLICKCLACK_ADDR", "") + t.Setenv("CLICKCLACK_DATA", "") + t.Setenv("CLICKCLACK_DB", "") + t.Setenv("CLICKCLACK_PUBLIC_URL", "") + t.Setenv("CLICKCLACK_GITHUB_CLIENT_ID", "") + t.Setenv("CLICKCLACK_GITHUB_CLIENT_SECRET", "") + emptyPath := filepath.Join(t.TempDir(), "empty.json") + if err := os.WriteFile(emptyPath, []byte(`{}`), 0o644); err != nil { + t.Fatal(err) + } + cfg, err = Load(emptyPath) + if err != nil { + t.Fatal(err) + } + if cfg.Addr != ":8080" || cfg.Data != "./data" { + t.Fatalf("unexpected fallback config: %#v", cfg) + } + if _, err := Load(filepath.Join(t.TempDir(), "missing.json")); err == nil { + t.Fatal("expected missing config error") + } + badPath := filepath.Join(t.TempDir(), "bad.json") + if err := os.WriteFile(badPath, []byte(`{`), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(badPath); err == nil { + t.Fatal("expected bad json error") + } +} diff --git a/apps/api/internal/httpapi/auth.go b/apps/api/internal/httpapi/auth.go new file mode 100644 index 0000000..dca67e2 --- /dev/null +++ b/apps/api/internal/httpapi/auth.go @@ -0,0 +1,33 @@ +package httpapi + +import "net/http" + +func (s *Server) requestMagicLink(w http.ResponseWriter, r *http.Request) { + var body struct { + Email string `json:"email"` + DisplayName string `json:"display_name"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + link, err := s.store.CreateMagicLink(r.Context(), body.Email, body.DisplayName) + writeResultStatus(w, http.StatusCreated, map[string]any{"magic_link": link, "token": link.Token}, err) +} + +func (s *Server) consumeMagicLink(w http.ResponseWriter, r *http.Request) { + var body struct { + Token string `json:"token"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + user, session, err := s.store.ConsumeMagicLink(r.Context(), body.Token) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + setSessionCookie(w, session) + writeJSON(w, http.StatusOK, map[string]any{"user": user, "session": session, "token": session.Token}) +} diff --git a/apps/api/internal/httpapi/authz_test.go b/apps/api/internal/httpapi/authz_test.go new file mode 100644 index 0000000..d40c661 --- /dev/null +++ b/apps/api/internal/httpapi/authz_test.go @@ -0,0 +1,296 @@ +package httpapi + +import ( + "bytes" + "context" + "io" + "io/fs" + "mime/multipart" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/openclaw/clickclack/apps/api/internal/realtime" + "github.com/openclaw/clickclack/apps/api/internal/store" + sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite" + "github.com/openclaw/clickclack/apps/api/internal/webassets" +) + +func TestHTTPUnauthorizedRoutes(t *testing.T) { + t.Parallel() + st := newEmptyHTTPStore(t) + server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(t.TempDir(), "uploads")}).Handler()) + t.Cleanup(server.Close) + + cases := []struct { + method string + path string + body string + }{ + {http.MethodGet, "/api/me", ""}, + {http.MethodGet, "/api/workspaces", ""}, + {http.MethodPost, "/api/workspaces", `{"name":"x"}`}, + {http.MethodGet, "/api/workspaces/wsp_missing", ""}, + {http.MethodGet, "/api/workspaces/wsp_missing/channels", ""}, + {http.MethodPost, "/api/workspaces/wsp_missing/channels", `{"name":"x"}`}, + {http.MethodGet, "/api/channels/chn_missing/messages", ""}, + {http.MethodPost, "/api/channels/chn_missing/messages", `{"body":"x"}`}, + {http.MethodGet, "/api/messages/msg_missing/thread", ""}, + {http.MethodPost, "/api/messages/msg_missing/thread/replies", `{"body":"x"}`}, + {http.MethodPost, "/api/messages/msg_missing/reactions", `{"emoji":"x"}`}, + {http.MethodDelete, "/api/messages/msg_missing/reactions/x", ""}, + {http.MethodGet, "/api/realtime/events?workspace_id=wsp_missing", ""}, + {http.MethodGet, "/api/realtime/ws?workspace_id=wsp_missing", ""}, + {http.MethodGet, "/api/search?workspace_id=wsp_missing&q=x", ""}, + {http.MethodPost, "/api/uploads", ""}, + {http.MethodGet, "/api/uploads/upl_missing", ""}, + {http.MethodPost, "/api/messages/msg_missing/attachments", `{"upload_id":"upl_missing"}`}, + {http.MethodGet, "/api/dms?workspace_id=wsp_missing", ""}, + {http.MethodPost, "/api/dms", `{"workspace_id":"wsp_missing","member_ids":[]}`}, + {http.MethodGet, "/api/dms/dm_missing/messages", ""}, + {http.MethodPost, "/api/dms/dm_missing/messages", `{"body":"x"}`}, + {http.MethodPost, "/api/hooks/mattermost/chn_missing", `{"text":"x"}`}, + } + for _, tc := range cases { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + req, err := http.NewRequest(tc.method, server.URL+tc.path, strings.NewReader(tc.body)) + if err != nil { + t.Fatal(err) + } + if tc.body != "" { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected unauthorized, got %s %s", resp.Status, string(body)) + } + }) + } +} + +func TestHTTPUploadNotConfiguredAndCookieAuth(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newHTTPStore(t) + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + link, err := st.CreateMagicLink(ctx, "cookie@example.com", "Cookie User") + if err != nil { + t.Fatal(err) + } + _, session, err := st.ConsumeMagicLink(ctx, link.Token) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(New(st, realtime.NewHub(), Options{}).Handler()) + t.Cleanup(server.Close) + + req, err := http.NewRequest(http.MethodGet, server.URL+"/api/me", nil) + if err != nil { + t.Fatal(err) + } + req.AddCookie(&http.Cookie{Name: "cc_session", Value: session.Token}) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected cookie auth, got %s", resp.Status) + } + resp.Body.Close() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("workspace_id", "unused"); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req, err = http.NewRequest(http.MethodPost, server.URL+"/api/uploads", &body) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("X-ClickClack-User", owner.ID) + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusInternalServerError { + t.Fatalf("expected upload config error, got %s", resp.Status) + } +} + +func TestHTTPMalformedJSONRoutes(t *testing.T) { + t.Parallel() + ctx := context.Background() + dataDir := t.TempDir() + st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID) + if err != nil { + t.Fatal(err) + } + root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channels[0].ID, AuthorID: owner.ID, Body: "root"}) + if err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler()) + t.Cleanup(server.Close) + paths := []string{ + "/api/auth/magic/request", + "/api/auth/magic/consume", + "/api/workspaces", + "/api/workspaces/" + workspaces[0].ID + "/channels", + "/api/channels/" + channels[0].ID + "/messages", + "/api/messages/" + root.ID + "/thread/replies", + "/api/messages/" + root.ID + "/reactions", + "/api/messages/" + root.ID + "/attachments", + "/api/dms", + "/api/hooks/mattermost/" + channels[0].ID, + } + for _, path := range paths { + t.Run(path, func(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, server.URL+path, strings.NewReader("{")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected bad request, got %s %s", resp.Status, string(body)) + } + }) + } + + other, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Other", Email: "other@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspaces[0].ID, other.ID, "member"); err != nil { + t.Fatal(err) + } + dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspaces[0].ID, UserID: owner.ID, MemberIDs: []string{other.ID}}) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(http.MethodPost, server.URL+"/api/dms/"+dm.ID+"/messages", strings.NewReader("{")) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected malformed dm message error, got %s", resp.Status) + } + resp.Body.Close() + + resp, err = http.PostForm(server.URL+"/api/hooks/slash/"+channels[0].ID, nil) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected empty slash command error, got %s", resp.Status) + } +} + +func TestHTTPServesEmbeddedAsset(t *testing.T) { + t.Parallel() + st := newEmptyHTTPStore(t) + server := httptest.NewServer(New(st, realtime.NewHub(), Options{}).Handler()) + t.Cleanup(server.Close) + + assets, err := fs.ReadDir(webassets.Dist, "dist/assets") + if err != nil { + t.Fatal(err) + } + if len(assets) == 0 { + t.Fatal("expected embedded assets") + } + resp, err := http.Get(server.URL + "/assets/" + assets[0].Name()) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected asset response, got %s", resp.Status) + } +} + +func TestListenAndServeStopsWithContext(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- ListenAndServe(ctx, "127.0.0.1:0", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + }() + time.Sleep(20 * time.Millisecond) + cancel() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("server did not stop") + } +} + +func newEmptyHTTPStore(t *testing.T) *sqlitestore.Store { + t.Helper() + st, err := sqlitestore.Open("sqlite://" + filepath.Join(t.TempDir(), "clickclack.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(context.Background()); err != nil { + t.Fatal(err) + } + return st +} + +func newHTTPStore(t *testing.T) *sqlitestore.Store { + t.Helper() + st := newEmptyHTTPStore(t) + _, _ = st.CreateUser(context.Background(), store.CreateUserInput{DisplayName: "seed", Email: "seed@example.com"}) + return st +} diff --git a/apps/api/internal/httpapi/features.go b/apps/api/internal/httpapi/features.go new file mode 100644 index 0000000..8210346 --- /dev/null +++ b/apps/api/internal/httpapi/features.go @@ -0,0 +1,212 @@ +package httpapi + +import ( + "errors" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func (s *Server) search(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + results, err := s.store.SearchMessages(r.Context(), r.URL.Query().Get("workspace_id"), user.ID, r.URL.Query().Get("q"), queryInt(r, "limit", 50)) + writeResult(w, map[string]any{"results": results}, err) +} + +func (s *Server) createUpload(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + if s.uploadDir == "" { + writeError(w, http.StatusInternalServerError, errors.New("uploads are not configured")) + return + } + if err := r.ParseMultipartForm(32 << 20); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + workspaceID := r.FormValue("workspace_id") + file, header, err := r.FormFile("file") + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + defer file.Close() + if err := os.MkdirAll(s.uploadDir, 0o755); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + tmp, err := os.CreateTemp(s.uploadDir, "upload-*") + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + defer tmp.Close() + size, err := io.Copy(tmp, file) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + contentType := header.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + upload, err := s.store.CreateUpload(r.Context(), store.CreateUploadInput{ + WorkspaceID: workspaceID, + OwnerID: user.ID, + Filename: filepath.Base(header.Filename), + ContentType: contentType, + ByteSize: size, + StoragePath: tmp.Name(), + }) + writeResultStatus(w, http.StatusCreated, map[string]any{"upload": upload}, err) +} + +func (s *Server) getUpload(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + upload, err := s.store.GetUpload(r.Context(), chi.URLParam(r, "upload_id"), user.ID) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + http.ServeFile(w, r, upload.StoragePath) +} + +func (s *Server) attachUpload(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + var body struct { + UploadID string `json:"upload_id"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + err = s.store.AttachUpload(r.Context(), store.AttachUploadInput{MessageID: chi.URLParam(r, "message_id"), UploadID: body.UploadID, UserID: user.ID}) + writeResult(w, map[string]any{"ok": true}, err) +} + +func (s *Server) listDirectConversations(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + items, err := s.store.ListDirectConversations(r.Context(), r.URL.Query().Get("workspace_id"), user.ID) + writeResult(w, map[string]any{"conversations": items}, err) +} + +func (s *Server) createDirectConversation(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + var body struct { + WorkspaceID string `json:"workspace_id"` + MemberIDs []string `json:"member_ids"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + dm, err := s.store.CreateDirectConversation(r.Context(), store.CreateDirectConversationInput{WorkspaceID: body.WorkspaceID, UserID: user.ID, MemberIDs: body.MemberIDs}) + writeResultStatus(w, http.StatusCreated, map[string]any{"conversation": dm}, err) +} + +func (s *Server) listDirectMessages(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + messages, err := s.store.ListDirectMessages(r.Context(), chi.URLParam(r, "conversation_id"), user.ID, queryInt64(r, "after_seq", 0), queryInt(r, "limit", 100)) + writeResult(w, map[string]any{"messages": messages}, err) +} + +func (s *Server) createDirectMessage(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + var body struct { + Body string `json:"body"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + message, event, err := s.store.CreateDirectMessage(r.Context(), store.CreateDirectMessageInput{ConversationID: chi.URLParam(r, "conversation_id"), AuthorID: user.ID, Body: body.Body}) + if err == nil { + s.hub.Publish(event) + } + writeResultStatus(w, http.StatusCreated, map[string]any{"message": message, "event": event}, err) +} + +func (s *Server) mattermostWebhook(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + var body struct { + Text string `json:"text"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + message, event, err := s.store.CreateMessage(r.Context(), store.CreateMessageInput{ChannelID: chi.URLParam(r, "channel_id"), AuthorID: user.ID, Body: body.Text}) + if err == nil { + s.hub.Publish(event) + } + writeResultStatus(w, http.StatusCreated, map[string]any{"message": message, "event": event}, err) +} + +func (s *Server) slashCommand(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + if err := r.ParseForm(); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + text := strings.TrimSpace(r.FormValue("text")) + command := strings.TrimSpace(r.FormValue("command")) + if text == "" && command == "" { + writeError(w, http.StatusBadRequest, errors.New("slash command text is required")) + return + } + body := strings.TrimSpace(command + " " + text) + message, event, err := s.store.CreateMessage(r.Context(), store.CreateMessageInput{ChannelID: chi.URLParam(r, "channel_id"), AuthorID: user.ID, Body: body}) + if err == nil { + s.hub.Publish(event) + } + writeResultStatus(w, http.StatusCreated, map[string]any{ + "response_type": "in_channel", + "text": message.Body, + "message": message, + "event": event, + }, err) +} diff --git a/apps/api/internal/httpapi/github.go b/apps/api/internal/httpapi/github.go new file mode 100644 index 0000000..0145e72 --- /dev/null +++ b/apps/api/internal/httpapi/github.go @@ -0,0 +1,230 @@ +package httpapi + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +type GitHubOAuthConfig struct { + ClientID string + ClientSecret string + PublicURL string + AuthURL string + TokenURL string + UserURL string + EmailsURL string + HTTPClient *http.Client +} + +func (c GitHubOAuthConfig) withDefaults() GitHubOAuthConfig { + if c.AuthURL == "" { + c.AuthURL = "https://github.com/login/oauth/authorize" + } + if c.TokenURL == "" { + c.TokenURL = "https://github.com/login/oauth/access_token" + } + if c.UserURL == "" { + c.UserURL = "https://api.github.com/user" + } + if c.EmailsURL == "" { + c.EmailsURL = "https://api.github.com/user/emails" + } + if c.HTTPClient == nil { + c.HTTPClient = http.DefaultClient + } + return c +} + +func (s *Server) githubStart(w http.ResponseWriter, r *http.Request) { + if s.githubOAuth.ClientID == "" || s.githubOAuth.ClientSecret == "" { + writeError(w, http.StatusNotImplemented, errors.New("github oauth is not configured")) + return + } + state, err := randomToken() + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + http.SetCookie(w, &http.Cookie{Name: "cc_github_state", Value: state, Path: "/", MaxAge: 600, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + values := url.Values{ + "client_id": {s.githubOAuth.ClientID}, + "redirect_uri": {s.githubRedirectURL(r)}, + "scope": {"read:user user:email"}, + "state": {state}, + } + http.Redirect(w, r, s.githubOAuth.AuthURL+"?"+values.Encode(), http.StatusFound) +} + +func (s *Server) githubCallback(w http.ResponseWriter, r *http.Request) { + state, err := r.Cookie("cc_github_state") + if err != nil || state.Value == "" || state.Value != r.URL.Query().Get("state") { + writeError(w, http.StatusBadRequest, errors.New("invalid github oauth state")) + return + } + code := strings.TrimSpace(r.URL.Query().Get("code")) + if code == "" { + writeError(w, http.StatusBadRequest, errors.New("github oauth code is required")) + return + } + token, err := s.exchangeGitHubCode(r.Context(), r, code) + if err != nil { + writeError(w, http.StatusBadGateway, err) + return + } + profile, err := s.fetchGitHubProfile(r.Context(), token) + if err != nil { + writeError(w, http.StatusBadGateway, err) + return + } + user, err := s.store.UpsertIdentityUser(r.Context(), store.UpsertIdentityUserInput{ + Provider: "github", + ProviderSubject: strconv.FormatInt(profile.ID, 10), + Email: profile.Email, + DisplayName: firstNonEmpty(profile.Name, profile.Login, profile.Email), + AvatarURL: profile.AvatarURL, + }) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + session, err := s.store.CreateSession(r.Context(), user.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + setSessionCookie(w, session) + http.Redirect(w, r, "/", http.StatusFound) +} + +func (s *Server) exchangeGitHubCode(ctx context.Context, r *http.Request, code string) (string, error) { + body := url.Values{ + "client_id": {s.githubOAuth.ClientID}, + "client_secret": {s.githubOAuth.ClientSecret}, + "code": {code}, + "redirect_uri": {s.githubRedirectURL(r)}, + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.githubOAuth.TokenURL, strings.NewReader(body.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := s.githubOAuth.HTTPClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return "", errors.New("github token exchange failed") + } + var out struct { + AccessToken string `json:"access_token"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + if out.Error != "" { + return "", errors.New(out.Error) + } + if out.AccessToken == "" { + return "", errors.New("github access token missing") + } + return out.AccessToken, nil +} + +type githubProfile struct { + ID int64 `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` +} + +func (s *Server) fetchGitHubProfile(ctx context.Context, token string) (githubProfile, error) { + var profile githubProfile + if err := s.githubGetJSON(ctx, s.githubOAuth.UserURL, token, &profile); err != nil { + return githubProfile{}, err + } + if profile.ID == 0 { + return githubProfile{}, errors.New("github profile id missing") + } + if profile.Email == "" { + var emails []struct { + Email string `json:"email"` + Primary bool `json:"primary"` + } + if err := s.githubGetJSON(ctx, s.githubOAuth.EmailsURL, token, &emails); err != nil { + return githubProfile{}, err + } + for _, item := range emails { + if item.Primary { + profile.Email = item.Email + break + } + } + } + return profile, nil +} + +func (s *Server) githubGetJSON(ctx context.Context, endpoint, token string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := s.githubOAuth.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return errors.New("github api request failed") + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func (s *Server) githubRedirectURL(r *http.Request) string { + base := strings.TrimRight(s.githubOAuth.PublicURL, "/") + if base == "" { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + base = scheme + "://" + r.Host + } + return base + "/api/auth/github/callback" +} + +func setSessionCookie(w http.ResponseWriter, session store.Session) { + expires, _ := time.Parse(time.RFC3339Nano, session.ExpiresAt) + http.SetCookie(w, &http.Cookie{Name: "cc_session", Value: session.Token, Path: "/", Expires: expires, HttpOnly: true, SameSite: http.SameSiteLaxMode}) +} + +func randomToken() (string, error) { + var data [16]byte + if _, err := rand.Read(data[:]); err != nil { + return "", err + } + return hex.EncodeToString(data[:]), nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/apps/api/internal/httpapi/github_test.go b/apps/api/internal/httpapi/github_test.go new file mode 100644 index 0000000..04ad4ce --- /dev/null +++ b/apps/api/internal/httpapi/github_test.go @@ -0,0 +1,200 @@ +package httpapi + +import ( + "context" + "crypto/tls" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/openclaw/clickclack/apps/api/internal/realtime" + sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite" +) + +func TestGitHubOAuthFlow(t *testing.T) { + t.Parallel() + ctx := context.Background() + dataDir := t.TempDir() + st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + switch r.FormValue("code") { + case "ok": + _ = json.NewEncoder(w).Encode(map[string]string{"access_token": "gh-token"}) + case "empty": + _ = json.NewEncoder(w).Encode(map[string]string{}) + case "api-fail": + _ = json.NewEncoder(w).Encode(map[string]string{"access_token": "api-fail"}) + case "missing-id": + _ = json.NewEncoder(w).Encode(map[string]string{"access_token": "missing-id"}) + default: + w.WriteHeader(http.StatusBadRequest) + } + case "/user": + switch r.Header.Get("Authorization") { + case "Bearer gh-token": + _ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "login": "octo", "name": "Octo User", "avatar_url": "https://example.com/a.png"}) + case "Bearer missing-id": + _ = json.NewEncoder(w).Encode(map[string]any{"login": "missing"}) + default: + w.WriteHeader(http.StatusInternalServerError) + } + case "/emails": + _ = json.NewEncoder(w).Encode([]map[string]any{{"email": "octo@example.com", "primary": true}}) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(provider.Close) + + server := httptest.NewServer(New(st, realtime.NewHub(), Options{GitHubOAuth: GitHubOAuthConfig{ + ClientID: "client", + ClientSecret: "secret", + AuthURL: provider.URL + "/authorize", + TokenURL: provider.URL + "/token", + UserURL: provider.URL + "/user", + EmailsURL: provider.URL + "/emails", + }}).Handler()) + t.Cleanup(server.Close) + client := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} + + resp, err := client.Get(server.URL + "/api/auth/github/start") + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusFound || !strings.HasPrefix(resp.Header.Get("Location"), provider.URL+"/authorize?") { + t.Fatalf("unexpected start response: %s %s", resp.Status, resp.Header.Get("Location")) + } + var stateCookie *http.Cookie + for _, cookie := range resp.Cookies() { + if cookie.Name == "cc_github_state" { + stateCookie = cookie + } + } + resp.Body.Close() + if stateCookie == nil || stateCookie.Value == "" { + t.Fatal("expected github state cookie") + } + + req, err := http.NewRequest(http.MethodGet, server.URL+"/api/auth/github/callback?code=ok&state="+stateCookie.Value, nil) + if err != nil { + t.Fatal(err) + } + req.AddCookie(stateCookie) + resp, err = client.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "/" { + t.Fatalf("unexpected callback response: %s %s", resp.Status, resp.Header.Get("Location")) + } + var sessionCookie *http.Cookie + for _, cookie := range resp.Cookies() { + if cookie.Name == "cc_session" { + sessionCookie = cookie + } + } + resp.Body.Close() + if sessionCookie == nil || sessionCookie.Value == "" { + t.Fatal("expected session cookie") + } + + req, err = http.NewRequest(http.MethodGet, server.URL+"/api/me", nil) + if err != nil { + t.Fatal(err) + } + req.AddCookie(sessionCookie) + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected session auth, got %s", resp.Status) + } + + for _, tc := range []struct { + name string + code string + want int + }{ + {"missing code", "", http.StatusBadRequest}, + {"token status", "bad", http.StatusBadGateway}, + {"missing token", "empty", http.StatusBadGateway}, + {"api failure", "api-fail", http.StatusBadGateway}, + {"missing profile id", "missing-id", http.StatusBadGateway}, + } { + t.Run(tc.name, func(t *testing.T) { + path := server.URL + "/api/auth/github/callback?state=" + stateCookie.Value + if tc.code != "" { + path += "&code=" + tc.code + } + req, err := http.NewRequest(http.MethodGet, path, nil) + if err != nil { + t.Fatal(err) + } + req.AddCookie(stateCookie) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != tc.want { + t.Fatalf("expected %d, got %s", tc.want, resp.Status) + } + }) + } + + if got := firstNonEmpty("", " ", "value"); got != "value" { + t.Fatalf("unexpected first non-empty value %q", got) + } + if got := firstNonEmpty("", " "); got != "" { + t.Fatalf("expected empty fallback, got %q", got) + } + req, err = http.NewRequest(http.MethodGet, server.URL+"/anything", nil) + if err != nil { + t.Fatal(err) + } + srv := New(st, realtime.NewHub(), Options{GitHubOAuth: GitHubOAuthConfig{PublicURL: "https://public.example"}}) + if got := srv.githubRedirectURL(req); got != "https://public.example/api/auth/github/callback" { + t.Fatalf("unexpected public redirect url %q", got) + } +} + +func TestGitHubOAuthErrors(t *testing.T) { + t.Parallel() + st := newEmptyHTTPStore(t) + server := httptest.NewServer(New(st, realtime.NewHub(), Options{}).Handler()) + t.Cleanup(server.Close) + + expectStatus(t, http.MethodGet, server.URL+"/api/auth/github/start", nil, http.StatusNotImplemented) + expectStatus(t, http.MethodGet, server.URL+"/api/auth/github/callback?code=x&state=bad", nil, http.StatusBadRequest) + + srv := New(st, realtime.NewHub(), Options{GitHubOAuth: GitHubOAuthConfig{ClientID: "c", ClientSecret: "s", TokenURL: "://bad", UserURL: "://bad"}}) + req := httptest.NewRequest(http.MethodGet, "https://example.test/callback", nil) + req.TLS = &tls.ConnectionState{} + if got := srv.githubRedirectURL(req); got != "https://example.test/api/auth/github/callback" { + t.Fatalf("unexpected tls redirect %q", got) + } + if _, err := srv.exchangeGitHubCode(context.Background(), req, "x"); err == nil { + t.Fatal("expected bad token url error") + } + if err := srv.githubGetJSON(context.Background(), "://bad", "token", &struct{}{}); err == nil { + t.Fatal("expected bad github api url error") + } +} diff --git a/apps/api/internal/httpapi/mutations.go b/apps/api/internal/httpapi/mutations.go new file mode 100644 index 0000000..4d4124a --- /dev/null +++ b/apps/api/internal/httpapi/mutations.go @@ -0,0 +1,105 @@ +package httpapi + +import ( + "errors" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func (s *Server) updateChannel(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + var body struct { + Name string `json:"name"` + Kind string `json:"kind"` + Archived *bool `json:"archived"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + channel, event, err := s.store.UpdateChannel(r.Context(), store.UpdateChannelInput{ChannelID: chi.URLParam(r, "channel_id"), UserID: user.ID, Name: body.Name, Kind: body.Kind, Archived: body.Archived}) + if err == nil { + s.hub.Publish(event) + } + writeResult(w, map[string]any{"channel": channel, "event": event}, err) +} + +func (s *Server) updateMessage(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + var body struct { + Body string `json:"body"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + message, event, err := s.store.UpdateMessage(r.Context(), store.UpdateMessageInput{MessageID: chi.URLParam(r, "message_id"), UserID: user.ID, Body: body.Body}) + if err == nil { + s.hub.Publish(event) + } + writeResult(w, map[string]any{"message": message, "event": event}, err) +} + +func (s *Server) deleteMessage(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + message, event, err := s.store.DeleteMessage(r.Context(), store.DeleteMessageInput{MessageID: chi.URLParam(r, "message_id"), UserID: user.ID}) + if err == nil { + s.hub.Publish(event) + } + writeResult(w, map[string]any{"message": message, "event": event}, err) +} + +func (s *Server) publishEphemeral(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + var body struct { + WorkspaceID string `json:"workspace_id"` + ChannelID string `json:"channel_id"` + Type string `json:"type"` + Payload map[string]any `json:"payload"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if body.Type != "typing.started" && body.Type != "typing.stopped" && body.Type != "presence.changed" { + writeError(w, http.StatusBadRequest, errors.New("unsupported ephemeral event type")) + return + } + if _, err := s.store.GetWorkspace(r.Context(), body.WorkspaceID, user.ID); err != nil { + writeError(w, http.StatusForbidden, err) + return + } + if body.Payload == nil { + body.Payload = map[string]any{} + } + body.Payload["user_id"] = user.ID + event := store.Event{ + ID: "eph_" + time.Now().UTC().Format("20060102150405.000000000"), + Type: body.Type, + WorkspaceID: body.WorkspaceID, + ChannelID: body.ChannelID, + CreatedAt: time.Now().UTC().Format(time.RFC3339Nano), + Payload: body.Payload, + } + s.hub.Publish(event) + writeJSON(w, http.StatusAccepted, map[string]any{"event": event}) +} diff --git a/apps/api/internal/httpapi/mutations_test.go b/apps/api/internal/httpapi/mutations_test.go new file mode 100644 index 0000000..fc106f5 --- /dev/null +++ b/apps/api/internal/httpapi/mutations_test.go @@ -0,0 +1,128 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/openclaw/clickclack/apps/api/internal/realtime" + "github.com/openclaw/clickclack/apps/api/internal/store" + sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite" +) + +func TestMutationAndEphemeralEndpoints(t *testing.T) { + t.Parallel() + ctx := context.Background() + dataDir := t.TempDir() + st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler()) + t.Cleanup(server.Close) + + updatedChannel := patchJSON[struct { + Channel store.Channel `json:"channel"` + Event store.Event `json:"event"` + }](t, server.URL+"/api/channels/"+channels[0].ID, map[string]any{"name": "dock"}) + if updatedChannel.Channel.Name != "dock" || updatedChannel.Event.Type != "channel.updated" { + t.Fatalf("unexpected channel update: %#v", updatedChannel) + } + message := postJSON[struct { + Message store.Message `json:"message"` + }](t, server.URL+"/api/channels/"+channels[0].ID+"/messages", map[string]string{"body": "original"}).Message + updatedMessage := patchJSON[struct { + Message store.Message `json:"message"` + Event store.Event `json:"event"` + }](t, server.URL+"/api/messages/"+message.ID, map[string]string{"body": "edited"}) + if updatedMessage.Message.Body != "edited" || updatedMessage.Event.Type != "message.updated" { + t.Fatalf("unexpected message update: %#v", updatedMessage) + } + deletedMessage := deleteJSONBody[struct { + Message store.Message `json:"message"` + Event store.Event `json:"event"` + }](t, server.URL+"/api/messages/"+message.ID) + if deletedMessage.Message.DeletedAt == nil || deletedMessage.Event.Type != "message.deleted" { + t.Fatalf("unexpected message delete: %#v", deletedMessage) + } + ephemeral := postJSON[struct { + Event store.Event `json:"event"` + }](t, server.URL+"/api/realtime/ephemeral", map[string]any{"workspace_id": workspaces[0].ID, "channel_id": channels[0].ID, "type": "typing.started"}) + if ephemeral.Event.Type != "typing.started" || ephemeral.Event.Cursor != "" { + t.Fatalf("unexpected ephemeral event: %#v", ephemeral.Event) + } + presence := postJSON[struct { + Event store.Event `json:"event"` + }](t, server.URL+"/api/realtime/ephemeral", map[string]any{"workspace_id": workspaces[0].ID, "type": "presence.changed", "payload": map[string]any{"status": "afk"}}) + if presence.Event.Type != "presence.changed" { + t.Fatalf("unexpected presence event: %#v", presence.Event) + } + expectStatus(t, http.MethodPatch, server.URL+"/api/channels/"+channels[0].ID, bytes.NewReader([]byte(`{`)), http.StatusBadRequest) + expectStatus(t, http.MethodPatch, server.URL+"/api/channels/missing", bytes.NewReader([]byte(`{"name":"missing"}`)), http.StatusBadRequest) + expectStatus(t, http.MethodPatch, server.URL+"/api/messages/"+message.ID, bytes.NewReader([]byte(`{`)), http.StatusBadRequest) + expectStatus(t, http.MethodPatch, server.URL+"/api/messages/"+message.ID, bytes.NewReader([]byte(`{"body":" "}`)), http.StatusBadRequest) + expectStatus(t, http.MethodDelete, server.URL+"/api/messages/missing", nil, http.StatusBadRequest) + expectStatus(t, http.MethodPost, server.URL+"/api/realtime/ephemeral", bytes.NewReader([]byte(`{`)), http.StatusBadRequest) + expectStatus(t, http.MethodPost, server.URL+"/api/realtime/ephemeral", bytes.NewReader([]byte(`{"workspace_id":"`+workspaces[0].ID+`","type":"bad"}`)), http.StatusBadRequest) + expectStatus(t, http.MethodPost, server.URL+"/api/realtime/ephemeral", bytes.NewReader([]byte(`{"workspace_id":"missing","type":"typing.started"}`)), http.StatusForbidden) +} + +func patchJSON[T any](t *testing.T, endpoint string, body any) T { + t.Helper() + payload, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + return doJSON[T](t, req) +} + +func deleteJSONBody[T any](t *testing.T, endpoint string) T { + t.Helper() + req, err := http.NewRequest(http.MethodDelete, endpoint, nil) + if err != nil { + t.Fatal(err) + } + return doJSON[T](t, req) +} + +func doJSON[T any](t *testing.T, req *http.Request) T { + t.Helper() + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + t.Fatalf("%s %s: %s", req.Method, req.URL, resp.Status) + } + var out T + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + return out +} diff --git a/apps/api/internal/httpapi/server.go b/apps/api/internal/httpapi/server.go new file mode 100644 index 0000000..5f2a68f --- /dev/null +++ b/apps/api/internal/httpapi/server.go @@ -0,0 +1,412 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "strconv" + "strings" + "time" + + "github.com/coder/websocket" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/openclaw/clickclack/apps/api/internal/realtime" + "github.com/openclaw/clickclack/apps/api/internal/store" + "github.com/openclaw/clickclack/apps/api/internal/webassets" +) + +type Server struct { + store store.Store + hub *realtime.Hub + uploadDir string + githubOAuth GitHubOAuthConfig +} + +type Options struct { + UploadDir string + GitHubOAuth GitHubOAuthConfig +} + +func New(st store.Store, hub *realtime.Hub, options Options) *Server { + return &Server{store: st, hub: hub, uploadDir: options.UploadDir, githubOAuth: options.GitHubOAuth.withDefaults()} +} + +func (s *Server) Handler() http.Handler { + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + + r.Route("/api", func(r chi.Router) { + r.Post("/auth/magic/request", s.requestMagicLink) + r.Post("/auth/magic/consume", s.consumeMagicLink) + r.Get("/auth/github/start", s.githubStart) + r.Get("/auth/github/callback", s.githubCallback) + r.Get("/me", s.me) + r.Get("/workspaces", s.listWorkspaces) + r.Post("/workspaces", s.createWorkspace) + r.Get("/workspaces/{workspace_id}", s.getWorkspace) + r.Get("/workspaces/{workspace_id}/channels", s.listChannels) + r.Post("/workspaces/{workspace_id}/channels", s.createChannel) + r.Patch("/channels/{channel_id}", s.updateChannel) + r.Get("/channels/{channel_id}/messages", s.listMessages) + r.Post("/channels/{channel_id}/messages", s.createMessage) + r.Patch("/messages/{message_id}", s.updateMessage) + r.Delete("/messages/{message_id}", s.deleteMessage) + r.Get("/messages/{message_id}/thread", s.getThread) + r.Post("/messages/{message_id}/thread/replies", s.createThreadReply) + r.Post("/messages/{message_id}/reactions", s.addReaction) + r.Delete("/messages/{message_id}/reactions/{emoji}", s.removeReaction) + r.Get("/realtime/events", s.listEvents) + r.Post("/realtime/ephemeral", s.publishEphemeral) + r.Get("/realtime/ws", s.websocket) + r.Get("/search", s.search) + r.Post("/uploads", s.createUpload) + r.Get("/uploads/{upload_id}", s.getUpload) + r.Post("/messages/{message_id}/attachments", s.attachUpload) + r.Get("/dms", s.listDirectConversations) + r.Post("/dms", s.createDirectConversation) + r.Get("/dms/{conversation_id}/messages", s.listDirectMessages) + r.Post("/dms/{conversation_id}/messages", s.createDirectMessage) + r.Post("/hooks/mattermost/{channel_id}", s.mattermostWebhook) + r.Post("/hooks/slash/{channel_id}", s.slashCommand) + }) + + r.NotFound(s.serveSPA) + r.Get("/*", s.serveSPA) + return r +} + +func (s *Server) me(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"user": user}) +} + +func (s *Server) listWorkspaces(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + items, err := s.store.ListWorkspaces(r.Context(), user.ID) + writeResult(w, map[string]any{"workspaces": items}, err) +} + +func (s *Server) createWorkspace(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + var body struct { + Name string `json:"name"` + Slug string `json:"slug"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + workspace, err := s.store.CreateWorkspace(r.Context(), store.CreateWorkspaceInput{Name: body.Name, Slug: body.Slug}, user.ID) + writeResultStatus(w, http.StatusCreated, map[string]any{"workspace": workspace}, err) +} + +func (s *Server) getWorkspace(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + workspace, err := s.store.GetWorkspace(r.Context(), chi.URLParam(r, "workspace_id"), user.ID) + writeResult(w, map[string]any{"workspace": workspace}, err) +} + +func (s *Server) listChannels(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + channels, err := s.store.ListChannels(r.Context(), chi.URLParam(r, "workspace_id"), user.ID) + writeResult(w, map[string]any{"channels": channels}, err) +} + +func (s *Server) createChannel(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + var body struct { + Name string `json:"name"` + Kind string `json:"kind"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + channel, event, err := s.store.CreateChannel(r.Context(), store.CreateChannelInput{WorkspaceID: chi.URLParam(r, "workspace_id"), Name: body.Name, Kind: body.Kind, UserID: user.ID}) + if err == nil { + s.hub.Publish(event) + } + writeResultStatus(w, http.StatusCreated, map[string]any{"channel": channel, "event": event}, err) +} + +func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + messages, err := s.store.ListMessages(r.Context(), chi.URLParam(r, "channel_id"), user.ID, queryInt64(r, "after_seq", 0), queryInt(r, "limit", 100)) + writeResult(w, map[string]any{"messages": messages}, err) +} + +func (s *Server) createMessage(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + var body struct { + Body string `json:"body"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + message, event, err := s.store.CreateMessage(r.Context(), store.CreateMessageInput{ChannelID: chi.URLParam(r, "channel_id"), AuthorID: user.ID, Body: body.Body}) + if err == nil { + s.hub.Publish(event) + } + writeResultStatus(w, http.StatusCreated, map[string]any{"message": message, "event": event}, err) +} + +func (s *Server) getThread(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + root, replies, state, err := s.store.GetThread(r.Context(), chi.URLParam(r, "message_id"), user.ID, queryInt(r, "limit", 100)) + writeResult(w, map[string]any{"root": root, "replies": replies, "thread_state": state}, err) +} + +func (s *Server) createThreadReply(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + var body struct { + Body string `json:"body"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + message, state, events, err := s.store.CreateThreadReply(r.Context(), store.CreateThreadReplyInput{RootMessageID: chi.URLParam(r, "message_id"), AuthorID: user.ID, Body: body.Body}) + if err == nil { + s.hub.PublishMany(events) + } + writeResultStatus(w, http.StatusCreated, map[string]any{"message": message, "thread_state": state, "events": events}, err) +} + +func (s *Server) addReaction(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + var body struct { + Emoji string `json:"emoji"` + } + if err := readJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + event, err := s.store.AddReaction(r.Context(), store.CreateReactionInput{MessageID: chi.URLParam(r, "message_id"), UserID: user.ID, Emoji: body.Emoji}) + if err == nil { + s.hub.Publish(event) + } + writeResultStatus(w, http.StatusCreated, map[string]any{"event": event}, err) +} + +func (s *Server) removeReaction(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + event, err := s.store.RemoveReaction(r.Context(), store.CreateReactionInput{MessageID: chi.URLParam(r, "message_id"), UserID: user.ID, Emoji: chi.URLParam(r, "emoji")}) + if err == nil { + s.hub.Publish(event) + } + writeResult(w, map[string]any{"event": event}, err) +} + +func (s *Server) listEvents(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + events, err := s.store.ListEventsAfter(r.Context(), r.URL.Query().Get("workspace_id"), user.ID, r.URL.Query().Get("after_cursor"), queryInt(r, "limit", 200)) + writeResult(w, map[string]any{"events": events}, err) +} + +func (s *Server) websocket(w http.ResponseWriter, r *http.Request) { + user, err := s.currentUser(r) + if err != nil { + writeError(w, http.StatusUnauthorized, err) + return + } + workspaceID := r.URL.Query().Get("workspace_id") + if workspaceID == "" { + writeError(w, http.StatusBadRequest, errors.New("workspace_id is required")) + return + } + if _, err := s.store.GetWorkspace(r.Context(), workspaceID, user.ID); err != nil { + writeError(w, http.StatusForbidden, err) + return + } + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + return + } + defer conn.CloseNow() + ctx := r.Context() + backlog, err := s.store.ListEventsAfter(ctx, workspaceID, user.ID, r.URL.Query().Get("after_cursor"), 500) + if err != nil { + _ = conn.Close(websocket.StatusPolicyViolation, err.Error()) + return + } + for _, event := range backlog { + if err := writeWS(ctx, conn, event); err != nil { + return + } + } + events, unsubscribe := s.hub.Subscribe(workspaceID) + defer unsubscribe() + for { + select { + case <-ctx.Done(): + return + case event := <-events: + if err := writeWS(ctx, conn, event); err != nil { + return + } + } + } +} + +func (s *Server) currentUser(r *http.Request) (store.User, error) { + if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") { + return s.store.GetSessionUser(r.Context(), strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))) + } + if cookie, err := r.Cookie("cc_session"); err == nil && cookie.Value != "" { + return s.store.GetSessionUser(r.Context(), cookie.Value) + } + if id := r.Header.Get("X-ClickClack-User"); id != "" { + return s.store.GetUser(r.Context(), id) + } + return s.store.FirstUser(r.Context()) +} + +func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) { + dist, err := fs.Sub(webassets.Dist, "dist") + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if r.URL.Path != "/" { + if file, err := dist.Open(strings.TrimPrefix(r.URL.Path, "/")); err == nil { + _ = file.Close() + http.FileServer(http.FS(dist)).ServeHTTP(w, r) + return + } + } + index, err := fs.ReadFile(dist, "index.html") + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write(index) +} + +func writeWS(ctx context.Context, conn *websocket.Conn, event store.Event) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + body, err := json.Marshal(event) + if err != nil { + return err + } + return conn.Write(ctx, websocket.MessageText, body) +} + +func readJSON(r *http.Request, out any) error { + defer r.Body.Close() + return json.NewDecoder(r.Body).Decode(out) +} + +func writeResult(w http.ResponseWriter, body any, err error) { + writeResultStatus(w, http.StatusOK, body, err) +} + +func writeResultStatus(w http.ResponseWriter, status int, body any, err error) { + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + writeJSON(w, status, body) +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeError(w http.ResponseWriter, status int, err error) { + writeJSON(w, status, map[string]any{"error": err.Error()}) +} + +func queryInt(r *http.Request, key string, fallback int) int { + value, err := strconv.Atoi(r.URL.Query().Get(key)) + if err != nil { + return fallback + } + return value +} + +func queryInt64(r *http.Request, key string, fallback int64) int64 { + value, err := strconv.ParseInt(r.URL.Query().Get(key), 10, 64) + if err != nil { + return fallback + } + return value +} + +func ListenAndServe(ctx context.Context, addr string, handler http.Handler) error { + server := &http.Server{Addr: addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second} + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + }() + err := server.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return fmt.Errorf("serve %s: %w", addr, err) +} diff --git a/apps/api/internal/httpapi/server_test.go b/apps/api/internal/httpapi/server_test.go new file mode 100644 index 0000000..defb0e3 --- /dev/null +++ b/apps/api/internal/httpapi/server_test.go @@ -0,0 +1,441 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/openclaw/clickclack/apps/api/internal/realtime" + "github.com/openclaw/clickclack/apps/api/internal/store" + sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite" +) + +func TestChatAPIVerticalSlice(t *testing.T) { + t.Parallel() + ctx := context.Background() + dataDir := t.TempDir() + st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + second, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Second", Email: "second@example.com"}) + if err != nil { + t.Fatal(err) + } + + hub := realtime.NewHub() + server := httptest.NewServer(New(st, hub, Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler()) + t.Cleanup(server.Close) + + me := getJSON[struct { + User store.User `json:"user"` + }](t, server.URL+"/api/me") + if me.User.ID != owner.ID { + t.Fatalf("expected owner %s, got %s", owner.ID, me.User.ID) + } + + workspaces := getJSON[struct { + Workspaces []store.Workspace `json:"workspaces"` + }](t, server.URL+"/api/workspaces") + workspace := workspaces.Workspaces[0] + createdWorkspace := postJSON[struct { + Workspace store.Workspace `json:"workspace"` + }](t, server.URL+"/api/workspaces", map[string]string{"name": "Side Dock"}) + if createdWorkspace.Workspace.Slug != "side-dock" { + t.Fatalf("unexpected workspace slug %q", createdWorkspace.Workspace.Slug) + } + gotWorkspace := getJSON[struct { + Workspace store.Workspace `json:"workspace"` + }](t, server.URL+"/api/workspaces/"+workspace.ID) + if gotWorkspace.Workspace.ID != workspace.ID { + t.Fatalf("unexpected workspace response: %#v", gotWorkspace.Workspace) + } + if err := st.AddWorkspaceMember(ctx, workspace.ID, second.ID, "member"); err != nil { + t.Fatal(err) + } + + channels := getJSON[struct { + Channels []store.Channel `json:"channels"` + }](t, server.URL+"/api/workspaces/"+workspace.ID+"/channels") + channel := channels.Channels[0] + createdChannel := postJSON[struct { + Channel store.Channel `json:"channel"` + }](t, server.URL+"/api/workspaces/"+workspace.ID+"/channels", map[string]string{"name": "random"}) + if createdChannel.Channel.Name != "random" { + t.Fatalf("unexpected channel: %#v", createdChannel.Channel) + } + + wsURL := strings.Replace(server.URL, "http://", "ws://", 1) + "/api/realtime/ws?workspace_id=" + url.QueryEscape(workspace.ID) + conn, _, err := websocket.Dial(ctx, wsURL, nil) + if err != nil { + t.Fatal(err) + } + defer conn.CloseNow() + + created := postJSON[struct { + Message store.Message `json:"message"` + Event store.Event `json:"event"` + }](t, server.URL+"/api/channels/"+channel.ID+"/messages", map[string]string{"body": "findable **lobster**"}) + if created.Message.ChannelSeq == nil || *created.Message.ChannelSeq != 1 { + t.Fatalf("unexpected channel seq: %#v", created.Message.ChannelSeq) + } + if event := readEventType(t, conn, "message.created"); event.Type != "message.created" { + t.Fatalf("unexpected websocket event %s", event.Type) + } + + messages := getJSON[struct { + Messages []store.Message `json:"messages"` + }](t, server.URL+"/api/channels/"+channel.ID+"/messages") + if len(messages.Messages) != 1 { + t.Fatalf("expected one root message, got %d", len(messages.Messages)) + } + + reply := postJSON[struct { + Message store.Message `json:"message"` + ThreadState store.ThreadState `json:"thread_state"` + }](t, server.URL+"/api/messages/"+created.Message.ID+"/thread/replies", map[string]string{"body": "thread _reply_"}) + if reply.ThreadState.ReplyCount != 1 { + t.Fatalf("expected reply count 1, got %d", reply.ThreadState.ReplyCount) + } + + thread := getJSON[struct { + Root store.Message `json:"root"` + Replies []store.Message `json:"replies"` + ThreadState store.ThreadState `json:"thread_state"` + }](t, server.URL+"/api/messages/"+created.Message.ID+"/thread") + if thread.Root.ID != created.Message.ID || len(thread.Replies) != 1 { + t.Fatalf("unexpected thread payload: %#v", thread) + } + + search := getJSON[struct { + Results []store.SearchResult `json:"results"` + }](t, server.URL+"/api/search?workspace_id="+url.QueryEscape(workspace.ID)+"&q=lobster") + if len(search.Results) != 1 || search.Results[0].Message.ID != created.Message.ID { + t.Fatalf("unexpected search results: %#v", search.Results) + } + + upload := uploadFile(t, server.URL+"/api/uploads", workspace.ID, "note.txt", "hello upload") + attach := postJSON[map[string]bool](t, server.URL+"/api/messages/"+created.Message.ID+"/attachments", map[string]string{"upload_id": upload.ID}) + if !attach["ok"] { + t.Fatal("expected attachment success") + } + body := getBody(t, server.URL+"/api/uploads/"+upload.ID) + if body != "hello upload" { + t.Fatalf("unexpected upload body %q", body) + } + + reaction := postJSON[struct { + Event store.Event `json:"event"` + }](t, server.URL+"/api/messages/"+created.Message.ID+"/reactions", map[string]string{"emoji": "lobster"}) + if reaction.Event.Type != "reaction.added" { + t.Fatalf("unexpected reaction event: %s", reaction.Event.Type) + } + deleteJSON(t, server.URL+"/api/messages/"+created.Message.ID+"/reactions/lobster") + + dm := postJSON[struct { + Conversation store.DirectConversation `json:"conversation"` + }](t, server.URL+"/api/dms", map[string]any{"workspace_id": workspace.ID, "member_ids": []string{second.ID}}) + if len(dm.Conversation.Members) != 2 { + t.Fatalf("expected two dm members, got %d", len(dm.Conversation.Members)) + } + dmMessage := postJSON[struct { + Message store.Message `json:"message"` + }](t, server.URL+"/api/dms/"+dm.Conversation.ID+"/messages", map[string]string{"body": "private click"}) + if dmMessage.Message.DirectConversationID != dm.Conversation.ID { + t.Fatalf("unexpected dm message: %#v", dmMessage.Message) + } + dms := getJSON[struct { + Conversations []store.DirectConversation `json:"conversations"` + }](t, server.URL+"/api/dms?workspace_id="+url.QueryEscape(workspace.ID)) + if len(dms.Conversations) != 1 { + t.Fatalf("expected one dm conversation, got %d", len(dms.Conversations)) + } + dmMessages := getJSON[struct { + Messages []store.Message `json:"messages"` + }](t, server.URL+"/api/dms/"+dm.Conversation.ID+"/messages") + if len(dmMessages.Messages) != 1 { + t.Fatalf("expected one dm message, got %d", len(dmMessages.Messages)) + } + + webhook := postJSON[struct { + Message store.Message `json:"message"` + }](t, server.URL+"/api/hooks/mattermost/"+channel.ID, map[string]string{"text": "from webhook"}) + if webhook.Message.Body != "from webhook" { + t.Fatalf("unexpected webhook body %q", webhook.Message.Body) + } + slash := postForm[struct { + Text string `json:"text"` + Message store.Message `json:"message"` + }](t, server.URL+"/api/hooks/slash/"+channel.ID, url.Values{"command": {"/clack"}, "text": {"from slash"}}) + if slash.Text != "/clack from slash" || slash.Message.Body != slash.Text { + t.Fatalf("unexpected slash response: %#v", slash) + } + + events := getJSON[struct { + Events []store.Event `json:"events"` + }](t, server.URL+"/api/realtime/events?workspace_id="+url.QueryEscape(workspace.ID)+"&after_cursor="+url.QueryEscape(created.Event.Cursor)) + if len(events.Events) == 0 { + t.Fatal("expected recoverable events after cursor") + } +} + +func TestHTTPErrorPathsAndSPA(t *testing.T) { + t.Parallel() + ctx := context.Background() + dataDir := t.TempDir() + st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler()) + t.Cleanup(server.Close) + + index := getBody(t, server.URL+"/") + if !strings.Contains(index, "ClickClack") { + t.Fatalf("expected embedded app shell, got %q", index) + } + fallback := getBody(t, server.URL+"/not-a-real-route") + if !strings.Contains(fallback, "ClickClack") { + t.Fatal("expected SPA fallback") + } + + req, err := http.NewRequest(http.MethodGet, server.URL+"/api/me", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("X-ClickClack-User", owner.ID) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected header auth success, got %s", resp.Status) + } + resp.Body.Close() + + link := postJSON[struct { + Token string `json:"token"` + }](t, server.URL+"/api/auth/magic/request", map[string]string{"email": "auth@example.com", "display_name": "Auth User"}) + auth := postJSON[struct { + User store.User `json:"user"` + Session store.Session `json:"session"` + }](t, server.URL+"/api/auth/magic/consume", map[string]string{"token": link.Token}) + if auth.User.DisplayName != "Auth User" || auth.Session.Token == "" { + t.Fatalf("unexpected auth payload: %#v", auth) + } + req, err = http.NewRequest(http.MethodGet, server.URL+"/api/me", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer "+auth.Session.Token) + resp, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("expected bearer auth success, got %s %s", resp.Status, string(body)) + } + + expectStatus(t, http.MethodPost, server.URL+"/api/workspaces", strings.NewReader("{"), http.StatusBadRequest) + expectStatus(t, http.MethodPost, server.URL+"/api/auth/magic/request", strings.NewReader(`{"email":""}`), http.StatusBadRequest) + expectStatus(t, http.MethodPost, server.URL+"/api/auth/magic/consume", strings.NewReader(`{"token":"missing"}`), http.StatusBadRequest) + expectStatus(t, http.MethodPost, server.URL+"/api/workspaces/missing/channels", strings.NewReader(`{"name":"x"}`), http.StatusBadRequest) + expectStatus(t, http.MethodGet, server.URL+"/api/realtime/ws", nil, http.StatusBadRequest) + expectStatus(t, http.MethodPost, server.URL+"/api/uploads", strings.NewReader("not multipart"), http.StatusBadRequest) + expectStatus(t, http.MethodGet, server.URL+"/api/uploads/missing", nil, http.StatusNotFound) + expectStatus(t, http.MethodGet, server.URL+"/api/search?workspace_id=missing&q=x", nil, http.StatusBadRequest) +} + +func getJSON[T any](t *testing.T, endpoint string) T { + t.Helper() + resp, err := http.Get(endpoint) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("GET %s: %s %s", endpoint, resp.Status, string(body)) + } + var out T + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + return out +} + +func postJSON[T any](t *testing.T, endpoint string, body any) T { + t.Helper() + payload, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + resp, err := http.Post(endpoint, "application/json", bytes.NewReader(payload)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("POST %s: %s %s", endpoint, resp.Status, string(body)) + } + var out T + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + return out +} + +func postForm[T any](t *testing.T, endpoint string, form url.Values) T { + t.Helper() + resp, err := http.PostForm(endpoint, form) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("POST %s: %s %s", endpoint, resp.Status, string(body)) + } + var out T + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + return out +} + +func deleteJSON(t *testing.T, endpoint string) { + t.Helper() + req, err := http.NewRequest(http.MethodDelete, endpoint, nil) + if err != nil { + t.Fatal(err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("DELETE %s: %s %s", endpoint, resp.Status, string(body)) + } +} + +func expectStatus(t *testing.T, method, endpoint string, body io.Reader, status int) { + t.Helper() + req, err := http.NewRequest(method, endpoint, body) + if err != nil { + t.Fatal(err) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != status { + payload, _ := io.ReadAll(resp.Body) + t.Fatalf("%s %s: expected %d, got %s %s", method, endpoint, status, resp.Status, string(payload)) + } +} + +func uploadFile(t *testing.T, endpoint, workspaceID, filename, content string) store.Upload { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("workspace_id", workspaceID); err != nil { + t.Fatal(err) + } + part, err := writer.CreateFormFile("file", filename) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write([]byte(content)); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + resp, err := http.Post(endpoint, writer.FormDataContentType(), &body) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("upload: %s %s", resp.Status, string(body)) + } + var out struct { + Upload store.Upload `json:"upload"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + return out.Upload +} + +func getBody(t *testing.T, endpoint string) string { + t.Helper() + resp, err := http.Get(endpoint) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode >= 300 { + t.Fatalf("GET %s: %s %s", endpoint, resp.Status, string(body)) + } + return string(body) +} + +func readEventType(t *testing.T, conn *websocket.Conn, eventType string) store.Event { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for { + _, body, err := conn.Read(ctx) + if err != nil { + t.Fatal(err) + } + var event store.Event + if err := json.Unmarshal(body, &event); err != nil { + t.Fatal(err) + } + if event.Type == eventType { + return event + } + } +} diff --git a/apps/api/internal/realtime/hub.go b/apps/api/internal/realtime/hub.go new file mode 100644 index 0000000..3eeb0a3 --- /dev/null +++ b/apps/api/internal/realtime/hub.go @@ -0,0 +1,49 @@ +package realtime + +import ( + "sync" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +type Hub struct { + mu sync.RWMutex + subs map[string]map[chan store.Event]struct{} +} + +func NewHub() *Hub { + return &Hub{subs: map[string]map[chan store.Event]struct{}{}} +} + +func (h *Hub) Subscribe(workspaceID string) (<-chan store.Event, func()) { + ch := make(chan store.Event, 32) + h.mu.Lock() + if h.subs[workspaceID] == nil { + h.subs[workspaceID] = map[chan store.Event]struct{}{} + } + h.subs[workspaceID][ch] = struct{}{} + h.mu.Unlock() + return ch, func() { + h.mu.Lock() + delete(h.subs[workspaceID], ch) + close(ch) + h.mu.Unlock() + } +} + +func (h *Hub) Publish(event store.Event) { + h.mu.RLock() + defer h.mu.RUnlock() + for ch := range h.subs[event.WorkspaceID] { + select { + case ch <- event: + default: + } + } +} + +func (h *Hub) PublishMany(events []store.Event) { + for _, event := range events { + h.Publish(event) + } +} diff --git a/apps/api/internal/realtime/hub_test.go b/apps/api/internal/realtime/hub_test.go new file mode 100644 index 0000000..44171a9 --- /dev/null +++ b/apps/api/internal/realtime/hub_test.go @@ -0,0 +1,38 @@ +package realtime + +import ( + "testing" + "time" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func TestHubSubscribePublishAndUnsubscribe(t *testing.T) { + t.Parallel() + hub := NewHub() + events, unsubscribe := hub.Subscribe("wsp_1") + event := store.Event{ID: "evt_1", WorkspaceID: "wsp_1", Type: "message.created"} + hub.Publish(event) + + select { + case got := <-events: + if got.ID != event.ID { + t.Fatalf("expected %s, got %s", event.ID, got.ID) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } + + hub.PublishMany([]store.Event{{ID: "evt_2", WorkspaceID: "wsp_other"}}) + select { + case got := <-events: + t.Fatalf("unexpected event for other workspace: %#v", got) + case <-time.After(10 * time.Millisecond): + } + + unsubscribe() + _, ok := <-events + if ok { + t.Fatal("expected subscription channel to close") + } +} diff --git a/apps/api/internal/store/sqlite/auth.go b/apps/api/internal/store/sqlite/auth.go new file mode 100644 index 0000000..294091d --- /dev/null +++ b/apps/api/internal/store/sqlite/auth.go @@ -0,0 +1,135 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func (s *Store) CreateMagicLink(ctx context.Context, email, displayName string) (store.MagicLink, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" { + return store.MagicLink{}, errors.New("email is required") + } + link := store.MagicLink{ + ID: newID("mln"), + Token: newID("mgt"), + Email: email, + DisplayName: strings.TrimSpace(displayName), + CreatedAt: now(), + ExpiresAt: time.Now().UTC().Add(15 * time.Minute).Format(time.RFC3339Nano), + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO auth_magic_links (id, token, email, display_name, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?)`, link.ID, link.Token, link.Email, link.DisplayName, link.CreatedAt, link.ExpiresAt) + return link, err +} + +func (s *Store) ConsumeMagicLink(ctx context.Context, token string) (store.User, store.Session, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.User{}, store.Session{}, err + } + defer tx.Rollback() + link, err := scanMagicLink(tx.QueryRowContext(ctx, ` + SELECT id, token, email, display_name, created_at, expires_at, used_at + FROM auth_magic_links WHERE token = ?`, strings.TrimSpace(token))) + if err != nil { + return store.User{}, store.Session{}, err + } + if link.UsedAt != nil { + return store.User{}, store.Session{}, errors.New("magic link already used") + } + expiresAt, err := time.Parse(time.RFC3339Nano, link.ExpiresAt) + if err != nil || time.Now().UTC().After(expiresAt) { + return store.User{}, store.Session{}, errors.New("magic link expired") + } + user, err := getOrCreateMagicUser(ctx, tx, link.Email, link.DisplayName) + if err != nil { + return store.User{}, store.Session{}, err + } + usedAt := now() + if _, err := tx.ExecContext(ctx, `UPDATE auth_magic_links SET used_at = ? WHERE id = ?`, usedAt, link.ID); err != nil { + return store.User{}, store.Session{}, err + } + session, err := createSessionTx(ctx, tx, user.ID) + if err != nil { + return store.User{}, store.Session{}, err + } + return user, session, tx.Commit() +} + +func (s *Store) GetSessionUser(ctx context.Context, token string) (store.User, error) { + return scanUser(s.db.QueryRowContext(ctx, ` + SELECT u.id, u.display_name, u.avatar_url, u.created_at + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.token = ? AND s.revoked_at IS NULL AND s.expires_at > ?`, token, now())) +} + +func (s *Store) CreateSession(ctx context.Context, userID string) (store.Session, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.Session{}, err + } + defer tx.Rollback() + session, err := createSessionTx(ctx, tx, userID) + if err != nil { + return store.Session{}, err + } + return session, tx.Commit() +} + +func getOrCreateMagicUser(ctx context.Context, tx *sql.Tx, email, displayName string) (store.User, error) { + user, err := scanUser(tx.QueryRowContext(ctx, ` + SELECT u.id, u.display_name, u.avatar_url, u.created_at + FROM identities i + JOIN users u ON u.id = i.user_id + WHERE i.email = ? + ORDER BY u.created_at LIMIT 1`, email)) + if err == nil { + return user, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return store.User{}, err + } + user = store.User{ID: newID("usr"), DisplayName: strings.TrimSpace(displayName), CreatedAt: now()} + if user.DisplayName == "" { + user.DisplayName = email + } + if _, err := tx.ExecContext(ctx, `INSERT INTO users (id, display_name, avatar_url, created_at) VALUES (?, ?, '', ?)`, user.ID, user.DisplayName, user.CreatedAt); err != nil { + return store.User{}, err + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO identities (id, user_id, provider, provider_subject, email, created_at) + VALUES (?, ?, 'magic', ?, ?, ?)`, newID("idn"), user.ID, email, email, user.CreatedAt) + return user, err +} + +func createSessionTx(ctx context.Context, tx *sql.Tx, userID string) (store.Session, error) { + session := store.Session{ + ID: newID("ses"), + Token: newID("sst"), + UserID: userID, + CreatedAt: now(), + ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour).Format(time.RFC3339Nano), + } + _, err := tx.ExecContext(ctx, ` + INSERT INTO sessions (id, token, user_id, created_at, expires_at) + VALUES (?, ?, ?, ?, ?)`, session.ID, session.Token, session.UserID, session.CreatedAt, session.ExpiresAt) + return session, err +} + +func scanMagicLink(row scanner) (store.MagicLink, error) { + var link store.MagicLink + var usedAt sql.NullString + err := row.Scan(&link.ID, &link.Token, &link.Email, &link.DisplayName, &link.CreatedAt, &link.ExpiresAt, &usedAt) + if usedAt.Valid { + link.UsedAt = &usedAt.String + } + return link, err +} diff --git a/apps/api/internal/store/sqlite/chat_test.go b/apps/api/internal/store/sqlite/chat_test.go new file mode 100644 index 0000000..b78c8fe --- /dev/null +++ b/apps/api/internal/store/sqlite/chat_test.go @@ -0,0 +1,472 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func TestStoreChatThreadsSearchUploadsAndEvents(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + workspace := workspaces[0] + channels, err := st.ListChannels(ctx, workspace.ID, owner.ID) + if err != nil { + t.Fatal(err) + } + channel := channels[0] + + createdChannel, channelEvent, err := st.CreateChannel(ctx, store.CreateChannelInput{ + WorkspaceID: workspace.ID, + Name: "Store Room", + UserID: owner.ID, + }) + if err != nil { + t.Fatal(err) + } + if createdChannel.Name != "store-room" || channelEvent.Type != "channel.created" { + t.Fatalf("unexpected channel create result: %#v %#v", createdChannel, channelEvent) + } + + root, event, err := st.CreateMessage(ctx, store.CreateMessageInput{ + ChannelID: channel.ID, + AuthorID: owner.ID, + Body: "searchable **message**", + }) + if err != nil { + t.Fatal(err) + } + if event.Type != "message.created" || event.ChannelID != channel.ID { + t.Fatalf("unexpected message event: %#v", event) + } + if root.ChannelSeq == nil || *root.ChannelSeq != 1 { + t.Fatalf("expected first channel sequence, got %#v", root.ChannelSeq) + } + + messages, err := st.ListMessages(ctx, channel.ID, owner.ID, 0, 0) + if err != nil { + t.Fatal(err) + } + if len(messages) != 1 || messages[0].ID != root.ID { + t.Fatalf("unexpected messages: %#v", messages) + } + after, err := st.ListMessages(ctx, channel.ID, owner.ID, *root.ChannelSeq, 10) + if err != nil { + t.Fatal(err) + } + if len(after) != 0 { + t.Fatalf("expected no messages after seq, got %#v", after) + } + + reply, state, events, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{ + RootMessageID: root.ID, + AuthorID: owner.ID, + Body: "reply body", + }) + if err != nil { + t.Fatal(err) + } + if reply.ThreadSeq == nil || *reply.ThreadSeq != 1 || state.ReplyCount != 1 || len(events) != 2 { + t.Fatalf("unexpected reply result: %#v %#v %#v", reply, state, events) + } + threadRoot, replies, threadState, err := st.GetThread(ctx, root.ID, owner.ID, 10) + if err != nil { + t.Fatal(err) + } + if threadRoot.ID != root.ID || len(replies) != 1 || threadState.ReplyCount != 1 { + t.Fatalf("unexpected thread: %#v %#v %#v", threadRoot, replies, threadState) + } + + results, err := st.SearchMessages(ctx, workspace.ID, owner.ID, "searchable", 10) + if err != nil { + t.Fatal(err) + } + if len(results) != 1 || results[0].Message.ID != root.ID { + t.Fatalf("unexpected search results: %#v", results) + } + eventsAfter, err := st.ListEventsAfter(ctx, workspace.ID, owner.ID, channelEvent.Cursor, 10) + if err != nil { + t.Fatal(err) + } + if len(eventsAfter) == 0 { + t.Fatal("expected events after channel cursor") + } + allEvents, err := st.ListEventsAfter(ctx, workspace.ID, owner.ID, "", 0) + if err != nil { + t.Fatal(err) + } + if len(allEvents) == 0 { + t.Fatal("expected events with empty cursor") + } + + upload, err := st.CreateUpload(ctx, store.CreateUploadInput{ + WorkspaceID: workspace.ID, + OwnerID: owner.ID, + Filename: "note.txt", + ContentType: "text/plain", + ByteSize: 4, + StoragePath: "/tmp/note.txt", + }) + if err != nil { + t.Fatal(err) + } + gotUpload, err := st.GetUpload(ctx, upload.ID, owner.ID) + if err != nil { + t.Fatal(err) + } + if gotUpload.ID != upload.ID || gotUpload.Filename != "note.txt" { + t.Fatalf("unexpected upload: %#v", gotUpload) + } + if err := st.AttachUpload(ctx, store.AttachUploadInput{MessageID: root.ID, UploadID: upload.ID, UserID: owner.ID}); err != nil { + t.Fatal(err) + } + withAttachment, err := st.ListMessages(ctx, channel.ID, owner.ID, 0, 10) + if err != nil { + t.Fatal(err) + } + if len(withAttachment[0].Attachments) != 1 { + t.Fatalf("expected attachment on message, got %#v", withAttachment[0]) + } + + added, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: owner.ID, Emoji: "claw"}) + if err != nil { + t.Fatal(err) + } + removed, err := st.RemoveReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: owner.ID, Emoji: "claw"}) + if err != nil { + t.Fatal(err) + } + if added.Type != "reaction.added" || removed.Type != "reaction.removed" { + t.Fatalf("unexpected reaction events: %#v %#v", added, removed) + } +} + +func TestStoreAccessErrors(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outsider", Email: "out@example.com"}) + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID) + if err != nil { + t.Fatal(err) + } + root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channels[0].ID, AuthorID: owner.ID, Body: "private"}) + if err != nil { + t.Fatal(err) + } + upload, err := st.CreateUpload(ctx, store.CreateUploadInput{WorkspaceID: workspaces[0].ID, OwnerID: owner.ID, Filename: "x", ContentType: "text/plain", ByteSize: 1, StoragePath: "/tmp/x"}) + if err != nil { + t.Fatal(err) + } + + errorCases := []struct { + name string + fn func() error + }{ + {"list workspaces outsider empty ok", func() error { + items, err := st.ListWorkspaces(ctx, outsider.ID) + if err != nil { + return err + } + if len(items) != 0 { + t.Fatalf("expected no workspaces for outsider, got %#v", items) + } + return nil + }}, + {"get workspace denied", func() error { + _, err := st.GetWorkspace(ctx, workspaces[0].ID, outsider.ID) + return err + }}, + {"list channels denied", func() error { + _, err := st.ListChannels(ctx, workspaces[0].ID, outsider.ID) + return err + }}, + {"list messages denied", func() error { + _, err := st.ListMessages(ctx, channels[0].ID, outsider.ID, 0, 10) + return err + }}, + {"thread denied", func() error { + _, _, _, err := st.GetThread(ctx, root.ID, outsider.ID, 10) + return err + }}, + {"events denied", func() error { + _, err := st.ListEventsAfter(ctx, workspaces[0].ID, outsider.ID, "", 10) + return err + }}, + {"upload denied", func() error { + _, err := st.GetUpload(ctx, upload.ID, outsider.ID) + return err + }}, + {"attach denied", func() error { + return st.AttachUpload(ctx, store.AttachUploadInput{MessageID: root.ID, UploadID: upload.ID, UserID: outsider.ID}) + }}, + } + for _, tc := range errorCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.fn() + if tc.name == "list workspaces outsider empty ok" { + if err != nil { + t.Fatal(err) + } + return + } + if err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestStoreDirectMessagesAndUserLookup(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + if got, err := st.GetUser(ctx, owner.ID); err != nil || got.ID != owner.ID { + t.Fatalf("unexpected user lookup: %#v err=%v", got, err) + } + other, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Other", Email: "other@example.com"}) + if err != nil { + t.Fatal(err) + } + third, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Third", Email: "third@example.com"}) + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + workspace := workspaces[0] + if err := st.AddWorkspaceMember(ctx, workspace.ID, other.ID, "member"); err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspace.ID, third.ID, "member"); err != nil { + t.Fatal(err) + } + if _, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{ + WorkspaceID: workspace.ID, + UserID: owner.ID, + MemberIDs: []string{"", other.ID, other.ID}, + }); err != nil { + t.Fatal(err) + } + dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{ + WorkspaceID: workspace.ID, + UserID: owner.ID, + MemberIDs: []string{other.ID, third.ID}, + }) + if err != nil { + t.Fatal(err) + } + if len(dm.Members) != 3 { + t.Fatalf("expected three dm members, got %#v", dm.Members) + } + list, err := st.ListDirectConversations(ctx, workspace.ID, other.ID) + if err != nil { + t.Fatal(err) + } + if len(list) != 2 { + t.Fatalf("expected two dm conversations for other member, got %#v", list) + } + msg, event, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ + ConversationID: dm.ID, + AuthorID: other.ID, + Body: " direct hello ", + }) + if err != nil { + t.Fatal(err) + } + if msg.DirectConversationID != dm.ID || event.Type != "message.created" || event.ChannelID != "" { + t.Fatalf("unexpected direct message result: %#v %#v", msg, event) + } + messages, err := st.ListDirectMessages(ctx, dm.ID, third.ID, 0, 0) + if err != nil { + t.Fatal(err) + } + if len(messages) != 1 || messages[0].Body != "direct hello" { + t.Fatalf("unexpected direct messages: %#v", messages) + } + after, err := st.ListDirectMessages(ctx, dm.ID, third.ID, *messages[0].ChannelSeq, 10) + if err != nil { + t.Fatal(err) + } + if len(after) != 0 { + t.Fatalf("expected no direct messages after seq, got %#v", after) + } + + errorCases := []struct { + name string + fn func() error + }{ + {"single member", func() error { + _, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID}) + return err + }}, + {"nonmember create dm", func() error { + outside, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outside", Email: "outside@example.com"}) + if err != nil { + return err + } + _, err = st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{outside.ID}}) + return err + }}, + {"empty dm body", func() error { + _, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: owner.ID}) + return err + }}, + {"missing dm", func() error { + _, err := st.ListDirectMessages(ctx, "dm_missing", owner.ID, 0, 10) + return err + }}, + } + for _, tc := range errorCases { + t.Run(tc.name, func(t *testing.T) { + if err := tc.fn(); err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestStoreBranchCases(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + if err := st.Migrate(ctx); err != nil { + t.Fatal(err) + } + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + again, err := st.EnsureBootstrap(ctx, "Ignored", "ignored@example.com") + if err != nil { + t.Fatal(err) + } + if again.ID != owner.ID { + t.Fatalf("expected existing bootstrap user, got %#v", again) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + workspace := workspaces[0] + channels, err := st.ListChannels(ctx, workspace.ID, owner.ID) + if err != nil { + t.Fatal(err) + } + channel := channels[0] + + secondWorkspace, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "Other"}, owner.ID) + if err != nil { + t.Fatal(err) + } + defaultChannel, _, err := st.CreateChannel(ctx, store.CreateChannelInput{WorkspaceID: secondWorkspace.ID, UserID: owner.ID}) + if err != nil { + t.Fatal(err) + } + if defaultChannel.Name != "general" || defaultChannel.Kind != "public" { + t.Fatalf("unexpected default channel: %#v", defaultChannel) + } + otherUpload, err := st.CreateUpload(ctx, store.CreateUploadInput{WorkspaceID: secondWorkspace.ID, OwnerID: owner.ID, Filename: "other", ContentType: "text/plain", ByteSize: 1, StoragePath: "/tmp/other"}) + if err != nil { + t.Fatal(err) + } + root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "root for branches"}) + if err != nil { + t.Fatal(err) + } + if err := st.AttachUpload(ctx, store.AttachUploadInput{MessageID: root.ID, UploadID: otherUpload.ID, UserID: owner.ID}); err == nil { + t.Fatal("expected mismatched upload workspace error") + } + reply, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: root.ID, AuthorID: owner.ID, Body: "reply"}) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := st.GetThread(ctx, reply.ID, owner.ID, 10); err == nil { + t.Fatal("expected reply-as-root error") + } + if _, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: reply.ID, AuthorID: owner.ID, Body: "nested"}); err == nil { + t.Fatal("expected nested reply error") + } + if _, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: root.ID, AuthorID: owner.ID}); err == nil { + t.Fatal("expected empty reply body error") + } + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: "chn_missing", AuthorID: owner.ID, Body: "x"}); err == nil { + t.Fatal("expected missing channel error") + } + if results, err := st.SearchMessages(ctx, workspace.ID, owner.ID, "missingterm", 999); err != nil || len(results) != 0 { + t.Fatalf("expected no search results, got %#v err=%v", results, err) + } + if _, err := st.ListEventsAfter(ctx, workspace.ID, owner.ID, "", 999); err != nil { + t.Fatal(err) + } + outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Branch Outsider", Email: "branch-out@example.com"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.CreateInvite(ctx, workspace.ID, outsider.ID); err == nil { + t.Fatal("expected invite membership error") + } + if _, err := st.SearchMessages(ctx, workspace.ID, outsider.ID, "root", 10); err == nil { + t.Fatal("expected search membership error") + } + + firstLink, err := st.CreateMagicLink(ctx, "reuse@example.com", "Reuse One") + if err != nil { + t.Fatal(err) + } + firstUser, _, err := st.ConsumeMagicLink(ctx, firstLink.Token) + if err != nil { + t.Fatal(err) + } + secondLink, err := st.CreateMagicLink(ctx, "reuse@example.com", "Reuse Two") + if err != nil { + t.Fatal(err) + } + secondUser, _, err := st.ConsumeMagicLink(ctx, secondLink.Token) + if err != nil { + t.Fatal(err) + } + if firstUser.ID != secondUser.ID { + t.Fatalf("expected reused magic user, got %s and %s", firstUser.ID, secondUser.ID) + } + expired, err := st.CreateMagicLink(ctx, "expired@example.com", "Expired") + if err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(ctx, `UPDATE auth_magic_links SET expires_at = '2000-01-01T00:00:00Z' WHERE token = ?`, expired.Token); err != nil { + t.Fatal(err) + } + if _, _, err := st.ConsumeMagicLink(ctx, expired.Token); err == nil { + t.Fatal("expected expired magic link error") + } +} diff --git a/apps/api/internal/store/sqlite/dms.go b/apps/api/internal/store/sqlite/dms.go new file mode 100644 index 0000000..fefc89c --- /dev/null +++ b/apps/api/internal/store/sqlite/dms.go @@ -0,0 +1,201 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "slices" + "strings" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func (s *Store) ListDirectConversations(ctx context.Context, workspaceID, userID string) ([]store.DirectConversation, error) { + if err := s.requireMembership(ctx, workspaceID, userID); err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, ` + SELECT dc.id, dc.workspace_id, dc.created_at + FROM direct_conversations dc + JOIN direct_conversation_members dcm ON dcm.conversation_id = dc.id + WHERE dc.workspace_id = ? AND dcm.user_id = ? + ORDER BY dc.created_at`, workspaceID, userID) + if err != nil { + return nil, err + } + out := []store.DirectConversation{} + for rows.Next() { + var dm store.DirectConversation + if err := rows.Scan(&dm.ID, &dm.WorkspaceID, &dm.CreatedAt); err != nil { + return nil, err + } + out = append(out, dm) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { + return nil, err + } + for i := range out { + members, err := s.directConversationMembers(ctx, out[i].ID) + if err != nil { + return nil, err + } + out[i].Members = members + } + return out, nil +} + +func (s *Store) CreateDirectConversation(ctx context.Context, input store.CreateDirectConversationInput) (store.DirectConversation, error) { + if err := s.requireMembership(ctx, input.WorkspaceID, input.UserID); err != nil { + return store.DirectConversation{}, err + } + memberIDs := append([]string{input.UserID}, input.MemberIDs...) + memberIDs = compactStrings(memberIDs) + if len(memberIDs) < 2 { + return store.DirectConversation{}, errors.New("direct conversation needs at least two members") + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.DirectConversation{}, err + } + defer tx.Rollback() + for _, memberID := range memberIDs { + if err := requireMembershipTx(ctx, tx, input.WorkspaceID, memberID); err != nil { + return store.DirectConversation{}, err + } + } + dm := store.DirectConversation{ID: newID("dm"), WorkspaceID: input.WorkspaceID, CreatedAt: now()} + if _, err := tx.ExecContext(ctx, `INSERT INTO direct_conversations (id, workspace_id, created_at) VALUES (?, ?, ?)`, dm.ID, dm.WorkspaceID, dm.CreatedAt); err != nil { + return store.DirectConversation{}, err + } + for _, memberID := range memberIDs { + if _, err := tx.ExecContext(ctx, `INSERT INTO direct_conversation_members (conversation_id, user_id, created_at) VALUES (?, ?, ?)`, dm.ID, memberID, dm.CreatedAt); err != nil { + return store.DirectConversation{}, err + } + } + if err := tx.Commit(); err != nil { + return store.DirectConversation{}, err + } + members, err := s.directConversationMembers(ctx, dm.ID) + if err != nil { + return store.DirectConversation{}, err + } + dm.Members = members + return dm, nil +} + +func (s *Store) ListDirectMessages(ctx context.Context, conversationID, userID string, afterSeq int64, limit int) ([]store.Message, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + if err := s.requireDirectMembership(ctx, conversationID, userID); err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, ` + SELECT m.id, m.workspace_id, COALESCE(m.channel_id, ''), COALESCE(m.direct_conversation_id, ''), m.author_id, m.parent_message_id, m.thread_root_id, m.channel_seq, m.thread_seq, + m.body, m.body_format, m.created_at, m.edited_at, m.deleted_at, + u.id, u.display_name, u.avatar_url, u.created_at + FROM messages m + JOIN users u ON u.id = m.author_id + WHERE m.direct_conversation_id = ? AND m.channel_seq > ? + ORDER BY m.channel_seq + LIMIT ?`, conversationID, afterSeq, limit) + if err != nil { + return nil, err + } + defer rows.Close() + messages, err := scanMessages(rows) + if err != nil { + return nil, err + } + return s.hydrateAttachments(ctx, messages) +} + +func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirectMessageInput) (store.Message, store.Event, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.Message{}, store.Event{}, err + } + defer tx.Rollback() + var workspaceID string + if err := tx.QueryRowContext(ctx, `SELECT workspace_id FROM direct_conversations WHERE id = ?`, input.ConversationID).Scan(&workspaceID); err != nil { + return store.Message{}, store.Event{}, err + } + if err := requireDirectMembershipTx(ctx, tx, input.ConversationID, input.AuthorID); err != nil { + return store.Message{}, store.Event{}, err + } + var seq int64 + if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(channel_seq), 0) + 1 FROM messages WHERE direct_conversation_id = ?`, input.ConversationID).Scan(&seq); err != nil { + return store.Message{}, store.Event{}, err + } + id := newID("msg") + createdAt := now() + body := strings.TrimSpace(input.Body) + if body == "" { + return store.Message{}, store.Event{}, errors.New("message body is required") + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, channel_seq, thread_seq, body, body_format, created_at) + VALUES (?, ?, NULL, ?, ?, NULL, ?, ?, NULL, ?, 'markdown', ?)`, id, workspaceID, input.ConversationID, input.AuthorID, id, seq, body, createdAt); err != nil { + return store.Message{}, store.Event{}, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO thread_state (root_message_id) VALUES (?)`, id); err != nil { + return store.Message{}, store.Event{}, err + } + event, err := insertEvent(ctx, tx, workspaceID, "", "message.created", &seq, map[string]string{"message_id": id, "direct_conversation_id": input.ConversationID}) + if err != nil { + return store.Message{}, store.Event{}, err + } + msg, err := getMessageTx(ctx, tx, id) + if err != nil { + return store.Message{}, store.Event{}, err + } + return msg, event, tx.Commit() +} + +func (s *Store) requireDirectMembership(ctx context.Context, conversationID, userID string) error { + var one int + return s.db.QueryRowContext(ctx, `SELECT 1 FROM direct_conversation_members WHERE conversation_id = ? AND user_id = ?`, conversationID, userID).Scan(&one) +} + +func requireDirectMembershipTx(ctx context.Context, tx *sql.Tx, conversationID, userID string) error { + var one int + return tx.QueryRowContext(ctx, `SELECT 1 FROM direct_conversation_members WHERE conversation_id = ? AND user_id = ?`, conversationID, userID).Scan(&one) +} + +func (s *Store) directConversationMembers(ctx context.Context, conversationID string) ([]store.User, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT u.id, u.display_name, u.avatar_url, u.created_at + FROM users u + JOIN direct_conversation_members dcm ON dcm.user_id = u.id + WHERE dcm.conversation_id = ? + ORDER BY u.display_name`, conversationID) + if err != nil { + return nil, err + } + defer rows.Close() + members := []store.User{} + for rows.Next() { + member, err := scanUser(rows) + if err != nil { + return nil, err + } + members = append(members, member) + } + return members, rows.Err() +} + +func compactStrings(values []string) []string { + var out []string + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || slices.Contains(out, value) { + continue + } + out = append(out, value) + } + return out +} diff --git a/apps/api/internal/store/sqlite/export.go b/apps/api/internal/store/sqlite/export.go new file mode 100644 index 0000000..f36adb0 --- /dev/null +++ b/apps/api/internal/store/sqlite/export.go @@ -0,0 +1,67 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "io" +) + +func (s *Store) Backup(ctx context.Context, outPath string) error { + _, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, outPath) + return err +} + +func (s *Store) ExportJSON(ctx context.Context, writer io.Writer) error { + out := map[string]any{} + tables := []string{ + "users", "identities", "workspaces", "workspace_members", "channels", + "messages", "thread_state", "reactions", "events", "uploads", + "message_attachments", "direct_conversations", "direct_conversation_members", + "invites", "auth_magic_links", "sessions", + } + for _, table := range tables { + rows, err := s.db.QueryContext(ctx, `SELECT * FROM `+table) + if err != nil { + return err + } + values, err := rowsToMaps(rows) + if err != nil { + return err + } + out[table] = values + } + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + return encoder.Encode(out) +} + +func rowsToMaps(rows *sql.Rows) ([]map[string]any, error) { + defer rows.Close() + cols, err := rows.Columns() + if err != nil { + return nil, err + } + out := []map[string]any{} + for rows.Next() { + values := make([]any, len(cols)) + scan := make([]any, len(cols)) + for i := range values { + scan[i] = &values[i] + } + if err := rows.Scan(scan...); err != nil { + return nil, err + } + row := map[string]any{} + for i, col := range cols { + switch value := values[i].(type) { + case []byte: + row[col] = string(value) + default: + row[col] = value + } + } + out = append(out, row) + } + return out, rows.Err() +} diff --git a/apps/api/internal/store/sqlite/fault_test.go b/apps/api/internal/store/sqlite/fault_test.go new file mode 100644 index 0000000..a17693d --- /dev/null +++ b/apps/api/internal/store/sqlite/fault_test.go @@ -0,0 +1,335 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func TestStoreFaultBranches(t *testing.T) { + t.Parallel() + t.Run("event insert failure", func(t *testing.T) { + t.Parallel() + ctx, st, owner, workspace, _ := seededStore(t) + if _, err := st.db.ExecContext(ctx, `DROP TABLE events`); err != nil { + t.Fatal(err) + } + if _, _, err := st.CreateChannel(ctx, store.CreateChannelInput{WorkspaceID: workspace.ID, UserID: owner.ID, Name: "events-down"}); err == nil { + t.Fatal("expected event insert failure") + } + }) + + t.Run("thread state failures", func(t *testing.T) { + t.Parallel() + ctx, st, owner, _, channel := seededStore(t) + root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "root"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(ctx, `DROP TABLE thread_state`); err != nil { + t.Fatal(err) + } + if _, _, _, err := st.GetThread(ctx, root.ID, owner.ID, 10); err == nil { + t.Fatal("expected get thread state failure") + } + if _, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: root.ID, AuthorID: owner.ID, Body: "reply"}); err == nil { + t.Fatal("expected update thread state failure") + } + }) + + t.Run("attachment hydration failure", func(t *testing.T) { + t.Parallel() + ctx, st, owner, _, channel := seededStore(t) + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "root"}); err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(ctx, `DROP TABLE message_attachments`); err != nil { + t.Fatal(err) + } + if _, err := st.ListMessages(ctx, channel.ID, owner.ID, 0, 10); err == nil { + t.Fatal("expected attachment hydration failure") + } + }) + + t.Run("direct conversation query failure", func(t *testing.T) { + t.Parallel() + ctx, st, owner, workspace, _ := seededStore(t) + if _, err := st.db.ExecContext(ctx, `DROP TABLE direct_conversation_members`); err != nil { + t.Fatal(err) + } + if _, err := st.ListDirectConversations(ctx, workspace.ID, owner.ID); err == nil { + t.Fatal("expected direct conversation query failure") + } + if _, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{owner.ID}}); err == nil { + t.Fatal("expected direct conversation membership failure") + } + }) + + t.Run("direct member hydration failure", func(t *testing.T) { + t.Parallel() + ctx, st, owner, workspace, _ := seededStore(t) + member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Member", Email: "member-hydrate@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspace.ID, member.ID, "member"); err != nil { + t.Fatal(err) + } + if _, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{member.ID}}); err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(ctx, `DROP TABLE users`); err != nil { + t.Fatal(err) + } + if _, err := st.ListDirectConversations(ctx, workspace.ID, owner.ID); err == nil { + t.Fatal("expected direct member hydration failure") + } + }) + + t.Run("upload query failure", func(t *testing.T) { + t.Parallel() + ctx, st, owner, workspace, channel := seededStore(t) + root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "root"}) + if err != nil { + t.Fatal(err) + } + upload, err := st.CreateUpload(ctx, store.CreateUploadInput{WorkspaceID: workspace.ID, OwnerID: owner.ID, Filename: "x", ContentType: "text/plain", ByteSize: 1, StoragePath: "/tmp/x"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(ctx, `DROP TABLE uploads`); err != nil { + t.Fatal(err) + } + if _, err := st.GetUpload(ctx, upload.ID, owner.ID); err == nil { + t.Fatal("expected get upload failure") + } + if err := st.AttachUpload(ctx, store.AttachUploadInput{MessageID: root.ID, UploadID: upload.ID, UserID: owner.ID}); err == nil { + t.Fatal("expected attach upload failure") + } + }) + + t.Run("bad magic link expiration", func(t *testing.T) { + t.Parallel() + ctx, st, _, _, _ := seededStore(t) + link, err := st.CreateMagicLink(ctx, "bad-expiry@example.com", "Bad") + if err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(ctx, `UPDATE auth_magic_links SET expires_at = 'bad' WHERE token = ?`, link.Token); err != nil { + t.Fatal(err) + } + if _, _, err := st.ConsumeMagicLink(ctx, link.Token); err == nil { + t.Fatal("expected bad expiry error") + } + if _, _, err := st.ConsumeMagicLink(ctx, "missing"); err == nil { + t.Fatal("expected missing link error") + } + }) + + t.Run("magic link session failure", func(t *testing.T) { + t.Parallel() + ctx, st, _, _, _ := seededStore(t) + link, err := st.CreateMagicLink(ctx, "session-fail@example.com", "Session") + if err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(ctx, `DROP TABLE sessions`); err != nil { + t.Fatal(err) + } + if _, _, err := st.ConsumeMagicLink(ctx, link.Token); err == nil { + t.Fatal("expected session create failure") + } + }) + + t.Run("duplicate local identity", func(t *testing.T) { + t.Parallel() + ctx, st, _, _, _ := seededStore(t) + if _, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "One", Email: "dupe@example.com"}); err != nil { + t.Fatal(err) + } + if _, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Two", Email: "dupe@example.com"}); err == nil { + t.Fatal("expected duplicate identity error") + } + }) + + t.Run("message table failures", func(t *testing.T) { + t.Parallel() + ctx, st, owner, _, channel := seededStore(t) + if _, err := st.db.ExecContext(ctx, `DROP TABLE messages`); err != nil { + t.Fatal(err) + } + if _, err := st.ListMessages(ctx, channel.ID, owner.ID, 0, 10); err == nil { + t.Fatal("expected list messages failure") + } + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "x"}); err == nil { + t.Fatal("expected create message sequence failure") + } + if _, _, _, err := st.GetThread(ctx, "msg_missing", owner.ID, 10); err == nil { + t.Fatal("expected get thread message failure") + } + }) + + t.Run("message transaction failures", func(t *testing.T) { + t.Parallel() + ctx, st, owner, _, channel := seededStore(t) + if _, err := st.db.ExecContext(ctx, `DROP TABLE thread_state`); err != nil { + t.Fatal(err) + } + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "x"}); err == nil { + t.Fatal("expected message thread-state failure") + } + }) + + t.Run("message event failure", func(t *testing.T) { + t.Parallel() + ctx, st, owner, workspace, channel := seededStore(t) + if _, err := st.db.ExecContext(ctx, `DROP TABLE events`); err != nil { + t.Fatal(err) + } + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "x"}); err == nil { + t.Fatal("expected message event failure") + } + if _, err := st.ListEventsAfter(ctx, workspace.ID, owner.ID, "", 10); err == nil { + t.Fatal("expected list events failure") + } + }) + + t.Run("channel and workspace write failures", func(t *testing.T) { + t.Parallel() + ctx, st, owner, workspace, _ := seededStore(t) + outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outsider", Email: "outsider@example.com"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := st.CreateChannel(ctx, store.CreateChannelInput{WorkspaceID: workspace.ID, UserID: outsider.ID, Name: "denied"}); err == nil { + t.Fatal("expected channel membership failure") + } + if _, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "No Owner"}, "usr_missing"); err == nil { + t.Fatal("expected workspace member foreign-key failure") + } + if _, err := st.db.ExecContext(ctx, `DROP TABLE channels`); err != nil { + t.Fatal(err) + } + if _, err := st.ListChannels(ctx, workspace.ID, owner.ID); err == nil { + t.Fatal("expected list channels failure") + } + }) + + t.Run("reaction failures", func(t *testing.T) { + t.Parallel() + ctx, st, owner, _, channel := seededStore(t) + root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "root"}) + if err != nil { + t.Fatal(err) + } + outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outsider", Email: "out@example.com"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: outsider.ID, Emoji: "x"}); err == nil { + t.Fatal("expected reaction membership failure") + } + if _, err := st.db.ExecContext(ctx, `DROP TABLE reactions`); err != nil { + t.Fatal(err) + } + if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: owner.ID, Emoji: "x"}); err == nil { + t.Fatal("expected reaction write failure") + } + }) + + t.Run("direct message failures", func(t *testing.T) { + t.Parallel() + ctx, st, owner, workspace, _ := seededStore(t) + member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Member", Email: "member@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspace.ID, member.ID, "member"); err != nil { + t.Fatal(err) + } + dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{member.ID}}) + if err != nil { + t.Fatal(err) + } + outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outsider", Email: "dmout@example.com"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: outsider.ID, Body: "x"}); err == nil { + t.Fatal("expected direct message membership failure") + } + if _, err := st.db.ExecContext(ctx, `DROP TABLE messages`); err != nil { + t.Fatal(err) + } + if _, err := st.ListDirectMessages(ctx, dm.ID, owner.ID, 0, 10); err == nil { + t.Fatal("expected direct list failure") + } + if _, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: owner.ID, Body: "x"}); err == nil { + t.Fatal("expected direct message sequence failure") + } + }) + + t.Run("direct message transaction failures", func(t *testing.T) { + t.Parallel() + ctx, st, owner, workspace, _ := seededStore(t) + member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Member", Email: "direct-tx@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspace.ID, member.ID, "member"); err != nil { + t.Fatal(err) + } + dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{member.ID}}) + if err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(ctx, `DROP TABLE thread_state`); err != nil { + t.Fatal(err) + } + if _, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: owner.ID, Body: "x"}); err == nil { + t.Fatal("expected direct thread-state failure") + } + }) + + t.Run("direct message event failure", func(t *testing.T) { + t.Parallel() + ctx, st, owner, workspace, _ := seededStore(t) + member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Member", Email: "direct-event@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspace.ID, member.ID, "member"); err != nil { + t.Fatal(err) + } + dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{member.ID}}) + if err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(ctx, `DROP TABLE events`); err != nil { + t.Fatal(err) + } + if _, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: owner.ID, Body: "x"}); err == nil { + t.Fatal("expected direct event failure") + } + }) +} + +func seededStore(t *testing.T) (context.Context, *Store, store.User, store.Workspace, store.Channel) { + t.Helper() + ctx := context.Background() + st := newTestStore(t) + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID) + if err != nil { + t.Fatal(err) + } + return ctx, st, owner, workspaces[0], channels[0] +} diff --git a/apps/api/internal/store/sqlite/helpers.go b/apps/api/internal/store/sqlite/helpers.go new file mode 100644 index 0000000..9f5f98f --- /dev/null +++ b/apps/api/internal/store/sqlite/helpers.go @@ -0,0 +1,198 @@ +package sqlite + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/json" + "regexp" + "strings" + "time" + + "github.com/oklog/ulid/v2" + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +type scanner interface { + Scan(dest ...any) error +} + +func scanUser(row scanner) (store.User, error) { + var u store.User + err := row.Scan(&u.ID, &u.DisplayName, &u.AvatarURL, &u.CreatedAt) + return u, err +} + +func scanWorkspace(row scanner) (store.Workspace, error) { + var w store.Workspace + err := row.Scan(&w.ID, &w.Name, &w.Slug, &w.CreatedAt) + return w, err +} + +func scanChannel(row scanner) (store.Channel, error) { + var ch store.Channel + err := row.Scan(&ch.ID, &ch.WorkspaceID, &ch.Name, &ch.Kind, &ch.CreatedAt, &ch.ArchivedAt) + return ch, err +} + +func getMessage(ctx context.Context, db *sql.DB, id string) (store.Message, error) { + return scanMessage(db.QueryRowContext(ctx, messageSelect()+` WHERE m.id = ?`, id)) +} + +func getMessageTx(ctx context.Context, tx *sql.Tx, id string) (store.Message, error) { + return scanMessage(tx.QueryRowContext(ctx, messageSelect()+` WHERE m.id = ?`, id)) +} + +func messageSelect() string { + return `SELECT m.id, m.workspace_id, COALESCE(m.channel_id, ''), COALESCE(m.direct_conversation_id, ''), m.author_id, m.parent_message_id, m.thread_root_id, m.channel_seq, m.thread_seq, + m.body, m.body_format, m.created_at, m.edited_at, m.deleted_at, + u.id, u.display_name, u.avatar_url, u.created_at + FROM messages m + JOIN users u ON u.id = m.author_id` +} + +func scanMessage(row scanner) (store.Message, error) { + var m store.Message + var parent, edited, deleted sql.NullString + var channelSeq, threadSeq sql.NullInt64 + var author store.User + err := row.Scan(&m.ID, &m.WorkspaceID, &m.ChannelID, &m.DirectConversationID, &m.AuthorID, &parent, &m.ThreadRootID, &channelSeq, &threadSeq, &m.Body, &m.BodyFormat, &m.CreatedAt, &edited, &deleted, &author.ID, &author.DisplayName, &author.AvatarURL, &author.CreatedAt) + if err != nil { + return store.Message{}, err + } + if parent.Valid { + m.ParentMessageID = &parent.String + } + if channelSeq.Valid { + m.ChannelSeq = &channelSeq.Int64 + } + if threadSeq.Valid { + m.ThreadSeq = &threadSeq.Int64 + } + if edited.Valid { + m.EditedAt = &edited.String + } + if deleted.Valid { + m.DeletedAt = &deleted.String + } + m.Author = &author + return m, nil +} + +func scanMessages(rows *sql.Rows) ([]store.Message, error) { + out := []store.Message{} + for rows.Next() { + msg, err := scanMessage(rows) + if err != nil { + return nil, err + } + out = append(out, msg) + } + return out, rows.Err() +} + +func getThreadState(ctx context.Context, db *sql.DB, rootID string) (store.ThreadState, error) { + return scanThreadState(db.QueryRowContext(ctx, `SELECT root_message_id, reply_count, last_reply_at, last_reply_author_ids_json FROM thread_state WHERE root_message_id = ?`, rootID)) +} + +func scanThreadState(row scanner) (store.ThreadState, error) { + var state store.ThreadState + var lastReply sql.NullString + if err := row.Scan(&state.RootMessageID, &state.ReplyCount, &lastReply, &state.LastReplyAuthorIDsJSON); err != nil { + return store.ThreadState{}, err + } + if lastReply.Valid { + state.LastReplyAt = &lastReply.String + } + _ = json.Unmarshal([]byte(state.LastReplyAuthorIDsJSON), &state.LastReplyAuthorIDs) + return state, nil +} + +func updateThreadState(ctx context.Context, tx *sql.Tx, rootID, authorID, createdAt string) (store.ThreadState, error) { + state, err := scanThreadState(tx.QueryRowContext(ctx, `SELECT root_message_id, reply_count, last_reply_at, last_reply_author_ids_json FROM thread_state WHERE root_message_id = ?`, rootID)) + if err != nil { + return store.ThreadState{}, err + } + ids := append([]string{authorID}, state.LastReplyAuthorIDs...) + seen := map[string]bool{} + compact := make([]string, 0, 3) + for _, id := range ids { + if seen[id] { + continue + } + seen[id] = true + compact = append(compact, id) + if len(compact) == 3 { + break + } + } + body, _ := json.Marshal(compact) + if _, err := tx.ExecContext(ctx, `UPDATE thread_state SET reply_count = reply_count + 1, last_reply_at = ?, last_reply_author_ids_json = ? WHERE root_message_id = ?`, createdAt, string(body), rootID); err != nil { + return store.ThreadState{}, err + } + return scanThreadState(tx.QueryRowContext(ctx, `SELECT root_message_id, reply_count, last_reply_at, last_reply_author_ids_json FROM thread_state WHERE root_message_id = ?`, rootID)) +} + +func insertEvent(ctx context.Context, tx *sql.Tx, workspaceID, channelID, eventType string, seq *int64, payload any) (store.Event, error) { + payloadJSON, err := json.Marshal(payload) + if err != nil { + return store.Event{}, err + } + event := store.Event{ + ID: newID("evt"), + Cursor: newID("cur"), + Type: eventType, + WorkspaceID: workspaceID, + ChannelID: channelID, + Seq: seq, + CreatedAt: now(), + PayloadJSON: string(payloadJSON), + Payload: payload, + } + if _, err := tx.ExecContext(ctx, `INSERT INTO events (id, cursor, workspace_id, channel_id, type, seq, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, event.ID, event.Cursor, event.WorkspaceID, nullableString(event.ChannelID), event.Type, event.Seq, event.PayloadJSON, event.CreatedAt); err != nil { + return store.Event{}, err + } + return event, nil +} + +func scanEvents(rows *sql.Rows) ([]store.Event, error) { + out := []store.Event{} + for rows.Next() { + var event store.Event + var seq sql.NullInt64 + if err := rows.Scan(&event.ID, &event.Cursor, &event.WorkspaceID, &event.ChannelID, &event.Type, &seq, &event.PayloadJSON, &event.CreatedAt); err != nil { + return nil, err + } + if seq.Valid { + event.Seq = &seq.Int64 + } + var payload any + _ = json.Unmarshal([]byte(event.PayloadJSON), &payload) + event.Payload = payload + out = append(out, event) + } + return out, rows.Err() +} + +func nullableString(value string) any { + if value == "" { + return nil + } + return value +} + +func newID(prefix string) string { + return prefix + "_" + strings.ToLower(ulid.MustNew(ulid.Timestamp(time.Now()), rand.Reader).String()) +} + +func now() string { + return time.Now().UTC().Format(time.RFC3339Nano) +} + +var slugRE = regexp.MustCompile(`[^a-z0-9]+`) + +func slug(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = slugRE.ReplaceAllString(value, "-") + return strings.Trim(value, "-") +} diff --git a/apps/api/internal/store/sqlite/identity.go b/apps/api/internal/store/sqlite/identity.go new file mode 100644 index 0000000..79cc735 --- /dev/null +++ b/apps/api/internal/store/sqlite/identity.go @@ -0,0 +1,56 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "strings" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func (s *Store) UpsertIdentityUser(ctx context.Context, input store.UpsertIdentityUserInput) (store.User, error) { + provider := strings.TrimSpace(input.Provider) + subject := strings.TrimSpace(input.ProviderSubject) + if provider == "" || subject == "" { + return store.User{}, errors.New("identity provider and subject are required") + } + user, err := scanUser(s.db.QueryRowContext(ctx, ` + SELECT u.id, u.display_name, u.avatar_url, u.created_at + FROM identities i + JOIN users u ON u.id = i.user_id + WHERE i.provider = ? AND i.provider_subject = ?`, provider, subject)) + if err == nil { + return user, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return store.User{}, err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.User{}, err + } + defer tx.Rollback() + user = store.User{ + ID: newID("usr"), + DisplayName: strings.TrimSpace(input.DisplayName), + AvatarURL: strings.TrimSpace(input.AvatarURL), + CreatedAt: now(), + } + if user.DisplayName == "" { + user.DisplayName = strings.TrimSpace(input.Email) + } + if user.DisplayName == "" { + user.DisplayName = provider + ":" + subject + } + if _, err := tx.ExecContext(ctx, `INSERT INTO users (id, display_name, avatar_url, created_at) VALUES (?, ?, ?, ?)`, user.ID, user.DisplayName, user.AvatarURL, user.CreatedAt); err != nil { + return store.User{}, err + } + _, err = tx.ExecContext(ctx, ` + INSERT INTO identities (id, user_id, provider, provider_subject, email, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, newID("idn"), user.ID, provider, subject, strings.TrimSpace(input.Email), user.CreatedAt) + if err != nil { + return store.User{}, err + } + return user, tx.Commit() +} diff --git a/apps/api/internal/store/sqlite/invites.go b/apps/api/internal/store/sqlite/invites.go new file mode 100644 index 0000000..318be7f --- /dev/null +++ b/apps/api/internal/store/sqlite/invites.go @@ -0,0 +1,24 @@ +package sqlite + +import ( + "context" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func (s *Store) CreateInvite(ctx context.Context, workspaceID, createdBy string) (store.Invite, error) { + if err := s.requireMembership(ctx, workspaceID, createdBy); err != nil { + return store.Invite{}, err + } + invite := store.Invite{ + ID: newID("inv"), + WorkspaceID: workspaceID, + Token: newID("tok"), + CreatedBy: createdBy, + CreatedAt: now(), + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO invites (id, workspace_id, token, created_by, created_at) + VALUES (?, ?, ?, ?, ?)`, invite.ID, invite.WorkspaceID, invite.Token, invite.CreatedBy, invite.CreatedAt) + return invite, err +} diff --git a/apps/api/internal/store/sqlite/members.go b/apps/api/internal/store/sqlite/members.go new file mode 100644 index 0000000..a31cf16 --- /dev/null +++ b/apps/api/internal/store/sqlite/members.go @@ -0,0 +1,13 @@ +package sqlite + +import "context" + +func (s *Store) AddWorkspaceMember(ctx context.Context, workspaceID, userID, role string) error { + if role == "" { + role = "member" + } + _, err := s.db.ExecContext(ctx, ` + INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at) + VALUES (?, ?, ?, ?)`, workspaceID, userID, role, now()) + return err +} diff --git a/apps/api/internal/store/sqlite/migrations/0001_initial.sql b/apps/api/internal/store/sqlite/migrations/0001_initial.sql new file mode 100644 index 0000000..0c6ffbf --- /dev/null +++ b/apps/api/internal/store/sqlite/migrations/0001_initial.sql @@ -0,0 +1,150 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + avatar_url TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS identities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_subject TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + UNIQUE(provider, provider_subject) +); + +CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workspace_members ( + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, user_id) +); + +CREATE TABLE IF NOT EXISTS channels ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL, + created_at TEXT NOT NULL, + archived_at TEXT, + UNIQUE(workspace_id, name) +); + +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_id TEXT REFERENCES channels(id) ON DELETE CASCADE, + direct_conversation_id TEXT, + author_id TEXT NOT NULL REFERENCES users(id), + parent_message_id TEXT REFERENCES messages(id) ON DELETE CASCADE, + thread_root_id TEXT NOT NULL, + channel_seq INTEGER, + thread_seq INTEGER, + body TEXT NOT NULL, + body_format TEXT NOT NULL, + created_at TEXT NOT NULL, + edited_at TEXT, + deleted_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_messages_channel_seq ON messages(channel_id, channel_seq); +CREATE INDEX IF NOT EXISTS idx_messages_thread_seq ON messages(thread_root_id, thread_seq); +CREATE INDEX IF NOT EXISTS idx_messages_direct_seq ON messages(direct_conversation_id, channel_seq); + +CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + message_id UNINDEXED, + workspace_id UNINDEXED, + body, + tokenize = 'porter unicode61' +); + +CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(message_id, workspace_id, body) VALUES (new.id, new.workspace_id, new.body); +END; + +CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages BEGIN + DELETE FROM messages_fts WHERE message_id = old.id; +END; + +CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE OF body ON messages BEGIN + DELETE FROM messages_fts WHERE message_id = old.id; + INSERT INTO messages_fts(message_id, workspace_id, body) VALUES (new.id, new.workspace_id, new.body); +END; + +CREATE TABLE IF NOT EXISTS thread_state ( + root_message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + reply_count INTEGER NOT NULL DEFAULT 0, + last_reply_at TEXT, + last_reply_author_ids_json TEXT NOT NULL DEFAULT '[]' +); + +CREATE TABLE IF NOT EXISTS reactions ( + message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (message_id, user_id, emoji) +); + +CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + cursor TEXT NOT NULL UNIQUE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_id TEXT, + type TEXT NOT NULL, + seq INTEGER, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_events_workspace_cursor ON events(workspace_id, cursor); + +CREATE TABLE IF NOT EXISTS uploads ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + content_type TEXT NOT NULL, + byte_size INTEGER NOT NULL, + storage_path TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS message_attachments ( + message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + upload_id TEXT NOT NULL REFERENCES uploads(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (message_id, upload_id) +); + +CREATE TABLE IF NOT EXISTS direct_conversations ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS direct_conversation_members ( + conversation_id TEXT NOT NULL REFERENCES direct_conversations(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (conversation_id, user_id) +); + +CREATE TABLE IF NOT EXISTS invites ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + accepted_at TEXT +); diff --git a/apps/api/internal/store/sqlite/migrations/0002_auth.sql b/apps/api/internal/store/sqlite/migrations/0002_auth.sql new file mode 100644 index 0000000..8c81792 --- /dev/null +++ b/apps/api/internal/store/sqlite/migrations/0002_auth.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS auth_magic_links ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_auth_magic_links_token ON auth_magic_links(token); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); diff --git a/apps/api/internal/store/sqlite/misc_test.go b/apps/api/internal/store/sqlite/misc_test.go new file mode 100644 index 0000000..6c03735 --- /dev/null +++ b/apps/api/internal/store/sqlite/misc_test.go @@ -0,0 +1,156 @@ +package sqlite + +import ( + "context" + "path/filepath" + "testing" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func TestStoreMiscBranches(t *testing.T) { + t.Parallel() + ctx := context.Background() + raw, err := Open(filepath.Join(t.TempDir(), "raw.db")) + if err != nil { + t.Fatal(err) + } + if err := raw.Close(); err != nil { + t.Fatal(err) + } + + st := newTestStore(t) + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + unnamed, err := st.CreateUser(ctx, store.CreateUserInput{}) + if err != nil { + t.Fatal(err) + } + if unnamed.DisplayName != "Local User" { + t.Fatalf("unexpected default user: %#v", unnamed) + } + identityUser, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{ + Provider: "github", + ProviderSubject: "42", + Email: "octo@example.com", + DisplayName: "Octo", + AvatarURL: "https://example.com/a.png", + }) + if err != nil { + t.Fatal(err) + } + againIdentity, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{Provider: "github", ProviderSubject: "42"}) + if err != nil { + t.Fatal(err) + } + if againIdentity.ID != identityUser.ID { + t.Fatalf("expected existing identity user, got %#v", againIdentity) + } + session, err := st.CreateSession(ctx, identityUser.ID) + if err != nil { + t.Fatal(err) + } + if session.UserID != identityUser.ID || session.Token == "" { + t.Fatalf("unexpected session: %#v", session) + } + if _, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{}); err == nil { + t.Fatal("expected missing identity error") + } + fallbackIdentity, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{Provider: "github", ProviderSubject: "fallback"}) + if err != nil { + t.Fatal(err) + } + if fallbackIdentity.DisplayName != "github:fallback" { + t.Fatalf("unexpected fallback identity display: %#v", fallbackIdentity) + } + emailIdentity, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{Provider: "github", ProviderSubject: "email", Email: "email@example.com"}) + if err != nil { + t.Fatal(err) + } + if emailIdentity.DisplayName != "email@example.com" { + t.Fatalf("unexpected email identity display: %#v", emailIdentity) + } + if _, err := st.CreateSession(ctx, "usr_missing"); err == nil { + t.Fatal("expected missing session user error") + } + untitled, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{}, owner.ID) + if err != nil { + t.Fatal(err) + } + if untitled.Name != "Untitled" || untitled.Slug != "untitled" { + t.Fatalf("unexpected default workspace: %#v", untitled) + } + if err := st.AddWorkspaceMember(ctx, untitled.ID, unnamed.ID, ""); err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID) + if err != nil { + t.Fatal(err) + } + root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channels[0].ID, AuthorID: owner.ID, Body: "edited root"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(ctx, `UPDATE messages SET edited_at = created_at, deleted_at = created_at WHERE id = ?`, root.ID); err != nil { + t.Fatal(err) + } + messages, err := st.ListMessages(ctx, channels[0].ID, owner.ID, 0, 10) + if err != nil { + t.Fatal(err) + } + if messages[0].EditedAt == nil || messages[0].DeletedAt == nil { + t.Fatalf("expected edited/deleted fields, got %#v", messages[0]) + } + + authors := []store.User{owner} + for _, name := range []string{"One", "Two", "Three", "Four"} { + user, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: name, Email: name + "@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspaces[0].ID, user.ID, "member"); err != nil { + t.Fatal(err) + } + authors = append(authors, user) + } + var reply store.Message + for i, author := range []store.User{authors[0], authors[1], authors[0], authors[2], authors[3], authors[4]} { + reply, _, _, err = st.CreateThreadReply(ctx, store.CreateThreadReplyInput{ + RootMessageID: root.ID, + AuthorID: author.ID, + Body: "reply searchable", + }) + if err != nil { + t.Fatalf("reply %d: %v", i, err) + } + } + if _, err := st.db.ExecContext(ctx, `UPDATE messages SET edited_at = created_at, deleted_at = created_at WHERE id = ?`, reply.ID); err != nil { + t.Fatal(err) + } + _, replies, threadState, err := st.GetThread(ctx, root.ID, owner.ID, 10) + if err != nil { + t.Fatal(err) + } + if _, _, _, err := st.GetThread(ctx, root.ID, owner.ID, 0); err != nil { + t.Fatal(err) + } + if len(replies) != 6 || len(threadState.LastReplyAuthorIDs) != 3 { + t.Fatalf("unexpected thread compaction: replies=%d state=%#v", len(replies), threadState) + } + if replies[len(replies)-1].EditedAt == nil || replies[len(replies)-1].DeletedAt == nil { + t.Fatalf("expected edited/deleted reply fields, got %#v", replies[len(replies)-1]) + } + results, err := st.SearchMessages(ctx, workspaces[0].ID, owner.ID, "reply", 10) + if err != nil { + t.Fatal(err) + } + if len(results) == 0 || results[0].Message.ParentMessageID == nil || results[0].Message.ThreadSeq == nil { + t.Fatalf("expected reply search result with thread fields, got %#v", results) + } +} diff --git a/apps/api/internal/store/sqlite/mutations.go b/apps/api/internal/store/sqlite/mutations.go new file mode 100644 index 0000000..fa9526e --- /dev/null +++ b/apps/api/internal/store/sqlite/mutations.go @@ -0,0 +1,120 @@ +package sqlite + +import ( + "context" + "errors" + "strings" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func (s *Store) UpdateChannel(ctx context.Context, input store.UpdateChannelInput) (store.Channel, store.Event, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.Channel{}, store.Event{}, err + } + defer tx.Rollback() + ch, err := scanChannel(tx.QueryRowContext(ctx, `SELECT id, workspace_id, name, kind, created_at, archived_at FROM channels WHERE id = ?`, input.ChannelID)) + if err != nil { + return store.Channel{}, store.Event{}, err + } + if err := requireMembershipTx(ctx, tx, ch.WorkspaceID, input.UserID); err != nil { + return store.Channel{}, store.Event{}, err + } + name := slug(input.Name) + if name == "" { + name = ch.Name + } + kind := strings.TrimSpace(input.Kind) + if kind == "" { + kind = ch.Kind + } + archivedValue := ch.ArchivedAt + if input.Archived != nil { + archivedValue = nil + if *input.Archived { + value := now() + archivedValue = &value + } + } + var archivedAt any + if archivedValue != nil { + archivedAt = *archivedValue + } + if _, err := tx.ExecContext(ctx, `UPDATE channels SET name = ?, kind = ?, archived_at = ? WHERE id = ?`, name, kind, archivedAt, ch.ID); err != nil { + return store.Channel{}, store.Event{}, err + } + event, err := insertEvent(ctx, tx, ch.WorkspaceID, ch.ID, "channel.updated", nil, map[string]string{"channel_id": ch.ID}) + if err != nil { + return store.Channel{}, store.Event{}, err + } + ch.Name = name + ch.Kind = kind + ch.ArchivedAt = archivedValue + return ch, event, tx.Commit() +} + +func (s *Store) UpdateMessage(ctx context.Context, input store.UpdateMessageInput) (store.Message, store.Event, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.Message{}, store.Event{}, err + } + defer tx.Rollback() + msg, err := getMessageTx(ctx, tx, input.MessageID) + if err != nil { + return store.Message{}, store.Event{}, err + } + if err := requireMembershipTx(ctx, tx, msg.WorkspaceID, input.UserID); err != nil { + return store.Message{}, store.Event{}, err + } + body := strings.TrimSpace(input.Body) + if body == "" { + return store.Message{}, store.Event{}, errors.New("message body is required") + } + editedAt := now() + if _, err := tx.ExecContext(ctx, `UPDATE messages SET body = ?, edited_at = ? WHERE id = ?`, body, editedAt, msg.ID); err != nil { + return store.Message{}, store.Event{}, err + } + payload := messagePayload(msg) + event, err := insertEvent(ctx, tx, msg.WorkspaceID, msg.ChannelID, "message.updated", msg.ChannelSeq, payload) + if err != nil { + return store.Message{}, store.Event{}, err + } + msg.Body = body + msg.EditedAt = &editedAt + return msg, event, tx.Commit() +} + +func (s *Store) DeleteMessage(ctx context.Context, input store.DeleteMessageInput) (store.Message, store.Event, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.Message{}, store.Event{}, err + } + defer tx.Rollback() + msg, err := getMessageTx(ctx, tx, input.MessageID) + if err != nil { + return store.Message{}, store.Event{}, err + } + if err := requireMembershipTx(ctx, tx, msg.WorkspaceID, input.UserID); err != nil { + return store.Message{}, store.Event{}, err + } + deletedAt := now() + if _, err := tx.ExecContext(ctx, `UPDATE messages SET body = '', deleted_at = ? WHERE id = ?`, deletedAt, msg.ID); err != nil { + return store.Message{}, store.Event{}, err + } + event, err := insertEvent(ctx, tx, msg.WorkspaceID, msg.ChannelID, "message.deleted", msg.ChannelSeq, messagePayload(msg)) + if err != nil { + return store.Message{}, store.Event{}, err + } + msg.Body = "" + msg.DeletedAt = &deletedAt + return msg, event, tx.Commit() +} + +func messagePayload(msg store.Message) map[string]string { + payload := map[string]string{"message_id": msg.ID, "root_message_id": msg.ThreadRootID} + if msg.DirectConversationID != "" { + payload["direct_conversation_id"] = msg.DirectConversationID + } + return payload +} diff --git a/apps/api/internal/store/sqlite/mutations_test.go b/apps/api/internal/store/sqlite/mutations_test.go new file mode 100644 index 0000000..fe6f4bb --- /dev/null +++ b/apps/api/internal/store/sqlite/mutations_test.go @@ -0,0 +1,182 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func TestMutationsCreateDurableEvents(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID) + if err != nil { + t.Fatal(err) + } + channel := channels[0] + + archived := true + updatedChannel, channelEvent, err := st.UpdateChannel(ctx, store.UpdateChannelInput{ChannelID: channel.ID, UserID: owner.ID, Name: "harbor", Archived: &archived}) + if err != nil { + t.Fatal(err) + } + if updatedChannel.Name != "harbor" || updatedChannel.ArchivedAt == nil || channelEvent.Type != "channel.updated" { + t.Fatalf("unexpected channel update: %#v %#v", updatedChannel, channelEvent) + } + archived = false + updatedChannel, _, err = st.UpdateChannel(ctx, store.UpdateChannelInput{ChannelID: channel.ID, UserID: owner.ID, Kind: "private", Archived: &archived}) + if err != nil { + t.Fatal(err) + } + if updatedChannel.Kind != "private" || updatedChannel.ArchivedAt != nil { + t.Fatalf("unexpected channel unarchive: %#v", updatedChannel) + } + + message, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "before"}) + if err != nil { + t.Fatal(err) + } + updatedMessage, updateEvent, err := st.UpdateMessage(ctx, store.UpdateMessageInput{MessageID: message.ID, UserID: owner.ID, Body: "after"}) + if err != nil { + t.Fatal(err) + } + if updatedMessage.Body != "after" || updatedMessage.EditedAt == nil || updateEvent.Type != "message.updated" { + t.Fatalf("unexpected message update: %#v %#v", updatedMessage, updateEvent) + } + deletedMessage, deleteEvent, err := st.DeleteMessage(ctx, store.DeleteMessageInput{MessageID: message.ID, UserID: owner.ID}) + if err != nil { + t.Fatal(err) + } + if deletedMessage.DeletedAt == nil || deleteEvent.Type != "message.deleted" { + t.Fatalf("unexpected message delete: %#v %#v", deletedMessage, deleteEvent) + } + second, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Second", Email: "second@example.com"}) + if err != nil { + t.Fatal(err) + } + if err := st.AddWorkspaceMember(ctx, workspaces[0].ID, second.ID, "member"); err != nil { + t.Fatal(err) + } + dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspaces[0].ID, UserID: owner.ID, MemberIDs: []string{second.ID}}) + if err != nil { + t.Fatal(err) + } + dmMessage, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: owner.ID, Body: "dm before"}) + if err != nil { + t.Fatal(err) + } + updatedDM, dmEvent, err := st.UpdateMessage(ctx, store.UpdateMessageInput{MessageID: dmMessage.ID, UserID: second.ID, Body: "dm after"}) + if err != nil { + t.Fatal(err) + } + if updatedDM.DirectConversationID != dm.ID || dmEvent.ChannelID != "" { + t.Fatalf("unexpected dm update: %#v %#v", updatedDM, dmEvent) + } + if _, _, err := st.DeleteMessage(ctx, store.DeleteMessageInput{MessageID: dmMessage.ID, UserID: second.ID}); err != nil { + t.Fatal(err) + } + events, err := st.ListEventsAfter(ctx, workspaces[0].ID, owner.ID, "", 20) + if err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, event := range events { + seen[event.Type] = true + } + for _, eventType := range []string{"channel.updated", "message.updated", "message.deleted"} { + if !seen[eventType] { + t.Fatalf("missing event %s in %#v", eventType, events) + } + } +} + +func TestMutationsRejectInvalidInput(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID) + if err != nil { + t.Fatal(err) + } + message, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channels[0].ID, AuthorID: owner.ID, Body: "body"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := st.UpdateMessage(ctx, store.UpdateMessageInput{MessageID: message.ID, UserID: owner.ID, Body: " "}); err == nil { + t.Fatal("expected empty message update error") + } + if _, _, err := st.UpdateMessage(ctx, store.UpdateMessageInput{MessageID: "missing", UserID: owner.ID, Body: "x"}); err == nil { + t.Fatal("expected missing message update error") + } + if _, _, err := st.DeleteMessage(ctx, store.DeleteMessageInput{MessageID: "missing", UserID: owner.ID}); err == nil { + t.Fatal("expected missing message delete error") + } + if _, _, err := st.UpdateChannel(ctx, store.UpdateChannelInput{ChannelID: "missing", UserID: owner.ID}); err == nil { + t.Fatal("expected missing channel error") + } + outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outsider", Email: "outsider@example.com"}) + if err != nil { + t.Fatal(err) + } + if _, _, err := st.UpdateChannel(ctx, store.UpdateChannelInput{ChannelID: channels[0].ID, UserID: outsider.ID, Name: "nope"}); err == nil { + t.Fatal("expected outsider channel update error") + } + if _, _, err := st.UpdateMessage(ctx, store.UpdateMessageInput{MessageID: message.ID, UserID: outsider.ID, Body: "nope"}); err == nil { + t.Fatal("expected outsider message update error") + } + if _, _, err := st.DeleteMessage(ctx, store.DeleteMessageInput{MessageID: message.ID, UserID: outsider.ID}); err == nil { + t.Fatal("expected outsider message delete error") + } +} + +func TestMutationsReturnOutboxErrors(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID) + if err != nil { + t.Fatal(err) + } + message, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channels[0].ID, AuthorID: owner.ID, Body: "body"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(ctx, `DROP TABLE events`); err != nil { + t.Fatal(err) + } + if _, _, err := st.UpdateChannel(ctx, store.UpdateChannelInput{ChannelID: channels[0].ID, UserID: owner.ID, Name: "after-events"}); err == nil { + t.Fatal("expected channel outbox error") + } + if _, _, err := st.UpdateMessage(ctx, store.UpdateMessageInput{MessageID: message.ID, UserID: owner.ID, Body: "after-events"}); err == nil { + t.Fatal("expected message update outbox error") + } + if _, _, err := st.DeleteMessage(ctx, store.DeleteMessageInput{MessageID: message.ID, UserID: owner.ID}); err == nil { + t.Fatal("expected message delete outbox error") + } +} diff --git a/apps/api/internal/store/sqlite/search.go b/apps/api/internal/store/sqlite/search.go new file mode 100644 index 0000000..21bc569 --- /dev/null +++ b/apps/api/internal/store/sqlite/search.go @@ -0,0 +1,75 @@ +package sqlite + +import ( + "context" + "database/sql" + "strings" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func (s *Store) SearchMessages(ctx context.Context, workspaceID, userID, query string, limit int) ([]store.SearchResult, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + if err := s.requireMembership(ctx, workspaceID, userID); err != nil { + return nil, err + } + query = strings.TrimSpace(query) + if query == "" { + return []store.SearchResult{}, nil + } + rows, err := s.db.QueryContext(ctx, ` + SELECT m.id, m.workspace_id, COALESCE(m.channel_id, ''), COALESCE(m.direct_conversation_id, ''), m.author_id, m.parent_message_id, m.thread_root_id, m.channel_seq, m.thread_seq, + m.body, m.body_format, m.created_at, m.edited_at, m.deleted_at, + u.id, u.display_name, u.avatar_url, u.created_at, + bm25(messages_fts) AS rank + FROM messages_fts + JOIN messages m ON m.id = messages_fts.message_id + JOIN users u ON u.id = m.author_id + WHERE messages_fts.workspace_id = ? AND messages_fts MATCH ? + ORDER BY rank + LIMIT ?`, workspaceID, query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []store.SearchResult{} + for rows.Next() { + msg, rank, err := scanSearchMessage(rows) + if err != nil { + return nil, err + } + out = append(out, store.SearchResult{Message: msg, Rank: rank}) + } + return out, rows.Err() +} + +func scanSearchMessage(row scanner) (store.Message, float64, error) { + var msg store.Message + var parent, edited, deleted sql.NullString + var channelSeq, threadSeq sql.NullInt64 + var author store.User + var rank float64 + err := row.Scan(&msg.ID, &msg.WorkspaceID, &msg.ChannelID, &msg.DirectConversationID, &msg.AuthorID, &parent, &msg.ThreadRootID, &channelSeq, &threadSeq, &msg.Body, &msg.BodyFormat, &msg.CreatedAt, &edited, &deleted, &author.ID, &author.DisplayName, &author.AvatarURL, &author.CreatedAt, &rank) + if err != nil { + return store.Message{}, 0, err + } + if parent.Valid { + msg.ParentMessageID = &parent.String + } + if channelSeq.Valid { + msg.ChannelSeq = &channelSeq.Int64 + } + if threadSeq.Valid { + msg.ThreadSeq = &threadSeq.Int64 + } + if edited.Valid { + msg.EditedAt = &edited.String + } + if deleted.Valid { + msg.DeletedAt = &deleted.String + } + msg.Author = &author + return msg, rank, nil +} diff --git a/apps/api/internal/store/sqlite/sqlite.go b/apps/api/internal/store/sqlite/sqlite.go new file mode 100644 index 0000000..a5a50d1 --- /dev/null +++ b/apps/api/internal/store/sqlite/sqlite.go @@ -0,0 +1,490 @@ +package sqlite + +import ( + "context" + "database/sql" + "embed" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/openclaw/clickclack/apps/api/internal/store" + _ "modernc.org/sqlite" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +type Store struct { + db *sql.DB +} + +func Open(dbURL string) (*Store, error) { + path := strings.TrimPrefix(dbURL, "sqlite://") + if path == "" || path == dbURL { + path = dbURL + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for _, pragma := range []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA foreign_keys=ON", + "PRAGMA busy_timeout=5000", + } { + if _, err := db.ExecContext(ctx, pragma); err != nil { + _ = db.Close() + return nil, err + } + } + return &Store{db: db}, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) Migrate(ctx context.Context) error { + if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`); err != nil { + return err + } + entries, err := fs.ReadDir(migrationsFS, "migrations") + if err != nil { + return err + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + for _, entry := range entries { + name := entry.Name() + var applied string + err := s.db.QueryRowContext(ctx, `SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&applied) + if err == nil { + continue + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + body, err := migrationsFS.ReadFile("migrations/" + name) + if err != nil { + return err + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, string(body)); err != nil { + _ = tx.Rollback() + return fmt.Errorf("%s: %w", name, err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`, name, now()); err != nil { + _ = tx.Rollback() + return err + } + if err := tx.Commit(); err != nil { + return err + } + } + return nil +} + +func (s *Store) EnsureBootstrap(ctx context.Context, name, email string) (store.User, error) { + user, err := s.FirstUser(ctx) + if err == nil { + return user, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return store.User{}, err + } + user, err = s.CreateUser(ctx, store.CreateUserInput{DisplayName: name, Email: email}) + if err != nil { + return store.User{}, err + } + ws, err := s.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "ClickClack", Slug: "clickclack"}, user.ID) + if err != nil { + return store.User{}, err + } + _, _, err = s.CreateChannel(ctx, store.CreateChannelInput{WorkspaceID: ws.ID, Name: "general", Kind: "public", UserID: user.ID}) + return user, err +} + +func (s *Store) CreateUser(ctx context.Context, input store.CreateUserInput) (store.User, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.User{}, err + } + defer tx.Rollback() + user := store.User{ + ID: newID("usr"), + DisplayName: strings.TrimSpace(input.DisplayName), + AvatarURL: "", + CreatedAt: now(), + } + if user.DisplayName == "" { + user.DisplayName = "Local User" + } + if _, err := tx.ExecContext(ctx, `INSERT INTO users (id, display_name, avatar_url, created_at) VALUES (?, ?, ?, ?)`, user.ID, user.DisplayName, user.AvatarURL, user.CreatedAt); err != nil { + return store.User{}, err + } + if input.Email != "" { + _, err = tx.ExecContext(ctx, `INSERT INTO identities (id, user_id, provider, provider_subject, email, created_at) VALUES (?, ?, 'local', ?, ?, ?)`, newID("idn"), user.ID, input.Email, input.Email, user.CreatedAt) + if err != nil { + return store.User{}, err + } + } + return user, tx.Commit() +} + +func (s *Store) FirstUser(ctx context.Context) (store.User, error) { + return scanUser(s.db.QueryRowContext(ctx, `SELECT id, display_name, avatar_url, created_at FROM users ORDER BY created_at LIMIT 1`)) +} + +func (s *Store) GetUser(ctx context.Context, id string) (store.User, error) { + return scanUser(s.db.QueryRowContext(ctx, `SELECT id, display_name, avatar_url, created_at FROM users WHERE id = ?`, id)) +} + +func (s *Store) ListWorkspaces(ctx context.Context, userID string) ([]store.Workspace, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT w.id, w.name, w.slug, w.created_at + FROM workspaces w + JOIN workspace_members wm ON wm.workspace_id = w.id + WHERE wm.user_id = ? + ORDER BY w.created_at`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []store.Workspace{} + for rows.Next() { + var w store.Workspace + if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.CreatedAt); err != nil { + return nil, err + } + out = append(out, w) + } + return out, rows.Err() +} + +func (s *Store) CreateWorkspace(ctx context.Context, input store.CreateWorkspaceInput, ownerID string) (store.Workspace, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.Workspace{}, err + } + defer tx.Rollback() + w := store.Workspace{ID: newID("wsp"), Name: strings.TrimSpace(input.Name), Slug: slug(input.Slug), CreatedAt: now()} + if w.Name == "" { + w.Name = "Untitled" + } + if w.Slug == "" { + w.Slug = slug(w.Name) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workspaces (id, name, slug, created_at) VALUES (?, ?, ?, ?)`, w.ID, w.Name, w.Slug, w.CreatedAt); err != nil { + return store.Workspace{}, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO workspace_members (workspace_id, user_id, role, created_at) VALUES (?, ?, 'owner', ?)`, w.ID, ownerID, w.CreatedAt); err != nil { + return store.Workspace{}, err + } + return w, tx.Commit() +} + +func (s *Store) GetWorkspace(ctx context.Context, workspaceID, userID string) (store.Workspace, error) { + return scanWorkspace(s.db.QueryRowContext(ctx, ` + SELECT w.id, w.name, w.slug, w.created_at + FROM workspaces w + JOIN workspace_members wm ON wm.workspace_id = w.id + WHERE w.id = ? AND wm.user_id = ?`, workspaceID, userID)) +} + +func (s *Store) ListChannels(ctx context.Context, workspaceID, userID string) ([]store.Channel, error) { + if err := s.requireMembership(ctx, workspaceID, userID); err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, `SELECT id, workspace_id, name, kind, created_at, archived_at FROM channels WHERE workspace_id = ? ORDER BY name`, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []store.Channel{} + for rows.Next() { + ch, err := scanChannel(rows) + if err != nil { + return nil, err + } + out = append(out, ch) + } + return out, rows.Err() +} + +func (s *Store) CreateChannel(ctx context.Context, input store.CreateChannelInput) (store.Channel, store.Event, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.Channel{}, store.Event{}, err + } + defer tx.Rollback() + if err := requireMembershipTx(ctx, tx, input.WorkspaceID, input.UserID); err != nil { + return store.Channel{}, store.Event{}, err + } + ch := store.Channel{ID: newID("chn"), WorkspaceID: input.WorkspaceID, Name: slug(input.Name), Kind: input.Kind, CreatedAt: now()} + if ch.Name == "" { + ch.Name = "general" + } + if ch.Kind == "" { + ch.Kind = "public" + } + if _, err := tx.ExecContext(ctx, `INSERT INTO channels (id, workspace_id, name, kind, created_at) VALUES (?, ?, ?, ?, ?)`, ch.ID, ch.WorkspaceID, ch.Name, ch.Kind, ch.CreatedAt); err != nil { + return store.Channel{}, store.Event{}, err + } + event, err := insertEvent(ctx, tx, ch.WorkspaceID, ch.ID, "channel.created", nil, map[string]string{"channel_id": ch.ID}) + if err != nil { + return store.Channel{}, store.Event{}, err + } + return ch, event, tx.Commit() +} + +func (s *Store) ListMessages(ctx context.Context, channelID, userID string, afterSeq int64, limit int) ([]store.Message, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + var workspaceID string + if err := s.db.QueryRowContext(ctx, `SELECT workspace_id FROM channels WHERE id = ?`, channelID).Scan(&workspaceID); err != nil { + return nil, err + } + if err := s.requireMembership(ctx, workspaceID, userID); err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, ` + SELECT m.id, m.workspace_id, COALESCE(m.channel_id, ''), COALESCE(m.direct_conversation_id, ''), m.author_id, m.parent_message_id, m.thread_root_id, m.channel_seq, m.thread_seq, + m.body, m.body_format, m.created_at, m.edited_at, m.deleted_at, + u.id, u.display_name, u.avatar_url, u.created_at + FROM messages m + JOIN users u ON u.id = m.author_id + WHERE m.channel_id = ? AND m.parent_message_id IS NULL AND m.channel_seq > ? + ORDER BY m.channel_seq + LIMIT ?`, channelID, afterSeq, limit) + if err != nil { + return nil, err + } + defer rows.Close() + messages, err := scanMessages(rows) + if err != nil { + return nil, err + } + return s.hydrateAttachments(ctx, messages) +} + +func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInput) (store.Message, store.Event, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.Message{}, store.Event{}, err + } + defer tx.Rollback() + var workspaceID string + if err := tx.QueryRowContext(ctx, `SELECT workspace_id FROM channels WHERE id = ?`, input.ChannelID).Scan(&workspaceID); err != nil { + return store.Message{}, store.Event{}, err + } + if err := requireMembershipTx(ctx, tx, workspaceID, input.AuthorID); err != nil { + return store.Message{}, store.Event{}, err + } + var seq int64 + if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(channel_seq), 0) + 1 FROM messages WHERE channel_id = ? AND parent_message_id IS NULL`, input.ChannelID).Scan(&seq); err != nil { + return store.Message{}, store.Event{}, err + } + id := newID("msg") + createdAt := now() + body := strings.TrimSpace(input.Body) + if body == "" { + return store.Message{}, store.Event{}, errors.New("message body is required") + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, channel_seq, thread_seq, body, body_format, created_at) + VALUES (?, ?, ?, NULL, ?, NULL, ?, ?, NULL, ?, 'markdown', ?)`, id, workspaceID, input.ChannelID, input.AuthorID, id, seq, body, createdAt); err != nil { + return store.Message{}, store.Event{}, err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO thread_state (root_message_id) VALUES (?)`, id); err != nil { + return store.Message{}, store.Event{}, err + } + event, err := insertEvent(ctx, tx, workspaceID, input.ChannelID, "message.created", &seq, map[string]string{"message_id": id}) + if err != nil { + return store.Message{}, store.Event{}, err + } + msg, err := getMessageTx(ctx, tx, id) + if err != nil { + return store.Message{}, store.Event{}, err + } + return msg, event, tx.Commit() +} + +func (s *Store) GetThread(ctx context.Context, rootMessageID, userID string, limit int) (store.Message, []store.Message, store.ThreadState, error) { + if limit <= 0 || limit > 200 { + limit = 100 + } + root, err := getMessage(ctx, s.db, rootMessageID) + if err != nil { + return store.Message{}, nil, store.ThreadState{}, err + } + if root.ParentMessageID != nil { + return store.Message{}, nil, store.ThreadState{}, errors.New("thread root must be a root message") + } + if err := s.requireMembership(ctx, root.WorkspaceID, userID); err != nil { + return store.Message{}, nil, store.ThreadState{}, err + } + roots, err := s.hydrateAttachments(ctx, []store.Message{root}) + if err != nil { + return store.Message{}, nil, store.ThreadState{}, err + } + root = roots[0] + rows, err := s.db.QueryContext(ctx, ` + SELECT m.id, m.workspace_id, COALESCE(m.channel_id, ''), COALESCE(m.direct_conversation_id, ''), m.author_id, m.parent_message_id, m.thread_root_id, m.channel_seq, m.thread_seq, + m.body, m.body_format, m.created_at, m.edited_at, m.deleted_at, + u.id, u.display_name, u.avatar_url, u.created_at + FROM messages m + JOIN users u ON u.id = m.author_id + WHERE m.thread_root_id = ? AND m.parent_message_id = ? + ORDER BY m.thread_seq + LIMIT ?`, rootMessageID, rootMessageID, limit) + if err != nil { + return store.Message{}, nil, store.ThreadState{}, err + } + defer rows.Close() + replies, err := scanMessages(rows) + if err != nil { + return store.Message{}, nil, store.ThreadState{}, err + } + replies, err = s.hydrateAttachments(ctx, replies) + if err != nil { + return store.Message{}, nil, store.ThreadState{}, err + } + state, err := getThreadState(ctx, s.db, rootMessageID) + return root, replies, state, err +} + +func (s *Store) CreateThreadReply(ctx context.Context, input store.CreateThreadReplyInput) (store.Message, store.ThreadState, []store.Event, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } + defer tx.Rollback() + root, err := getMessageTx(ctx, tx, input.RootMessageID) + if err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } + if root.ParentMessageID != nil { + return store.Message{}, store.ThreadState{}, nil, errors.New("nested thread replies are not supported") + } + if err := requireMembershipTx(ctx, tx, root.WorkspaceID, input.AuthorID); err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } + var seq int64 + if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(thread_seq), 0) + 1 FROM messages WHERE thread_root_id = ? AND parent_message_id = ?`, root.ID, root.ID).Scan(&seq); err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } + id := newID("msg") + createdAt := now() + body := strings.TrimSpace(input.Body) + if body == "" { + return store.Message{}, store.ThreadState{}, nil, errors.New("reply body is required") + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, channel_seq, thread_seq, body, body_format, created_at) + VALUES (?, ?, ?, NULL, ?, ?, ?, NULL, ?, ?, 'markdown', ?)`, id, root.WorkspaceID, root.ChannelID, input.AuthorID, root.ID, root.ID, seq, body, createdAt); err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } + state, err := updateThreadState(ctx, tx, root.ID, input.AuthorID, createdAt) + if err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } + replyEvent, err := insertEvent(ctx, tx, root.WorkspaceID, root.ChannelID, "thread.reply_created", nil, map[string]string{"message_id": id, "root_message_id": root.ID}) + if err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } + stateEvent, err := insertEvent(ctx, tx, root.WorkspaceID, root.ChannelID, "thread.state_updated", nil, map[string]string{"root_message_id": root.ID}) + if err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } + msg, err := getMessageTx(ctx, tx, id) + if err != nil { + return store.Message{}, store.ThreadState{}, nil, err + } + return msg, state, []store.Event{replyEvent, stateEvent}, tx.Commit() +} + +func (s *Store) AddReaction(ctx context.Context, input store.CreateReactionInput) (store.Event, error) { + return s.reaction(ctx, input, true) +} + +func (s *Store) RemoveReaction(ctx context.Context, input store.CreateReactionInput) (store.Event, error) { + return s.reaction(ctx, input, false) +} + +func (s *Store) ListEventsAfter(ctx context.Context, workspaceID, userID, cursor string, limit int) ([]store.Event, error) { + if limit <= 0 || limit > 500 { + limit = 200 + } + if err := s.requireMembership(ctx, workspaceID, userID); err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, ` + SELECT id, cursor, workspace_id, COALESCE(channel_id, ''), type, seq, payload_json, created_at + FROM events + WHERE workspace_id = ? AND cursor > ? + ORDER BY cursor + LIMIT ?`, workspaceID, cursor, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanEvents(rows) +} + +func (s *Store) reaction(ctx context.Context, input store.CreateReactionInput, add bool) (store.Event, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return store.Event{}, err + } + defer tx.Rollback() + msg, err := getMessageTx(ctx, tx, input.MessageID) + if err != nil { + return store.Event{}, err + } + if err := requireMembershipTx(ctx, tx, msg.WorkspaceID, input.UserID); err != nil { + return store.Event{}, err + } + if add { + _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO reactions (message_id, user_id, emoji, created_at) VALUES (?, ?, ?, ?)`, input.MessageID, input.UserID, input.Emoji, now()) + } else { + _, err = tx.ExecContext(ctx, `DELETE FROM reactions WHERE message_id = ? AND user_id = ? AND emoji = ?`, input.MessageID, input.UserID, input.Emoji) + } + if err != nil { + return store.Event{}, err + } + eventType := "reaction.added" + if !add { + eventType = "reaction.removed" + } + event, err := insertEvent(ctx, tx, msg.WorkspaceID, msg.ChannelID, eventType, msg.ChannelSeq, map[string]string{"message_id": input.MessageID, "emoji": input.Emoji}) + if err != nil { + return store.Event{}, err + } + return event, tx.Commit() +} + +func (s *Store) requireMembership(ctx context.Context, workspaceID, userID string) error { + var one int + err := s.db.QueryRowContext(ctx, `SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?`, workspaceID, userID).Scan(&one) + return err +} + +func requireMembershipTx(ctx context.Context, tx *sql.Tx, workspaceID, userID string) error { + var one int + err := tx.QueryRowContext(ctx, `SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?`, workspaceID, userID).Scan(&one) + return err +} diff --git a/apps/api/internal/store/sqlite/sqlite_test.go b/apps/api/internal/store/sqlite/sqlite_test.go new file mode 100644 index 0000000..20e931a --- /dev/null +++ b/apps/api/internal/store/sqlite/sqlite_test.go @@ -0,0 +1,232 @@ +package sqlite + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func TestStoreValidationAndAdminHelpers(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + + owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com") + if err != nil { + t.Fatal(err) + } + workspaces, err := st.ListWorkspaces(ctx, owner.ID) + if err != nil { + t.Fatal(err) + } + workspace := workspaces[0] + channels, err := st.ListChannels(ctx, workspace.ID, owner.ID) + if err != nil { + t.Fatal(err) + } + channel := channels[0] + + if _, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "ClickClack", Slug: workspace.Slug}, owner.ID); err == nil { + t.Fatal("expected duplicate workspace slug error") + } + if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID}); err == nil { + t.Fatal("expected empty message error") + } + if _, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: channel.ID, AuthorID: owner.ID, Body: "nope"}); err == nil { + t.Fatal("expected missing root message error") + } + if results, err := st.SearchMessages(ctx, workspace.ID, owner.ID, "", 10); err != nil || len(results) != 0 { + t.Fatalf("expected empty search results, got %#v err=%v", results, err) + } + if _, err := st.CreateInvite(ctx, workspace.ID, owner.ID); err != nil { + t.Fatal(err) + } + link, err := st.CreateMagicLink(ctx, "magic@example.com", "Magic User") + if err != nil { + t.Fatal(err) + } + magicUser, session, err := st.ConsumeMagicLink(ctx, link.Token) + if err != nil { + t.Fatal(err) + } + if magicUser.DisplayName != "Magic User" || session.Token == "" { + t.Fatalf("unexpected magic auth result: %#v %#v", magicUser, session) + } + sessionUser, err := st.GetSessionUser(ctx, session.Token) + if err != nil { + t.Fatal(err) + } + if sessionUser.ID != magicUser.ID { + t.Fatalf("expected session user %s, got %s", magicUser.ID, sessionUser.ID) + } + if _, _, err := st.ConsumeMagicLink(ctx, link.Token); err == nil { + t.Fatal("expected consumed magic link error") + } + if _, err := st.CreateMagicLink(ctx, "", "No Email"); err == nil { + t.Fatal("expected missing email error") + } + var exported bytes.Buffer + if err := st.ExportJSON(ctx, &exported); err != nil { + t.Fatal(err) + } + var exportBody map[string][]map[string]any + if err := json.Unmarshal(exported.Bytes(), &exportBody); err != nil { + t.Fatal(err) + } + if len(exportBody["auth_magic_links"]) == 0 || len(exportBody["sessions"]) == 0 { + t.Fatalf("expected auth tables in export, got keys %#v", exportBody) + } + if err := st.Backup(ctx, filepath.Join(t.TempDir(), "backup.db")); err != nil { + t.Fatal(err) + } + if _, err := st.db.ExecContext(ctx, `DROP TABLE sessions`); err != nil { + t.Fatal(err) + } + if err := st.ExportJSON(ctx, &bytes.Buffer{}); err == nil { + t.Fatal("expected export failure") + } + + second, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Second", Email: "second@example.com"}) + if err != nil { + t.Fatal(err) + } + if _, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{second.ID}}); err == nil { + t.Fatal("expected dm membership error for second user") + } + if err := st.AddWorkspaceMember(ctx, workspace.ID, second.ID, "member"); err != nil { + t.Fatal(err) + } + dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{second.ID}}) + if err != nil { + t.Fatal(err) + } + dms, err := st.ListDirectConversations(ctx, workspace.ID, owner.ID) + if err != nil { + t.Fatal(err) + } + if len(dms) != 1 || dms[0].ID != dm.ID { + t.Fatalf("unexpected dm list: %#v", dms) + } + if _, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: second.ID}); err == nil { + t.Fatal("expected empty dm message error") + } +} + +func TestOpenRejectsBadDirectory(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "not-a-dir") + if err := os.WriteFile(path, []byte("file"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Open("sqlite://" + filepath.Join(path, "db.sqlite")); err == nil { + t.Fatal("expected bad directory error") + } +} + +func TestStoreClosedDatabaseErrors(t *testing.T) { + t.Parallel() + ctx := context.Background() + st := newTestStore(t) + if err := st.db.Close(); err != nil { + t.Fatal(err) + } + + errorCases := []struct { + name string + fn func() error + }{ + {"migrate", func() error { return st.Migrate(ctx) }}, + {"create user", func() error { + _, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "x"}) + return err + }}, + {"first user", func() error { + _, err := st.FirstUser(ctx) + return err + }}, + {"get user", func() error { + _, err := st.GetUser(ctx, "usr_missing") + return err + }}, + {"list workspaces", func() error { + _, err := st.ListWorkspaces(ctx, "usr_missing") + return err + }}, + {"create workspace", func() error { + _, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "x"}, "usr_missing") + return err + }}, + {"create channel", func() error { + _, _, err := st.CreateChannel(ctx, store.CreateChannelInput{}) + return err + }}, + {"create message", func() error { + _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{}) + return err + }}, + {"create reply", func() error { + _, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{}) + return err + }}, + {"add reaction", func() error { + _, err := st.AddReaction(ctx, store.CreateReactionInput{}) + return err + }}, + {"remove reaction", func() error { + _, err := st.RemoveReaction(ctx, store.CreateReactionInput{}) + return err + }}, + {"create upload", func() error { + _, err := st.CreateUpload(ctx, store.CreateUploadInput{}) + return err + }}, + {"attach upload", func() error { + return st.AttachUpload(ctx, store.AttachUploadInput{}) + }}, + {"create dm", func() error { + _, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{}) + return err + }}, + {"create dm message", func() error { + _, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{}) + return err + }}, + {"magic link", func() error { + _, err := st.CreateMagicLink(ctx, "x@example.com", "x") + return err + }}, + {"identity", func() error { + _, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{Provider: "github", ProviderSubject: "1"}) + return err + }}, + {"session", func() error { + _, err := st.CreateSession(ctx, "usr_missing") + return err + }}, + } + for _, tc := range errorCases { + t.Run(tc.name, func(t *testing.T) { + if err := tc.fn(); err == nil { + t.Fatal("expected closed database error") + } + }) + } +} + +func newTestStore(t *testing.T) *Store { + t.Helper() + st, err := Open("sqlite://" + filepath.Join(t.TempDir(), "clickclack.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(context.Background()); err != nil { + t.Fatal(err) + } + return st +} diff --git a/apps/api/internal/store/sqlite/uploads.go b/apps/api/internal/store/sqlite/uploads.go new file mode 100644 index 0000000..1d2a6ad --- /dev/null +++ b/apps/api/internal/store/sqlite/uploads.go @@ -0,0 +1,106 @@ +package sqlite + +import ( + "context" + "errors" + + "github.com/openclaw/clickclack/apps/api/internal/store" +) + +func (s *Store) CreateUpload(ctx context.Context, input store.CreateUploadInput) (store.Upload, error) { + if err := s.requireMembership(ctx, input.WorkspaceID, input.OwnerID); err != nil { + return store.Upload{}, err + } + upload := store.Upload{ + ID: newID("upl"), + WorkspaceID: input.WorkspaceID, + OwnerID: input.OwnerID, + Filename: input.Filename, + ContentType: input.ContentType, + ByteSize: input.ByteSize, + StoragePath: input.StoragePath, + CreatedAt: now(), + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO uploads (id, workspace_id, owner_id, filename, content_type, byte_size, storage_path, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + upload.ID, upload.WorkspaceID, upload.OwnerID, upload.Filename, upload.ContentType, upload.ByteSize, upload.StoragePath, upload.CreatedAt) + return upload, err +} + +func (s *Store) GetUpload(ctx context.Context, uploadID, userID string) (store.Upload, error) { + upload, err := scanUpload(s.db.QueryRowContext(ctx, ` + SELECT id, workspace_id, owner_id, filename, content_type, byte_size, storage_path, created_at + FROM uploads + WHERE id = ?`, uploadID)) + if err != nil { + return store.Upload{}, err + } + if err := s.requireMembership(ctx, upload.WorkspaceID, userID); err != nil { + return store.Upload{}, err + } + return upload, nil +} + +func (s *Store) AttachUpload(ctx context.Context, input store.AttachUploadInput) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + msg, err := getMessageTx(ctx, tx, input.MessageID) + if err != nil { + return err + } + if err := requireMembershipTx(ctx, tx, msg.WorkspaceID, input.UserID); err != nil { + return err + } + var uploadWorkspace string + if err := tx.QueryRowContext(ctx, `SELECT workspace_id FROM uploads WHERE id = ?`, input.UploadID).Scan(&uploadWorkspace); err != nil { + return err + } + if uploadWorkspace != msg.WorkspaceID { + return errors.New("upload and message workspaces differ") + } + _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO message_attachments (message_id, upload_id, created_at) VALUES (?, ?, ?)`, input.MessageID, input.UploadID, now()) + if err != nil { + return err + } + return tx.Commit() +} + +func scanUpload(row scanner) (store.Upload, error) { + var upload store.Upload + err := row.Scan(&upload.ID, &upload.WorkspaceID, &upload.OwnerID, &upload.Filename, &upload.ContentType, &upload.ByteSize, &upload.StoragePath, &upload.CreatedAt) + return upload, err +} + +func (s *Store) hydrateAttachments(ctx context.Context, messages []store.Message) ([]store.Message, error) { + for i := range messages { + rows, err := s.db.QueryContext(ctx, ` + SELECT u.id, u.workspace_id, u.owner_id, u.filename, u.content_type, u.byte_size, u.storage_path, u.created_at + FROM uploads u + JOIN message_attachments ma ON ma.upload_id = u.id + WHERE ma.message_id = ? + ORDER BY ma.created_at`, messages[i].ID) + if err != nil { + return nil, err + } + uploads := []store.Upload{} + for rows.Next() { + upload, err := scanUpload(rows) + if err != nil { + _ = rows.Close() + return nil, err + } + uploads = append(uploads, upload) + } + if err := rows.Close(); err != nil { + return nil, err + } + if len(uploads) > 0 { + messages[i].Attachments = uploads + } + } + return messages, nil +} diff --git a/apps/api/internal/store/types.go b/apps/api/internal/store/types.go new file mode 100644 index 0000000..a8c0887 --- /dev/null +++ b/apps/api/internal/store/types.go @@ -0,0 +1,243 @@ +package store + +import "context" + +type User struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + AvatarURL string `json:"avatar_url"` + CreatedAt string `json:"created_at"` +} + +type Workspace struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + CreatedAt string `json:"created_at"` +} + +type Channel struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + Kind string `json:"kind"` + CreatedAt string `json:"created_at"` + ArchivedAt *string `json:"archived_at,omitempty"` +} + +type Message struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + ChannelID string `json:"channel_id,omitempty"` + DirectConversationID string `json:"direct_conversation_id,omitempty"` + AuthorID string `json:"author_id"` + ParentMessageID *string `json:"parent_message_id,omitempty"` + ThreadRootID string `json:"thread_root_id"` + ChannelSeq *int64 `json:"channel_seq,omitempty"` + ThreadSeq *int64 `json:"thread_seq,omitempty"` + Body string `json:"body"` + BodyFormat string `json:"body_format"` + CreatedAt string `json:"created_at"` + EditedAt *string `json:"edited_at,omitempty"` + DeletedAt *string `json:"deleted_at,omitempty"` + Author *User `json:"author,omitempty"` + Attachments []Upload `json:"attachments,omitempty"` +} + +type ThreadState struct { + RootMessageID string `json:"root_message_id"` + ReplyCount int64 `json:"reply_count"` + LastReplyAt *string `json:"last_reply_at,omitempty"` + LastReplyAuthorIDs []string `json:"last_reply_author_ids"` + LastReplyAuthorIDsJSON string `json:"-"` +} + +type Event struct { + ID string `json:"id"` + Cursor string `json:"cursor"` + Type string `json:"type"` + WorkspaceID string `json:"workspace_id"` + ChannelID string `json:"channel_id,omitempty"` + Seq *int64 `json:"seq,omitempty"` + CreatedAt string `json:"created_at"` + PayloadJSON string `json:"-"` + Payload any `json:"payload"` +} + +type CreateUserInput struct { + DisplayName string + Email string +} + +type UpsertIdentityUserInput struct { + Provider string + ProviderSubject string + Email string + DisplayName string + AvatarURL string +} + +type CreateWorkspaceInput struct { + Name string + Slug string +} + +type CreateChannelInput struct { + WorkspaceID string + Name string + Kind string + UserID string +} + +type UpdateChannelInput struct { + ChannelID string + UserID string + Name string + Kind string + Archived *bool +} + +type CreateMessageInput struct { + ChannelID string + AuthorID string + Body string +} + +type UpdateMessageInput struct { + MessageID string + UserID string + Body string +} + +type DeleteMessageInput struct { + MessageID string + UserID string +} + +type CreateThreadReplyInput struct { + RootMessageID string + AuthorID string + Body string +} + +type CreateReactionInput struct { + MessageID string + UserID string + Emoji string +} + +type Upload struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + OwnerID string `json:"owner_id"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + ByteSize int64 `json:"byte_size"` + StoragePath string `json:"storage_path,omitempty"` + CreatedAt string `json:"created_at"` +} + +type CreateUploadInput struct { + WorkspaceID string + OwnerID string + Filename string + ContentType string + ByteSize int64 + StoragePath string +} + +type AttachUploadInput struct { + MessageID string + UploadID string + UserID string +} + +type SearchResult struct { + Message Message `json:"message"` + Rank float64 `json:"rank"` +} + +type DirectConversation struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + CreatedAt string `json:"created_at"` + Members []User `json:"members"` +} + +type CreateDirectConversationInput struct { + WorkspaceID string + UserID string + MemberIDs []string +} + +type CreateDirectMessageInput struct { + ConversationID string + AuthorID string + Body string +} + +type Invite struct { + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Token string `json:"token"` + CreatedBy string `json:"created_by"` + CreatedAt string `json:"created_at"` + AcceptedAt *string `json:"accepted_at,omitempty"` +} + +type MagicLink struct { + ID string `json:"id"` + Token string `json:"token"` + Email string `json:"email"` + DisplayName string `json:"display_name"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + UsedAt *string `json:"used_at,omitempty"` +} + +type Session struct { + ID string `json:"id"` + Token string `json:"token"` + UserID string `json:"user_id"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` +} + +type Store interface { + Close() error + Migrate(ctx context.Context) error + EnsureBootstrap(ctx context.Context, name, email string) (User, error) + CreateUser(ctx context.Context, input CreateUserInput) (User, error) + UpsertIdentityUser(ctx context.Context, input UpsertIdentityUserInput) (User, error) + AddWorkspaceMember(ctx context.Context, workspaceID, userID, role string) error + FirstUser(ctx context.Context) (User, error) + GetUser(ctx context.Context, id string) (User, error) + ListWorkspaces(ctx context.Context, userID string) ([]Workspace, error) + CreateWorkspace(ctx context.Context, input CreateWorkspaceInput, ownerID string) (Workspace, error) + GetWorkspace(ctx context.Context, workspaceID, userID string) (Workspace, error) + ListChannels(ctx context.Context, workspaceID, userID string) ([]Channel, error) + CreateChannel(ctx context.Context, input CreateChannelInput) (Channel, Event, error) + UpdateChannel(ctx context.Context, input UpdateChannelInput) (Channel, Event, error) + ListMessages(ctx context.Context, channelID, userID string, afterSeq int64, limit int) ([]Message, error) + CreateMessage(ctx context.Context, input CreateMessageInput) (Message, Event, error) + UpdateMessage(ctx context.Context, input UpdateMessageInput) (Message, Event, error) + DeleteMessage(ctx context.Context, input DeleteMessageInput) (Message, Event, error) + GetThread(ctx context.Context, rootMessageID, userID string, limit int) (Message, []Message, ThreadState, error) + CreateThreadReply(ctx context.Context, input CreateThreadReplyInput) (Message, ThreadState, []Event, error) + AddReaction(ctx context.Context, input CreateReactionInput) (Event, error) + RemoveReaction(ctx context.Context, input CreateReactionInput) (Event, error) + ListEventsAfter(ctx context.Context, workspaceID, userID, cursor string, limit int) ([]Event, error) + CreateUpload(ctx context.Context, input CreateUploadInput) (Upload, error) + GetUpload(ctx context.Context, uploadID, userID string) (Upload, error) + AttachUpload(ctx context.Context, input AttachUploadInput) error + SearchMessages(ctx context.Context, workspaceID, userID, query string, limit int) ([]SearchResult, error) + ListDirectConversations(ctx context.Context, workspaceID, userID string) ([]DirectConversation, error) + CreateDirectConversation(ctx context.Context, input CreateDirectConversationInput) (DirectConversation, error) + ListDirectMessages(ctx context.Context, conversationID, userID string, afterSeq int64, limit int) ([]Message, error) + CreateDirectMessage(ctx context.Context, input CreateDirectMessageInput) (Message, Event, error) + CreateInvite(ctx context.Context, workspaceID, createdBy string) (Invite, error) + CreateMagicLink(ctx context.Context, email, displayName string) (MagicLink, error) + ConsumeMagicLink(ctx context.Context, token string) (User, Session, error) + CreateSession(ctx context.Context, userID string) (Session, error) + GetSessionUser(ctx context.Context, token string) (User, error) +} diff --git a/apps/api/internal/webassets/dist/assets/index-4rv34_La.js b/apps/api/internal/webassets/dist/assets/index-4rv34_La.js new file mode 100644 index 0000000..e7c05bc --- /dev/null +++ b/apps/api/internal/webassets/dist/assets/index-4rv34_La.js @@ -0,0 +1,61 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))i(r);new MutationObserver(r=>{for(const s of r)if(s.type==="childList")for(const a of s.addedNodes)a.tagName==="LINK"&&a.rel==="modulepreload"&&i(a)}).observe(document,{childList:!0,subtree:!0});function n(r){const s={};return r.integrity&&(s.integrity=r.integrity),r.referrerPolicy&&(s.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?s.credentials="include":r.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function i(r){if(r.ep)return;r.ep=!0;const s=n(r);fetch(r.href,s)}})();const ma=!1;var cs=Array.isArray,_a=Array.prototype.indexOf,hn=Array.prototype.includes,Rr=Array.from,va=Object.defineProperty,Gn=Object.getOwnPropertyDescriptor,us=Object.getOwnPropertyDescriptors,ba=Object.prototype,ka=Array.prototype,fi=Object.getPrototypeOf,Li=Object.isExtensible;const wa=()=>{};function ya(e){return e()}function Xr(e){for(var t=0;t{e=i,t=r});return{promise:n,resolve:e,reject:t}}const pe=2,Yn=4,Xn=8,ps=1<<24,Je=16,We=32,yt=64,Qr=128,Ce=512,te=1024,ue=2048,qe=4096,_e=8192,Ne=16384,Ht=32768,Ci=1<<25,dn=65536,Kr=1<<17,hs=1<<18,Gt=1<<19,ds=1<<20,Ke=1<<25,$t=65536,yr=1<<21,Zn=1<<22,bt=1<<23,Wn=Symbol("$state"),at=new class extends Error{name="StaleReactionError";message="The reaction that called `getAbortSignal()` was re-run or destroyed"};function gs(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function xa(){throw new Error("https://svelte.dev/e/async_derived_orphan")}function Ta(e,t,n){throw new Error("https://svelte.dev/e/each_key_duplicate")}function Ea(e){throw new Error("https://svelte.dev/e/effect_in_teardown")}function Sa(){throw new Error("https://svelte.dev/e/effect_in_unowned_derived")}function Aa(e){throw new Error("https://svelte.dev/e/effect_orphan")}function Ra(){throw new Error("https://svelte.dev/e/effect_update_depth_exceeded")}function Ia(){throw new Error("https://svelte.dev/e/state_descriptors_fixed")}function Oa(){throw new Error("https://svelte.dev/e/state_prototype_fixed")}function Da(){throw new Error("https://svelte.dev/e/state_unsafe_mutation")}function La(){throw new Error("https://svelte.dev/e/svelte_boundary_reset_onerror")}const Ca=1,Na=2,ms=4,Ma=8,Pa=16,za=1,$a=2,le=Symbol(),_s="http://www.w3.org/1999/xhtml",Fa="http://www.w3.org/2000/svg",Ua="http://www.w3.org/1998/Math/MathML";function Ba(){console.warn("https://svelte.dev/e/derived_inert")}function Ha(){console.warn("https://svelte.dev/e/svelte_boundary_reset_noop")}function vs(e){return e===this.v}function Ga(e,t){return e!=e?t==t:e!==t||e!==null&&typeof e=="object"||typeof e=="function"}function bs(e){return!Ga(e,this.v)}let Qn=!1,Wa=!1;function qa(){Qn=!0}let G=null;function gn(e){G=e}function ks(e,t=!1,n){G={p:G,i:!1,c:null,e:null,s:e,x:null,r:L,l:Qn&&!t?{s:null,u:null,$:[]}:null}}function ws(e){var t=G,n=t.e;if(n!==null){t.e=null;for(var i of n)Hs(i)}return t.i=!0,G=t.p,{}}function Kn(){return!Qn||G!==null&&G.l===null}let Ct=[];function ys(){var e=Ct;Ct=[],Xr(e)}function kt(e){if(Ct.length===0&&!qn){var t=Ct;queueMicrotask(()=>{t===Ct&&ys()})}Ct.push(e)}function ja(){for(;Ct.length>0;)ys()}function xs(e){var t=L;if(t===null)return P.f|=bt,e;if((t.f&Ht)===0&&(t.f&Yn)===0)throw e;_t(e,t)}function _t(e,t){for(;t!==null;){if((t.f&Qr)!==0){if((t.f&Ht)===0)throw e;try{t.b.error(e);return}catch(n){e=n}}t=t.parent}throw e}const Ya=-7169;function Q(e,t){e.f=e.f&Ya|t}function pi(e){(e.f&Ce)!==0||e.deps===null?Q(e,te):Q(e,qe)}function Ts(e){if(e!==null)for(const t of e)(t.f&pe)===0||(t.f&$t)===0||(t.f^=$t,Ts(t.deps))}function Es(e,t,n){(e.f&ue)!==0?t.add(e):(e.f&qe)!==0&&n.add(e),Ts(e.deps),Q(e,te)}const Dt=new Set;let O=null,He=null,Jr=null,qn=!1,Fr=!1,pn=null,kr=null;var Ni=0;let Za=1;class xt{id=Za++;current=new Map;previous=new Map;#n=new Set;#o=new Set;#e=new Set;#i=new Map;#r=new Map;#s=null;#t=[];#a=[];#c=new Set;#u=new Set;#l=new Map;#p=new Set;is_fork=!1;#d=!1;#h=new Set;#f(){return this.is_fork||this.#r.size>0}#_(){for(const i of this.#h)for(const r of i.#r.keys()){for(var t=!1,n=r;n.parent!==null;){if(this.#l.has(n)){t=!0;break}n=n.parent}if(!t)return!0}return!1}skip_effect(t){this.#l.has(t)||this.#l.set(t,{d:[],m:[]}),this.#p.delete(t)}unskip_effect(t,n=i=>this.schedule(i)){var i=this.#l.get(t);if(i){this.#l.delete(t);for(var r of i.d)Q(r,ue),n(r);for(r of i.m)Q(r,qe),n(r)}this.#p.add(t)}#g(){if(Ni++>1e3&&(Dt.delete(this),Xa()),!this.#f()){for(const o of this.#c)this.#u.delete(o),Q(o,ue),this.schedule(o);for(const o of this.#u)Q(o,qe),this.schedule(o)}const t=this.#t;this.#t=[],this.apply();var n=pn=[],i=[],r=kr=[];for(const o of t)try{this.#v(o,n,i)}catch(l){throw Rs(o),l}if(O=null,r.length>0){var s=xt.ensure();for(const o of r)s.schedule(o)}if(pn=null,kr=null,this.#f()||this.#_()){this.#m(i),this.#m(n);for(const[o,l]of this.#l)As(o,l)}else{this.#i.size===0&&Dt.delete(this),this.#c.clear(),this.#u.clear();for(const o of this.#n)o(this);this.#n.clear(),Mi(i),Mi(n),this.#s?.resolve()}var a=O;if(this.#t.length>0){const o=a??=this;o.#t.push(...this.#t.filter(l=>!o.#t.includes(l)))}a!==null&&(Dt.add(a),a.#g())}#v(t,n,i){t.f^=te;for(var r=t.first;r!==null;){var s=r.f,a=(s&(We|yt))!==0,o=a&&(s&te)!==0,l=o||(s&_e)!==0||this.#l.has(r);if(!l&&r.fn!==null){a?r.f^=te:(s&Yn)!==0?n.push(r):_n(r)&&((s&Je)!==0&&this.#u.add(r),Ut(r));var f=r.first;if(f!==null){r=f;continue}}for(;r!==null;){var u=r.next;if(u!==null){r=u;break}r=r.parent}}}#m(t){for(var n=0;n!this.current.has(h));if(r.length===0)t&&u.discard();else if(n.length>0){if(t)for(const h of this.#p)u.unskip_effect(h,d=>{(d.f&(Je|Zn))!==0?u.schedule(d):u.#m([d])});u.activate();var s=new Set,a=new Map;for(var o of n)Ss(o,r,s,a);a=new Map;var l=[...u.current.keys()].filter(h=>this.current.has(h)?this.current.get(h)[0]!==h:!0);for(const h of this.#a)(h.f&(Ne|_e|Kr))===0&&hi(h,l,a)&&((h.f&(Zn|Je))!==0?(Q(h,ue),u.schedule(h)):u.#c.add(h));if(u.#t.length>0){u.apply();for(var f of u.#t)u.#v(f,[],[]);u.#t=[]}u.deactivate()}}for(const u of Dt)u.#h.has(this)&&(u.#h.delete(this),u.#h.size===0&&!u.#f()&&(u.activate(),u.#g()))}increment(t,n){let i=this.#i.get(n)??0;if(this.#i.set(n,i+1),t){let r=this.#r.get(n)??0;this.#r.set(n,r+1)}}decrement(t,n,i){let r=this.#i.get(n)??0;if(r===1?this.#i.delete(n):this.#i.set(n,r-1),t){let s=this.#r.get(n)??0;s===1?this.#r.delete(n):this.#r.set(n,s-1)}this.#d||i||(this.#d=!0,kt(()=>{this.#d=!1,this.flush()}))}transfer_effects(t,n){for(const i of t)this.#c.add(i);for(const i of n)this.#u.add(i);t.clear(),n.clear()}oncommit(t){this.#n.add(t)}ondiscard(t){this.#o.add(t)}on_fork_commit(t){this.#e.add(t)}run_fork_commit_callbacks(){for(const t of this.#e)t(this);this.#e.clear()}settled(){return(this.#s??=fs()).promise}static ensure(){if(O===null){const t=O=new xt;Fr||(Dt.add(O),qn||kt(()=>{O===t&&t.flush()}))}return O}apply(){{He=null;return}}schedule(t){if(Jr=t,t.b?.is_pending&&(t.f&(Yn|Xn|ps))!==0&&(t.f&Ht)===0){t.b.defer_effect(t);return}for(var n=t;n.parent!==null;){n=n.parent;var i=n.f;if(pn!==null&&n===L&&(P===null||(P.f&pe)===0))return;if((i&(yt|We))!==0){if((i&te)===0)return;n.f^=te}}this.#t.push(n)}}function Va(e){var t=qn;qn=!0;try{for(var n;;){if(ja(),O===null)return n;O.flush()}}finally{qn=t}}function Xa(){try{Ra()}catch(e){_t(e,Jr)}}let st=null;function Mi(e){var t=e.length;if(t!==0){for(var n=0;n0)){Mt.clear();for(const r of st){if((r.f&(Ne|_e))!==0)continue;const s=[r];let a=r.parent;for(;a!==null;)st.has(a)&&(st.delete(a),s.push(a)),a=a.parent;for(let o=s.length-1;o>=0;o--){const l=s[o];(l.f&(Ne|_e))===0&&Ut(l)}}st.clear()}}st=null}}function Ss(e,t,n,i){if(!n.has(e)&&(n.add(e),e.reactions!==null))for(const r of e.reactions){const s=r.f;(s&pe)!==0?Ss(r,t,n,i):(s&(Zn|Je))!==0&&(s&ue)===0&&hi(r,t,i)&&(Q(r,ue),di(r))}}function hi(e,t,n){const i=n.get(e);if(i!==void 0)return i;if(e.deps!==null)for(const r of e.deps){if(hn.call(t,r))return!0;if((r.f&pe)!==0&&hi(r,t,n))return n.set(r,!0),!0}return n.set(e,!1),!1}function di(e){O.schedule(e)}function As(e,t){if(!((e.f&We)!==0&&(e.f&te)!==0)){(e.f&ue)!==0?t.d.push(e):(e.f&qe)!==0&&t.m.push(e),Q(e,te);for(var n=e.first;n!==null;)As(n,t),n=n.next}}function Rs(e){Q(e,te);for(var t=e.first;t!==null;)Rs(t),t=t.next}function Qa(e){let t=0,n=Ft(0),i;return()=>{_i()&&(p(n),Or(()=>(t===0&&(i=$(()=>e(()=>jn(n)))),t+=1,()=>{kt(()=>{t-=1,t===0&&(i?.(),i=void 0,jn(n))})})))}}var Ka=dn|Gt;function Ja(e,t,n,i){new el(e,t,n,i)}class el{parent;is_pending=!1;transform_error;#n;#o=null;#e;#i;#r;#s=null;#t=null;#a=null;#c=null;#u=0;#l=0;#p=!1;#d=new Set;#h=new Set;#f=null;#_=Qa(()=>(this.#f=Ft(this.#u),()=>{this.#f=null}));constructor(t,n,i,r){this.#n=t,this.#e=n,this.#i=s=>{var a=L;a.b=this,a.f|=Qr,i(s)},this.parent=L.b,this.transform_error=r??this.parent?.transform_error??(s=>s),this.#r=Dr(()=>{this.#b()},Ka)}#g(){try{this.#s=Le(()=>this.#i(this.#n))}catch(t){this.error(t)}}#v(t){const n=this.#e.failed;n&&(this.#a=Le(()=>{n(this.#n,()=>t,()=>()=>{})}))}#m(){const t=this.#e.pending;t&&(this.is_pending=!0,this.#t=Le(()=>t(this.#n)),kt(()=>{var n=this.#c=document.createDocumentFragment(),i=wt();n.append(i),this.#s=this.#w(()=>Le(()=>this.#i(i))),this.#l===0&&(this.#n.before(n),this.#c=null,Pt(this.#t,()=>{this.#t=null}),this.#k(O))}))}#b(){try{if(this.is_pending=this.has_pending_snippet(),this.#l=0,this.#u=0,this.#s=Le(()=>{this.#i(this.#n)}),this.#l>0){var t=this.#c=document.createDocumentFragment();ki(this.#s,t);const n=this.#e.pending;this.#t=Le(()=>n(this.#n))}else this.#k(O)}catch(n){this.error(n)}}#k(t){this.is_pending=!1,t.transfer_effects(this.#d,this.#h)}defer_effect(t){Es(t,this.#d,this.#h)}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!this.#e.pending}#w(t){var n=L,i=P,r=G;ze(this.#r),Pe(this.#r),gn(this.#r.ctx);try{return xt.ensure(),t()}catch(s){return xs(s),null}finally{ze(n),Pe(i),gn(r)}}#y(t,n){if(!this.has_pending_snippet()){this.parent&&this.parent.#y(t,n);return}this.#l+=t,this.#l===0&&(this.#k(n),this.#t&&Pt(this.#t,()=>{this.#t=null}),this.#c&&(this.#n.before(this.#c),this.#c=null))}update_pending_count(t,n){this.#y(t,n),this.#u+=t,!(!this.#f||this.#p)&&(this.#p=!0,kt(()=>{this.#p=!1,this.#f&&mn(this.#f,this.#u)}))}get_effect_pending(){return this.#_(),p(this.#f)}error(t){if(!this.#e.onerror&&!this.#e.failed)throw t;O?.is_fork?(this.#s&&O.skip_effect(this.#s),this.#t&&O.skip_effect(this.#t),this.#a&&O.skip_effect(this.#a),O.on_fork_commit(()=>{this.#x(t)})):this.#x(t)}#x(t){this.#s&&(Te(this.#s),this.#s=null),this.#t&&(Te(this.#t),this.#t=null),this.#a&&(Te(this.#a),this.#a=null);var n=this.#e.onerror;let i=this.#e.failed;var r=!1,s=!1;const a=()=>{if(r){Ha();return}r=!0,s&&La(),this.#a!==null&&Pt(this.#a,()=>{this.#a=null}),this.#w(()=>{this.#b()})},o=l=>{try{s=!0,n?.(l,a),s=!1}catch(f){_t(f,this.#r&&this.#r.parent)}i&&(this.#a=this.#w(()=>{try{return Le(()=>{var f=L;f.b=this,f.f|=Qr,i(this.#n,()=>l,()=>a)})}catch(f){return _t(f,this.#r.parent),null}}))};kt(()=>{var l;try{l=this.transform_error(t)}catch(f){_t(f,this.#r&&this.#r.parent);return}l!==null&&typeof l=="object"&&typeof l.then=="function"?l.then(o,f=>_t(f,this.#r&&this.#r.parent)):o(l)})}}function tl(e,t,n,i){const r=Kn()?gi:Os;var s=e.filter(d=>!d.settled);if(n.length===0&&s.length===0){i(t.map(r));return}var a=L,o=nl(),l=s.length===1?s[0].promise:s.length>1?Promise.all(s.map(d=>d.promise)):null;function f(d){o();try{i(d)}catch(v){(a.f&Ne)===0&&_t(v,a)}xr()}if(n.length===0){l.then(()=>f(t.map(r)));return}var u=Is();function h(){Promise.all(n.map(d=>rl(d))).then(d=>f([...t.map(r),...d])).catch(d=>_t(d,a)).finally(()=>u())}l?l.then(()=>{o(),h(),xr()}):h()}function nl(){var e=L,t=P,n=G,i=O;return function(s=!0){ze(e),Pe(t),gn(n),s&&(e.f&Ne)===0&&(i?.activate(),i?.apply())}}function xr(e=!0){ze(null),Pe(null),gn(null),e&&O?.deactivate()}function Is(){var e=L,t=e.b,n=O,i=t.is_rendered();return t.update_pending_count(1,n),n.increment(i,e),(r=!1)=>{t.update_pending_count(-1,n),n.decrement(i,e,r)}}function gi(e){var t=pe|ue;return L!==null&&(L.f|=Gt),{ctx:G,deps:null,effects:null,equals:vs,f:t,fn:e,reactions:null,rv:0,v:le,wv:0,parent:L,ac:null}}function rl(e,t,n){let i=L;i===null&&xa();var r=void 0,s=Ft(le),a=!P,o=new Map;return ml(()=>{var l=L,f=fs();r=f.promise;try{Promise.resolve(e()).then(f.resolve,f.reject).finally(xr)}catch(v){f.reject(v),xr()}var u=O;if(a){if((l.f&Ht)!==0)var h=Is();if(i.b.is_rendered())o.get(u)?.reject(at),o.delete(u);else{for(const v of o.values())v.reject(at);o.clear()}o.set(u,f)}const d=(v,_=void 0)=>{if(h){var T=_===at;h(T)}if(!(_===at||(l.f&Ne)!==0)){if(u.activate(),_)s.f|=bt,mn(s,_);else{(s.f&bt)!==0&&(s.f^=bt),mn(s,v);for(const[b,S]of o){if(o.delete(b),b===u)break;S.reject(at)}}u.deactivate()}};f.promise.then(d,v=>d(null,v||"unknown"))}),Bs(()=>{for(const l of o.values())l.reject(at)}),new Promise(l=>{function f(u){function h(){u===r?l(s):f(r)}u.then(h,h)}f(r)})}function Os(e){const t=gi(e);return t.equals=bs,t}function il(e){var t=e.effects;if(t!==null){e.effects=null;for(var n=0;n0&&!Cs&&al()}return t}function al(){Cs=!1;for(const e of ei)(e.f&te)!==0&&Q(e,qe),_n(e)&&Ut(e);ei.clear()}function jn(e){y(e,e.v+1)}function Ns(e,t,n){var i=e.reactions;if(i!==null)for(var r=Kn(),s=i.length,a=0;a{if(zt===s)return o();var l=P,f=zt;Pe(null),Fi(s);var u=o();return Pe(l),Fi(f),u};return i&&n.set("length",gt(e.length)),new Proxy(e,{defineProperty(o,l,f){(!("value"in f)||f.configurable===!1||f.enumerable===!1||f.writable===!1)&&Ia();var u=n.get(l);return u===void 0?a(()=>{var h=gt(f.value);return n.set(l,h),h}):y(u,f.value,!0),!0},deleteProperty(o,l){var f=n.get(l);if(f===void 0){if(l in o){const u=a(()=>gt(le));n.set(l,u),jn(r)}}else y(f,le),jn(r);return!0},get(o,l,f){if(l===Wn)return e;var u=n.get(l),h=l in o;if(u===void 0&&(!h||Gn(o,l)?.writable)&&(u=a(()=>{var v=$n(h?o[l]:le),_=gt(v);return _}),n.set(l,u)),u!==void 0){var d=p(u);return d===le?void 0:d}return Reflect.get(o,l,f)},getOwnPropertyDescriptor(o,l){var f=Reflect.getOwnPropertyDescriptor(o,l);if(f&&"value"in f){var u=n.get(l);u&&(f.value=p(u))}else if(f===void 0){var h=n.get(l),d=h?.v;if(h!==void 0&&d!==le)return{enumerable:!0,configurable:!0,value:d,writable:!0}}return f},has(o,l){if(l===Wn)return!0;var f=n.get(l),u=f!==void 0&&f.v!==le||Reflect.has(o,l);if(f!==void 0||L!==null&&(!u||Gn(o,l)?.writable)){f===void 0&&(f=a(()=>{var d=u?$n(o[l]):le,v=gt(d);return v}),n.set(l,f));var h=p(f);if(h===le)return!1}return u},set(o,l,f,u){var h=n.get(l),d=l in o;if(i&&l==="length")for(var v=f;vgt(le)),n.set(v+"",_))}if(h===void 0)(!d||Gn(o,l)?.writable)&&(h=a(()=>gt(void 0)),y(h,$n(f)),n.set(l,h));else{d=h.v!==le;var T=a(()=>$n(f));y(h,T)}var b=Reflect.getOwnPropertyDescriptor(o,l);if(b?.set&&b.set.call(u,f),!d){if(i&&typeof l=="string"){var S=n.get("length"),U=Number(l);Number.isInteger(U)&&U>=S.v&&y(S,U+1)}jn(r)}return!0},ownKeys(o){p(r);var l=Reflect.ownKeys(o).filter(h=>{var d=n.get(h);return d===void 0||d.v!==le});for(var[f,u]of n)u.v!==le&&!(f in o)&&l.push(f);return l},setPrototypeOf(){Oa()}})}var Pi,Ms,Ps,zs;function ll(){if(Pi===void 0){Pi=window,Ms=/Firefox/.test(navigator.userAgent);var e=Element.prototype,t=Node.prototype,n=Text.prototype;Ps=Gn(t,"firstChild").get,zs=Gn(t,"nextSibling").get,Li(e)&&(e.__click=void 0,e.__className=void 0,e.__attributes=null,e.__style=void 0,e.__e=void 0),Li(n)&&(n.__t=void 0)}}function wt(e=""){return document.createTextNode(e)}function vt(e){return Ps.call(e)}function Jn(e){return zs.call(e)}function E(e,t){return vt(e)}function ol(e,t=!1){{var n=vt(e);return n instanceof Comment&&n.data===""?Jn(n):n}}function I(e,t=1,n=!1){let i=e;for(;t--;)i=Jn(i);return i}function cl(e){e.textContent=""}function $s(){return!1}function Fs(e,t,n){return document.createElementNS(t??_s,e,void 0)}let zi=!1;function ul(){zi||(zi=!0,document.addEventListener("reset",e=>{Promise.resolve().then(()=>{if(!e.defaultPrevented)for(const t of e.target.elements)t.__on_r?.()})},{capture:!0}))}function Ir(e){var t=P,n=L;Pe(null),ze(null);try{return e()}finally{Pe(t),ze(n)}}function fl(e,t,n,i=n){e.addEventListener(t,()=>Ir(n));const r=e.__on_r;r?e.__on_r=()=>{r(),i(!0)}:e.__on_r=()=>i(!0),ul()}function Us(e){L===null&&(P===null&&Aa(),Sa()),Tt&&Ea()}function pl(e,t){var n=t.last;n===null?t.last=t.first=e:(n.next=e,e.prev=n,t.last=e)}function lt(e,t){var n=L;n!==null&&(n.f&_e)!==0&&(e|=_e);var i={ctx:G,deps:null,nodes:null,f:e|ue|Ce,first:null,fn:t,last:null,next:null,parent:n,b:n&&n.b,prev:null,teardown:null,wv:0,ac:null};O?.register_created_effect(i);var r=i;if((e&Yn)!==0)pn!==null?pn.push(i):xt.ensure().schedule(i);else if(t!==null){try{Ut(i)}catch(a){throw Te(i),a}r.deps===null&&r.teardown===null&&r.nodes===null&&r.first===r.last&&(r.f&Gt)===0&&(r=r.first,(e&Je)!==0&&(e&dn)!==0&&r!==null&&(r.f|=dn))}if(r!==null&&(r.parent=n,n!==null&&pl(r,n),P!==null&&(P.f&pe)!==0&&(e&yt)===0)){var s=P;(s.effects??=[]).push(r)}return i}function _i(){return P!==null&&!Ge}function Bs(e){const t=lt(Xn,null);return Q(t,te),t.teardown=e,t}function ti(e){Us();var t=L.f,n=!P&&(t&We)!==0&&(t&Ht)===0;if(n){var i=G;(i.e??=[]).push(e)}else return Hs(e)}function Hs(e){return lt(Yn|ds,e)}function hl(e){return Us(),lt(Xn|ds,e)}function dl(e){xt.ensure();const t=lt(yt|Gt,e);return(n={})=>new Promise(i=>{n.outro?Pt(t,()=>{Te(t),i(void 0)}):(Te(t),i(void 0))})}function Ur(e,t){var n=G,i={effect:null,ran:!1,deps:e};n.l.$.push(i),i.effect=Or(()=>{if(e(),!i.ran){i.ran=!0;var r=L;try{ze(r.parent),$(t)}finally{ze(r)}}})}function gl(){var e=G;Or(()=>{for(var t of e.l.$){t.deps();var n=t.effect;(n.f&te)!==0&&n.deps!==null&&Q(n,qe),_n(n)&&Ut(n),t.ran=!1}})}function ml(e){return lt(Zn|Gt,e)}function Or(e,t=0){return lt(Xn|t,e)}function Xe(e,t=[],n=[],i=[]){tl(i,t,n,r=>{lt(Xn,()=>e(...r.map(p)))})}function Dr(e,t=0){var n=lt(Je|t,e);return n}function Le(e){return lt(We|Gt,e)}function Gs(e){var t=e.teardown;if(t!==null){const n=Tt,i=P;$i(!0),Pe(null);try{t.call(null)}finally{$i(n),Pe(i)}}}function vi(e,t=!1){var n=e.first;for(e.first=e.last=null;n!==null;){const r=n.ac;r!==null&&Ir(()=>{r.abort(at)});var i=n.next;(n.f&yt)!==0?n.parent=null:Te(n,t),n=i}}function _l(e){for(var t=e.first;t!==null;){var n=t.next;(t.f&We)===0&&Te(t),t=n}}function Te(e,t=!0){var n=!1;(t||(e.f&hs)!==0)&&e.nodes!==null&&e.nodes.end!==null&&(Ws(e.nodes.start,e.nodes.end),n=!0),Q(e,Ci),vi(e,t&&!n),Vn(e,0);var i=e.nodes&&e.nodes.t;if(i!==null)for(const s of i)s.stop();Gs(e),e.f^=Ci,e.f|=Ne;var r=e.parent;r!==null&&r.first!==null&&qs(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes=e.ac=e.b=null}function Ws(e,t){for(;e!==null;){var n=e===t?null:Jn(e);e.remove(),e=n}}function qs(e){var t=e.parent,n=e.prev,i=e.next;n!==null&&(n.next=i),i!==null&&(i.prev=n),t!==null&&(t.first===e&&(t.first=i),t.last===e&&(t.last=n))}function Pt(e,t,n=!0){var i=[];js(e,i,!0);var r=()=>{n&&Te(e),t&&t()},s=i.length;if(s>0){var a=()=>--s||r();for(var o of i)o.out(a)}else r()}function js(e,t,n){if((e.f&_e)===0){e.f^=_e;var i=e.nodes&&e.nodes.t;if(i!==null)for(const o of i)(o.is_global||n)&&t.push(o);for(var r=e.first;r!==null;){var s=r.next;if((r.f&yt)===0){var a=(r.f&dn)!==0||(r.f&We)!==0&&(e.f&Je)!==0;js(r,t,a?n:!1)}r=s}}}function bi(e){Ys(e,!0)}function Ys(e,t){if((e.f&_e)!==0){e.f^=_e,(e.f&te)===0&&(Q(e,ue),xt.ensure().schedule(e));for(var n=e.first;n!==null;){var i=n.next,r=(n.f&dn)!==0||(n.f&We)!==0;Ys(n,r?t:!1),n=i}var s=e.nodes&&e.nodes.t;if(s!==null)for(const a of s)(a.is_global||t)&&a.in()}}function ki(e,t){if(e.nodes)for(var n=e.nodes.start,i=e.nodes.end;n!==null;){var r=n===i?null:Jn(n);t.append(n),n=r}}let wr=!1,Tt=!1;function $i(e){Tt=e}let P=null,Ge=!1;function Pe(e){P=e}let L=null;function ze(e){L=e}let Me=null;function vl(e){P!==null&&(Me===null?Me=[e]:Me.push(e))}let xe=null,Ie=0,De=null;function bl(e){De=e}let Zs=1,Nt=0,zt=Nt;function Fi(e){zt=e}function Vs(){return++Zs}function _n(e){var t=e.f;if((t&ue)!==0)return!0;if(t&pe&&(e.f&=~$t),(t&qe)!==0){for(var n=e.deps,i=n.length,r=0;re.wv)return!0}(t&Ce)!==0&&He===null&&Q(e,te)}return!1}function Xs(e,t,n=!0){var i=e.reactions;if(i!==null&&!(Me!==null&&hn.call(Me,e)))for(var r=0;r{e.ac.abort(at)}),e.ac=null);try{e.f|=yr;var u=e.fn,h=u();e.f|=Ht;var d=e.deps,v=O?.is_fork;if(xe!==null){var _;if(v||Vn(e,Ie),d!==null&&Ie>0)for(d.length=Ie+xe.length,_=0;_n?.call(this,s))}return e.startsWith("pointer")||e.startsWith("touch")||e==="wheel"?kt(()=>{t.addEventListener(e,r,i)}):t.addEventListener(e,r,i),r}function rn(e,t,n,i,r){var s={capture:i,passive:r},a=Tl(e,t,n,s);(t===document.body||t===window||t===document||t instanceof HTMLMediaElement)&&Bs(()=>{t.removeEventListener(e,a,s)})}function it(e,t,n){(t[Fn]??={})[e]=n}function El(e){for(var t=0;t{throw b});throw d}}finally{e[Fn]=t,delete e.currentTarget,Pe(u),ze(h)}}}const Sl=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:e=>e});function Al(e){return Sl?.createHTML(e)??e}function Rl(e){var t=Fs("template");return t.innerHTML=Al(e.replaceAll("","")),t.content}function Tr(e,t){var n=L;n.nodes===null&&(n.nodes={start:e,end:t,a:null,t:null})}function Oe(e,t){var n=(t&za)!==0,i=(t&$a)!==0,r,s=!e.startsWith("");return()=>{r===void 0&&(r=Rl(s?e:""+e),n||(r=vt(r)));var a=i||Ms?document.importNode(r,!0):r.cloneNode(!0);if(n){var o=vt(a),l=a.lastChild;Tr(o,l)}else Tr(a,a);return a}}function Re(e,t){e!==null&&e.before(t)}function oe(e,t){var n=t==null?"":typeof t=="object"?`${t}`:t;n!==(e.__t??=e.nodeValue)&&(e.__t=n,e.nodeValue=`${n}`)}function Il(e,t){return Ol(e,t)}const dr=new Map;function Ol(e,{target:t,anchor:n,props:i={},events:r,context:s,intro:a=!0,transformError:o}){ll();var l=void 0,f=dl(()=>{var u=n??t.appendChild(wt());Ja(u,{pending:()=>{}},v=>{ks({});var _=G;s&&(_.c=s),r&&(i.$$events=r),l=e(v,i)||{},ws()},o);var h=new Set,d=v=>{for(var _=0;_{for(var v of h)for(const b of[t,document]){var _=dr.get(b),T=_.get(v);--T==0?(b.removeEventListener(v,ii),_.delete(v),_.size===0&&dr.delete(b)):_.set(v,T)}ri.delete(d),u!==n&&u.parentNode?.removeChild(u)}});return Dl.set(l,f),l}let Dl=new WeakMap;class Ll{anchor;#n=new Map;#o=new Map;#e=new Map;#i=new Set;#r=!0;constructor(t,n=!0){this.anchor=t,this.#r=n}#s=t=>{if(this.#n.has(t)){var n=this.#n.get(t),i=this.#o.get(n);if(i)bi(i),this.#i.delete(n);else{var r=this.#e.get(n);r&&(this.#o.set(n,r.effect),this.#e.delete(n),r.fragment.lastChild.remove(),this.anchor.before(r.fragment),i=r.effect)}for(const[s,a]of this.#n){if(this.#n.delete(s),s===t)break;const o=this.#e.get(a);o&&(Te(o.effect),this.#e.delete(a))}for(const[s,a]of this.#o){if(s===n||this.#i.has(s))continue;const o=()=>{if(Array.from(this.#n.values()).includes(s)){var f=document.createDocumentFragment();ki(a,f),f.append(wt()),this.#e.set(s,{effect:a,fragment:f})}else Te(a);this.#i.delete(s),this.#o.delete(s)};this.#r||!i?(this.#i.add(s),Pt(a,o,!1)):o()}}};#t=t=>{this.#n.delete(t);const n=Array.from(this.#n.values());for(const[i,r]of this.#e)n.includes(i)||(Te(r.effect),this.#e.delete(i))};ensure(t,n){var i=O,r=$s();if(n&&!this.#o.has(t)&&!this.#e.has(t))if(r){var s=document.createDocumentFragment(),a=wt();s.append(a),this.#e.set(t,{effect:Le(()=>n(a)),fragment:s})}else this.#o.set(t,Le(()=>n(this.anchor)));if(this.#n.set(i,t),r){for(const[o,l]of this.#o)o===t?i.unskip_effect(l):i.skip_effect(l);for(const[o,l]of this.#e)o===t?i.unskip_effect(l.effect):i.skip_effect(l.effect);i.oncommit(this.#s),i.ondiscard(this.#t)}else this.#s(i)}}function gr(e,t,n=!1){var i=new Ll(e),r=n?dn:0;function s(a,o){i.ensure(a,o)}Dr(()=>{var a=!1;t((o,l=0)=>{a=!0,s(l,o)}),a||s(-1,null)},r)}function Br(e,t){return t}function Cl(e,t,n){for(var i=[],r=t.length,s,a=t.length,o=0;o{if(s){if(s.pending.delete(h),s.done.add(h),s.pending.size===0){var d=e.outrogroups;si(e,Rr(s.done)),d.delete(s),d.size===0&&(e.outrogroups=null)}}else a-=1},!1)}if(a===0){var l=i.length===0&&n!==null;if(l){var f=n,u=f.parentNode;cl(u),u.append(f),e.items.clear()}si(e,t,!l)}else s={pending:new Set(t),done:new Set},(e.outrogroups??=new Set).add(s)}function si(e,t,n=!0){var i;if(e.pending.size>0){i=new Set;for(const a of e.pending.values())for(const o of a)i.add(e.items.get(o).e)}for(var r=0;r{var N=n();return cs(N)?N:N==null?[]:Rr(N)}),d,v=new Map,_=!0;function T(N){(U.effect.f&Ne)===0&&(U.pending.delete(N),U.fallback=u,Nl(U,d,a,t,i),u!==null&&(d.length===0?(u.f&Ke)===0?bi(u):(u.f^=Ke,Un(u,null,a)):Pt(u,()=>{u=null})))}function b(N){U.pending.delete(N)}var S=Dr(()=>{d=p(h);for(var N=d.length,D=new Set,H=O,W=$s(),q=0;qs(a)):(u=Le(()=>s(Bi??=wt())),u.f|=Ke)),N>D.size&&Ta(),!_)if(v.set(H,D),W){for(const[re,ot]of o)D.has(re)||H.skip_effect(ot.e);H.oncommit(T),H.ondiscard(b)}else T(H);p(h)}),U={effect:S,items:o,pending:v,outrogroups:null,fallback:u};_=!1}function On(e){for(;e!==null&&(e.f&We)===0;)e=e.next;return e}function Nl(e,t,n,i,r){var s=(i&Ma)!==0,a=t.length,o=e.items,l=On(e.effect.first),f,u=null,h,d=[],v=[],_,T,b,S;if(s)for(S=0;S0){var ae=(i&ms)!==0&&a===0?n:null;if(s){for(S=0;S{if(h!==void 0)for(b of h)b.nodes?.a?.apply()})}function Ml(e,t,n,i,r,s,a,o){var l=(a&Ca)!==0?(a&Pa)===0?B(n,!1,!1):Ft(n):null,f=(a&Na)!==0?Ft(r):null;return{v:l,i:f,e:Le(()=>(s(t,l??n,f??r,o),()=>{e.delete(i)}))}}function Un(e,t,n){if(e.nodes)for(var i=e.nodes.start,r=e.nodes.end,s=t&&(t.f&Ke)===0?t.nodes.start:n;i!==null;){var a=Jn(i);if(s.before(i),i===r)return;i=a}}function mt(e,t,n){t===null?e.effect.first=n:t.next=n,n===null?e.effect.last=t:n.prev=t}function Hr(e,t,n=!1,i=!1,r=!1,s=!1){var a=e,o="";if(n)var l=e;Xe(()=>{var f=L;if(o!==(o=t()??"")){if(n){f.nodes=null,l.innerHTML=o,o!==""&&Tr(vt(l),l.lastChild);return}if(f.nodes!==null&&(Ws(f.nodes.start,f.nodes.end),f.nodes=null),o!==""){var u=i?Fa:r?Ua:void 0,h=Fs(i?"svg":r?"math":"template",u);h.innerHTML=o;var d=i||r?h:h.content;if(Tr(vt(d),d.lastChild),i||r)for(;vt(d);)a.before(vt(d));else a.before(d)}}})}function Pl(e,t){var n;n=document.head.appendChild(wt()),Dr(()=>t(n),hs|Gt)}const Hi=[...` +\r\f \v\uFEFF`];function zl(e,t,n){var i=e==null?"":""+e;if(n){for(var r of Object.keys(n))if(n[r])i=i?i+" "+r:r;else if(i.length)for(var s=r.length,a=0;(a=i.indexOf(r,a))>=0;){var o=a+s;(a===0||Hi.includes(i[a-1]))&&(o===i.length||Hi.includes(i[o]))?i=(a===0?"":i.substring(0,a))+i.substring(o+1):a=o}}return i===""?null:i}function Dn(e,t,n,i,r,s){var a=e.__className;if(a!==n||a===void 0){var o=zl(n,i,s);o==null?e.removeAttribute("class"):e.className=o,e.__className=n}else if(s&&r!==s)for(var l in s){var f=!!s[l];(r==null||f!==!!r[l])&&e.classList.toggle(l,f)}return s}const $l=Symbol("is custom element"),Fl=Symbol("is html");function Ul(e,t,n,i){var r=Bl(e);r[t]!==(r[t]=n)&&(n==null?e.removeAttribute(t):typeof n!="string"&&Hl(e).includes(t)?e[t]=n:e.setAttribute(t,n))}function Bl(e){return e.__attributes??={[$l]:e.nodeName.includes("-"),[Fl]:e.namespaceURI===_s}}var Gi=new Map;function Hl(e){var t=e.getAttribute("is")||e.nodeName,n=Gi.get(t);if(n)return n;Gi.set(t,n=[]);for(var i,r=e,s=Element.prototype;s!==r;){i=us(r);for(var a in i)i[a].set&&n.push(a);r=fi(r)}return n}function an(e,t,n=t){var i=new WeakSet;fl(e,"input",async r=>{var s=r?e.defaultValue:e.value;if(s=Gr(e)?Wr(s):s,n(s),O!==null&&i.add(O),await wl(),s!==(s=t())){var a=e.selectionStart,o=e.selectionEnd,l=e.value.length;if(e.value=s??"",o!==null){var f=e.value.length;a===o&&o===l&&f>l?(e.selectionStart=f,e.selectionEnd=f):(e.selectionStart=a,e.selectionEnd=Math.min(o,f))}}}),$(t)==null&&e.value&&(n(Gr(e)?Wr(e.value):e.value),O!==null&&i.add(O)),Or(()=>{var r=t();if(e===document.activeElement){var s=O;if(i.has(s))return}Gr(e)&&r===Wr(e.value)||e.type==="date"&&!r&&!e.value||r!==e.value&&(e.value=r??"")})}function Gr(e){var t=e.type;return t==="number"||t==="range"}function Wr(e){return e===""?null:+e}function Gl(e=!1){const t=G,n=t.l.u;if(!n)return;let i=()=>cn(t.s);if(e){let r=0,s={};const a=gi(()=>{let o=!1;const l=t.s;for(const f in l)l[f]!==s[f]&&(s[f]=l[f],o=!0);return o&&r++,r});i=()=>p(a)}n.b.length&&hl(()=>{Wi(t,i),Xr(n.b)}),ti(()=>{const r=$(()=>n.m.map(ya));return()=>{for(const s of r)typeof s=="function"&&s()}}),n.a.length&&ti(()=>{Wi(t,i),Xr(n.a)})}function Wi(e,t){if(e.l.s)for(const n of e.l.s)p(n);t()}function ta(e){G===null&&gs(),Qn&&G.l!==null?ql(G).m.push(e):ti(()=>{const t=$(e);if(typeof t=="function")return t})}function Wl(e){G===null&&gs(),ta(()=>()=>$(e))}function ql(e){var t=e.l;return t.u??={a:[],b:[],m:[]}}const jl="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(jl);qa();async function de(e,t={}){const n=new Headers(t.headers);n.set("Accept","application/json"),t.body&&!(t.body instanceof FormData)&&n.set("Content-Type","application/json");const i=await fetch(e,{...t,headers:n});if(!i.ok)throw new Error(await i.text());return i.json()}const{entries:na,setPrototypeOf:qi,isFrozen:Yl,getPrototypeOf:Zl,getOwnPropertyDescriptor:Vl}=Object;let{freeze:ve,seal:$e,create:un}=Object,{apply:ai,construct:li}=typeof Reflect<"u"&&Reflect;ve||(ve=function(t){return t});$e||($e=function(t){return t});ai||(ai=function(t,n){for(var i=arguments.length,r=new Array(i>2?i-2:0),s=2;s1?n-1:0),r=1;r"u"?null:ne(BigInt.prototype.toString),Xi=typeof Symbol>"u"?null:ne(Symbol.prototype.toString),X=ne(Object.prototype.hasOwnProperty),Nn=ne(Object.prototype.toString),ce=ne(RegExp.prototype.test),mr=to(TypeError);function ne(e){return function(t){t instanceof RegExp&&(t.lastIndex=0);for(var n=arguments.length,i=new Array(n>1?n-1:0),r=1;r2&&arguments[2]!==void 0?arguments[2]:Bn;if(qi&&qi(e,null),!ge(t))return e;let i=t.length;for(;i--;){let r=t[i];if(typeof r=="string"){const s=n(r);s!==r&&(Yl(t)||(t[i]=s),r=s)}e[r]=!0}return e}function no(e){for(let t=0;t/gm),co=$e(/\$\{[\w\W]*/gm),uo=$e(/^data-[\-\w.\u00B7-\uFFFF]+$/),fo=$e(/^aria-[\-\w]+$/),ra=$e(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp|matrix):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i),po=$e(/^(?:\w+script|data):/i),ho=$e(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g),ia=$e(/^html$/i),go=$e(/^[a-z][.\w]*(-[.\w]+)+$/i);var ts=Object.freeze({__proto__:null,ARIA_ATTR:fo,ATTR_WHITESPACE:ho,CUSTOM_ELEMENT:go,DATA_ATTR:uo,DOCTYPE_NAME:ia,ERB_EXPR:oo,IS_ALLOWED_URI:ra,IS_SCRIPT_OR_DATA:po,MUSTACHE_EXPR:lo,TMPLIT_EXPR:co});const Mn={element:1,text:3,progressingInstruction:7,comment:8,document:9},mo=function(){return typeof window>"u"?null:window},_o=function(t,n){if(typeof t!="object"||typeof t.createPolicy!="function")return null;let i=null;const r="data-tt-policy-suffix";n&&n.hasAttribute(r)&&(i=n.getAttribute(r));const s="dompurify"+(i?"#"+i:"");try{return t.createPolicy(s,{createHTML(a){return a},createScriptURL(a){return a}})}catch{return console.warn("TrustedTypes policy "+s+" could not be created."),null}},ns=function(){return{afterSanitizeAttributes:[],afterSanitizeElements:[],afterSanitizeShadowDOM:[],beforeSanitizeAttributes:[],beforeSanitizeElements:[],beforeSanitizeShadowDOM:[],uponSanitizeAttribute:[],uponSanitizeElement:[],uponSanitizeShadowNode:[]}};function sa(){let e=arguments.length>0&&arguments[0]!==void 0?arguments[0]:mo();const t=x=>sa(x);if(t.version="3.4.2",t.removed=[],!e||!e.document||e.document.nodeType!==Mn.document||!e.Element)return t.isSupported=!1,t;let{document:n}=e;const i=n,r=i.currentScript,{DocumentFragment:s,HTMLTemplateElement:a,Node:o,Element:l,NodeFilter:f,NamedNodeMap:u=e.NamedNodeMap||e.MozNamedAttrMap,HTMLFormElement:h,DOMParser:d,trustedTypes:v}=e,_=l.prototype,T=fn(_,"cloneNode"),b=fn(_,"remove"),S=fn(_,"nextSibling"),U=fn(_,"childNodes"),N=fn(_,"parentNode");if(typeof a=="function"){const x=n.createElement("template");x.content&&x.content.ownerDocument&&(n=x.content.ownerDocument)}let D,H="";const{implementation:W,createNodeIterator:q,createDocumentFragment:be,getElementsByTagName:ae}=n,{importNode:Z}=i;let re=ns();t.isSupported=typeof na=="function"&&typeof N=="function"&&W&&W.createHTMLDocument!==void 0;const{MUSTACHE_EXPR:ot,ERB_EXPR:qt,TMPLIT_EXPR:et,DATA_ATTR:Nr,ARIA_ATTR:je,IS_SCRIPT_OR_DATA:tr,ATTR_WHITESPACE:bn,CUSTOM_ELEMENT:nr}=ts;let{IS_ALLOWED_URI:rr}=ts,K=null;const kn=R({},[...Qi,...jr,...Yr,...Zr,...Ki]);let ie=null;const Et=R({},[...Ji,...Vr,...es,..._r]);let j=Object.seal(un(null,{tagNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeNameCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},allowCustomizedBuiltInElements:{writable:!0,configurable:!1,enumerable:!0,value:!1}})),ct=null,St=null;const Fe=Object.seal(un(null,{tagCheck:{writable:!0,configurable:!1,enumerable:!0,value:null},attributeCheck:{writable:!0,configurable:!1,enumerable:!0,value:null}}));let ir=!0,wn=!0,sr=!1,yn=!0,Ye=!1,ut=!0,tt=!1,jt=!1,Yt=!1,nt=!1,Zt=!1,Vt=!1,xn=!0,Tn=!1;const ar="user-content-";let Xt=!0,ft=!1,rt={},Ee=null;const En=R({},["annotation-xml","audio","colgroup","desc","foreignobject","head","iframe","math","mi","mn","mo","ms","mtext","noembed","noframes","noscript","plaintext","script","style","svg","template","thead","title","video","xmp"]);let lr=null;const or=R({},["audio","video","img","source","image","track"]);let At=null;const cr=R({},["alt","class","for","id","label","name","pattern","placeholder","role","summary","title","value","style","xmlns"]),Rt="http://www.w3.org/1998/Math/MathML",Qt="http://www.w3.org/2000/svg",Se="http://www.w3.org/1999/xhtml";let pt=Se,Kt=!1,Jt=null;const Mr=R({},[Rt,Qt,Se],qr);let Sn=R({},["mi","mo","mn","ms","mtext"]),en=R({},["annotation-xml"]);const ur=R({},["title","style","font","a","script"]);let It=null;const fr=["application/xhtml+xml","text/html"],Pr="text/html";let V=null,ht=null;const zr=n.createElement("form"),An=function(c){return c instanceof RegExp||c instanceof Function},tn=function(){let c=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};if(ht&&ht===c)return;(!c||typeof c!="object")&&(c={}),c=ye(c),It=fr.indexOf(c.PARSER_MEDIA_TYPE)===-1?Pr:c.PARSER_MEDIA_TYPE,V=It==="application/xhtml+xml"?qr:Bn,K=X(c,"ALLOWED_TAGS")&&ge(c.ALLOWED_TAGS)?R({},c.ALLOWED_TAGS,V):kn,ie=X(c,"ALLOWED_ATTR")&&ge(c.ALLOWED_ATTR)?R({},c.ALLOWED_ATTR,V):Et,Jt=X(c,"ALLOWED_NAMESPACES")&&ge(c.ALLOWED_NAMESPACES)?R({},c.ALLOWED_NAMESPACES,qr):Mr,At=X(c,"ADD_URI_SAFE_ATTR")&&ge(c.ADD_URI_SAFE_ATTR)?R(ye(cr),c.ADD_URI_SAFE_ATTR,V):cr,lr=X(c,"ADD_DATA_URI_TAGS")&&ge(c.ADD_DATA_URI_TAGS)?R(ye(or),c.ADD_DATA_URI_TAGS,V):or,Ee=X(c,"FORBID_CONTENTS")&&ge(c.FORBID_CONTENTS)?R({},c.FORBID_CONTENTS,V):En,ct=X(c,"FORBID_TAGS")&&ge(c.FORBID_TAGS)?R({},c.FORBID_TAGS,V):ye({}),St=X(c,"FORBID_ATTR")&&ge(c.FORBID_ATTR)?R({},c.FORBID_ATTR,V):ye({}),rt=X(c,"USE_PROFILES")?c.USE_PROFILES&&typeof c.USE_PROFILES=="object"?ye(c.USE_PROFILES):c.USE_PROFILES:!1,ir=c.ALLOW_ARIA_ATTR!==!1,wn=c.ALLOW_DATA_ATTR!==!1,sr=c.ALLOW_UNKNOWN_PROTOCOLS||!1,yn=c.ALLOW_SELF_CLOSE_IN_ATTR!==!1,Ye=c.SAFE_FOR_TEMPLATES||!1,ut=c.SAFE_FOR_XML!==!1,tt=c.WHOLE_DOCUMENT||!1,nt=c.RETURN_DOM||!1,Zt=c.RETURN_DOM_FRAGMENT||!1,Vt=c.RETURN_TRUSTED_TYPE||!1,Yt=c.FORCE_BODY||!1,xn=c.SANITIZE_DOM!==!1,Tn=c.SANITIZE_NAMED_PROPS||!1,Xt=c.KEEP_CONTENT!==!1,ft=c.IN_PLACE||!1,rr=io(c.ALLOWED_URI_REGEXP)?c.ALLOWED_URI_REGEXP:ra,pt=typeof c.NAMESPACE=="string"?c.NAMESPACE:Se,Sn=X(c,"MATHML_TEXT_INTEGRATION_POINTS")&&c.MATHML_TEXT_INTEGRATION_POINTS&&typeof c.MATHML_TEXT_INTEGRATION_POINTS=="object"?ye(c.MATHML_TEXT_INTEGRATION_POINTS):R({},["mi","mo","mn","ms","mtext"]),en=X(c,"HTML_INTEGRATION_POINTS")&&c.HTML_INTEGRATION_POINTS&&typeof c.HTML_INTEGRATION_POINTS=="object"?ye(c.HTML_INTEGRATION_POINTS):R({},["annotation-xml"]);const m=X(c,"CUSTOM_ELEMENT_HANDLING")&&c.CUSTOM_ELEMENT_HANDLING&&typeof c.CUSTOM_ELEMENT_HANDLING=="object"?ye(c.CUSTOM_ELEMENT_HANDLING):un(null);if(j=un(null),X(m,"tagNameCheck")&&An(m.tagNameCheck)&&(j.tagNameCheck=m.tagNameCheck),X(m,"attributeNameCheck")&&An(m.attributeNameCheck)&&(j.attributeNameCheck=m.attributeNameCheck),X(m,"allowCustomizedBuiltInElements")&&typeof m.allowCustomizedBuiltInElements=="boolean"&&(j.allowCustomizedBuiltInElements=m.allowCustomizedBuiltInElements),Ye&&(wn=!1),Zt&&(nt=!0),rt&&(K=R({},Ki),ie=un(null),rt.html===!0&&(R(K,Qi),R(ie,Ji)),rt.svg===!0&&(R(K,jr),R(ie,Vr),R(ie,_r)),rt.svgFilters===!0&&(R(K,Yr),R(ie,Vr),R(ie,_r)),rt.mathMl===!0&&(R(K,Zr),R(ie,es),R(ie,_r))),Fe.tagCheck=null,Fe.attributeCheck=null,X(c,"ADD_TAGS")&&(typeof c.ADD_TAGS=="function"?Fe.tagCheck=c.ADD_TAGS:ge(c.ADD_TAGS)&&(K===kn&&(K=ye(K)),R(K,c.ADD_TAGS,V))),X(c,"ADD_ATTR")&&(typeof c.ADD_ATTR=="function"?Fe.attributeCheck=c.ADD_ATTR:ge(c.ADD_ATTR)&&(ie===Et&&(ie=ye(ie)),R(ie,c.ADD_ATTR,V))),X(c,"ADD_URI_SAFE_ATTR")&&ge(c.ADD_URI_SAFE_ATTR)&&R(At,c.ADD_URI_SAFE_ATTR,V),X(c,"FORBID_CONTENTS")&&ge(c.FORBID_CONTENTS)&&(Ee===En&&(Ee=ye(Ee)),R(Ee,c.FORBID_CONTENTS,V)),X(c,"ADD_FORBID_CONTENTS")&&ge(c.ADD_FORBID_CONTENTS)&&(Ee===En&&(Ee=ye(Ee)),R(Ee,c.ADD_FORBID_CONTENTS,V)),Xt&&(K["#text"]=!0),tt&&R(K,["html","head","body"]),K.table&&(R(K,["tbody"]),delete ct.tbody),c.TRUSTED_TYPES_POLICY){if(typeof c.TRUSTED_TYPES_POLICY.createHTML!="function")throw mr('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.');if(typeof c.TRUSTED_TYPES_POLICY.createScriptURL!="function")throw mr('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.');D=c.TRUSTED_TYPES_POLICY,H=D.createHTML("")}else D===void 0&&(D=_o(v,r)),D!==null&&typeof H=="string"&&(H=D.createHTML(""));ve&&ve(c),ht=c},pr=R({},[...jr,...Yr,...so]),hr=R({},[...Zr,...ao]),$r=function(c){let m=N(c);(!m||!m.tagName)&&(m={namespaceURI:pt,tagName:"template"});const w=Bn(c.tagName),z=Bn(m.tagName);return Jt[c.namespaceURI]?c.namespaceURI===Qt?m.namespaceURI===Se?w==="svg":m.namespaceURI===Rt?w==="svg"&&(z==="annotation-xml"||Sn[z]):!!pr[w]:c.namespaceURI===Rt?m.namespaceURI===Se?w==="math":m.namespaceURI===Qt?w==="math"&&en[z]:!!hr[w]:c.namespaceURI===Se?m.namespaceURI===Qt&&!en[z]||m.namespaceURI===Rt&&!Sn[z]?!1:!hr[w]&&(ur[w]||!pr[w]):!!(It==="application/xhtml+xml"&&Jt[c.namespaceURI]):!1},g=function(c){Cn(t.removed,{element:c});try{N(c).removeChild(c)}catch{b(c)}},k=function(c,m){try{Cn(t.removed,{attribute:m.getAttributeNode(c),from:m})}catch{Cn(t.removed,{attribute:null,from:m})}if(m.removeAttribute(c),c==="is")if(nt||Zt)try{g(m)}catch{}else try{m.setAttribute(c,"")}catch{}},A=function(c){let m=null,w=null;if(Yt)c=""+c;else{const Y=Yi(c,/^[\r\n\t ]+/);w=Y&&Y[0]}It==="application/xhtml+xml"&&pt===Se&&(c=''+c+"");const z=D?D.createHTML(c):c;if(pt===Se)try{m=new d().parseFromString(z,It)}catch{}if(!m||!m.documentElement){m=W.createDocument(pt,"template",null);try{m.documentElement.innerHTML=Kt?H:z}catch{}}const se=m.body||m.documentElement;return c&&w&&se.insertBefore(n.createTextNode(w),se.childNodes[0]||null),pt===Se?ae.call(m,tt?"html":"body")[0]:tt?m.documentElement:se},C=function(c){return q.call(c.ownerDocument||c,c,f.SHOW_ELEMENT|f.SHOW_COMMENT|f.SHOW_TEXT|f.SHOW_PROCESSING_INSTRUCTION|f.SHOW_CDATA_SECTION,null)},J=function(c){return c instanceof h&&(typeof c.nodeName!="string"||typeof c.textContent!="string"||typeof c.removeChild!="function"||!(c.attributes instanceof u)||typeof c.removeAttribute!="function"||typeof c.setAttribute!="function"||typeof c.namespaceURI!="string"||typeof c.insertBefore!="function"||typeof c.hasChildNodes!="function")},ke=function(c){return typeof o=="function"&&c instanceof o};function he(x,c,m){Ln(x,w=>{w.call(t,c,m,ht)})}const Ze=function(c){let m=null;if(he(re.beforeSanitizeElements,c,null),J(c))return g(c),!0;const w=V(c.nodeName);if(he(re.uponSanitizeElement,c,{tagName:w,allowedTags:K}),ut&&c.hasChildNodes()&&!ke(c.firstElementChild)&&ce(/<[/\w!]/g,c.innerHTML)&&ce(/<[/\w!]/g,c.textContent)||ut&&c.namespaceURI===Se&&w==="style"&&ke(c.firstElementChild)||c.nodeType===Mn.progressingInstruction||ut&&c.nodeType===Mn.comment&&ce(/<[/\w]/g,c.data))return g(c),!0;if(ct[w]||!(Fe.tagCheck instanceof Function&&Fe.tagCheck(w))&&!K[w]){if(!ct[w]&&nn(w)&&(j.tagNameCheck instanceof RegExp&&ce(j.tagNameCheck,w)||j.tagNameCheck instanceof Function&&j.tagNameCheck(w)))return!1;if(Xt&&!Ee[w]){const z=N(c)||c.parentNode,se=U(c)||c.childNodes;if(se&&z){const Y=se.length;for(let fe=Y-1;fe>=0;--fe){const we=T(se[fe],!0);z.insertBefore(we,S(c))}}}return g(c),!0}return c instanceof l&&!$r(c)||(w==="noscript"||w==="noembed"||w==="noframes")&&ce(/<\/no(script|embed|frames)/i,c.innerHTML)?(g(c),!0):(Ye&&c.nodeType===Mn.text&&(m=c.textContent,Ln([ot,qt,et],z=>{m=ln(m,z," ")}),c.textContent!==m&&(Cn(t.removed,{element:c.cloneNode()}),c.textContent=m)),he(re.afterSanitizeElements,c,null),!1)},Ve=function(c,m,w){if(St[m]||xn&&(m==="id"||m==="name")&&(w in n||w in zr))return!1;const z=ie[m]||Fe.attributeCheck instanceof Function&&Fe.attributeCheck(m,c);if(!(wn&&!St[m]&&ce(Nr,m))){if(!(ir&&ce(je,m))){if(!z||St[m]){if(!(nn(c)&&(j.tagNameCheck instanceof RegExp&&ce(j.tagNameCheck,c)||j.tagNameCheck instanceof Function&&j.tagNameCheck(c))&&(j.attributeNameCheck instanceof RegExp&&ce(j.attributeNameCheck,m)||j.attributeNameCheck instanceof Function&&j.attributeNameCheck(m,c))||m==="is"&&j.allowCustomizedBuiltInElements&&(j.tagNameCheck instanceof RegExp&&ce(j.tagNameCheck,w)||j.tagNameCheck instanceof Function&&j.tagNameCheck(w))))return!1}else if(!At[m]){if(!ce(rr,ln(w,bn,""))){if(!((m==="src"||m==="xlink:href"||m==="href")&&c!=="script"&&Zi(w,"data:")===0&&lr[c])){if(!(sr&&!ce(tr,ln(w,bn,"")))){if(w)return!1}}}}}}return!0},Rn=R({},["annotation-xml","color-profile","font-face","font-face-format","font-face-name","font-face-src","font-face-uri","missing-glyph"]),nn=function(c){return!Rn[Bn(c)]&&ce(nr,c)},Ot=function(c){he(re.beforeSanitizeAttributes,c,null);const{attributes:m}=c;if(!m||J(c))return;const w={attrName:"",attrValue:"",keepAttr:!0,allowedAttributes:ie,forceKeepAttr:void 0};let z=m.length;for(;z--;){const se=m[z],{name:Y,namespaceURI:fe,value:we}=se,Ae=V(Y),In=we;let ee=Y==="value"?In:Kl(In);if(w.attrName=Ae,w.attrValue=ee,w.keepAttr=!0,w.forceKeepAttr=void 0,he(re.uponSanitizeAttribute,c,w),ee=w.attrValue,Tn&&(Ae==="id"||Ae==="name")&&Zi(ee,ar)!==0&&(k(Y,c),ee=ar+ee),ut&&ce(/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i,ee)){k(Y,c);continue}if(Ae==="attributename"&&Yi(ee,"href")){k(Y,c);continue}if(w.forceKeepAttr)continue;if(!w.keepAttr){k(Y,c);continue}if(!yn&&ce(/\/>/i,ee)){k(Y,c);continue}Ye&&Ln([ot,qt,et],Di=>{ee=ln(ee,Di," ")});const Oi=V(c.nodeName);if(!Ve(Oi,Ae,ee)){k(Y,c);continue}if(D&&typeof v=="object"&&typeof v.getAttributeType=="function"&&!fe)switch(v.getAttributeType(Oi,Ae)){case"TrustedHTML":{ee=D.createHTML(ee);break}case"TrustedScriptURL":{ee=D.createScriptURL(ee);break}}if(ee!==In)try{fe?c.setAttributeNS(fe,Y,ee):c.setAttribute(Y,ee),J(c)?g(c):ji(t.removed)}catch{k(Y,c)}}he(re.afterSanitizeAttributes,c,null)},dt=function(c){let m=null;const w=C(c);for(he(re.beforeSanitizeShadowDOM,c,null);m=w.nextNode();)he(re.uponSanitizeShadowNode,m,null),Ze(m),Ot(m),m.content instanceof s&&dt(m.content);he(re.afterSanitizeShadowDOM,c,null)};return t.sanitize=function(x){let c=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{},m=null,w=null,z=null,se=null;if(Kt=!x,Kt&&(x=""),typeof x!="string"&&!ke(x)&&(x=ro(x),typeof x!="string"))throw mr("dirty is not a string, aborting");if(!t.isSupported)return x;if(jt||tn(c),t.removed=[],typeof x=="string"&&(ft=!1),ft){const we=x.nodeName;if(typeof we=="string"){const Ae=V(we);if(!K[Ae]||ct[Ae])throw mr("root node is forbidden and cannot be sanitized in-place")}}else if(x instanceof o)m=A(""),w=m.ownerDocument.importNode(x,!0),w.nodeType===Mn.element&&w.nodeName==="BODY"||w.nodeName==="HTML"?m=w:m.appendChild(w);else{if(!nt&&!Ye&&!tt&&x.indexOf("<")===-1)return D&&Vt?D.createHTML(x):x;if(m=A(x),!m)return nt?null:Vt?H:""}m&&Yt&&g(m.firstChild);const Y=C(ft?x:m);for(;z=Y.nextNode();)Ze(z),Ot(z),z.content instanceof s&&dt(z.content);if(ft)return x;if(nt){if(Ye){m.normalize();let we=m.innerHTML;Ln([ot,qt,et],Ae=>{we=ln(we,Ae," ")}),m.innerHTML=we}if(Zt)for(se=be.call(m.ownerDocument);m.firstChild;)se.appendChild(m.firstChild);else se=m;return(ie.shadowroot||ie.shadowrootmode)&&(se=Z.call(i,se,!0)),se}let fe=tt?m.outerHTML:m.innerHTML;return tt&&K["!doctype"]&&m.ownerDocument&&m.ownerDocument.doctype&&m.ownerDocument.doctype.name&&ce(ia,m.ownerDocument.doctype.name)&&(fe=" +`+fe),Ye&&Ln([ot,qt,et],we=>{fe=ln(fe,we," ")}),D&&Vt?D.createHTML(fe):fe},t.setConfig=function(){let x=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{};tn(x),jt=!0},t.clearConfig=function(){ht=null,jt=!1},t.isValidAttribute=function(x,c,m){ht||tn({});const w=V(x),z=V(c);return Ve(w,z,m)},t.addHook=function(x,c){typeof c=="function"&&Cn(re[x],c)},t.removeHook=function(x,c){if(c!==void 0){const m=Xl(re[x],c);return m===-1?void 0:Ql(re[x],m,1)[0]}return ji(re[x])},t.removeHooks=function(x){re[x]=[]},t.removeAllHooks=function(){re=ns()},t}var vo=sa();function wi(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var Wt=wi();function aa(e){Wt=e}var Lt={exec:()=>null};function M(e,t=""){let n=typeof e=="string"?e:e.source,i={replace:(r,s)=>{let a=typeof s=="string"?s:s.source;return a=a.replace(me.caret,"$1"),n=n.replace(r,a),i},getRegex:()=>new RegExp(n,t)};return i}var bo=(()=>{try{return!!new RegExp("(?<=1)(?/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] +\S/,listReplaceTask:/^\[[ xX]\] +/,listTaskCheckbox:/\[[ xX]\]/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i"),blockquoteBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}>`)},ko=/^(?:[ \t]*(?:\n|$))+/,wo=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,yo=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,er=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,xo=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,yi=/ {0,3}(?:[*+-]|\d{1,9}[.)])/,la=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,oa=M(la).replace(/bull/g,yi).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),To=M(la).replace(/bull/g,yi).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),xi=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Eo=/^[^\n]+/,Ti=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,So=M(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",Ti).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ao=M(/^(bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,yi).getRegex(),Lr="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",Ei=/|$))/,Ro=M("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",Ei).replace("tag",Lr).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),ca=M(xi).replace("hr",er).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Lr).getRegex(),Io=M(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",ca).getRegex(),Si={blockquote:Io,code:wo,def:So,fences:yo,heading:xo,hr:er,html:Ro,lheading:oa,list:Ao,newline:ko,paragraph:ca,table:Lt,text:Eo},rs=M("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",er).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Lr).getRegex(),Oo={...Si,lheading:To,table:rs,paragraph:M(xi).replace("hr",er).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",rs).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",Lr).getRegex()},Do={...Si,html:M(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",Ei).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:Lt,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:M(xi).replace("hr",er).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",oa).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Lo=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,Co=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ua=/^( {2,}|\\)\n(?!\s*$)/,No=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",bo?"(?`+)[^`]+\k(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),pa=/^(?:\*+(?:((?!\*)punct)|([^\s*]))?)|^_+(?:((?!_)punct)|([^\s_]))?/,Fo=M(pa,"u").replace(/punct/g,vn).getRegex(),Uo=M(pa,"u").replace(/punct/g,fa).getRegex(),ha="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Bo=M(ha,"gu").replace(/notPunctSpace/g,Ai).replace(/punctSpace/g,Cr).replace(/punct/g,vn).getRegex(),Ho=M(ha,"gu").replace(/notPunctSpace/g,zo).replace(/punctSpace/g,Po).replace(/punct/g,fa).getRegex(),Go=M("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,Ai).replace(/punctSpace/g,Cr).replace(/punct/g,vn).getRegex(),Wo=M(/^~~?(?:((?!~)punct)|[^\s~])/,"u").replace(/punct/g,vn).getRegex(),qo="^[^~]+(?=[^~])|(?!~)punct(~~?)(?=[\\s]|$)|notPunctSpace(~~?)(?!~)(?=punctSpace|$)|(?!~)punctSpace(~~?)(?=notPunctSpace)|[\\s](~~?)(?!~)(?=punct)|(?!~)punct(~~?)(?!~)(?=punct)|notPunctSpace(~~?)(?=notPunctSpace)",jo=M(qo,"gu").replace(/notPunctSpace/g,Ai).replace(/punctSpace/g,Cr).replace(/punct/g,vn).getRegex(),Yo=M(/\\(punct)/,"gu").replace(/punct/g,vn).getRegex(),Zo=M(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[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])?)+(?![-_])/).getRegex(),Vo=M(Ei).replace("(?:-->|$)","-->").getRegex(),Xo=M("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",Vo).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),Er=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+(?!`)[^`]*?`+(?!`)|``+(?=\])|[^\[\]\\`])*?/,Qo=M(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]+(?:\n[ \t]*)?|\n[ \t]*)(title))?\s*\)/).replace("label",Er).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),da=M(/^!?\[(label)\]\[(ref)\]/).replace("label",Er).replace("ref",Ti).getRegex(),ga=M(/^!?\[(ref)\](?:\[\])?/).replace("ref",Ti).getRegex(),Ko=M("reflink|nolink(?!\\()","g").replace("reflink",da).replace("nolink",ga).getRegex(),is=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,Ri={_backpedal:Lt,anyPunctuation:Yo,autolink:Zo,blockSkip:$o,br:ua,code:Co,del:Lt,delLDelim:Lt,delRDelim:Lt,emStrongLDelim:Fo,emStrongRDelimAst:Bo,emStrongRDelimUnd:Go,escape:Lo,link:Qo,nolink:ga,punctuation:Mo,reflink:da,reflinkSearch:Ko,tag:Xo,text:No,url:Lt},Jo={...Ri,link:M(/^!?\[(label)\]\((.*?)\)/).replace("label",Er).getRegex(),reflink:M(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",Er).getRegex()},oi={...Ri,emStrongRDelimAst:Ho,emStrongLDelim:Uo,delLDelim:Wo,delRDelim:jo,url:M(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",is).replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:M(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},ss=e=>tc[e];function Qe(e,t){if(t){if(me.escapeTest.test(e))return e.replace(me.escapeReplace,ss)}else if(me.escapeTestNoEncode.test(e))return e.replace(me.escapeReplaceNoEncode,ss);return e}function as(e){try{e=encodeURI(e).replace(me.percentDecode,"%")}catch{return null}return e}function ls(e,t){let n=e.replace(me.findPipe,(s,a,o)=>{let l=!1,f=a;for(;--f>=0&&o[f]==="\\";)l=!l;return l?"|":" |"}),i=n.split(me.splitPipe),r=0;if(i[0].trim()||i.shift(),i.length>0&&!i.at(-1)?.trim()&&i.pop(),t)if(i.length>t)i.splice(t);else for(;i.length0?-2:-1}function rc(e,t=0){let n=t,i="";for(let r of e)if(r===" "){let s=4-n%4;i+=" ".repeat(s),n+=s}else i+=r,n++;return i}function os(e,t,n,i,r){let s=t.href,a=t.title||null,o=e[1].replace(r.other.outputLinkReplace,"$1");i.state.inLink=!0;let l={type:e[0].charAt(0)==="!"?"image":"link",raw:n,href:s,title:a,text:o,tokens:i.inlineTokens(o)};return i.state.inLink=!1,l}function ic(e,t,n){let i=e.match(n.other.indentCodeCompensation);if(i===null)return t;let r=i[1];return t.split(` +`).map(s=>{let a=s.match(n.other.beginningSpace);if(a===null)return s;let[o]=a;return o.length>=r.length?s.slice(r.length):s}).join(` +`)}var Sr=class{options;rules;lexer;constructor(e){this.options=e||Wt}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:zn(n,` +`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],i=ic(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:i}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let i=zn(n,"#");(this.options.pedantic||!i||this.rules.other.endingSpaceChar.test(i))&&(n=i.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:zn(t[0],` +`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=zn(t[0],` +`).split(` +`),i="",r="",s=[];for(;n.length>0;){let a=!1,o=[],l;for(l=0;l1,r={type:"list",raw:"",ordered:i,start:i?+n.slice(0,-1):"",loose:!1,items:[]};n=i?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=i?n:"[*+-]");let s=this.rules.other.listItemRegex(n),a=!1;for(;e;){let l=!1,f="",u="";if(!(t=s.exec(e))||this.rules.block.hr.test(e))break;f=t[0],e=e.substring(f.length);let h=rc(t[2].split(` +`,1)[0],t[1].length),d=e.split(` +`,1)[0],v=!h.trim(),_=0;if(this.options.pedantic?(_=2,u=h.trimStart()):v?_=t[1].length+1:(_=h.search(this.rules.other.nonSpaceChar),_=_>4?1:_,u=h.slice(_),_+=t[1].length),v&&this.rules.other.blankLine.test(d)&&(f+=d+` +`,e=e.substring(d.length+1),l=!0),!l){let T=this.rules.other.nextBulletRegex(_),b=this.rules.other.hrRegex(_),S=this.rules.other.fencesBeginRegex(_),U=this.rules.other.headingBeginRegex(_),N=this.rules.other.htmlBeginRegex(_),D=this.rules.other.blockquoteBeginRegex(_);for(;e;){let H=e.split(` +`,1)[0],W;if(d=H,this.options.pedantic?(d=d.replace(this.rules.other.listReplaceNesting," "),W=d):W=d.replace(this.rules.other.tabCharGlobal," "),S.test(d)||U.test(d)||N.test(d)||D.test(d)||T.test(d)||b.test(d))break;if(W.search(this.rules.other.nonSpaceChar)>=_||!d.trim())u+=` +`+W.slice(_);else{if(v||h.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||S.test(h)||U.test(h)||b.test(h))break;u+=` +`+d}v=!d.trim(),f+=H+` +`,e=e.substring(H.length+1),h=W.slice(_)}}r.loose||(a?r.loose=!0:this.rules.other.doubleBlankLine.test(f)&&(a=!0)),r.items.push({type:"list_item",raw:f,task:!!this.options.gfm&&this.rules.other.listIsTask.test(u),loose:!1,text:u,tokens:[]}),r.raw+=f}let o=r.items.at(-1);if(o)o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd();else return;r.raw=r.raw.trimEnd();for(let l of r.items){if(this.lexer.state.top=!1,l.tokens=this.lexer.blockTokens(l.text,[]),l.task){if(l.text=l.text.replace(this.rules.other.listReplaceTask,""),l.tokens[0]?.type==="text"||l.tokens[0]?.type==="paragraph"){l.tokens[0].raw=l.tokens[0].raw.replace(this.rules.other.listReplaceTask,""),l.tokens[0].text=l.tokens[0].text.replace(this.rules.other.listReplaceTask,"");for(let u=this.lexer.inlineQueue.length-1;u>=0;u--)if(this.rules.other.listIsTask.test(this.lexer.inlineQueue[u].src)){this.lexer.inlineQueue[u].src=this.lexer.inlineQueue[u].src.replace(this.rules.other.listReplaceTask,"");break}}let f=this.rules.other.listTaskCheckbox.exec(l.raw);if(f){let u={type:"checkbox",raw:f[0]+" ",checked:f[0]!=="[ ]"};l.checked=u.checked,r.loose?l.tokens[0]&&["paragraph","text"].includes(l.tokens[0].type)&&"tokens"in l.tokens[0]&&l.tokens[0].tokens?(l.tokens[0].raw=u.raw+l.tokens[0].raw,l.tokens[0].text=u.raw+l.tokens[0].text,l.tokens[0].tokens.unshift(u)):l.tokens.unshift({type:"paragraph",raw:u.raw,text:u.raw,tokens:[u]}):l.tokens.unshift(u)}}if(!r.loose){let f=l.tokens.filter(h=>h.type==="space"),u=f.length>0&&f.some(h=>this.rules.other.anyLine.test(h.raw));r.loose=u}}if(r.loose)for(let l of r.items){l.loose=!0;for(let f of l.tokens)f.type==="text"&&(f.type="paragraph")}return r}}html(e){let t=this.rules.block.html.exec(e);if(t)return{type:"html",block:!0,raw:t[0],pre:t[1]==="pre"||t[1]==="script"||t[1]==="style",text:t[0]}}def(e){let t=this.rules.block.def.exec(e);if(t){let n=t[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),i=t[2]?t[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",r=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:n,raw:t[0],href:i,title:r}}}table(e){let t=this.rules.block.table.exec(e);if(!t||!this.rules.other.tableDelimiter.test(t[2]))return;let n=ls(t[1]),i=t[2].replace(this.rules.other.tableAlignChars,"").split("|"),r=t[3]?.trim()?t[3].replace(this.rules.other.tableRowBlankLine,"").split(` +`):[],s={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===i.length){for(let a of i)this.rules.other.tableAlignRight.test(a)?s.align.push("right"):this.rules.other.tableAlignCenter.test(a)?s.align.push("center"):this.rules.other.tableAlignLeft.test(a)?s.align.push("left"):s.align.push(null);for(let a=0;a({text:o,tokens:this.lexer.inline(o),header:!1,align:s.align[l]})));return s}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t){let n=t[1].trim();return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:n,tokens:this.lexer.inline(n)}}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===` +`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let s=zn(n.slice(0,-1),"\\");if((n.length-s.length)%2===0)return}else{let s=nc(t[2],"()");if(s===-2)return;if(s>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+s;t[2]=t[2].substring(0,s),t[0]=t[0].substring(0,a).trim(),t[3]=""}}let i=t[2],r="";if(this.options.pedantic){let s=this.rules.other.pedanticHrefTitle.exec(i);s&&(i=s[1],r=s[3])}else r=t[3]?t[3].slice(1,-1):"";return i=i.trim(),this.rules.other.startAngleBracket.test(i)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?i=i.slice(1):i=i.slice(1,-1)),os(t,{href:i&&i.replace(this.rules.inline.anyPunctuation,"$1"),title:r&&r.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let i=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),r=t[i.toLowerCase()];if(!r){let s=n[0].charAt(0);return{type:"text",raw:s,text:s}}return os(n,r,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let i=this.rules.inline.emStrongLDelim.exec(e);if(!(!i||!i[1]&&!i[2]&&!i[3]&&!i[4]||i[4]&&n.match(this.rules.other.unicodeAlphaNumeric))&&(!(i[1]||i[3])||!n||this.rules.inline.punctuation.exec(n))){let r=[...i[0]].length-1,s,a,o=r,l=0,f=i[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(f.lastIndex=0,t=t.slice(-1*e.length+r);(i=f.exec(t))!==null;){if(s=i[1]||i[2]||i[3]||i[4]||i[5]||i[6],!s)continue;if(a=[...s].length,i[3]||i[4]){o+=a;continue}else if((i[5]||i[6])&&r%3&&!((r+a)%3)){l+=a;continue}if(o-=a,o>0)continue;a=Math.min(a,a+o+l);let u=[...i[0]][0].length,h=e.slice(0,r+i.index+u+a);if(Math.min(r,a)%2){let v=h.slice(1,-1);return{type:"em",raw:h,text:v,tokens:this.lexer.inlineTokens(v)}}let d=h.slice(2,-2);return{type:"strong",raw:h,text:d,tokens:this.lexer.inlineTokens(d)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),i=this.rules.other.nonSpaceChar.test(n),r=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return i&&r&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e,t,n=""){let i=this.rules.inline.delLDelim.exec(e);if(i&&(!i[1]||!n||this.rules.inline.punctuation.exec(n))){let r=[...i[0]].length-1,s,a,o=r,l=this.rules.inline.delRDelim;for(l.lastIndex=0,t=t.slice(-1*e.length+r);(i=l.exec(t))!==null;){if(s=i[1]||i[2]||i[3]||i[4]||i[5]||i[6],!s||(a=[...s].length,a!==r))continue;if(i[3]||i[4]){o+=a;continue}if(o-=a,o>0)continue;a=Math.min(a,a+o);let f=[...i[0]][0].length,u=e.slice(0,r+i.index+f+a),h=u.slice(r,-r);return{type:"del",raw:u,text:h,tokens:this.lexer.inlineTokens(h)}}}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,i;return t[2]==="@"?(n=t[1],i="mailto:"+n):(n=t[1],i=n),{type:"link",raw:t[0],text:n,href:i,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,i;if(t[2]==="@")n=t[0],i="mailto:"+n;else{let r;do r=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(r!==t[0]);n=t[0],t[1]==="www."?i="http://"+t[0]:i=t[0]}return{type:"link",raw:t[0],text:n,href:i,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}},Ue=class ci{tokens;options;state;inlineQueue;tokenizer;constructor(t){this.tokens=[],this.tokens.links=Object.create(null),this.options=t||Wt,this.options.tokenizer=this.options.tokenizer||new Sr,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let n={other:me,block:vr.normal,inline:Pn.normal};this.options.pedantic?(n.block=vr.pedantic,n.inline=Pn.pedantic):this.options.gfm&&(n.block=vr.gfm,this.options.breaks?n.inline=Pn.breaks:n.inline=Pn.gfm),this.tokenizer.rules=n}static get rules(){return{block:vr,inline:Pn}}static lex(t,n){return new ci(n).lex(t)}static lexInline(t,n){return new ci(n).inlineTokens(t)}lex(t){t=t.replace(me.carriageReturn,` +`),this.blockTokens(t,this.tokens);for(let n=0;n(r=a.call({lexer:this},t,n))?(t=t.substring(r.raw.length),n.push(r),!0):!1))continue;if(r=this.tokenizer.space(t)){t=t.substring(r.raw.length);let a=n.at(-1);r.raw.length===1&&a!==void 0?a.raw+=` +`:n.push(r);continue}if(r=this.tokenizer.code(t)){t=t.substring(r.raw.length);let a=n.at(-1);a?.type==="paragraph"||a?.type==="text"?(a.raw+=(a.raw.endsWith(` +`)?"":` +`)+r.raw,a.text+=` +`+r.text,this.inlineQueue.at(-1).src=a.text):n.push(r);continue}if(r=this.tokenizer.fences(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.heading(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.hr(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.blockquote(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.list(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.html(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.def(t)){t=t.substring(r.raw.length);let a=n.at(-1);a?.type==="paragraph"||a?.type==="text"?(a.raw+=(a.raw.endsWith(` +`)?"":` +`)+r.raw,a.text+=` +`+r.raw,this.inlineQueue.at(-1).src=a.text):this.tokens.links[r.tag]||(this.tokens.links[r.tag]={href:r.href,title:r.title},n.push(r));continue}if(r=this.tokenizer.table(t)){t=t.substring(r.raw.length),n.push(r);continue}if(r=this.tokenizer.lheading(t)){t=t.substring(r.raw.length),n.push(r);continue}let s=t;if(this.options.extensions?.startBlock){let a=1/0,o=t.slice(1),l;this.options.extensions.startBlock.forEach(f=>{l=f.call({lexer:this},o),typeof l=="number"&&l>=0&&(a=Math.min(a,l))}),a<1/0&&a>=0&&(s=t.substring(0,a+1))}if(this.state.top&&(r=this.tokenizer.paragraph(s))){let a=n.at(-1);i&&a?.type==="paragraph"?(a.raw+=(a.raw.endsWith(` +`)?"":` +`)+r.raw,a.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=a.text):n.push(r),i=s.length!==t.length,t=t.substring(r.raw.length);continue}if(r=this.tokenizer.text(t)){t=t.substring(r.raw.length);let a=n.at(-1);a?.type==="text"?(a.raw+=(a.raw.endsWith(` +`)?"":` +`)+r.raw,a.text+=` +`+r.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=a.text):n.push(r);continue}if(t){let a="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(a);break}else throw new Error(a)}}return this.state.top=!0,n}inline(t,n=[]){return this.inlineQueue.push({src:t,tokens:n}),n}inlineTokens(t,n=[]){this.tokenizer.lexer=this;let i=t,r=null;if(this.tokens.links){let l=Object.keys(this.tokens.links);if(l.length>0)for(;(r=this.tokenizer.rules.inline.reflinkSearch.exec(i))!==null;)l.includes(r[0].slice(r[0].lastIndexOf("[")+1,-1))&&(i=i.slice(0,r.index)+"["+"a".repeat(r[0].length-2)+"]"+i.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(r=this.tokenizer.rules.inline.anyPunctuation.exec(i))!==null;)i=i.slice(0,r.index)+"++"+i.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let s;for(;(r=this.tokenizer.rules.inline.blockSkip.exec(i))!==null;)s=r[2]?r[2].length:0,i=i.slice(0,r.index+s)+"["+"a".repeat(r[0].length-s-2)+"]"+i.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);i=this.options.hooks?.emStrongMask?.call({lexer:this},i)??i;let a=!1,o="";for(;t;){a||(o=""),a=!1;let l;if(this.options.extensions?.inline?.some(u=>(l=u.call({lexer:this},t,n))?(t=t.substring(l.raw.length),n.push(l),!0):!1))continue;if(l=this.tokenizer.escape(t)){t=t.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.tag(t)){t=t.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.link(t)){t=t.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.reflink(t,this.tokens.links)){t=t.substring(l.raw.length);let u=n.at(-1);l.type==="text"&&u?.type==="text"?(u.raw+=l.raw,u.text+=l.text):n.push(l);continue}if(l=this.tokenizer.emStrong(t,i,o)){t=t.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.codespan(t)){t=t.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.br(t)){t=t.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.del(t,i,o)){t=t.substring(l.raw.length),n.push(l);continue}if(l=this.tokenizer.autolink(t)){t=t.substring(l.raw.length),n.push(l);continue}if(!this.state.inLink&&(l=this.tokenizer.url(t))){t=t.substring(l.raw.length),n.push(l);continue}let f=t;if(this.options.extensions?.startInline){let u=1/0,h=t.slice(1),d;this.options.extensions.startInline.forEach(v=>{d=v.call({lexer:this},h),typeof d=="number"&&d>=0&&(u=Math.min(u,d))}),u<1/0&&u>=0&&(f=t.substring(0,u+1))}if(l=this.tokenizer.inlineText(f)){t=t.substring(l.raw.length),l.raw.slice(-1)!=="_"&&(o=l.raw.slice(-1)),a=!0;let u=n.at(-1);u?.type==="text"?(u.raw+=l.raw,u.text+=l.text):n.push(l);continue}if(t){let u="Infinite loop on byte: "+t.charCodeAt(0);if(this.options.silent){console.error(u);break}else throw new Error(u)}}return n}},Ar=class{options;parser;constructor(e){this.options=e||Wt}space(e){return""}code({text:e,lang:t,escaped:n}){let i=(t||"").match(me.notSpaceStart)?.[0],r=e.replace(me.endingNewline,"")+` +`;return i?'
'+(n?r:Qe(r,!0))+`
+`:"
"+(n?r:Qe(r,!0))+`
+`}blockquote({tokens:e}){return`
+${this.parser.parse(e)}
+`}html({text:e}){return e}def(e){return""}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)} +`}hr(e){return`
+`}list(e){let t=e.ordered,n=e.start,i="";for(let a=0;a +`+i+" +`}listitem(e){return`
  • ${this.parser.parse(e.tokens)}
  • +`}checkbox({checked:e}){return" '}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    +`}table(e){let t="",n="";for(let r=0;r${i}`),` + +`+t+` +`+i+`
    +`}tablerow({text:e}){return` +${e} +`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+` +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${Qe(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let i=this.parser.parseInline(n),r=as(e);if(r===null)return i;e=r;let s='
    ",s}image({href:e,title:t,text:n,tokens:i}){i&&(n=this.parser.parseInline(i,this.parser.textRenderer));let r=as(e);if(r===null)return Qe(n);e=r;let s=`${Qe(n)}{let a=r[s].flat(1/0);n=n.concat(this.walkTokens(a,t))}):r.tokens&&(n=n.concat(this.walkTokens(r.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let i={...n};if(i.async=this.defaults.async||i.async||!1,n.extensions&&(n.extensions.forEach(r=>{if(!r.name)throw new Error("extension name required");if("renderer"in r){let s=t.renderers[r.name];s?t.renderers[r.name]=function(...a){let o=r.renderer.apply(this,a);return o===!1&&(o=s.apply(this,a)),o}:t.renderers[r.name]=r.renderer}if("tokenizer"in r){if(!r.level||r.level!=="block"&&r.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let s=t[r.level];s?s.unshift(r.tokenizer):t[r.level]=[r.tokenizer],r.start&&(r.level==="block"?t.startBlock?t.startBlock.push(r.start):t.startBlock=[r.start]:r.level==="inline"&&(t.startInline?t.startInline.push(r.start):t.startInline=[r.start]))}"childTokens"in r&&r.childTokens&&(t.childTokens[r.name]=r.childTokens)}),i.extensions=t),n.renderer){let r=this.defaults.renderer||new Ar(this.defaults);for(let s in n.renderer){if(!(s in r))throw new Error(`renderer '${s}' does not exist`);if(["options","parser"].includes(s))continue;let a=s,o=n.renderer[a],l=r[a];r[a]=(...f)=>{let u=o.apply(r,f);return u===!1&&(u=l.apply(r,f)),u||""}}i.renderer=r}if(n.tokenizer){let r=this.defaults.tokenizer||new Sr(this.defaults);for(let s in n.tokenizer){if(!(s in r))throw new Error(`tokenizer '${s}' does not exist`);if(["options","rules","lexer"].includes(s))continue;let a=s,o=n.tokenizer[a],l=r[a];r[a]=(...f)=>{let u=o.apply(r,f);return u===!1&&(u=l.apply(r,f)),u}}i.tokenizer=r}if(n.hooks){let r=this.defaults.hooks||new Hn;for(let s in n.hooks){if(!(s in r))throw new Error(`hook '${s}' does not exist`);if(["options","block"].includes(s))continue;let a=s,o=n.hooks[a],l=r[a];Hn.passThroughHooks.has(s)?r[a]=f=>{if(this.defaults.async&&Hn.passThroughHooksRespectAsync.has(s))return(async()=>{let h=await o.call(r,f);return l.call(r,h)})();let u=o.call(r,f);return l.call(r,u)}:r[a]=(...f)=>{if(this.defaults.async)return(async()=>{let h=await o.apply(r,f);return h===!1&&(h=await l.apply(r,f)),h})();let u=o.apply(r,f);return u===!1&&(u=l.apply(r,f)),u}}i.hooks=r}if(n.walkTokens){let r=this.defaults.walkTokens,s=n.walkTokens;i.walkTokens=function(a){let o=[];return o.push(s.call(this,a)),r&&(o=o.concat(r.call(this,a))),o}}this.defaults={...this.defaults,...i}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return Ue.lex(e,t??this.defaults)}parser(e,t){return Be.parse(e,t??this.defaults)}parseMarkdown(e){return(t,n)=>{let i={...n},r={...this.defaults,...i},s=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&i.async===!1)return s(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof t>"u"||t===null)return s(new Error("marked(): input parameter is undefined or null"));if(typeof t!="string")return s(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(t)+", string expected"));if(r.hooks&&(r.hooks.options=r,r.hooks.block=e),r.async)return(async()=>{let a=r.hooks?await r.hooks.preprocess(t):t,o=await(r.hooks?await r.hooks.provideLexer(e):e?Ue.lex:Ue.lexInline)(a,r),l=r.hooks?await r.hooks.processAllTokens(o):o;r.walkTokens&&await Promise.all(this.walkTokens(l,r.walkTokens));let f=await(r.hooks?await r.hooks.provideParser(e):e?Be.parse:Be.parseInline)(l,r);return r.hooks?await r.hooks.postprocess(f):f})().catch(s);try{r.hooks&&(t=r.hooks.preprocess(t));let a=(r.hooks?r.hooks.provideLexer(e):e?Ue.lex:Ue.lexInline)(t,r);r.hooks&&(a=r.hooks.processAllTokens(a)),r.walkTokens&&this.walkTokens(a,r.walkTokens);let o=(r.hooks?r.hooks.provideParser(e):e?Be.parse:Be.parseInline)(a,r);return r.hooks&&(o=r.hooks.postprocess(o)),o}catch(a){return s(a)}}}onError(e,t){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,e){let i="

    An error occurred:

    "+Qe(n.message+"",!0)+"
    ";return t?Promise.resolve(i):i}if(t)return Promise.reject(n);throw n}}},Bt=new sc;function F(e,t){return Bt.parse(e,t)}F.options=F.setOptions=function(e){return Bt.setOptions(e),F.defaults=Bt.defaults,aa(F.defaults),F};F.getDefaults=wi;F.defaults=Wt;F.use=function(...e){return Bt.use(...e),F.defaults=Bt.defaults,aa(F.defaults),F};F.walkTokens=function(e,t){return Bt.walkTokens(e,t)};F.parseInline=Bt.parseInline;F.Parser=Be;F.parser=Be.parse;F.Renderer=Ar;F.TextRenderer=Ii;F.Lexer=Ue;F.lexer=Ue.lex;F.Tokenizer=Sr;F.Hooks=Hn;F.parse=F;F.options;F.setOptions;F.use;F.walkTokens;F.parseInline;Be.parse;Ue.lex;function on(e){return vo.sanitize(F.parse(e,{async:!1}))}function br(e){return new Intl.DateTimeFormat(void 0,{hour:"2-digit",minute:"2-digit"}).format(new Date(e))}var ac=Oe(''),lc=Oe(""),oc=Oe(""),cc=Oe(""),uc=Oe(""),fc=Oe('
    '),pc=Oe('
    Quiet tide. Start with Markdown. Threads open from any root message.
    '),hc=Oe('
    '),dc=Oe(' '),gc=Oe('
    '),mc=Oe('

    Thread

    ',1),_c=Oe('
    No thread open Pick a message to keep the side conversation tidy.
    '),vc=Oe('

    ');function bc(e,t){ks(t,!1);const n=B(),i=B(),r=B();let s=B(null),a=B([]),o=B([]),l=B([]),f=B([]),u=B([]),h=B(""),d=B(""),v=B(""),_=B(null),T=B(null),b=B(""),S=B(""),U=B(""),N=B(""),D=B(""),H=B(""),W=B([]),q=B(null),be=B("loading"),ae=B(null),Z;ta(()=>{re()}),Wl(()=>{p(ae)?.close(),Z&&window.clearTimeout(Z)});async function re(){try{const g=await de("/api/me");y(s,g.user),await ot(),y(be,"ready")}catch(g){y(be,g instanceof Error?g.message:"Could not load ClickClack")}}async function ot(){const g=await de("/api/workspaces");y(a,g.workspaces),y(h,p(h)||p(a)[0]?.id||""),await et(),await kn(),Et()}async function qt(){if(!p(U).trim())return;const g=await de("/api/workspaces",{method:"POST",body:JSON.stringify({name:p(U)})});y(U,""),y(a,[...p(a),g.workspace]),y(h,g.workspace.id),await et(),await kn(),Et()}async function et(){if(!p(h))return;const g=await de(`/api/workspaces/${p(h)}/channels`);y(o,g.channels),y(d,p(o).find(k=>k.id===p(d))?.id||p(o)[0]?.id||""),y(_,null),y(u,[]),await je()}async function Nr(){if(!p(h)||!p(N).trim())return;const g=await de(`/api/workspaces/${p(h)}/channels`,{method:"POST",body:JSON.stringify({name:p(N),kind:"public"})});y(N,""),y(o,[...p(o),g.channel]),y(d,g.channel.id),await je()}async function je(){if(p(v)){const k=await de(`/api/dms/${p(v)}/messages`);y(f,k.messages);return}if(!p(d)){y(f,[]);return}const g=await de(`/api/channels/${p(d)}/messages`);y(f,g.messages)}async function tr(){const g=p(b).trim();if(!g||!p(d)&&!p(v))return;y(b,"");const k=p(v)?`/api/dms/${p(v)}/messages`:`/api/channels/${p(d)}/messages`,A=await de(k,{method:"POST",body:JSON.stringify({body:g})});p(q)&&(await de(`/api/messages/${A.message.id}/attachments`,{method:"POST",body:JSON.stringify({upload_id:p(q).id})}),y(q,null)),p(f).some(C=>C.id===A.message.id)||y(f,[...p(f),A.message])}async function bn(g){y(_,g);const k=await de(`/api/messages/${g.id}/thread`);y(_,k.root),y(u,k.replies),y(T,k.thread_state)}async function nr(){const g=p(S).trim();if(!g||!p(_))return;y(S,"");const k=await de(`/api/messages/${p(_).id}/thread/replies`,{method:"POST",body:JSON.stringify({body:g})});p(u).some(A=>A.id===k.message.id)||y(u,[...p(u),k.message]),y(T,k.thread_state)}async function rr(){if(!p(h)||!p(H).trim()){y(W,[]);return}const g=await de(`/api/search?workspace_id=${encodeURIComponent(p(h))}&q=${encodeURIComponent(p(H).trim())}`);y(W,g.results)}async function K(g){const k=g.currentTarget,A=k.files?.[0];if(!A||!p(h))return;const C=new FormData;C.set("workspace_id",p(h)),C.set("file",A);const J=await de("/api/uploads",{method:"POST",body:C});y(q,J.upload),k.value=""}async function kn(){if(!p(h))return;const g=await de(`/api/dms?workspace_id=${p(h)}`);y(l,g.conversations)}async function ie(){if(!p(h)||!p(D).trim())return;const g=await de("/api/dms",{method:"POST",body:JSON.stringify({workspace_id:p(h),member_ids:[p(D).trim()]})});y(D,""),y(l,[...p(l),g.conversation]),y(v,g.conversation.id),y(d,""),y(_,null),await je()}function Et(){if(p(ae)?.close(),!p(h))return;const g=localStorage.getItem(`clickclack:${p(h)}:cursor`)||"",k=new URL("/api/realtime/ws",window.location.href);k.protocol=window.location.protocol==="https:"?"wss:":"ws:",k.searchParams.set("workspace_id",p(h)),g&&k.searchParams.set("after_cursor",g),y(ae,new WebSocket(k)),p(ae).addEventListener("message",A=>{const C=JSON.parse(String(A.data));C.cursor&&localStorage.setItem(`clickclack:${p(h)}:cursor`,C.cursor),j(C)}),p(ae).addEventListener("close",()=>{Z=window.setTimeout(Et,1200)})}async function j(g){if((g.type==="channel.created"||g.type==="channel.updated")&&g.workspace_id===p(h)){await et();return}(g.channel_id===p(d)||g.payload.direct_conversation_id===p(v))&&(g.type==="message.created"||g.type==="message.updated"||g.type==="message.deleted")&&await je();const k=g.payload.root_message_id||g.payload.message_id;p(_)&&k===p(_).id&&await bn(p(_))}Ur(()=>(p(a),p(h)),()=>{y(n,p(a).find(g=>g.id===p(h)))}),Ur(()=>(p(o),p(d)),()=>{y(i,p(o).find(g=>g.id===p(d)))}),Ur(()=>(p(l),p(v)),()=>{y(r,p(l).find(g=>g.id===p(v)))}),gl(),Gl();var ct=vc();Pl("1n46o8q",g=>{var k=ac();Re(g,k)});var St=E(ct),Fe=E(St),ir=I(E(Fe),2),wn=I(E(ir),2),sr=E(wn),yn=I(Fe,2),Ye=I(E(yn),2);sn(Ye,5,()=>p(a),Br,(g,k)=>{var A=lc();let C;var J=E(A);Xe(()=>{C=Dn(A,1,"",null,C,{active:p(k).id===p(h)}),oe(J,(p(k),$(()=>p(k).name)))}),it("click",A,async()=>{y(h,p(k).id),await et(),Et()}),Re(g,A)});var ut=I(Ye,2),tt=E(ut),jt=I(yn,2),Yt=I(E(jt),2);sn(Yt,5,()=>p(o),Br,(g,k)=>{var A=oc();let C;var J=I(E(A),1,!0);Xe(()=>{C=Dn(A,1,"",null,C,{active:p(k).id===p(d)}),oe(J,(p(k),$(()=>p(k).name)))}),it("click",A,async()=>{y(d,p(k).id),y(_,null),await je()}),Re(g,A)});var nt=I(Yt,2),Zt=E(nt),Vt=I(jt,2),xn=I(E(Vt),2);sn(xn,5,()=>p(l),Br,(g,k)=>{var A=cc();let C;var J=I(E(A),1,!0);Xe(ke=>{C=Dn(A,1,"",null,C,{active:p(k).id===p(v)}),oe(J,ke)},[()=>(p(k),$(()=>p(k).members.map(ke=>ke.display_name).join(", ")))]),it("click",A,async()=>{y(v,p(k).id),y(d,""),y(_,null),await je()}),Re(g,A)});var Tn=I(xn,2),ar=E(Tn),Xt=I(St,2),ft=E(Xt),rt=E(ft),Ee=E(rt),En=E(Ee),lr=I(Ee,2),or=E(lr),At=I(rt,2),cr=E(At),Rt=I(At,2),Qt=E(Rt),Se=I(ft,2);{var pt=g=>{var k=fc();sn(k,5,()=>p(W),A=>A.message.id,(A,C)=>{var J=uc(),ke=E(J),he=E(ke),Ze=I(ke,2),Ve=E(Ze);Xe(()=>{oe(he,(p(C),$(()=>p(C).message.author?.display_name||"Local User"))),oe(Ve,(p(C),$(()=>p(C).message.body)))}),it("click",J,async()=>{y(W,[]),p(C).message.channel_id&&(y(d,p(C).message.channel_id),y(v,""),await je()),p(C).message.direct_conversation_id&&(y(v,p(C).message.direct_conversation_id),y(d,""),await je())}),Re(A,J)}),Re(g,k)};gr(Se,g=>{p(W),$(()=>p(W).length>0)&&g(pt)})}var Kt=I(Se,2),Jt=E(Kt);{var Mr=g=>{var k=pc();Re(g,k)};gr(Jt,g=>{p(f),$(()=>p(f).length===0)&&g(Mr)})}var Sn=I(Jt,2);sn(Sn,1,()=>p(f),g=>g.id,(g,k)=>{var A=hc();let C;var J=E(A),ke=E(J),he=I(J,2),Ze=E(he),Ve=E(Ze),Rn=E(Ve),nn=I(Ve,2),Ot=E(nn),dt=I(Ze,2);Hr(dt,()=>(cn(on),p(k),$(()=>on(p(k).body))),!0);var x=I(dt,2);Xe((c,m)=>{C=Dn(A,1,"message",null,C,{selected:p(_)?.id===p(k).id}),oe(ke,c),oe(Rn,(p(k),$(()=>p(k).author?.display_name||"Local User"))),oe(Ot,m)},[()=>(p(k),$(()=>p(k).author?.display_name?.slice(0,1)||"c")),()=>(cn(br),p(k),$(()=>br(p(k).created_at)))]),it("click",x,()=>bn(p(k))),Re(g,A)});var en=I(Kt,2),ur=E(en),It=I(ur,2),fr=E(It),Pr=E(fr),V=I(fr,2);{var ht=g=>{var k=dc(),A=E(k);Xe(()=>oe(A,(p(q),$(()=>p(q).filename)))),Re(g,k)};gr(V,g=>{p(q)&&g(ht)})}var zr=I(V,2),An=I(Xt,2);let tn;var pr=E(An);{var hr=g=>{var k=mc(),A=ol(k),C=E(A),J=I(E(C),2),ke=E(J),he=I(C,2),Ze=I(A,2),Ve=E(Ze),Rn=E(Ve),nn=I(Ve,2);Hr(nn,()=>(cn(on),p(_),$(()=>on(p(_).body))),!0);var Ot=I(Ze,2);sn(Ot,5,()=>p(u),m=>m.id,(m,w)=>{var z=gc(),se=E(z),Y=E(se),fe=E(Y),we=I(Y,2),Ae=E(we),In=I(se,2);Hr(In,()=>(cn(on),p(w),$(()=>on(p(w).body))),!0),Xe(ee=>{oe(fe,(p(w),$(()=>p(w).author?.display_name||"Local User"))),oe(Ae,ee)},[()=>(cn(br),p(w),$(()=>br(p(w).created_at)))]),Re(m,z)});var dt=I(Ot,2),x=E(dt),c=I(x,2);Xe(()=>{oe(ke,`${p(T),p(u),$(()=>p(T)?.reply_count||p(u).length)??""} replies`),oe(Rn,(p(_),$(()=>p(_).author?.display_name||"Local User")))}),it("click",he,()=>{y(_,null),y(u,[])}),rn("submit",dt,m=>{m.preventDefault(),nr()}),an(x,()=>p(S),m=>y(S,m)),it("click",c,()=>{nr()}),Re(g,k)},$r=g=>{var k=_c();Re(g,k)};gr(pr,g=>{p(_)?g(hr):g($r,-1)})}Xe(g=>{oe(sr,(p(s),$(()=>p(s)?.display_name||"local"))),oe(En,(p(n),$(()=>p(n)?.name||"Workspace"))),oe(or,g),Ul(Rt,"data-state",(p(ae),$(()=>p(ae)?.readyState===WebSocket.OPEN?"live":"idle"))),oe(Qt,(p(ae),p(be),$(()=>p(ae)?.readyState===WebSocket.OPEN?"live":p(be)))),tn=Dn(An,1,"thread",null,tn,{open:p(_)})},[()=>(p(r),p(i),$(()=>p(r)?"@"+p(r).members.map(g=>g.display_name).join(", "):"#"+(p(i)?.name||"general")))]),rn("submit",ut,g=>{g.preventDefault(),qt()}),an(tt,()=>p(U),g=>y(U,g)),rn("submit",nt,g=>{g.preventDefault(),Nr()}),an(Zt,()=>p(N),g=>y(N,g)),rn("submit",Tn,g=>{g.preventDefault(),ie()}),an(ar,()=>p(D),g=>y(D,g)),rn("submit",At,g=>{g.preventDefault(),rr()}),an(cr,()=>p(H),g=>y(H,g)),rn("submit",en,g=>{g.preventDefault(),tr()}),an(ur,()=>p(b),g=>y(b,g)),it("change",Pr,K),it("click",zr,()=>{tr()}),Re(e,ct),ws()}El(["click","change"]);Il(bc,{target:document.getElementById("app")}); diff --git a/apps/api/internal/webassets/dist/assets/index-Du33dVG9.css b/apps/api/internal/webassets/dist/assets/index-Du33dVG9.css new file mode 100644 index 0000000..e4e1075 --- /dev/null +++ b/apps/api/internal/webassets/dist/assets/index-Du33dVG9.css @@ -0,0 +1 @@ +:root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;background:#f5f1ec;color:#171717;--bg: #f5f1ec;--panel: #fffaf2;--panel-2: #ece4d8;--text: #171717;--muted: #6d655d;--line: #d8cdbf;--accent: #dd5d45;--accent-2: #006d77;--ink: #102027;--shadow: 0 18px 60px rgba(16, 32, 39, .12)}@media(prefers-color-scheme:dark){:root{background:#121416;color:#f5efe7;--bg: #121416;--panel: #1c2022;--panel-2: #242a2c;--text: #f5efe7;--muted: #a59d93;--line: #343b3e;--accent: #ff735c;--accent-2: #6fc7cf;--ink: #f5efe7;--shadow: 0 18px 60px rgba(0, 0, 0, .35)}}*{box-sizing:border-box}body{margin:0;background:linear-gradient(135deg,rgba(221,93,69,.12),transparent 34%),linear-gradient(315deg,rgba(0,109,119,.12),transparent 32%),var(--bg)}button,input,textarea{font:inherit}button{cursor:pointer}.shell{display:grid;grid-template-columns:260px minmax(0,1fr) minmax(320px,28vw);height:100vh;min-height:620px}.sidebar,.thread{background:color-mix(in srgb,var(--panel) 88%,transparent);border-color:var(--line);border-style:solid;-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.sidebar{border-width:0 1px 0 0;padding:18px 14px;overflow:auto}.brand{display:flex;align-items:center;gap:10px;margin-bottom:28px}.mark{display:grid;place-items:center;width:42px;height:42px;border-radius:8px;background:var(--ink);color:var(--panel);font-weight:900;letter-spacing:0;text-transform:uppercase}.brand strong,.brand span{display:block}.brand span,.section-title,.topbar p,.thread header p,time,.empty span,.thread-empty span{color:var(--muted);font-size:12px}.section-title{margin:18px 8px 8px;text-transform:uppercase;font-weight:800}.nav-list{display:grid;gap:4px}.nav-list button{width:100%;min-height:36px;border:0;border-radius:8px;background:transparent;color:var(--text);text-align:left;padding:8px 10px}.nav-list button:hover,.nav-list button.active{background:var(--panel-2)}.channels button{display:flex;gap:7px}.channels span{color:var(--accent);font-weight:900}.inline-create{margin:10px 0}.inline-create input,textarea{width:100%;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);outline:0}.inline-create input{height:36px;padding:0 10px}.timeline{display:grid;grid-template-rows:auto minmax(0,1fr) auto;min-width:0}.topbar{display:flex;align-items:center;justify-content:space-between;gap:14px;min-height:78px;padding:14px 22px;border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--bg) 82%,transparent)}.topbar h1,.topbar p{margin:0}.topbar h1{font-size:22px}.search{display:flex;align-items:center;gap:8px;min-width:min(360px,42vw)}.search input{width:100%;min-width:120px;height:34px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);padding:0 10px}.search button{height:34px;border:0;border-radius:8px;background:var(--accent-2);color:var(--panel);font-weight:800;padding:0 12px}.connection{border:1px solid var(--line);border-radius:999px;color:var(--muted);padding:5px 10px;font-size:12px}.connection[data-state=live]{border-color:color-mix(in srgb,var(--accent-2) 50%,var(--line));color:var(--accent-2)}.search-results{display:grid;gap:4px;padding:8px 22px;border-bottom:1px solid var(--line);background:color-mix(in srgb,var(--panel) 86%,transparent)}.search-results button{display:grid;gap:2px;border:0;border-radius:8px;background:transparent;color:var(--text);padding:8px;text-align:left}.search-results button:hover{background:var(--panel-2)}.search-results span{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.messages{overflow:auto;padding:18px 22px 28px}.empty,.thread-empty{display:grid;place-items:center;align-content:center;min-height:220px;text-align:center;gap:6px}.message{display:grid;grid-template-columns:38px minmax(0,1fr);gap:10px;padding:12px 8px;border-radius:8px}.message:hover,.message.selected{background:color-mix(in srgb,var(--panel-2) 62%,transparent)}.avatar{display:grid;place-items:center;width:34px;height:34px;border-radius:8px;background:var(--accent);color:#fff;font-weight:900;text-transform:uppercase}.message header,.reply header{display:flex;align-items:baseline;gap:8px}.markdown{line-height:1.45;overflow-wrap:anywhere}.markdown p{margin:4px 0 0}.markdown code{border-radius:5px;background:var(--panel-2);padding:1px 4px}.thread-button{border:0;background:transparent;color:var(--accent-2);margin-top:5px;padding:0;font-size:12px;font-weight:800}.composer,.reply-composer{display:grid;gap:10px;padding:14px;border-top:1px solid var(--line);background:color-mix(in srgb,var(--panel) 72%,transparent)}.composer{grid-template-columns:minmax(0,1fr) minmax(104px,auto);align-items:end}textarea{resize:vertical;min-height:70px;max-height:190px;padding:11px 12px}.composer button,.reply-composer button{min-height:42px;border:0;border-radius:8px;background:var(--ink);color:var(--panel);font-weight:900}.composer-actions{display:grid;gap:8px;min-width:104px}.upload-button{display:grid;place-items:center;min-height:36px;border:1px solid var(--line);border-radius:8px;color:var(--accent-2);font-size:12px;font-weight:900}.upload-button input{position:absolute;inline-size:1px;block-size:1px;opacity:0}.pending-upload{color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.thread{display:grid;grid-template-rows:auto auto minmax(0,1fr) auto;border-width:0 0 0 1px;min-width:0}.thread>header{display:flex;align-items:center;justify-content:space-between;min-height:72px;padding:14px;border-bottom:1px solid var(--line)}.thread>header p,.thread>header strong{margin:0}.thread>header button{width:32px;height:32px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--text)}.thread-root{padding:14px;border-bottom:1px solid var(--line)}.reply-list{overflow:auto;padding:8px 14px}.reply{padding:10px 0;border-bottom:1px solid color-mix(in srgb,var(--line) 65%,transparent)}@media(max-width:980px){.shell{grid-template-columns:220px minmax(0,1fr)}.thread{position:fixed;inset:0 0 0 auto;width:min(420px,100vw);box-shadow:var(--shadow);transform:translate(100%);transition:transform .16s ease;z-index:4}.thread.open{transform:translate(0)}}@media(max-width:720px){.shell{grid-template-columns:1fr;min-height:100vh}.sidebar{display:none}.composer{grid-template-columns:1fr}.topbar{align-items:stretch;flex-direction:column}.search{min-width:0;width:100%}} diff --git a/apps/api/internal/webassets/dist/index.html b/apps/api/internal/webassets/dist/index.html new file mode 100644 index 0000000..8f1b656 --- /dev/null +++ b/apps/api/internal/webassets/dist/index.html @@ -0,0 +1,13 @@ + + + + + + ClickClack + + + + +
    + + diff --git a/apps/api/internal/webassets/webassets.go b/apps/api/internal/webassets/webassets.go new file mode 100644 index 0000000..e16b941 --- /dev/null +++ b/apps/api/internal/webassets/webassets.go @@ -0,0 +1,8 @@ +package webassets + +import "embed" + +// Dist is replaced by the root pnpm build script after the Svelte app builds. +// +//go:embed dist/* +var Dist embed.FS diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..0845251 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + ClickClack + + +
    + + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..aa0143a --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "@clickclack/web", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "typecheck": "tsgo --noEmit -p tsconfig.json", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "dompurify": "^3.3.0", + "marked": "^17.0.1", + "svelte": "^5.45.6", + "vite": "^7.2.4" + }, + "devDependencies": { + "@typescript/native-preview": "7.0.0-dev.20260507.1" + } +} diff --git a/apps/web/src/App.svelte b/apps/web/src/App.svelte new file mode 100644 index 0000000..5ea06de --- /dev/null +++ b/apps/web/src/App.svelte @@ -0,0 +1,472 @@ + + + + + + +
    + + +
    +
    +
    +

    {selectedWorkspace?.name || "Workspace"}

    +

    {selectedDirect ? "@" + selectedDirect.members.map((member) => member.display_name).join(", ") : "#" + (selectedChannel?.name || "general")}

    +
    + +
    + {socket?.readyState === WebSocket.OPEN ? "live" : status} +
    +
    + + {#if searchResults.length > 0} +
    + {#each searchResults as result (result.message.id)} + + {/each} +
    + {/if} + +
    + {#if messages.length === 0} +
    + Quiet tide. + Start with Markdown. Threads open from any root message. +
    + {/if} + {#each messages as message (message.id)} +
    +
    {message.author?.display_name?.slice(0, 1) || "c"}
    +
    +
    + {message.author?.display_name || "Local User"} + +
    +
    {@html markdown(message.body)}
    + +
    +
    + {/each} +
    + +
    { + event.preventDefault(); + void sendMessage(); + }} + > + +
    + + {#if pendingUpload} + {pendingUpload.filename} + {/if} + +
    +
    +
    + + +
    diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..bb3d09e --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,11 @@ +export async function api(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set("Accept", "application/json"); + if (init.body && !(init.body instanceof FormData)) + headers.set("Content-Type", "application/json"); + const response = await fetch(path, { ...init, headers }); + if (!response.ok) { + throw new Error(await response.text()); + } + return response.json() as Promise; +} diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts new file mode 100644 index 0000000..5324510 --- /dev/null +++ b/apps/web/src/lib/format.ts @@ -0,0 +1,12 @@ +import DOMPurify from "dompurify"; +import { marked } from "marked"; + +export function markdown(body: string) { + return DOMPurify.sanitize(marked.parse(body, { async: false })); +} + +export function time(value: string) { + return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format( + new Date(value), + ); +} diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts new file mode 100644 index 0000000..77d4fb9 --- /dev/null +++ b/apps/web/src/lib/types.ts @@ -0,0 +1,83 @@ +export type User = { + id: string; + display_name: string; + avatar_url: string; + created_at: string; +}; + +export type Workspace = { + id: string; + name: string; + slug: string; + created_at: string; +}; + +export type Channel = { + id: string; + workspace_id: string; + name: string; + kind: string; + created_at: string; + archived_at?: string; +}; + +export type Message = { + id: string; + workspace_id: string; + channel_id?: string; + direct_conversation_id?: string; + author_id: string; + parent_message_id?: string; + thread_root_id: string; + channel_seq?: number; + thread_seq?: number; + body: string; + body_format: "markdown"; + created_at: string; + edited_at?: string; + deleted_at?: string; + author?: User; +}; + +export type Upload = { + id: string; + filename: string; + byte_size: number; +}; + +export type SearchResult = { + message: Message; + rank: number; +}; + +export type DirectConversation = { + id: string; + workspace_id: string; + created_at: string; + members: User[]; +}; + +export type ThreadState = { + root_message_id: string; + reply_count: number; + last_reply_at?: string; + last_reply_author_ids: string[]; +}; + +export type EventPayload = { + message_id?: string; + root_message_id?: string; + channel_id?: string; + direct_conversation_id?: string; +}; + +export type RealtimeEvent = { + id: string; + cursor: string; + type: string; + workspace_id: string; + channel_id?: string; + seq?: number; + created_at: string; + payload: EventPayload; +}; diff --git a/apps/web/src/main.ts b/apps/web/src/main.ts new file mode 100644 index 0000000..c18af30 --- /dev/null +++ b/apps/web/src/main.ts @@ -0,0 +1,9 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; +import "./styles.css"; + +const app = mount(App, { + target: document.getElementById("app")!, +}); + +export default app; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..cc22351 --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,490 @@ +:root { + color-scheme: light dark; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #f5f1ec; + color: #171717; + --bg: #f5f1ec; + --panel: #fffaf2; + --panel-2: #ece4d8; + --text: #171717; + --muted: #6d655d; + --line: #d8cdbf; + --accent: #dd5d45; + --accent-2: #006d77; + --ink: #102027; + --shadow: 0 18px 60px rgba(16, 32, 39, 0.12); +} + +@media (prefers-color-scheme: dark) { + :root { + background: #121416; + color: #f5efe7; + --bg: #121416; + --panel: #1c2022; + --panel-2: #242a2c; + --text: #f5efe7; + --muted: #a59d93; + --line: #343b3e; + --accent: #ff735c; + --accent-2: #6fc7cf; + --ink: #f5efe7; + --shadow: 0 18px 60px rgba(0, 0, 0, 0.35); + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: + linear-gradient(135deg, rgba(221, 93, 69, 0.12), transparent 34%), + linear-gradient(315deg, rgba(0, 109, 119, 0.12), transparent 32%), + var(--bg); +} + +button, +input, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +.shell { + display: grid; + grid-template-columns: 260px minmax(0, 1fr) minmax(320px, 28vw); + height: 100vh; + min-height: 620px; +} + +.sidebar, +.thread { + background: color-mix(in srgb, var(--panel) 88%, transparent); + border-color: var(--line); + border-style: solid; + backdrop-filter: blur(18px); +} + +.sidebar { + border-width: 0 1px 0 0; + padding: 18px 14px; + overflow: auto; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 28px; +} + +.mark { + display: grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 8px; + background: var(--ink); + color: var(--panel); + font-weight: 900; + letter-spacing: 0; + text-transform: uppercase; +} + +.brand strong, +.brand span { + display: block; +} + +.brand span, +.section-title, +.topbar p, +.thread header p, +time, +.empty span, +.thread-empty span { + color: var(--muted); + font-size: 12px; +} + +.section-title { + margin: 18px 8px 8px; + text-transform: uppercase; + font-weight: 800; +} + +.nav-list { + display: grid; + gap: 4px; +} + +.nav-list button { + width: 100%; + min-height: 36px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text); + text-align: left; + padding: 8px 10px; +} + +.nav-list button:hover, +.nav-list button.active { + background: var(--panel-2); +} + +.channels button { + display: flex; + gap: 7px; +} + +.channels span { + color: var(--accent); + font-weight: 900; +} + +.inline-create { + margin: 10px 0; +} + +.inline-create input, +textarea { + width: 100%; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + color: var(--text); + outline: 0; +} + +.inline-create input { + height: 36px; + padding: 0 10px; +} + +.timeline { + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + min-width: 0; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + min-height: 78px; + padding: 14px 22px; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--bg) 82%, transparent); +} + +.topbar h1, +.topbar p { + margin: 0; +} + +.topbar h1 { + font-size: 22px; +} + +.search { + display: flex; + align-items: center; + gap: 8px; + min-width: min(360px, 42vw); +} + +.search input { + width: 100%; + min-width: 120px; + height: 34px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + color: var(--text); + padding: 0 10px; +} + +.search button { + height: 34px; + border: 0; + border-radius: 8px; + background: var(--accent-2); + color: var(--panel); + font-weight: 800; + padding: 0 12px; +} + +.connection { + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + padding: 5px 10px; + font-size: 12px; +} + +.connection[data-state="live"] { + border-color: color-mix(in srgb, var(--accent-2) 50%, var(--line)); + color: var(--accent-2); +} + +.search-results { + display: grid; + gap: 4px; + padding: 8px 22px; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--panel) 86%, transparent); +} + +.search-results button { + display: grid; + gap: 2px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--text); + padding: 8px; + text-align: left; +} + +.search-results button:hover { + background: var(--panel-2); +} + +.search-results span { + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.messages { + overflow: auto; + padding: 18px 22px 28px; +} + +.empty, +.thread-empty { + display: grid; + place-items: center; + align-content: center; + min-height: 220px; + text-align: center; + gap: 6px; +} + +.message { + display: grid; + grid-template-columns: 38px minmax(0, 1fr); + gap: 10px; + padding: 12px 8px; + border-radius: 8px; +} + +.message:hover, +.message.selected { + background: color-mix(in srgb, var(--panel-2) 62%, transparent); +} + +.avatar { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border-radius: 8px; + background: var(--accent); + color: white; + font-weight: 900; + text-transform: uppercase; +} + +.message header, +.reply header { + display: flex; + align-items: baseline; + gap: 8px; +} + +.markdown { + line-height: 1.45; + overflow-wrap: anywhere; +} + +.markdown p { + margin: 4px 0 0; +} + +.markdown code { + border-radius: 5px; + background: var(--panel-2); + padding: 1px 4px; +} + +.thread-button { + border: 0; + background: transparent; + color: var(--accent-2); + margin-top: 5px; + padding: 0; + font-size: 12px; + font-weight: 800; +} + +.composer, +.reply-composer { + display: grid; + gap: 10px; + padding: 14px; + border-top: 1px solid var(--line); + background: color-mix(in srgb, var(--panel) 72%, transparent); +} + +.composer { + grid-template-columns: minmax(0, 1fr) minmax(104px, auto); + align-items: end; +} + +textarea { + resize: vertical; + min-height: 70px; + max-height: 190px; + padding: 11px 12px; +} + +.composer button, +.reply-composer button { + min-height: 42px; + border: 0; + border-radius: 8px; + background: var(--ink); + color: var(--panel); + font-weight: 900; +} + +.composer-actions { + display: grid; + gap: 8px; + min-width: 104px; +} + +.upload-button { + display: grid; + place-items: center; + min-height: 36px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--accent-2); + font-size: 12px; + font-weight: 900; +} + +.upload-button input { + position: absolute; + inline-size: 1px; + block-size: 1px; + opacity: 0; +} + +.pending-upload { + color: var(--muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.thread { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + border-width: 0 0 0 1px; + min-width: 0; +} + +.thread > header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 72px; + padding: 14px; + border-bottom: 1px solid var(--line); +} + +.thread > header p, +.thread > header strong { + margin: 0; +} + +.thread > header button { + width: 32px; + height: 32px; + border: 1px solid var(--line); + border-radius: 8px; + background: transparent; + color: var(--text); +} + +.thread-root { + padding: 14px; + border-bottom: 1px solid var(--line); +} + +.reply-list { + overflow: auto; + padding: 8px 14px; +} + +.reply { + padding: 10px 0; + border-bottom: 1px solid color-mix(in srgb, var(--line) 65%, transparent); +} + +@media (max-width: 980px) { + .shell { + grid-template-columns: 220px minmax(0, 1fr); + } + + .thread { + position: fixed; + inset: 0 0 0 auto; + width: min(420px, 100vw); + box-shadow: var(--shadow); + transform: translateX(100%); + transition: transform 160ms ease; + z-index: 4; + } + + .thread.open { + transform: translateX(0); + } +} + +@media (max-width: 720px) { + .shell { + grid-template-columns: 1fr; + min-height: 100vh; + } + + .sidebar { + display: none; + } + + .composer { + grid-template-columns: 1fr; + } + + .topbar { + align-items: stretch; + flex-direction: column; + } + + .search { + min-width: 0; + width: 100%; + } +} diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..1f5bcd0 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "allowJs": false, + "checkJs": false, + "isolatedModules": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "target": "ES2022", + "types": ["svelte"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..cc19c8c --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,11 @@ +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [svelte()], + server: { + proxy: { + "/api": "http://127.0.0.1:8080" + } + } +}); diff --git a/docs/api/overview.md b/docs/api/overview.md new file mode 100644 index 0000000..2ec00c1 --- /dev/null +++ b/docs/api/overview.md @@ -0,0 +1,23 @@ +--- +read_when: + - changing REST endpoints, websocket behavior, SDK methods, or OpenAPI + - adding integrations or bots +--- + +# API Overview + +`packages/protocol/openapi.yaml` is the API contract source of truth. + +Main API groups: + +- Auth: local dev headers, bearer sessions, magic-link token request/consume, optional GitHub OAuth. +- Workspaces/channels: create/list workspaces and create/list/update channels. +- Messages: create/list/update/soft-delete root channel messages, reactions, attachments. +- Threads: one-level thread replies per root message. +- Realtime: websocket notifications, HTTP event recovery by cursor, and non-durable typing/presence publish. +- Search/uploads/DMs: SQLite FTS5 search, local upload storage, direct conversations. +- Integrations: Mattermost-compatible incoming webhook shape and simple slash command callbacks. + +TypeScript consumers should use `@clickclack/sdk-ts`. The SDK has no Svelte dependency and exposes HTTP helpers plus `events.subscribe(...)`. + +The bot example in `examples/bot-ts` sends a channel message using the SDK and either `CLICKCLACK_TOKEN` or `CLICKCLACK_USER_ID`. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md new file mode 100644 index 0000000..5cb4dff --- /dev/null +++ b/docs/architecture/overview.md @@ -0,0 +1,29 @@ +--- +read_when: + - changing backend storage, realtime events, auth, or embedded serving + - adding a second database implementation +--- + +# Architecture Overview + +ClickClack ships as one Go binary serving a Svelte SPA, JSON API, websocket endpoint, embedded SQLite migrations, local SQLite data, and local upload files. + +Durable state lives in SQLite. WebSockets are an update pipe only; clients recover missed durable events through `GET /api/realtime/events?after_cursor=...`. + +Core layers: + +- `apps/api/cmd/clickclack`: CLI and single-binary entrypoint. +- `apps/api/internal/httpapi`: chi routes, auth, API handlers, SPA serving. +- `apps/api/internal/store`: backend-facing store contracts and domain types. +- `apps/api/internal/store/sqlite`: SQLite implementation, embedded migrations, backups, exports. +- `apps/api/internal/realtime`: in-process workspace event hub. +- `apps/web`: Svelte 5 SPA with API-only client behavior. +- `packages/protocol`: OpenAPI contract. +- `packages/sdk-ts`: generated OpenAPI types plus framework-neutral TypeScript wrapper. + +Storage constraints: + +- SQLite uses `modernc.org/sqlite` and WAL. +- Transactions stay short and issue outbox events inside the same commit as durable writes. +- IDs are sortable ULID text with semantic prefixes. +- Postgres should be added behind the store layer without changing API handlers. diff --git a/examples/bot-ts/README.md b/examples/bot-ts/README.md new file mode 100644 index 0000000..521894c --- /dev/null +++ b/examples/bot-ts/README.md @@ -0,0 +1,13 @@ +# ClickClack Bot Example + +Tiny TypeScript bot using `@clickclack/sdk-ts`. + +```sh +CLICKCLACK_URL=http://localhost:8080 \ +CLICKCLACK_USER_ID=user_dev \ +CLICKCLACK_CHANNEL_ID=chan_... \ +CLICKCLACK_TEXT="clack from bot" \ +pnpm --filter @clickclack/example-bot start +``` + +Use `CLICKCLACK_TOKEN` instead of `CLICKCLACK_USER_ID` when running with a bearer session token. diff --git a/examples/bot-ts/package.json b/examples/bot-ts/package.json new file mode 100644 index 0000000..58f3b91 --- /dev/null +++ b/examples/bot-ts/package.json @@ -0,0 +1,18 @@ +{ + "name": "@clickclack/example-bot", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "start": "pnpm --filter @clickclack/sdk-ts build && node --experimental-strip-types src/index.ts", + "typecheck": "tsgo --noEmit -p tsconfig.json" + }, + "dependencies": { + "@clickclack/sdk-ts": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.6.2", + "@typescript/native-preview": "7.0.0-dev.20260507.1" + } +} diff --git a/examples/bot-ts/src/index.ts b/examples/bot-ts/src/index.ts new file mode 100644 index 0000000..ecf4ddc --- /dev/null +++ b/examples/bot-ts/src/index.ts @@ -0,0 +1,20 @@ +import { ClickClackClient } from "@clickclack/sdk-ts"; + +const baseUrl = requiredEnv("CLICKCLACK_URL"); +const channelId = requiredEnv("CLICKCLACK_CHANNEL_ID"); +const text = process.env.CLICKCLACK_TEXT ?? "clack from bot"; + +const client = new ClickClackClient({ + baseUrl, + token: process.env.CLICKCLACK_TOKEN, + userId: process.env.CLICKCLACK_USER_ID, +}); + +const message = await client.channels.sendMessage(channelId, { body: text }); +console.log(JSON.stringify({ message_id: message.id, channel_seq: message.channel_seq })); + +function requiredEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`missing ${name}`); + return value; +} diff --git a/examples/bot-ts/tsconfig.json b/examples/bot-ts/tsconfig.json new file mode 100644 index 0000000..da03d12 --- /dev/null +++ b/examples/bot-ts/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "paths": { + "@clickclack/sdk-ts": ["../../packages/sdk-ts/src/index.ts"] + }, + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..54f0ea9 --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module github.com/openclaw/clickclack + +go 1.26 + +require ( + github.com/coder/websocket v1.8.14 + github.com/go-chi/chi/v5 v5.2.5 + github.com/oklog/ulid/v2 v2.1.1 + modernc.org/sqlite v1.50.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/tools v0.44.0 // indirect + modernc.org/cc/v4 v4.28.2 // indirect + modernc.org/gc/v3 v3.1.3 // indirect + modernc.org/libc v1.72.2 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..55a5e72 --- /dev/null +++ b/go.sum @@ -0,0 +1,57 @@ +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M= +github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= +modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ= +modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.72.2 h1:HwRjrHwX7hZIFCfRyw6otVlY+BoZEHFQmHQa5B0BzDE= +modernc.org/libc v1.72.2/go.mod h1:43RZAMuEX483KwP1bW+3lTFm3dzwFpl6R8HMEutqy/w= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM= +modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/infra/migrations/sqlite/0001_initial.sql b/infra/migrations/sqlite/0001_initial.sql new file mode 100644 index 0000000..0c6ffbf --- /dev/null +++ b/infra/migrations/sqlite/0001_initial.sql @@ -0,0 +1,150 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + avatar_url TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS identities ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_subject TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + UNIQUE(provider, provider_subject) +); + +CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workspace_members ( + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, user_id) +); + +CREATE TABLE IF NOT EXISTS channels ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind TEXT NOT NULL, + created_at TEXT NOT NULL, + archived_at TEXT, + UNIQUE(workspace_id, name) +); + +CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_id TEXT REFERENCES channels(id) ON DELETE CASCADE, + direct_conversation_id TEXT, + author_id TEXT NOT NULL REFERENCES users(id), + parent_message_id TEXT REFERENCES messages(id) ON DELETE CASCADE, + thread_root_id TEXT NOT NULL, + channel_seq INTEGER, + thread_seq INTEGER, + body TEXT NOT NULL, + body_format TEXT NOT NULL, + created_at TEXT NOT NULL, + edited_at TEXT, + deleted_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_messages_channel_seq ON messages(channel_id, channel_seq); +CREATE INDEX IF NOT EXISTS idx_messages_thread_seq ON messages(thread_root_id, thread_seq); +CREATE INDEX IF NOT EXISTS idx_messages_direct_seq ON messages(direct_conversation_id, channel_seq); + +CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( + message_id UNINDEXED, + workspace_id UNINDEXED, + body, + tokenize = 'porter unicode61' +); + +CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts(message_id, workspace_id, body) VALUES (new.id, new.workspace_id, new.body); +END; + +CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages BEGIN + DELETE FROM messages_fts WHERE message_id = old.id; +END; + +CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE OF body ON messages BEGIN + DELETE FROM messages_fts WHERE message_id = old.id; + INSERT INTO messages_fts(message_id, workspace_id, body) VALUES (new.id, new.workspace_id, new.body); +END; + +CREATE TABLE IF NOT EXISTS thread_state ( + root_message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE, + reply_count INTEGER NOT NULL DEFAULT 0, + last_reply_at TEXT, + last_reply_author_ids_json TEXT NOT NULL DEFAULT '[]' +); + +CREATE TABLE IF NOT EXISTS reactions ( + message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (message_id, user_id, emoji) +); + +CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + cursor TEXT NOT NULL UNIQUE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + channel_id TEXT, + type TEXT NOT NULL, + seq INTEGER, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_events_workspace_cursor ON events(workspace_id, cursor); + +CREATE TABLE IF NOT EXISTS uploads ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + content_type TEXT NOT NULL, + byte_size INTEGER NOT NULL, + storage_path TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS message_attachments ( + message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + upload_id TEXT NOT NULL REFERENCES uploads(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (message_id, upload_id) +); + +CREATE TABLE IF NOT EXISTS direct_conversations ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS direct_conversation_members ( + conversation_id TEXT NOT NULL REFERENCES direct_conversations(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + PRIMARY KEY (conversation_id, user_id) +); + +CREATE TABLE IF NOT EXISTS invites ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + accepted_at TEXT +); diff --git a/infra/migrations/sqlite/0002_auth.sql b/infra/migrations/sqlite/0002_auth.sql new file mode 100644 index 0000000..8c81792 --- /dev/null +++ b/infra/migrations/sqlite/0002_auth.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS auth_magic_links ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + used_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_auth_magic_links_token ON auth_magic_links(token); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); diff --git a/package.json b/package.json new file mode 100644 index 0000000..6ac0443 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "clickclack", + "private": true, + "license": "MIT", + "scripts": { + "build": "pnpm --filter @clickclack/web build && pnpm --filter @clickclack/sdk-ts build && rm -rf apps/api/internal/webassets/dist && cp -R apps/web/dist apps/api/internal/webassets/dist", + "check": "go test ./... && pnpm -r typecheck && pnpm lint", + "coverage": "go test ./apps/api/internal/... -coverprofile=coverage.out && go tool cover -func=coverage.out | tee coverage.txt && awk '/^total:/ { sub(/%/, \"\", $3); if ($3 + 0 < 90) exit 1 }' coverage.txt", + "dev:web": "pnpm --filter @clickclack/web dev", + "dev:api": "go run ./apps/api/cmd/clickclack serve", + "fmt": "gofmt -w apps/api && oxfmt --write \"apps/web/src/**/*.{ts,svelte}\" \"packages/sdk-ts/src/**/*.ts\" \"examples/**/*.ts\"", + "lint": "oxlint apps/web/src packages/sdk-ts/src examples tests/e2e playwright.config.ts", + "test:e2e": "playwright test", + "typecheck": "tsgo --noEmit -p tsconfig.json", + "test": "go test ./... && pnpm build" + }, + "packageManager": "pnpm@11.0.7", + "devDependencies": { + "@playwright/test": "^1.59.1", + "@types/node": "^25.6.2", + "@typescript/native-preview": "7.0.0-dev.20260507.1", + "oxfmt": "^0.48.0", + "oxlint": "^1.63.0" + } +} diff --git a/packages/protocol/openapi.yaml b/packages/protocol/openapi.yaml new file mode 100644 index 0000000..f569999 --- /dev/null +++ b/packages/protocol/openapi.yaml @@ -0,0 +1,542 @@ +openapi: 3.1.0 +info: + title: ClickClack API + version: 0.1.0 + license: + name: MIT +servers: + - url: http://localhost:8080 +paths: + /api/auth/magic/request: + post: + operationId: requestMagicLink + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RequestMagicLinkRequest" + responses: + "201": + description: Created local magic-link token + /api/auth/magic/consume: + post: + operationId: consumeMagicLink + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ConsumeMagicLinkRequest" + responses: + "200": + description: Created session + /api/auth/github/start: + get: + operationId: startGitHubOAuth + responses: + "302": + description: Redirect to GitHub OAuth authorization + "501": + description: GitHub OAuth not configured + /api/auth/github/callback: + get: + operationId: finishGitHubOAuth + parameters: + - name: code + in: query + schema: + type: string + - name: state + in: query + schema: + type: string + responses: + "302": + description: Session created and redirected to app + "400": + description: Invalid OAuth callback + /api/me: + get: + operationId: getMe + responses: + "200": + description: Current local user + /api/workspaces: + get: + operationId: listWorkspaces + responses: + "200": + description: Workspace list + post: + operationId: createWorkspace + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateWorkspaceRequest" + responses: + "201": + description: Created workspace + /api/workspaces/{workspace_id}: + get: + operationId: getWorkspace + parameters: + - $ref: "#/components/parameters/workspace_id" + responses: + "200": + description: Workspace + /api/workspaces/{workspace_id}/channels: + get: + operationId: listChannels + parameters: + - $ref: "#/components/parameters/workspace_id" + responses: + "200": + description: Channel list + post: + operationId: createChannel + parameters: + - $ref: "#/components/parameters/workspace_id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateChannelRequest" + responses: + "201": + description: Created channel + /api/channels/{channel_id}: + patch: + operationId: updateChannel + parameters: + - $ref: "#/components/parameters/channel_id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateChannelRequest" + responses: + "200": + description: Updated channel + /api/channels/{channel_id}/messages: + get: + operationId: listMessages + parameters: + - $ref: "#/components/parameters/channel_id" + - name: after_seq + in: query + schema: + type: integer + - name: limit + in: query + schema: + type: integer + responses: + "200": + description: Root channel messages + post: + operationId: createMessage + parameters: + - $ref: "#/components/parameters/channel_id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMessageRequest" + responses: + "201": + description: Created message + /api/messages/{message_id}: + patch: + operationId: updateMessage + parameters: + - $ref: "#/components/parameters/message_id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMessageRequest" + responses: + "200": + description: Updated message + delete: + operationId: deleteMessage + parameters: + - $ref: "#/components/parameters/message_id" + responses: + "200": + description: Soft-deleted message + /api/messages/{message_id}/thread: + get: + operationId: getThread + parameters: + - $ref: "#/components/parameters/message_id" + responses: + "200": + description: Thread root and replies + /api/messages/{message_id}/thread/replies: + post: + operationId: createThreadReply + parameters: + - $ref: "#/components/parameters/message_id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMessageRequest" + responses: + "201": + description: Created thread reply + /api/messages/{message_id}/reactions: + post: + operationId: addReaction + parameters: + - $ref: "#/components/parameters/message_id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AddReactionRequest" + responses: + "201": + description: Added reaction + /api/messages/{message_id}/attachments: + post: + operationId: attachUpload + parameters: + - $ref: "#/components/parameters/message_id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AttachUploadRequest" + responses: + "200": + description: Attached upload to message + /api/messages/{message_id}/reactions/{emoji}: + delete: + operationId: removeReaction + parameters: + - $ref: "#/components/parameters/message_id" + - name: emoji + in: path + required: true + schema: + type: string + responses: + "200": + description: Removed reaction + /api/realtime/events: + get: + operationId: listEvents + parameters: + - name: workspace_id + in: query + required: true + schema: + type: string + - name: after_cursor + in: query + schema: + type: string + - name: limit + in: query + schema: + type: integer + responses: + "200": + description: Durable events after cursor + /api/realtime/ephemeral: + post: + operationId: publishEphemeral + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EphemeralEventRequest" + responses: + "202": + description: Ephemeral event accepted + /api/realtime/ws: + get: + operationId: realtimeWebSocket + parameters: + - name: workspace_id + in: query + required: true + schema: + type: string + - name: after_cursor + in: query + schema: + type: string + responses: + "101": + description: WebSocket upgrade + /api/search: + get: + operationId: search + parameters: + - name: workspace_id + in: query + required: true + schema: + type: string + - name: q + in: query + required: true + schema: + type: string + responses: + "200": + description: Search results + /api/uploads: + post: + operationId: createUpload + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [workspace_id, file] + properties: + workspace_id: + type: string + file: + type: string + format: binary + responses: + "201": + description: Created upload + /api/uploads/{upload_id}: + get: + operationId: getUpload + parameters: + - name: upload_id + in: path + required: true + schema: + type: string + responses: + "200": + description: Upload bytes + /api/dms: + get: + operationId: listDirectConversations + parameters: + - name: workspace_id + in: query + required: true + schema: + type: string + responses: + "200": + description: DM list + post: + operationId: createDirectConversation + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateDirectConversationRequest" + responses: + "201": + description: Created DM + /api/dms/{conversation_id}/messages: + get: + operationId: listDirectMessages + parameters: + - $ref: "#/components/parameters/conversation_id" + - name: after_seq + in: query + schema: + type: integer + - name: limit + in: query + schema: + type: integer + responses: + "200": + description: Direct messages + post: + operationId: createDirectMessage + parameters: + - $ref: "#/components/parameters/conversation_id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMessageRequest" + responses: + "201": + description: Created direct message + /api/hooks/mattermost/{channel_id}: + post: + operationId: mattermostIncomingWebhook + parameters: + - $ref: "#/components/parameters/channel_id" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MattermostWebhookRequest" + responses: + "201": + description: Created message from incoming webhook + /api/hooks/slash/{channel_id}: + post: + operationId: slashCommand + parameters: + - $ref: "#/components/parameters/channel_id" + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/SlashCommandRequest" + responses: + "201": + description: Slash-command callback response +components: + parameters: + workspace_id: + name: workspace_id + in: path + required: true + schema: + type: string + channel_id: + name: channel_id + in: path + required: true + schema: + type: string + message_id: + name: message_id + in: path + required: true + schema: + type: string + conversation_id: + name: conversation_id + in: path + required: true + schema: + type: string + schemas: + CreateWorkspaceRequest: + type: object + required: [name] + properties: + name: + type: string + slug: + type: string + RequestMagicLinkRequest: + type: object + required: [email] + properties: + email: + type: string + format: email + display_name: + type: string + ConsumeMagicLinkRequest: + type: object + required: [token] + properties: + token: + type: string + CreateChannelRequest: + type: object + required: [name] + properties: + name: + type: string + kind: + type: string + default: public + UpdateChannelRequest: + type: object + properties: + name: + type: string + kind: + type: string + archived: + type: boolean + CreateMessageRequest: + type: object + required: [body] + properties: + body: + type: string + body_format: + type: string + enum: [markdown] + default: markdown + AddReactionRequest: + type: object + required: [emoji] + properties: + emoji: + type: string + AttachUploadRequest: + type: object + required: [upload_id] + properties: + upload_id: + type: string + CreateDirectConversationRequest: + type: object + required: [workspace_id, member_ids] + properties: + workspace_id: + type: string + member_ids: + type: array + items: + type: string + EphemeralEventRequest: + type: object + required: [workspace_id, type] + properties: + workspace_id: + type: string + channel_id: + type: string + type: + type: string + enum: [typing.started, typing.stopped, presence.changed] + payload: + type: object + additionalProperties: true + MattermostWebhookRequest: + type: object + required: [text] + properties: + text: + type: string + SlashCommandRequest: + type: object + properties: + command: + type: string + text: + type: string + user_name: + type: string diff --git a/packages/protocol/package.json b/packages/protocol/package.json new file mode 100644 index 0000000..95eadfe --- /dev/null +++ b/packages/protocol/package.json @@ -0,0 +1,15 @@ +{ + "name": "@clickclack/protocol", + "version": "0.0.0", + "private": true, + "license": "MIT", + "scripts": { + "generate": "openapi-typescript openapi.yaml -o ../sdk-ts/src/generated/openapi.d.ts" + }, + "devDependencies": { + "openapi-typescript": "^7.13.0" + }, + "files": [ + "openapi.yaml" + ] +} diff --git a/packages/sdk-ts/package.json b/packages/sdk-ts/package.json new file mode 100644 index 0000000..9765337 --- /dev/null +++ b/packages/sdk-ts/package.json @@ -0,0 +1,16 @@ +{ + "name": "@clickclack/sdk-ts", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsgo -p tsconfig.json && mkdir -p dist/generated && cp src/generated/openapi.d.ts dist/generated/openapi.d.ts", + "typecheck": "tsgo --noEmit -p tsconfig.json" + }, + "devDependencies": { + "@typescript/native-preview": "7.0.0-dev.20260507.1" + } +} diff --git a/packages/sdk-ts/src/generated/openapi.d.ts b/packages/sdk-ts/src/generated/openapi.d.ts new file mode 100644 index 0000000..d7a339c --- /dev/null +++ b/packages/sdk-ts/src/generated/openapi.d.ts @@ -0,0 +1,1214 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/auth/magic/request": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["requestMagicLink"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/magic/consume": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["consumeMagicLink"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/github/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["startGitHubOAuth"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/github/callback": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["finishGitHubOAuth"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getMe"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/workspaces": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listWorkspaces"]; + put?: never; + post: operations["createWorkspace"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/workspaces/{workspace_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getWorkspace"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/workspaces/{workspace_id}/channels": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listChannels"]; + put?: never; + post: operations["createChannel"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/channels/{channel_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch: operations["updateChannel"]; + trace?: never; + }; + "/api/channels/{channel_id}/messages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listMessages"]; + put?: never; + post: operations["createMessage"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/messages/{message_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["deleteMessage"]; + options?: never; + head?: never; + patch: operations["updateMessage"]; + trace?: never; + }; + "/api/messages/{message_id}/thread": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getThread"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/messages/{message_id}/thread/replies": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["createThreadReply"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/messages/{message_id}/reactions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["addReaction"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/messages/{message_id}/attachments": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["attachUpload"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/messages/{message_id}/reactions/{emoji}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["removeReaction"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/realtime/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listEvents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/realtime/ephemeral": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["publishEphemeral"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/realtime/ws": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["realtimeWebSocket"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["search"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/uploads": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["createUpload"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/uploads/{upload_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getUpload"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/dms": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listDirectConversations"]; + put?: never; + post: operations["createDirectConversation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/dms/{conversation_id}/messages": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listDirectMessages"]; + put?: never; + post: operations["createDirectMessage"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/hooks/mattermost/{channel_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["mattermostIncomingWebhook"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/hooks/slash/{channel_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["slashCommand"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + CreateWorkspaceRequest: { + name: string; + slug?: string; + }; + RequestMagicLinkRequest: { + /** Format: email */ + email: string; + display_name?: string; + }; + ConsumeMagicLinkRequest: { + token: string; + }; + CreateChannelRequest: { + name: string; + /** @default public */ + kind: string; + }; + UpdateChannelRequest: { + name?: string; + kind?: string; + archived?: boolean; + }; + CreateMessageRequest: { + body: string; + /** + * @default markdown + * @enum {string} + */ + body_format: "markdown"; + }; + AddReactionRequest: { + emoji: string; + }; + AttachUploadRequest: { + upload_id: string; + }; + CreateDirectConversationRequest: { + workspace_id: string; + member_ids: string[]; + }; + EphemeralEventRequest: { + workspace_id: string; + channel_id?: string; + /** @enum {string} */ + type: "typing.started" | "typing.stopped" | "presence.changed"; + payload?: { + [key: string]: unknown; + }; + }; + MattermostWebhookRequest: { + text: string; + }; + SlashCommandRequest: { + command?: string; + text?: string; + user_name?: string; + }; + }; + responses: never; + parameters: { + workspace_id: string; + channel_id: string; + message_id: string; + conversation_id: string; + }; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + requestMagicLink: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RequestMagicLinkRequest"]; + }; + }; + responses: { + /** @description Created local magic-link token */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + consumeMagicLink: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConsumeMagicLinkRequest"]; + }; + }; + responses: { + /** @description Created session */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + startGitHubOAuth: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Redirect to GitHub OAuth authorization */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description GitHub OAuth not configured */ + 501: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + finishGitHubOAuth: { + parameters: { + query?: { + code?: string; + state?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Session created and redirected to app */ + 302: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Invalid OAuth callback */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getMe: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current local user */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listWorkspaces: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Workspace list */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createWorkspace: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateWorkspaceRequest"]; + }; + }; + responses: { + /** @description Created workspace */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getWorkspace: { + parameters: { + query?: never; + header?: never; + path: { + workspace_id: components["parameters"]["workspace_id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Workspace */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listChannels: { + parameters: { + query?: never; + header?: never; + path: { + workspace_id: components["parameters"]["workspace_id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Channel list */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createChannel: { + parameters: { + query?: never; + header?: never; + path: { + workspace_id: components["parameters"]["workspace_id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateChannelRequest"]; + }; + }; + responses: { + /** @description Created channel */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateChannel: { + parameters: { + query?: never; + header?: never; + path: { + channel_id: components["parameters"]["channel_id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateChannelRequest"]; + }; + }; + responses: { + /** @description Updated channel */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listMessages: { + parameters: { + query?: { + after_seq?: number; + limit?: number; + }; + header?: never; + path: { + channel_id: components["parameters"]["channel_id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Root channel messages */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createMessage: { + parameters: { + query?: never; + header?: never; + path: { + channel_id: components["parameters"]["channel_id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateMessageRequest"]; + }; + }; + responses: { + /** @description Created message */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteMessage: { + parameters: { + query?: never; + header?: never; + path: { + message_id: components["parameters"]["message_id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Soft-deleted message */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateMessage: { + parameters: { + query?: never; + header?: never; + path: { + message_id: components["parameters"]["message_id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateMessageRequest"]; + }; + }; + responses: { + /** @description Updated message */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getThread: { + parameters: { + query?: never; + header?: never; + path: { + message_id: components["parameters"]["message_id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Thread root and replies */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createThreadReply: { + parameters: { + query?: never; + header?: never; + path: { + message_id: components["parameters"]["message_id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateMessageRequest"]; + }; + }; + responses: { + /** @description Created thread reply */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + addReaction: { + parameters: { + query?: never; + header?: never; + path: { + message_id: components["parameters"]["message_id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AddReactionRequest"]; + }; + }; + responses: { + /** @description Added reaction */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + attachUpload: { + parameters: { + query?: never; + header?: never; + path: { + message_id: components["parameters"]["message_id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AttachUploadRequest"]; + }; + }; + responses: { + /** @description Attached upload to message */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + removeReaction: { + parameters: { + query?: never; + header?: never; + path: { + message_id: components["parameters"]["message_id"]; + emoji: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Removed reaction */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listEvents: { + parameters: { + query: { + workspace_id: string; + after_cursor?: string; + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Durable events after cursor */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + publishEphemeral: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EphemeralEventRequest"]; + }; + }; + responses: { + /** @description Ephemeral event accepted */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + realtimeWebSocket: { + parameters: { + query: { + workspace_id: string; + after_cursor?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description WebSocket upgrade */ + 101: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + search: { + parameters: { + query: { + workspace_id: string; + q: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Search results */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createUpload: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + workspace_id: string; + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description Created upload */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getUpload: { + parameters: { + query?: never; + header?: never; + path: { + upload_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Upload bytes */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listDirectConversations: { + parameters: { + query: { + workspace_id: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description DM list */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createDirectConversation: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateDirectConversationRequest"]; + }; + }; + responses: { + /** @description Created DM */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listDirectMessages: { + parameters: { + query?: { + after_seq?: number; + limit?: number; + }; + header?: never; + path: { + conversation_id: components["parameters"]["conversation_id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Direct messages */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + createDirectMessage: { + parameters: { + query?: never; + header?: never; + path: { + conversation_id: components["parameters"]["conversation_id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateMessageRequest"]; + }; + }; + responses: { + /** @description Created direct message */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + mattermostIncomingWebhook: { + parameters: { + query?: never; + header?: never; + path: { + channel_id: components["parameters"]["channel_id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MattermostWebhookRequest"]; + }; + }; + responses: { + /** @description Created message from incoming webhook */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + slashCommand: { + parameters: { + query?: never; + header?: never; + path: { + channel_id: components["parameters"]["channel_id"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/x-www-form-urlencoded": components["schemas"]["SlashCommandRequest"]; + }; + }; + responses: { + /** @description Slash-command callback response */ + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; +} diff --git a/packages/sdk-ts/src/index.ts b/packages/sdk-ts/src/index.ts new file mode 100644 index 0000000..f48242d --- /dev/null +++ b/packages/sdk-ts/src/index.ts @@ -0,0 +1,323 @@ +export type { components, paths } from "./generated/openapi"; + +export type User = { + id: string; + display_name: string; + avatar_url: string; + created_at: string; +}; + +export type Workspace = { + id: string; + name: string; + slug: string; + created_at: string; +}; + +export type Channel = { + id: string; + workspace_id: string; + name: string; + kind: string; + created_at: string; + archived_at?: string; +}; + +export type Message = { + id: string; + workspace_id: string; + channel_id: string; + author_id: string; + parent_message_id?: string; + thread_root_id: string; + channel_seq?: number; + thread_seq?: number; + body: string; + body_format: "markdown"; + created_at: string; + edited_at?: string; + deleted_at?: string; + author?: User; +}; + +export type Upload = { + id: string; + workspace_id: string; + owner_id: string; + filename: string; + content_type: string; + byte_size: number; + created_at: string; +}; + +export type DirectConversation = { + id: string; + workspace_id: string; + created_at: string; + members: User[]; +}; + +export type RealtimeEvent = { + id: string; + cursor: string; + type: string; + workspace_id: string; + channel_id?: string; + seq?: number; + created_at: string; + payload: unknown; +}; + +export type ClickClackClientOptions = { + baseUrl: string; + userId?: string; + token?: string; + fetch?: typeof fetch; +}; + +export class ClickClackClient { + private readonly baseUrl: string; + private readonly userId?: string; + private token?: string; + private readonly fetcher: typeof fetch; + + constructor(options: ClickClackClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/$/, ""); + this.userId = options.userId; + this.token = options.token; + this.fetcher = options.fetch ?? fetch; + } + + auth = { + requestMagicLink: async (input: { email: string; display_name?: string }) => { + return this.request("/api/auth/magic/request", { + method: "POST", + body: JSON.stringify(input), + }); + }, + consumeMagicLink: async ( + token: string, + ): Promise<{ user: User; session: { token: string } }> => { + const data = await this.request<{ user: User; session: { token: string } }>( + "/api/auth/magic/consume", + { + method: "POST", + body: JSON.stringify({ token }), + }, + ); + this.token = data.session.token; + return data; + }, + setToken: (token: string) => { + this.token = token; + }, + githubStartUrl: (): string => { + return `${this.baseUrl}/api/auth/github/start`; + }, + }; + + async me(): Promise { + const data = await this.request<{ user: User }>("/api/me"); + return data.user; + } + + workspaces = { + list: async (): Promise => { + const data = await this.request<{ workspaces: Workspace[] }>("/api/workspaces"); + return data.workspaces; + }, + create: async (input: { name: string; slug?: string }): Promise => { + const data = await this.request<{ workspace: Workspace }>("/api/workspaces", { + method: "POST", + body: JSON.stringify(input), + }); + return data.workspace; + }, + }; + + channels = { + list: async (workspaceId: string): Promise => { + const data = await this.request<{ channels: Channel[] }>( + `/api/workspaces/${workspaceId}/channels`, + ); + return data.channels; + }, + create: async ( + workspaceId: string, + input: { name: string; kind?: string }, + ): Promise => { + const data = await this.request<{ channel: Channel }>( + `/api/workspaces/${workspaceId}/channels`, + { + method: "POST", + body: JSON.stringify(input), + }, + ); + return data.channel; + }, + update: async ( + channelId: string, + input: { name?: string; kind?: string; archived?: boolean }, + ): Promise => { + const data = await this.request<{ channel: Channel }>(`/api/channels/${channelId}`, { + method: "PATCH", + body: JSON.stringify(input), + }); + return data.channel; + }, + messages: async (channelId: string, afterSeq = 0): Promise => { + const data = await this.request<{ messages: Message[] }>( + `/api/channels/${channelId}/messages?after_seq=${afterSeq}`, + ); + return data.messages; + }, + sendMessage: async (channelId: string, input: { body: string }): Promise => { + const data = await this.request<{ message: Message }>(`/api/channels/${channelId}/messages`, { + method: "POST", + body: JSON.stringify(input), + }); + return data.message; + }, + }; + + messages = { + update: async (messageId: string, input: { body: string }): Promise => { + const data = await this.request<{ message: Message }>(`/api/messages/${messageId}`, { + method: "PATCH", + body: JSON.stringify(input), + }); + return data.message; + }, + delete: async (messageId: string): Promise => { + const data = await this.request<{ message: Message }>(`/api/messages/${messageId}`, { + method: "DELETE", + }); + return data.message; + }, + }; + + threads = { + get: async (messageId: string) => { + return this.request(`/api/messages/${messageId}/thread`); + }, + reply: async (messageId: string, input: { body: string }): Promise => { + const data = await this.request<{ message: Message }>( + `/api/messages/${messageId}/thread/replies`, + { + method: "POST", + body: JSON.stringify(input), + }, + ); + return data.message; + }, + }; + + search = async (workspaceId: string, query: string) => { + return this.request( + `/api/search?workspace_id=${encodeURIComponent(workspaceId)}&q=${encodeURIComponent(query)}`, + ); + }; + + uploads = { + create: async ( + workspaceId: string, + file: File | Blob, + filename = "upload.bin", + ): Promise => { + const form = new FormData(); + form.set("workspace_id", workspaceId); + form.set("file", file, filename); + const data = await this.request<{ upload: Upload }>("/api/uploads", { + method: "POST", + body: form, + }); + return data.upload; + }, + attach: async (messageId: string, uploadId: string): Promise => { + await this.request(`/api/messages/${messageId}/attachments`, { + method: "POST", + body: JSON.stringify({ upload_id: uploadId }), + }); + }, + }; + + dms = { + list: async (workspaceId: string): Promise => { + const data = await this.request<{ conversations: DirectConversation[] }>( + `/api/dms?workspace_id=${encodeURIComponent(workspaceId)}`, + ); + return data.conversations; + }, + create: async (workspaceId: string, memberIds: string[]): Promise => { + const data = await this.request<{ conversation: DirectConversation }>("/api/dms", { + method: "POST", + body: JSON.stringify({ workspace_id: workspaceId, member_ids: memberIds }), + }); + return data.conversation; + }, + messages: async (conversationId: string, afterSeq = 0): Promise => { + const data = await this.request<{ messages: Message[] }>( + `/api/dms/${conversationId}/messages?after_seq=${afterSeq}`, + ); + return data.messages; + }, + sendMessage: async (conversationId: string, input: { body: string }): Promise => { + const data = await this.request<{ message: Message }>(`/api/dms/${conversationId}/messages`, { + method: "POST", + body: JSON.stringify(input), + }); + return data.message; + }, + }; + + events = { + publishEphemeral: async (input: { + workspaceId: string; + channelId?: string; + type: "typing.started" | "typing.stopped" | "presence.changed"; + payload?: Record; + }): Promise => { + const data = await this.request<{ event: RealtimeEvent }>("/api/realtime/ephemeral", { + method: "POST", + body: JSON.stringify({ + workspace_id: input.workspaceId, + channel_id: input.channelId, + type: input.type, + payload: input.payload, + }), + }); + return data.event; + }, + subscribe: (options: { + workspaceId: string; + afterCursor?: string; + onEvent: (event: RealtimeEvent) => void; + onClose?: () => void; + }): WebSocket => { + const url = new URL(`${this.baseUrl}/api/realtime/ws`); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + url.searchParams.set("workspace_id", options.workspaceId); + if (options.afterCursor) url.searchParams.set("after_cursor", options.afterCursor); + const socket = new WebSocket(url); + socket.addEventListener("message", (message) => + options.onEvent(JSON.parse(String(message.data))), + ); + if (options.onClose) socket.addEventListener("close", options.onClose); + return socket; + }, + }; + + private async request(path: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + headers.set("Accept", "application/json"); + if (init.body && !(init.body instanceof FormData)) + headers.set("Content-Type", "application/json"); + if (this.token) headers.set("Authorization", `Bearer ${this.token}`); + if (this.userId) headers.set("X-ClickClack-User", this.userId); + const response = await this.fetcher(`${this.baseUrl}${path}`, { ...init, headers }); + if (!response.ok) { + throw new Error(await response.text()); + } + return response.json() as Promise; + } +} diff --git a/packages/sdk-ts/tsconfig.json b/packages/sdk-ts/tsconfig.json new file mode 100644 index 0000000..2ccaf6d --- /dev/null +++ b/packages/sdk-ts/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": false, + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "target": "ES2022" + }, + "include": ["src/**/*.ts"] +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d6e3250 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,26 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "tests/e2e", + timeout: 30_000, + expect: { + timeout: 5_000, + }, + use: { + baseURL: "http://127.0.0.1:18082", + trace: "on-first-retry", + }, + webServer: { + command: + "rm -rf data/e2e && pnpm build && go run ./apps/api/cmd/clickclack serve --addr 127.0.0.1:18082 --data ./data/e2e", + url: "http://127.0.0.1:18082", + reuseExistingServer: false, + timeout: 30_000, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..11bb373 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1718 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260507.1 + version: 7.0.0-dev.20260507.1 + oxfmt: + specifier: ^0.48.0 + version: 0.48.0 + oxlint: + specifier: ^1.63.0 + version: 1.63.0 + + apps/web: + dependencies: + '@sveltejs/vite-plugin-svelte': + specifier: ^6.2.1 + version: 6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.6.2)) + dompurify: + specifier: ^3.3.0 + version: 3.4.2 + marked: + specifier: ^17.0.1 + version: 17.0.6 + svelte: + specifier: ^5.45.6 + version: 5.55.5 + vite: + specifier: ^7.2.4 + version: 7.3.3(@types/node@25.6.2) + devDependencies: + '@typescript/native-preview': + specifier: 7.0.0-dev.20260507.1 + version: 7.0.0-dev.20260507.1 + + examples/bot-ts: + dependencies: + '@clickclack/sdk-ts': + specifier: workspace:* + version: link:../../packages/sdk-ts + devDependencies: + '@types/node': + specifier: ^25.6.2 + version: 25.6.2 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260507.1 + version: 7.0.0-dev.20260507.1 + + packages/protocol: + devDependencies: + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@5.9.3) + + packages/sdk-ts: + devDependencies: + '@typescript/native-preview': + specifier: 7.0.0-dev.20260507.1 + version: 7.0.0-dev.20260507.1 + +packages: + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxfmt/binding-android-arm-eabi@0.48.0': + resolution: {integrity: sha512-uwqk+/KhQvBIpULD8SMM/zAafMRC/+DV/xsEQjkkIsJ/kLmEI/2bxonVowcYTiXqqZ/a0FEW8DPkZY3VvwELDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.48.0': + resolution: {integrity: sha512-VUCiKuXK5+McVssgHEJdrcGK7hRJzrRb36zm9/jwzMholyYt4BgXhw5Nm1V1DX6Ce717Zi/1jk432b/tgmQgtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.48.0': + resolution: {integrity: sha512-IkKp8rnIyQLW6Jt+6jragCbUVYSayk55lapiprLjIVvt4NczLyO/nwX2GgefLQ5iaBdfS8UEAFgCs/pLO6Cl0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.48.0': + resolution: {integrity: sha512-+aFuhsGIuvnoOjXyKVHMhPKJZR1kQkAl8QyrKoMlA7yJsSTC3N0Asl53La8TChSHhW8epToQ/Q0nvLmEmfNmLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.48.0': + resolution: {integrity: sha512-fbqzQL8FjI9gGnktI7RIo0dksDziTAYBy7xlI7jU7eID5fxLF/25fS4Xj6GydD8Y5oWHL83U4NK160QaOAxtyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.48.0': + resolution: {integrity: sha512-hn4i0zhAyTiB3ZHjQfYUZkDvrbVkohw1S7pySWxWUoZ87HnkDoTFThj7QTxk40hNPOTUP0vHbPRNamFIv1HBJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.48.0': + resolution: {integrity: sha512-R4WBD9qF3QM9hqgdAa+fBGXmquTvDUujrPQ36t2Sjk8RPOSKGHDeN7l/khr10hqbQaOq9KCgPHG9ubNET/X/RQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.48.0': + resolution: {integrity: sha512-5bVdwSwlm1M8wbYCorLOxWxUBw/8tBvHYyQNIfwWVPwOJaj5vg1APSGJQVpwJfV5VNE9PSrR91UKEpoNwHhqUA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.48.0': + resolution: {integrity: sha512-vCS3Fk7gFslTqE1lUE2IlroyVV7u/9SmMA/uBqDoshuck2psGWcjW0ePyPZI3rM3+qtf2pDaMVIKMHozraifuw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.48.0': + resolution: {integrity: sha512-gKtfFfueUClXDumyoHUbymqRf7prHejOOyzJK0eIJn93GF9JBdFHdo60TM1ZBHxkEwZvjuOgHmKtneKbEOc/Eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.48.0': + resolution: {integrity: sha512-SYt0UhOvZD/UwZz9sXq6J2uAw8o24f5VZpLB2DH01f6MevshmlgakQlZe2lwek2sZJkd07eLu7mZa0g7yeiw7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.48.0': + resolution: {integrity: sha512-JLbrwck2AopG4ud/XklZO5N+qxGC7cS7ROvXZVNfx0MCLDDL2kGOLvzuWORkVjnjAM0CMAfIMU2zNBtQbM+4dw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.48.0': + resolution: {integrity: sha512-mdxt5L8OQLxkQH+JVpdC/lknZNe0lX4hlO3d8+xvw2wToo+iDrid9tiGOd5bmHfUVd5wVhrUry0qlu5vq66NkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.48.0': + resolution: {integrity: sha512-oEz1BQwMrV7OMEFx/3VPDU3n9TM0AnxpktDYXjEg5i6nTX87wo18wSfBvkl4tzAICdKtoAQAdBIl7Y7hsPlx5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.48.0': + resolution: {integrity: sha512-g2SKTTurP5mWjd8Ecait0erYqmltL4IqW1EwttM25BxM6NiTt4ubobJYMR1uox1V2QgG4UfHH10CGRvWlUixjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.48.0': + resolution: {integrity: sha512-CIg24VgheEpvolHL2gQuax5qcQ602bRMHrJ9g8XsQr3iVj9aSPgopigBKuMqrXsupwkrU+RQCn5cG8PgFntR6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.48.0': + resolution: {integrity: sha512-zeaWkcxcEULwkGF3I/HgEvcDPN8buYDrxibBUa/IFh5Vmwyge+KpLO+hEwSovW349H0O/C0Z2kaFmEzEDm00/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.48.0': + resolution: {integrity: sha512-yiEKnIAGvx5CyZQOlMaNlZkAbwT7/Quk0j3WLt+PR5hK+qYjPTRRJYDfD77wCBPLvEYAG41v4KG3iL0H+uxoxg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.48.0': + resolution: {integrity: sha512-GSD2+7t2UoVMV2NgxXypa4bKewflPMAjYnF0Xw9/ht82ZfafAHhb8STwrEd7wlH2PFogt5zw3WVCxYJaHUdbeQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.63.0': + resolution: {integrity: sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.63.0': + resolution: {integrity: sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.63.0': + resolution: {integrity: sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.63.0': + resolution: {integrity: sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.63.0': + resolution: {integrity: sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.63.0': + resolution: {integrity: sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.63.0': + resolution: {integrity: sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.63.0': + resolution: {integrity: sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.63.0': + resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.63.0': + resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.63.0': + resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.63.0': + resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.63.0': + resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.63.0': + resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.63.0': + resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.63.0': + resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.63.0': + resolution: {integrity: sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.63.0': + resolution: {integrity: sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.63.0': + resolution: {integrity: sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.14': + resolution: {integrity: sha512-y+xFx+Zz54Xhr8jUdnLENYnt7Y7GEDL6Q03ga7rTtX8DVwefX9H+hQEPgJp1nda7vdH+wJ9/HBVvyfBuW9x6rA==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + + '@rollup/rollup-android-arm-eabi@4.60.3': + resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.3': + resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.3': + resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.3': + resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.3': + resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.3': + resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.3': + resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.3': + resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.3': + resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.3': + resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.3': + resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.3': + resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.3': + resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.3': + resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} + cpu: [x64] + os: [win32] + + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/vite-plugin-svelte-inspector@5.0.2': + resolution: {integrity: sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0 + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@sveltejs/vite-plugin-svelte@6.2.4': + resolution: {integrity: sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.3.0 || ^7.0.0 + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@25.6.2': + resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260507.1': + resolution: {integrity: sha512-2iWAWthp9tWDkgIOITi6GGQkEDQ8Mf+L28B83FR+MvvbLEtHJ5BIZoBOSBgKP5KW+eHlAv1LFUC9dggq1+NO0Q==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260507.1': + resolution: {integrity: sha512-pXBJx0gF4D9aNZsFavprlbPzL5clAvHsueaVpb3c7M8wv/3FFCdjK7NJUESxpK3+M1RW5MvNaKR6QTzkdunfvQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260507.1': + resolution: {integrity: sha512-0F2o8sbpSOHR04ghnhWPFsyuH9uew78v3fc2+tplxAnwZ5Wt72hk6Ka0yG07m8D6Ca0SK/GtTVIq7BfjmNCP8w==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260507.1': + resolution: {integrity: sha512-i2K4RRVjk9wSMpGcR5G2hKyUowSZMrnSKh5NYx1nVy6srBD7DVrTSBDH+KCVdAVAuZtsl0tOdVJixkRRjOsbpw==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260507.1': + resolution: {integrity: sha512-dST5xeuhREr73obBJj4j5Dtf0dEQr6WuUyHIoLaVQCX9PZhWk0Iu2/9jJ0+Gtx7fh3jWGcidNPP1SgmSrXP6Sw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260507.1': + resolution: {integrity: sha512-21rGqCoY2FgnbY2YQFGoAnaHFs5kagwdCmGdn7GmsdNF7P3zvS1ag56BFRYITZ2xw02xYa0fvbXcIIycysBS1A==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260507.1': + resolution: {integrity: sha512-PORjTM6k/7ySuN7qbKKLKPJ5AlSQuZbaDkLsdQglapQySeHcrdjOhFl172U+V954sR+KhrE3ckhuinEH+Vjkug==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260507.1': + resolution: {integrity: sha512-1NCr79LEzPErrYtminofTji5EvFDYwJ2JDQfDhcQyP8XVJF93LZ5jiDXcYE2MgqDvwPUpaHMY8seC28jHrc/ew==} + engines: {node: '>=16.20.0'} + hasBin: true + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + devalue@5.8.0: + resolution: {integrity: sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==} + + dompurify@3.4.2: + resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.2.6: + resolution: {integrity: sha512-WN0clHt0a4mzC780UBVVBpsj4vSSjOFNRd2WjYtduB9HeKxm1sjHMNUwLEHVjI3FdCQD/Hurgz9ftbKEzP79Ow==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + marked@17.0.6: + resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} + engines: {node: '>= 20'} + hasBin: true + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + + oxfmt@0.48.0: + resolution: {integrity: sha512-AVaLh+7XeGx+R1zfFV+f6VV61nT2MWVJXVUDhbTm5LBWGyNt64xAyh3NYYyjeY2WykNt9AvqSQLPHcbWquYF9g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + oxlint@1.63.0: + resolution: {integrity: sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rollup@4.60.3: + resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + svelte@5.55.5: + resolution: {integrity: sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@oxfmt/binding-android-arm-eabi@0.48.0': + optional: true + + '@oxfmt/binding-android-arm64@0.48.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.48.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.48.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.48.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.48.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.48.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.48.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.48.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.48.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.48.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.48.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.48.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.48.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.48.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.48.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.48.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.48.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.48.0': + optional: true + + '@oxlint/binding-android-arm-eabi@1.63.0': + optional: true + + '@oxlint/binding-android-arm64@1.63.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.63.0': + optional: true + + '@oxlint/binding-darwin-x64@1.63.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.63.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.63.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.63.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.63.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.63.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.63.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.63.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.63.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.63.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.63.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.63.0': + optional: true + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.14(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + + '@rollup/rollup-android-arm-eabi@4.60.3': + optional: true + + '@rollup/rollup-android-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.3': + optional: true + + '@rollup/rollup-darwin-x64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.3': + optional: true + + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/vite-plugin-svelte-inspector@5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.6.2)))(svelte@5.55.5)(vite@7.3.3(@types/node@25.6.2))': + dependencies: + '@sveltejs/vite-plugin-svelte': 6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.6.2)) + obug: 2.1.1 + svelte: 5.55.5 + vite: 7.3.3(@types/node@25.6.2) + + '@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.6.2))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 5.0.2(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.5)(vite@7.3.3(@types/node@25.6.2)))(svelte@5.55.5)(vite@7.3.3(@types/node@25.6.2)) + deepmerge: 4.3.1 + magic-string: 0.30.21 + obug: 2.1.1 + svelte: 5.55.5 + vite: 7.3.3(@types/node@25.6.2) + vitefu: 1.1.3(vite@7.3.3(@types/node@25.6.2)) + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/node@25.6.2': + dependencies: + undici-types: 7.19.2 + + '@types/trusted-types@2.0.7': {} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260507.1': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260507.1': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260507.1': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260507.1': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260507.1': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260507.1': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260507.1': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260507.1': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260507.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260507.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260507.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260507.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260507.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260507.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260507.1 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ansi-colors@4.1.3: {} + + argparse@2.0.1: {} + + aria-query@5.3.1: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + change-case@5.4.4: {} + + clsx@2.1.1: {} + + colorette@1.4.0: {} + + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + + deepmerge@4.3.1: {} + + devalue@5.8.0: {} + + dompurify@3.4.2: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + esm-env@1.2.2: {} + + esrap@2.2.6: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + fast-deep-equal@3.1.3: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + + index-to-position@1.2.0: {} + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + js-levenshtein@1.1.6: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + locate-character@3.0.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + marked@17.0.6: {} + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + obug@2.1.1: {} + + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.14(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + + oxfmt@0.48.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.48.0 + '@oxfmt/binding-android-arm64': 0.48.0 + '@oxfmt/binding-darwin-arm64': 0.48.0 + '@oxfmt/binding-darwin-x64': 0.48.0 + '@oxfmt/binding-freebsd-x64': 0.48.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.48.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.48.0 + '@oxfmt/binding-linux-arm64-gnu': 0.48.0 + '@oxfmt/binding-linux-arm64-musl': 0.48.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.48.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.48.0 + '@oxfmt/binding-linux-riscv64-musl': 0.48.0 + '@oxfmt/binding-linux-s390x-gnu': 0.48.0 + '@oxfmt/binding-linux-x64-gnu': 0.48.0 + '@oxfmt/binding-linux-x64-musl': 0.48.0 + '@oxfmt/binding-openharmony-arm64': 0.48.0 + '@oxfmt/binding-win32-arm64-msvc': 0.48.0 + '@oxfmt/binding-win32-ia32-msvc': 0.48.0 + '@oxfmt/binding-win32-x64-msvc': 0.48.0 + + oxlint@1.63.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.63.0 + '@oxlint/binding-android-arm64': 1.63.0 + '@oxlint/binding-darwin-arm64': 1.63.0 + '@oxlint/binding-darwin-x64': 1.63.0 + '@oxlint/binding-freebsd-x64': 1.63.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.63.0 + '@oxlint/binding-linux-arm-musleabihf': 1.63.0 + '@oxlint/binding-linux-arm64-gnu': 1.63.0 + '@oxlint/binding-linux-arm64-musl': 1.63.0 + '@oxlint/binding-linux-ppc64-gnu': 1.63.0 + '@oxlint/binding-linux-riscv64-gnu': 1.63.0 + '@oxlint/binding-linux-riscv64-musl': 1.63.0 + '@oxlint/binding-linux-s390x-gnu': 1.63.0 + '@oxlint/binding-linux-x64-gnu': 1.63.0 + '@oxlint/binding-linux-x64-musl': 1.63.0 + '@oxlint/binding-openharmony-arm64': 1.63.0 + '@oxlint/binding-win32-arm64-msvc': 1.63.0 + '@oxlint/binding-win32-ia32-msvc': 1.63.0 + '@oxlint/binding-win32-x64-msvc': 1.63.0 + + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.41.0 + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + + pluralize@8.0.0: {} + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + require-from-string@2.0.2: {} + + rollup@4.60.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.3 + '@rollup/rollup-android-arm64': 4.60.3 + '@rollup/rollup-darwin-arm64': 4.60.3 + '@rollup/rollup-darwin-x64': 4.60.3 + '@rollup/rollup-freebsd-arm64': 4.60.3 + '@rollup/rollup-freebsd-x64': 4.60.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 + '@rollup/rollup-linux-arm-musleabihf': 4.60.3 + '@rollup/rollup-linux-arm64-gnu': 4.60.3 + '@rollup/rollup-linux-arm64-musl': 4.60.3 + '@rollup/rollup-linux-loong64-gnu': 4.60.3 + '@rollup/rollup-linux-loong64-musl': 4.60.3 + '@rollup/rollup-linux-ppc64-gnu': 4.60.3 + '@rollup/rollup-linux-ppc64-musl': 4.60.3 + '@rollup/rollup-linux-riscv64-gnu': 4.60.3 + '@rollup/rollup-linux-riscv64-musl': 4.60.3 + '@rollup/rollup-linux-s390x-gnu': 4.60.3 + '@rollup/rollup-linux-x64-gnu': 4.60.3 + '@rollup/rollup-linux-x64-musl': 4.60.3 + '@rollup/rollup-openbsd-x64': 4.60.3 + '@rollup/rollup-openharmony-arm64': 4.60.3 + '@rollup/rollup-win32-arm64-msvc': 4.60.3 + '@rollup/rollup-win32-ia32-msvc': 4.60.3 + '@rollup/rollup-win32-x64-gnu': 4.60.3 + '@rollup/rollup-win32-x64-msvc': 4.60.3 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + supports-color@10.2.2: {} + + svelte@5.55.5: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.8.0 + esm-env: 1.2.2 + esrap: 2.2.6 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@2.1.0: {} + + type-fest@4.41.0: {} + + typescript@5.9.3: {} + + undici-types@7.19.2: {} + + uri-js-replace@1.0.1: {} + + vite@7.3.3(@types/node@25.6.2): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.14 + rollup: 4.60.3 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.2 + fsevents: 2.3.3 + + vitefu@1.1.3(vite@7.3.3(@types/node@25.6.2)): + optionalDependencies: + vite: 7.3.3(@types/node@25.6.2) + + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} + + zimmerframe@1.1.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..7daf3b7 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - apps/* + - examples/* + - packages/* +allowBuilds: + esbuild: true diff --git a/tests/e2e/chat.spec.ts b/tests/e2e/chat.spec.ts new file mode 100644 index 0000000..4f0f539 --- /dev/null +++ b/tests/e2e/chat.spec.ts @@ -0,0 +1,75 @@ +import { expect, test } from "@playwright/test"; +import { execFileSync } from "node:child_process"; + +test("sends messages, searches, uploads, opens a thread, and creates a DM", async ({ page }) => { + const consoleMessages: string[] = []; + page.on("console", (message) => consoleMessages.push(`${message.type()}: ${message.text()}`)); + page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`)); + const workspacesResponse = await page.request.get("/api/workspaces"); + const workspaces = (await workspacesResponse.json()) as { workspaces: { id: string }[] }; + const workspaceId = workspaces.workspaces[0].id; + const secondUserId = execFileSync( + "go", + [ + "run", + "./apps/api/cmd/clickclack", + "admin", + "user", + "create", + "--data", + "./data/e2e", + "--workspace", + workspaceId, + "--name", + "Second User", + "--email", + "second@example.com", + ], + { cwd: process.cwd(), encoding: "utf8" }, + ).trim(); + + await page.goto("/"); + + await page.getByRole("button", { name: "# general" }).click(); + await expect(page.getByRole("heading", { name: "#general" })).toBeVisible(); + + await page.getByLabel("Message body").fill("hello **playwright**"); + await page.getByRole("button", { name: "Send" }).click(); + await expect( + page.locator(".markdown").filter({ hasText: "hello playwright" }), + consoleMessages.join("\n"), + ).toBeVisible({ + timeout: 5_000, + }); + + await page.getByLabel("Search messages").fill("playwright"); + await page.getByRole("button", { name: "Search" }).click(); + await expect(page.getByLabel("Search results").getByText("hello **playwright**")).toBeVisible(); + + await page.getByLabel("Upload file").setInputFiles({ + name: "note.txt", + mimeType: "text/plain", + buffer: Buffer.from("uploaded from playwright"), + }); + await expect(page.getByText("note.txt")).toBeVisible(); + await page.getByLabel("Message body").fill("message with upload"); + await page.getByRole("button", { name: "Send" }).click(); + await expect(page.locator(".markdown").filter({ hasText: "message with upload" })).toBeVisible(); + + await page.getByRole("button", { name: "Open thread" }).first().click(); + await expect(page.getByText("Thread", { exact: true })).toBeVisible(); + + await page.getByLabel("Reply body").fill("thread _reply_"); + await page.getByRole("button", { name: "Reply" }).click(); + await expect(page.locator(".reply .markdown").filter({ hasText: "thread reply" })).toBeVisible(); + + await page.reload(); + await expect(page.locator(".markdown").filter({ hasText: "hello playwright" })).toBeVisible(); + + await page.getByLabel("DM member user ID").fill(secondUserId); + await page.getByLabel("DM member user ID").press("Enter"); + await expect(page.getByRole("heading", { name: /Second User/ })).toBeVisible(); + await page.getByLabel("Message body").fill("private playwright"); + await page.getByRole("button", { name: "Send" }).click(); + await expect(page.locator(".markdown").filter({ hasText: "private playwright" })).toBeVisible(); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d9293e8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "target": "ES2022", + "types": ["node"] + }, + "include": ["playwright.config.ts", "tests/**/*.ts"] +}