From e817eddd96aa62c578e8cdf2084f34825b9bc290 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Tue, 8 Sep 2026 17:21:50 -0400 Subject: [PATCH 1/2] Add agents, authentication, and file submodules --- pnpm-lock.yaml | 282 +++ spacetime-agents-ts/.npmrc | 1 + spacetime-agents-ts/LICENSE.txt | 759 ++++++++ spacetime-agents-ts/README.md | 165 ++ spacetime-agents-ts/example/.env.example | 40 + spacetime-agents-ts/example/.npmrc | 1 + spacetime-agents-ts/example/README.md | 229 +++ spacetime-agents-ts/example/package.json | 29 + spacetime-agents-ts/example/public/index.html | 403 +++++ .../example/public/markdown.js | 46 + spacetime-agents-ts/example/public/styles.css | 1218 +++++++++++++ spacetime-agents-ts/example/public/ui.js | 1085 ++++++++++++ .../example/scripts/test-markdown.mjs | 35 + spacetime-agents-ts/example/server.ts | 263 +++ .../example/spacetimedb/.npmrc | 1 + .../example/spacetimedb/package.json | 23 + .../example/spacetimedb/scripts/test-loop.ts | 1557 +++++++++++++++++ .../example/spacetimedb/scripts/tsconfig.json | 4 + .../example/spacetimedb/src/agent-runner.ts | 383 ++++ .../example/spacetimedb/src/agents/chat.ts | 18 + .../example/spacetimedb/src/agents/index.ts | 7 + .../spacetimedb/src/agents/summarizer.ts | 17 + .../example/spacetimedb/src/attachments.ts | 39 + .../example/spacetimedb/src/index.ts | 1065 +++++++++++ .../example/spacetimedb/src/loop.ts | 252 +++ .../example/spacetimedb/src/model.ts | 132 ++ .../example/spacetimedb/src/summarize.ts | 73 + .../example/spacetimedb/src/sweeper.ts | 12 + .../example/spacetimedb/src/tools/getTime.ts | 14 + .../example/spacetimedb/src/types.ts | 6 + .../example/spacetimedb/src/views.ts | 115 ++ .../example/spacetimedb/tsconfig.json | 14 + spacetime-agents-ts/example/src/app.ts | 644 +++++++ .../app/add_agent_admin_identity_reducer.ts | 15 + .../add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/agentRateLimit/consume_procedure.ts | 24 + .../agentRateLimit/rate_limit_config_table.ts | 17 + .../agentRateLimit/reset_buckets_reducer.ts | 15 + .../app/agentRateLimit/run_sweep_procedure.ts | 16 + .../app/agentRateLimit/types.ts | 56 + .../agentRateLimit/update_config_reducer.ts | 15 + .../app/agent_override_table.ts | 23 + .../app/auth/get_auth_public_key_procedure.ts | 19 + .../app/auth/link_connection_reducer.ts | 15 + .../app/auth/list_my_sessions_procedure.ts | 19 + .../app/auth/my_auth_user_table.ts | 21 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/auth/rateLimit/consume_procedure.ts | 24 + .../auth/rateLimit/rate_limit_config_table.ts | 17 + .../auth/rateLimit/reset_buckets_reducer.ts | 15 + .../app/auth/rateLimit/run_sweep_procedure.ts | 16 + .../app/auth/rateLimit/types.ts | 56 + .../auth/rateLimit/update_config_reducer.ts | 15 + .../app/auth/revoke_my_session_reducer.ts | 15 + .../app/auth/revoke_session_reducer.ts | 15 + .../app/auth/set_auth_config_reducer.ts | 23 + .../src/module_bindings/app/auth/types.ts | 137 ++ .../app/auth/unlink_connection_reducer.ts | 13 + .../app/auth/update_profile_reducer.ts | 16 + .../app/auth/whoami_procedure.ts | 19 + .../app/clear_agent_override_reducer.ts | 15 + .../app/clear_api_key_reducer.ts | 15 + .../app/clear_thread_lock_reducer.ts | 15 + .../app/delete_thread_reducer.ts | 15 + .../src/module_bindings/app/files/types.ts | 32 + .../app/generate_thread_title_procedure.ts | 16 + .../app/get_agent_config_status_procedure.ts | 19 + .../app/get_auth_public_key_procedure.ts | 19 + .../example/src/module_bindings/app/index.ts | 387 ++++ .../app/link_connection_procedure.ts | 20 + .../app/list_my_sessions_procedure.ts | 19 + .../module_bindings/app/my_auth_user_table.ts | 21 + .../src/module_bindings/app/my_files_table.ts | 28 + .../app/my_message_embeddings_table.ts | 20 + .../module_bindings/app/my_messages_table.ts | 25 + .../app/my_thread_locks_table.ts | 18 + .../module_bindings/app/my_threads_table.ts | 25 + .../app/regenerate_response_procedure.ts | 16 + .../remove_agent_admin_identity_reducer.ts | 15 + .../app/request_cancel_reducer.ts | 15 + .../app/revoke_my_session_reducer.ts | 15 + .../app/revoke_session_reducer.ts | 15 + .../app/send_message_procedure.ts | 24 + .../app/set_agent_override_reducer.ts | 22 + .../app/set_agent_secret_reducer.ts | 17 + .../app/set_api_key_reducer.ts | 16 + .../app/set_auth_config_reducer.ts | 23 + .../app/start_thread_procedure.ts | 19 + .../example/src/module_bindings/app/types.ts | 215 +++ .../module_bindings/app/types/procedures.ts | 34 + .../src/module_bindings/app/types/reducers.ts | 42 + .../app/unlink_connection_reducer.ts | 13 + .../app/update_profile_reducer.ts | 16 + .../app/update_thread_reducer.ts | 23 + spacetime-agents-ts/example/tsconfig.json | 14 + spacetime-agents-ts/package.json | 78 + spacetime-agents-ts/scripts/test.ts | 1199 +++++++++++++ spacetime-agents-ts/spacetimedb/.npmrc | 1 + spacetime-agents-ts/spacetimedb/package.json | 19 + spacetime-agents-ts/spacetimedb/src/index.ts | 2 + spacetime-agents-ts/spacetimedb/tsconfig.json | 12 + spacetime-agents-ts/src/agent.ts | 617 +++++++ spacetime-agents-ts/src/embeddings.ts | 195 +++ spacetime-agents-ts/src/index.ts | 45 + spacetime-agents-ts/src/openrouter.ts | 155 ++ spacetime-agents-ts/src/providers.ts | 290 +++ spacetime-agents-ts/src/stale-locks.ts | 34 + spacetime-agents-ts/src/submodule.ts | 25 + spacetime-agents-ts/src/submodule/index.ts | 1024 +++++++++++ spacetime-agents-ts/src/submodule/install.ts | 22 + spacetime-agents-ts/src/submodule/loop.ts | 231 +++ spacetime-agents-ts/src/submodule/model.ts | 100 ++ .../src/submodule/summarize.ts | 66 + spacetime-agents-ts/tsconfig.json | 15 + spacetime-auth-ts/.npmrc | 1 + spacetime-auth-ts/LICENSE.txt | 759 ++++++++ spacetime-auth-ts/README.md | 178 ++ spacetime-auth-ts/example/.env.example | 30 + spacetime-auth-ts/example/.npmrc | 1 + spacetime-auth-ts/example/README.md | 195 +++ spacetime-auth-ts/example/package.json | 28 + spacetime-auth-ts/example/public/index.html | 194 ++ spacetime-auth-ts/example/public/styles.css | 894 ++++++++++ spacetime-auth-ts/example/public/ui.js | 317 ++++ spacetime-auth-ts/example/server.ts | 200 +++ spacetime-auth-ts/example/spacetimedb/.npmrc | 1 + .../example/spacetimedb/package.json | 19 + .../example/spacetimedb/src/index.ts | 300 ++++ .../example/spacetimedb/tsconfig.json | 14 + spacetime-auth-ts/example/src/app.ts | 426 +++++ .../app/auth/get_auth_public_key_procedure.ts | 19 + .../app/auth/link_connection_reducer.ts | 15 + .../app/auth/list_my_sessions_procedure.ts | 19 + .../app/auth/my_auth_user_table.ts | 21 + .../rateLimit/add_rate_limit_admin_reducer.ts | 15 + .../admin_rate_limit_buckets_table.ts | 20 + .../app/auth/rateLimit/consume_procedure.ts | 24 + .../auth/rateLimit/rate_limit_config_table.ts | 17 + .../auth/rateLimit/reset_buckets_reducer.ts | 15 + .../app/auth/rateLimit/run_sweep_procedure.ts | 16 + .../app/auth/rateLimit/types.ts | 56 + .../auth/rateLimit/update_config_reducer.ts | 15 + .../app/auth/revoke_my_session_reducer.ts | 15 + .../app/auth/revoke_session_reducer.ts | 15 + .../app/auth/set_auth_config_reducer.ts | 23 + .../src/module_bindings/app/auth/types.ts | 137 ++ .../app/auth/unlink_connection_reducer.ts | 13 + .../app/auth/update_profile_reducer.ts | 16 + .../app/auth/whoami_procedure.ts | 19 + .../app/create_note_reducer.ts | 16 + .../app/delete_note_reducer.ts | 15 + .../app/get_auth_public_key_procedure.ts | 19 + .../example/src/module_bindings/app/index.ts | 259 +++ .../app/link_connection_reducer.ts | 15 + .../app/list_my_sessions_procedure.ts | 19 + .../module_bindings/app/my_auth_user_table.ts | 21 + .../src/module_bindings/app/my_notes_table.ts | 19 + .../app/revoke_my_session_reducer.ts | 15 + .../app/revoke_session_reducer.ts | 15 + .../app/set_auth_config_reducer.ts | 23 + .../example/src/module_bindings/app/types.ts | 68 + .../module_bindings/app/types/procedures.ts | 19 + .../src/module_bindings/app/types/reducers.ts | 28 + .../app/unlink_connection_reducer.ts | 13 + .../app/update_note_reducer.ts | 17 + .../app/update_profile_reducer.ts | 16 + .../module_bindings/app/whoami_procedure.ts | 19 + spacetime-auth-ts/example/tsconfig.json | 14 + spacetime-auth-ts/package.json | 85 + spacetime-auth-ts/scripts/test.ts | 345 ++++ spacetime-auth-ts/spacetimedb/.npmrc | 1 + spacetime-auth-ts/spacetimedb/package.json | 20 + spacetime-auth-ts/spacetimedb/src/index.ts | 2 + spacetime-auth-ts/spacetimedb/tsconfig.json | 14 + spacetime-auth-ts/src/admin.ts | 34 + spacetime-auth-ts/src/caller.ts | 48 + spacetime-auth-ts/src/context.ts | 37 + spacetime-auth-ts/src/crypto.ts | 173 ++ .../src/handlers/email_verify.ts | 178 ++ spacetime-auth-ts/src/handlers/github.ts | 97 + spacetime-auth-ts/src/handlers/google.ts | 36 + spacetime-auth-ts/src/handlers/http.ts | 166 ++ spacetime-auth-ts/src/handlers/index.ts | 33 + spacetime-auth-ts/src/handlers/oauth.ts | 451 +++++ spacetime-auth-ts/src/handlers/password.ts | 285 +++ .../src/handlers/password_reset.ts | 196 +++ spacetime-auth-ts/src/handlers/session.ts | 229 +++ spacetime-auth-ts/src/index.ts | 156 ++ spacetime-auth-ts/src/jwt.ts | 193 ++ spacetime-auth-ts/src/keys.ts | 279 +++ spacetime-auth-ts/src/mailer.ts | 46 + spacetime-auth-ts/src/procedures.ts | 374 ++++ spacetime-auth-ts/src/rate_limit.ts | 110 ++ spacetime-auth-ts/src/request-trust.ts | 74 + spacetime-auth-ts/src/submodule.ts | 48 + spacetime-auth-ts/src/submodule/index.ts | 161 ++ spacetime-auth-ts/src/submodule/install.ts | 26 + spacetime-auth-ts/src/tables.ts | 137 ++ spacetime-auth-ts/src/types.ts | 18 + spacetime-auth-ts/tsconfig.json | 15 + spacetime-files-ts/.npmrc | 1 + spacetime-files-ts/LICENSE.txt | 759 ++++++++ spacetime-files-ts/README.md | 257 +++ spacetime-files-ts/example/.env.example | 7 + spacetime-files-ts/example/.gitignore | 3 + spacetime-files-ts/example/.npmrc | 1 + spacetime-files-ts/example/README.md | 164 ++ spacetime-files-ts/example/package.json | 30 + spacetime-files-ts/example/public/index.html | 594 +++++++ spacetime-files-ts/example/public/styles.css | 1268 ++++++++++++++ .../example/scripts/test-downloads.ts | 120 ++ .../example/scripts/test-selection.ts | 49 + spacetime-files-ts/example/server.ts | 106 ++ spacetime-files-ts/example/spacetimedb/.npmrc | 1 + .../example/spacetimedb/package.json | 19 + .../example/spacetimedb/src/index.ts | 444 +++++ .../example/spacetimedb/tsconfig.json | 15 + spacetime-files-ts/example/src/app.ts | 1168 +++++++++++++ .../example/src/context-menu.ts | 135 ++ spacetime-files-ts/example/src/dialog.ts | 97 + spacetime-files-ts/example/src/downloads.ts | 127 ++ spacetime-files-ts/example/src/drop-target.ts | 68 + spacetime-files-ts/example/src/keyboard.ts | 96 + .../example/src/list-actions.ts | 157 ++ .../app/create_folder_reducer.ts | 15 + .../app/delete_file_reducer.ts | 15 + .../app/delete_folder_reducer.ts | 15 + .../src/module_bindings/app/files/types.ts | 32 + .../example/src/module_bindings/app/index.ts | 142 ++ .../module_bindings/app/move_file_reducer.ts | 16 + .../app/my_file_summaries_table.ts | 21 + .../module_bindings/app/my_folders_table.ts | 21 + .../app/read_file_bytes_procedure.ts | 20 + .../app/rename_file_reducer.ts | 16 + .../app/rename_folder_reducer.ts | 16 + .../app/set_file_visibility_reducer.ts | 16 + .../example/src/module_bindings/app/types.ts | 46 + .../module_bindings/app/types/procedures.ts | 13 + .../src/module_bindings/app/types/reducers.ts | 26 + .../app/upload_file_reducer.ts | 18 + spacetime-files-ts/example/src/paths.ts | 43 + .../example/src/presentation.ts | 101 ++ spacetime-files-ts/example/src/rendering.ts | 229 +++ spacetime-files-ts/example/src/selection.ts | 71 + spacetime-files-ts/example/src/session.ts | 30 + spacetime-files-ts/example/src/uploads.ts | 215 +++ spacetime-files-ts/example/src/viewer.ts | 186 ++ spacetime-files-ts/example/src/zip.ts | 129 ++ spacetime-files-ts/example/tsconfig.json | 15 + spacetime-files-ts/package.json | 79 + spacetime-files-ts/scripts/test.ts | 41 + spacetime-files-ts/src/constants.ts | 5 + spacetime-files-ts/src/handlers.ts | 155 ++ spacetime-files-ts/src/hash.ts | 10 + spacetime-files-ts/src/index.ts | 43 + spacetime-files-ts/src/procedures.ts | 301 ++++ spacetime-files-ts/src/query.ts | 17 + spacetime-files-ts/src/rows.ts | 39 + spacetime-files-ts/src/submodule.ts | 8 + spacetime-files-ts/src/submodule/install.ts | 6 + spacetime-files-ts/src/submodule/schema.ts | 42 + spacetime-files-ts/src/validation.ts | 79 + spacetime-files-ts/tsconfig.json | 14 + 265 files changed, 31767 insertions(+) create mode 100644 spacetime-agents-ts/.npmrc create mode 100644 spacetime-agents-ts/LICENSE.txt create mode 100644 spacetime-agents-ts/README.md create mode 100644 spacetime-agents-ts/example/.env.example create mode 100644 spacetime-agents-ts/example/.npmrc create mode 100644 spacetime-agents-ts/example/README.md create mode 100644 spacetime-agents-ts/example/package.json create mode 100644 spacetime-agents-ts/example/public/index.html create mode 100644 spacetime-agents-ts/example/public/markdown.js create mode 100644 spacetime-agents-ts/example/public/styles.css create mode 100644 spacetime-agents-ts/example/public/ui.js create mode 100644 spacetime-agents-ts/example/scripts/test-markdown.mjs create mode 100644 spacetime-agents-ts/example/server.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-agents-ts/example/spacetimedb/package.json create mode 100644 spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/scripts/tsconfig.json create mode 100644 spacetime-agents-ts/example/spacetimedb/src/agent-runner.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/agents/index.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/agents/summarizer.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/attachments.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/loop.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/model.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/summarize.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/sweeper.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/types.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/src/views.ts create mode 100644 spacetime-agents-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-agents-ts/example/src/app.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/add_agent_admin_identity_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/consume_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/rate_limit_config_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/run_sweep_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/types.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/update_config_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/agent_override_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/types.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/types.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/auth/whoami_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/clear_agent_override_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/clear_api_key_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/clear_thread_lock_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/delete_thread_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/files/types.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/generate_thread_title_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/get_agent_config_status_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/link_connection_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/my_auth_user_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/my_files_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/my_message_embeddings_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/my_messages_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/my_thread_locks_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/my_threads_table.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/regenerate_response_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/remove_agent_admin_identity_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/request_cancel_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/revoke_session_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/send_message_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/set_agent_override_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/set_agent_secret_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/set_api_key_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/set_auth_config_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/start_thread_procedure.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/unlink_connection_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/update_profile_reducer.ts create mode 100644 spacetime-agents-ts/example/src/module_bindings/app/update_thread_reducer.ts create mode 100644 spacetime-agents-ts/example/tsconfig.json create mode 100644 spacetime-agents-ts/package.json create mode 100644 spacetime-agents-ts/scripts/test.ts create mode 100644 spacetime-agents-ts/spacetimedb/.npmrc create mode 100644 spacetime-agents-ts/spacetimedb/package.json create mode 100644 spacetime-agents-ts/spacetimedb/src/index.ts create mode 100644 spacetime-agents-ts/spacetimedb/tsconfig.json create mode 100644 spacetime-agents-ts/src/agent.ts create mode 100644 spacetime-agents-ts/src/embeddings.ts create mode 100644 spacetime-agents-ts/src/index.ts create mode 100644 spacetime-agents-ts/src/openrouter.ts create mode 100644 spacetime-agents-ts/src/providers.ts create mode 100644 spacetime-agents-ts/src/stale-locks.ts create mode 100644 spacetime-agents-ts/src/submodule.ts create mode 100644 spacetime-agents-ts/src/submodule/index.ts create mode 100644 spacetime-agents-ts/src/submodule/install.ts create mode 100644 spacetime-agents-ts/src/submodule/loop.ts create mode 100644 spacetime-agents-ts/src/submodule/model.ts create mode 100644 spacetime-agents-ts/src/submodule/summarize.ts create mode 100644 spacetime-agents-ts/tsconfig.json create mode 100644 spacetime-auth-ts/.npmrc create mode 100644 spacetime-auth-ts/LICENSE.txt create mode 100644 spacetime-auth-ts/README.md create mode 100644 spacetime-auth-ts/example/.env.example create mode 100644 spacetime-auth-ts/example/.npmrc create mode 100644 spacetime-auth-ts/example/README.md create mode 100644 spacetime-auth-ts/example/package.json create mode 100644 spacetime-auth-ts/example/public/index.html create mode 100644 spacetime-auth-ts/example/public/styles.css create mode 100644 spacetime-auth-ts/example/public/ui.js create mode 100644 spacetime-auth-ts/example/server.ts create mode 100644 spacetime-auth-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-auth-ts/example/spacetimedb/package.json create mode 100644 spacetime-auth-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-auth-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-auth-ts/example/src/app.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/types.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/types.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/auth/whoami_procedure.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/create_note_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/delete_note_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/link_connection_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/my_auth_user_table.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/my_notes_table.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/revoke_session_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/set_auth_config_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/unlink_connection_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/update_note_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/update_profile_reducer.ts create mode 100644 spacetime-auth-ts/example/src/module_bindings/app/whoami_procedure.ts create mode 100644 spacetime-auth-ts/example/tsconfig.json create mode 100644 spacetime-auth-ts/package.json create mode 100644 spacetime-auth-ts/scripts/test.ts create mode 100644 spacetime-auth-ts/spacetimedb/.npmrc create mode 100644 spacetime-auth-ts/spacetimedb/package.json create mode 100644 spacetime-auth-ts/spacetimedb/src/index.ts create mode 100644 spacetime-auth-ts/spacetimedb/tsconfig.json create mode 100644 spacetime-auth-ts/src/admin.ts create mode 100644 spacetime-auth-ts/src/caller.ts create mode 100644 spacetime-auth-ts/src/context.ts create mode 100644 spacetime-auth-ts/src/crypto.ts create mode 100644 spacetime-auth-ts/src/handlers/email_verify.ts create mode 100644 spacetime-auth-ts/src/handlers/github.ts create mode 100644 spacetime-auth-ts/src/handlers/google.ts create mode 100644 spacetime-auth-ts/src/handlers/http.ts create mode 100644 spacetime-auth-ts/src/handlers/index.ts create mode 100644 spacetime-auth-ts/src/handlers/oauth.ts create mode 100644 spacetime-auth-ts/src/handlers/password.ts create mode 100644 spacetime-auth-ts/src/handlers/password_reset.ts create mode 100644 spacetime-auth-ts/src/handlers/session.ts create mode 100644 spacetime-auth-ts/src/index.ts create mode 100644 spacetime-auth-ts/src/jwt.ts create mode 100644 spacetime-auth-ts/src/keys.ts create mode 100644 spacetime-auth-ts/src/mailer.ts create mode 100644 spacetime-auth-ts/src/procedures.ts create mode 100644 spacetime-auth-ts/src/rate_limit.ts create mode 100644 spacetime-auth-ts/src/request-trust.ts create mode 100644 spacetime-auth-ts/src/submodule.ts create mode 100644 spacetime-auth-ts/src/submodule/index.ts create mode 100644 spacetime-auth-ts/src/submodule/install.ts create mode 100644 spacetime-auth-ts/src/tables.ts create mode 100644 spacetime-auth-ts/src/types.ts create mode 100644 spacetime-auth-ts/tsconfig.json create mode 100644 spacetime-files-ts/.npmrc create mode 100644 spacetime-files-ts/LICENSE.txt create mode 100644 spacetime-files-ts/README.md create mode 100644 spacetime-files-ts/example/.env.example create mode 100644 spacetime-files-ts/example/.gitignore create mode 100644 spacetime-files-ts/example/.npmrc create mode 100644 spacetime-files-ts/example/README.md create mode 100644 spacetime-files-ts/example/package.json create mode 100644 spacetime-files-ts/example/public/index.html create mode 100644 spacetime-files-ts/example/public/styles.css create mode 100644 spacetime-files-ts/example/scripts/test-downloads.ts create mode 100644 spacetime-files-ts/example/scripts/test-selection.ts create mode 100644 spacetime-files-ts/example/server.ts create mode 100644 spacetime-files-ts/example/spacetimedb/.npmrc create mode 100644 spacetime-files-ts/example/spacetimedb/package.json create mode 100644 spacetime-files-ts/example/spacetimedb/src/index.ts create mode 100644 spacetime-files-ts/example/spacetimedb/tsconfig.json create mode 100644 spacetime-files-ts/example/src/app.ts create mode 100644 spacetime-files-ts/example/src/context-menu.ts create mode 100644 spacetime-files-ts/example/src/dialog.ts create mode 100644 spacetime-files-ts/example/src/downloads.ts create mode 100644 spacetime-files-ts/example/src/drop-target.ts create mode 100644 spacetime-files-ts/example/src/keyboard.ts create mode 100644 spacetime-files-ts/example/src/list-actions.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/create_folder_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/delete_file_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/delete_folder_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/files/types.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/index.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/move_file_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/my_file_summaries_table.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/my_folders_table.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/read_file_bytes_procedure.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/rename_file_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/rename_folder_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/set_file_visibility_reducer.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/types.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/types/procedures.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/types/reducers.ts create mode 100644 spacetime-files-ts/example/src/module_bindings/app/upload_file_reducer.ts create mode 100644 spacetime-files-ts/example/src/paths.ts create mode 100644 spacetime-files-ts/example/src/presentation.ts create mode 100644 spacetime-files-ts/example/src/rendering.ts create mode 100644 spacetime-files-ts/example/src/selection.ts create mode 100644 spacetime-files-ts/example/src/session.ts create mode 100644 spacetime-files-ts/example/src/uploads.ts create mode 100644 spacetime-files-ts/example/src/viewer.ts create mode 100644 spacetime-files-ts/example/src/zip.ts create mode 100644 spacetime-files-ts/example/tsconfig.json create mode 100644 spacetime-files-ts/package.json create mode 100644 spacetime-files-ts/scripts/test.ts create mode 100644 spacetime-files-ts/src/constants.ts create mode 100644 spacetime-files-ts/src/handlers.ts create mode 100644 spacetime-files-ts/src/hash.ts create mode 100644 spacetime-files-ts/src/index.ts create mode 100644 spacetime-files-ts/src/procedures.ts create mode 100644 spacetime-files-ts/src/query.ts create mode 100644 spacetime-files-ts/src/rows.ts create mode 100644 spacetime-files-ts/src/submodule.ts create mode 100644 spacetime-files-ts/src/submodule/install.ts create mode 100644 spacetime-files-ts/src/submodule/schema.ts create mode 100644 spacetime-files-ts/src/validation.ts create mode 100644 spacetime-files-ts/tsconfig.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ad8187bffa..8227dd71554 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -340,6 +340,199 @@ importers: specifier: workspace:^ version: link:../../crates/bindings-typescript + spacetime-agents-ts: + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-agents-ts/example: + dependencies: + '@spacetimedb/submodule-shared': + specifier: workspace:* + version: link:../../spacetime-submodule-shared-ts + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.23 + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-agents-ts/example/spacetimedb: + dependencies: + '@spacetimedb/agents': + specifier: workspace:* + version: link:../.. + '@spacetimedb/auth': + specifier: workspace:* + version: link:../../../spacetime-auth-ts + '@spacetimedb/files': + specifier: workspace:* + version: link:../../../spacetime-files-ts + '@spacetimedb/rate-limit': + specifier: workspace:* + version: link:../../../spacetime-rate-limit-ts + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-agents-ts/spacetimedb: + dependencies: + '@spacetimedb/agents': + specifier: workspace:* + version: link:.. + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-auth-ts: + dependencies: + '@noble/curves': + specifier: ^2.2.0 + version: 2.3.0 + '@noble/hashes': + specifier: ^1.4.0 + version: 1.8.0 + '@spacetimedb/rate-limit': + specifier: workspace:^ + version: link:../spacetime-rate-limit-ts + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-auth-ts/example: + dependencies: + '@spacetimedb/submodule-shared': + specifier: workspace:* + version: link:../../spacetime-submodule-shared-ts + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.23 + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-auth-ts/example/spacetimedb: + dependencies: + '@spacetimedb/auth': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-auth-ts/spacetimedb: + dependencies: + '@spacetimedb/auth': + specifier: workspace:* + version: link:.. + '@spacetimedb/rate-limit': + specifier: workspace:* + version: link:../../spacetime-rate-limit-ts + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + spacetime-cron-ts: dependencies: cron-parser: @@ -450,6 +643,81 @@ importers: specifier: ^5.9.3 version: 5.9.3 + spacetime-files-ts: + dependencies: + '@spacetimedb/crypto': + specifier: workspace:^ + version: link:../spacetime-crypto-ts + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + prettier: + specifier: ^3.3.3 + version: 3.6.2 + spacetimedb: + specifier: workspace:* + version: link:../crates/bindings-typescript + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-files-ts/example: + dependencies: + '@spacetimedb/files': + specifier: workspace:* + version: link:.. + '@spacetimedb/submodule-shared': + specifier: workspace:* + version: link:../../spacetime-submodule-shared-ts + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + express: + specifier: ^4.21.2 + version: 4.21.2 + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.23 + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + esbuild: + specifier: ^0.28.0 + version: 0.28.2 + tsx: + specifier: ^4.21.0 + version: 4.23.12 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + + spacetime-files-ts/example/spacetimedb: + dependencies: + '@spacetimedb/files': + specifier: workspace:* + version: link:../.. + spacetimedb: + specifier: workspace:* + version: link:../../../crates/bindings-typescript + devDependencies: + '@types/node': + specifier: ^22.10.2 + version: 22.18.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + spacetime-rate-limit-ts: devDependencies: '@types/node': @@ -4467,6 +4735,14 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@noble/curves@2.3.0': + resolution: {integrity: sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.3.0': resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} engines: {node: '>= 20.19.0'} @@ -19554,6 +19830,12 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/curves@2.3.0': + dependencies: + '@noble/hashes': 2.3.0 + + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.3.0': {} '@node-rs/jieba-android-arm-eabi@1.10.4': diff --git a/spacetime-agents-ts/.npmrc b/spacetime-agents-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-agents-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-agents-ts/LICENSE.txt b/spacetime-agents-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-agents-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-agents-ts/README.md b/spacetime-agents-ts/README.md new file mode 100644 index 00000000000..52a4d528263 --- /dev/null +++ b/spacetime-agents-ts/README.md @@ -0,0 +1,165 @@ +# @spacetimedb/agents + +A prebuilt agent submodule and lower-level tools for custom SpacetimeDB +TypeScript modules. + +## Install + +```bash +npm install @spacetimedb/agents spacetimedb@^2.8.3 +``` + +`spacetimedb` is a peer dependency. Keep its version aligned with the SDK used +to build the host module. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +## Quick start + +Register the standard submodule when you want an identity-owned chat backend with +private provider keys, caller-scoped views, typed tools, summaries, embeddings, +and stale-lock cleanup. + +```ts +import { schema } from 'spacetimedb/server'; +import * as agents from '@spacetimedb/agents/submodule'; + +const spacetimedb = schema({ agents }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + agents.installAgents(ctx.as.agents); +}); +``` + +`installAgents` makes the installing identity the first Agents administrator +and schedules stale-lock cleanup. Configure provider keys through the submodule +administration operations after publishing the host module. + +## Custom integration + +### Integrate into an application + +Use the lower-level agent and provider primitives when your application needs +its own conversation tables, authorization, provider-key storage, or HTTP +procedure. Define the registry at module scope, then call the provider from a +procedure with `ctx.http`. + +Define tools with SpacetimeDB type builders. The declaration produces the JSON +Schema sent to the model and validates every returned tool call before the +handler runs. + +```ts +import { t } from 'spacetimedb/server'; +import { + agentTool, + callChat, + defineAgent, + makeAgentRegistry, + openRouterProvider, +} from '@spacetimedb/agents'; + +const getTime = agentTool('Return the current module time.', t.unit(), ctx => + String(ctx.timestamp.microsSinceUnixEpoch) +); + +const agents = { + support: defineAgent({ + defaultProvider: 'openrouter', + defaultModel: 'openai/gpt-4o-mini', + defaultSystemPrompt: 'Answer concisely.', + tools: { get_time: getTime }, + }), +}; + +const registry = makeAgentRegistry(agents); +const definition = registry.agentDef('support'); +if (!definition) throw new Error('unknown agent'); + +const result = callChat(ctx.http, openRouterProvider, { + apiKey, + model: definition.defaultModel, + system: definition.defaultSystemPrompt, + messages: [{ role: 'user', content: 'What time is it?' }], + tools: registry.llmToolDefsFor('support'), + retries: definition.defaultRetries, +}); +``` + +Run model calls from a procedure or HTTP handler. Reducers remain deterministic. +Keep API keys in private tables and pass the stored value to `callChat` at the +call site. + +The snippet uses application-owned `ctx` and `apiKey` values inside that +procedure. See the complete +[Agents host module](./example/spacetimedb/) +for private configuration, caller-scoped views, and an agent loop. + +## API + +- `agentTool(description, args, run)` defines a typed tool. +- `defineAgent(config)` applies defaults to an agent definition. +- `makeAgentDispatch(tools)` builds tool definitions and an invocation method. +- `makeAgentRegistry(agents)` selects agents and dispatches their tools. +- `typeBuilderToJsonSchema(typeBuilder)` converts supported tool arguments. +- `callChat(http, provider, request)` performs one synchronous chat request, + with optional immediate retries for retryable failures. +- `openRouterProvider`, `openAiProvider`, and `anthropicProvider` adapt their + providers' chat APIs. +- `openAiEmbeddingsProvider` and `openRouterEmbeddingsProvider` perform + embedding requests. +- `cosineSimilarity` and `topKByScore` provide in-memory ranking helpers. + +Documented subpath exports are `./submodule`, `./openrouter`, `./providers`, +`./embeddings`, and `./stale-locks`. + +Tool dispatch rejects malformed JSON, missing and unknown fields, incorrect +types, unsafe integers, inputs above 64 KiB, arrays above 1,000 items, and tool +results above 64 KiB. Agent definitions validate turns, history, token, retry, +and RAG limits during initialization. + +### Application boundary + +Export a host procedure that loads a private provider key, calls the selected +agent, and returns an application-specific result. After generating client +bindings, that procedure is called like any other SpacetimeDB procedure: + +```ts +const answer = await conn.procedures.askSupport({ + message: 'How do I update my billing address?', +}); +``` + +`askSupport` belongs to the host module. Its implementation should authorize +the caller, load the API key from a private table, pass `ctx.http` to +`callChat`, and map provider errors to stable application errors. Conversation +rows should be written through `ctx.withTx` and exposed through caller-scoped +views. + +Package entrypoints: + +- `@spacetimedb/agents` exports the complete public surface. +- `@spacetimedb/agents/submodule` exports the prebuilt Agents schema and + installer. +- `@spacetimedb/agents` exports typed agents, tools, and dispatch. +- `@spacetimedb/agents/providers` exports provider adapters. +- `@spacetimedb/agents/embeddings` exports embedding and ranking helpers. +- `@spacetimedb/agents/openrouter` exports the common chat request layer. +- `@spacetimedb/agents/stale-locks` exports the bounded stale-lock cleanup + helpers used by the reference host module. + +## Testing + +```bash +pnpm test +pnpm run lint +``` + +The unit suite uses mocked HTTP with deterministic provider fixtures. The +repository also builds the direct-publish module under `spacetimedb/`. See the +[complete example](./example/) for a custom host module and client. + +## License + +BUSL-1.1. See [`LICENSE.txt`](./LICENSE.txt). diff --git a/spacetime-agents-ts/example/.env.example b/spacetime-agents-ts/example/.env.example new file mode 100644 index 00000000000..ad375863ae5 --- /dev/null +++ b/spacetime-agents-ts/example/.env.example @@ -0,0 +1,40 @@ +# Copy to .env. The example server loads this on startup and bootstraps auth. +# LLM provider keys and agent tuning can be configured here or in the +# submodule/root .env. The example server seeds them on startup. + +# ---------------- Auth ---------------- +# Issuer URL is what gets embedded in the JWT and used for OAuth redirect +# construction. Must match the URL the browser loads the app from. +AUTH_ISSUER_URL=http://localhost:8789 + +# Optional auth service settings. AUTH_BASE_URL defaults to AUTH_ISSUER_URL. +# Use literal \n sequences when a PEM key is stored on one line. +AUTH_BASE_URL= +AUTH_COOKIE_NAME=stdb_auth +AUTH_SESSION_TTL_SECONDS=604800 +AUTH_ES256_PRIVATE_KEY_PEM= + +# OAuth provider credentials. Leave blank to hide those buttons in the login UI. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# ---------------- Agent providers ---------------- +OPENROUTER_API_KEY= +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +STALE_LOCK_THRESHOLD_SECS=900 +RATE_LIMIT_TOKENS_PER_WINDOW= +RATE_LIMIT_WINDOW_SECS= + +# ---------------- Static server ---------------- +HOST=127.0.0.1 +PORT=8789 + +# STDB endpoints. The browser connects via WebSocket; the express server +# proxies /auth/* over HTTP to the same instance. +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +SPACETIMEDB_DB_NAME=spacetime-agents-example +STDB_SERVER=http://127.0.0.1:3000 diff --git a/spacetime-agents-ts/example/.npmrc b/spacetime-agents-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-agents-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-agents-ts/example/README.md b/spacetime-agents-ts/example/README.md new file mode 100644 index 00000000000..2428d0cdb01 --- /dev/null +++ b/spacetime-agents-ts/example/README.md @@ -0,0 +1,229 @@ +# Agents example + +This example is a multi-provider chat application built with +[`@spacetimedb/agents`](../). Agent execution, thread state, tools, usage +accounting, and model requests live in SpacetimeDB. The browser connects directly +to SpacetimeDB; the Node server serves static files and proxies the module's auth +and file HTTP handlers. + +## What this demonstrates + +- Defining typed agents and tools with `@spacetimedb/agents`. +- Running an agent loop from a SpacetimeDB procedure with OpenRouter, OpenAI, or + Anthropic. +- Isolating threads, messages, locks, files, and embeddings by authenticated user. +- Publishing user-scoped views over private tables. +- Tool calls, cancellation, response regeneration, history summarization, and RAG. +- Per-message token accounting and optional per-user token limits. +- Bootstrapping auth and storing provider configuration in private module state. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity. A fresh publish seeds the publisher as the initial auth + and agents administrator. +- At least one supported model-provider API key for successful model responses. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +Confirm the server and login before continuing: + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-agents-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +# Add OPENROUTER_API_KEY, OPENAI_API_KEY, or ANTHROPIC_API_KEY to .env. +pnpm run build:module:fresh +pnpm run dev +``` + +Open , create an account, create a thread, and send a +message. + +`build:module:fresh` deletes and recreates only the local `spacetime-agents-example` +database. Use `pnpm run build:module` to republish while preserving existing rows. + +## Use in your project + +This workspace tests the submodule source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/agents spacetimedb@^2.8.3 +``` + +Start with the package's +[integration guide](../README.md#integrate-into-an-application). Copy the agent +registry and procedure boundary you need; the example's auth, files, RAG, and UI +are application-specific integrations around the helper. + +## Configuration + +The server loads non-empty values from the repository, package, and example +`.env` files. The example-local file has highest priority; environment variables +set by the launching process are never overwritten. + +| Variable | Default | Purpose | +| ------------------------------------------- | -------------------------- | ------------------------------------------------------------------ | +| `OPENROUTER_API_KEY` | empty | Enables OpenRouter-backed agents. | +| `OPENAI_API_KEY` | empty | Enables OpenAI-backed agents. | +| `ANTHROPIC_API_KEY` | empty | Enables Anthropic-backed agents. | +| `STALE_LOCK_THRESHOLD_SECS` | `900` | Age at which the lock sweeper may remove an abandoned thread lock. | +| `RATE_LIMIT_TOKENS_PER_WINDOW` | empty | Optional per-user prompt-plus-completion token cap. | +| `RATE_LIMIT_WINDOW_SECS` | empty | Sliding-window duration used with the token cap. | +| `AUTH_ISSUER_URL` | `http://localhost:8789` | JWT issuer and OAuth redirect origin. | +| `AUTH_BASE_URL` | `AUTH_ISSUER_URL` | Public base URL used by auth routes and redirects. | +| `AUTH_COOKIE_NAME` | `stdb_auth` | Name of the session cookie. | +| `AUTH_SESSION_TTL_SECONDS` | `604800` | Session lifetime in seconds. | +| `AUTH_ES256_PRIVATE_KEY_PEM` | generated by auth setup | Optional fixed ES256 signing key. Use literal `\n` in `.env`. | +| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | empty | Enables Google OAuth when both values are present. | +| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | empty | Enables GitHub OAuth when both values are present. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | HTTP endpoint used by the auth/file proxy. | +| `STDB_SERVER` | `STDB_HTTP` | CLI target used for startup configuration. | +| `SPACETIMEDB_DB_NAME` | `spacetime-agents-example` | Published database name. | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8789` | Static-server port. | + +On startup, the logged-in CLI identity calls `set_auth_config`, +`set_agent_secret`, and `set_api_key` for each configured provider. Provider keys +are stored in private module tables and are not returned by `/api/config`. + +## Architecture + +```text +Browser + -> /auth/* and /files HTTP requests through the local same-origin proxy + -> SpacetimeDB WebSocket for reducers, procedures, and subscriptions + +SpacetimeDB module + -> authenticated user/session mapping + -> caller-scoped thread, message, lock, file, and embedding views + -> agent procedure -> provider HTTPS API +``` + +The browser subscribes to `my_threads`, `my_thread_locks`, `my_files`, and +`my_auth_user`. It subscribes to `my_messages` only for the active thread. These +views resolve the authenticated user from the linked connection and filter rows +server-side. Browser subscriptions use these views exclusively. + +`send_message` is a procedure because model calls require `ProcedureCtx.http`. +Each completed turn commits messages through its own transaction, and +SpacetimeDB subscriptions deliver progress to the browser. + +## Agent behavior + +Configuration is resolved in this order: + +1. Per-thread overrides. +2. Operator-managed rows in `agent_override`. +3. Defaults in `spacetimedb/src/agents/`. + +The registered `chat` agent exposes the example tools; the `summarizer` agent +compacts long conversation history. Defensive limits cap user content and +tool output. A lock keyed by thread prevents two agent loops from interleaving on +the same thread, and a scheduled sweeper removes locks abandoned beyond the +configured threshold. + +Successful assistant messages record prompt and completion token counts. The UI +shows those values per message. The optional token window rejects new work with +`agent.rate_limited:/` after the configured per-user cap is reached. + +## Adding an agent or tool + +Agents are registered by key in `spacetimedb/src/agents/index.ts`. The registry key +is the runtime name stored on each thread. + +```ts +import { defineAgent } from '@spacetimedb/agents'; +import myTool from '../tools/myTool'; + +export default defineAgent({ + defaultModel: 'openai/gpt-4o-mini', + defaultSystemPrompt: 'Give concise, factual answers.', + tools: { my_tool: myTool }, +}); +``` + +Tools live in `spacetimedb/src/tools/` and use `agentTool` with a SpacetimeDB type +for their input. Import a tool only into agents that should be allowed to call it. +After changing an agent or tool, republish the module and regenerate the client. + +## Administration and security + +- A fresh publish seeds the publishing owner in the private + `auth_admin_identity` and `agent_admin_identity` tables. +- Public reducers never grant the first caller administrator access. +- The startup configuration calls run as the logged-in CLI identity. +- Browser users are not administrators by default. Grant a development identity + only with `add_agent_admin_identity` called by an existing administrator. +- Model-provider keys, auth signing material, `.env`, and generated local tokens + must not be committed. +- The included server is a development server. Put TLS, host validation, secret + management, and process supervision at the deployment boundary in production. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm --dir spacetimedb test +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For an end-to-end check, fresh-publish the database, start the server, sign up in +the browser, create a thread, and confirm all of the following: + +1. The thread appears after creation and remains after a reload. +2. A message receives a normal assistant response with a valid provider key. +3. Token usage appears on the assistant message. +4. Stop and regenerate update the current thread while preserving account + isolation. +5. A second account cannot subscribe to or mutate the first account's threads. + +Use a valid provider key for the release smoke test. Invalid keys cover only the +error path. + +## Troubleshooting + +- **Startup configuration is rejected:** confirm that the CLI is logged in as the + database owner or a registered administrator, and that `STDB_SERVER` targets + the same host used by the publish command. +- **The browser cannot connect:** make sure `STDB_URI`, `STDB_HTTP`, and + `STDB_SERVER` address the same SpacetimeDB instance. +- **Provider calls fail:** verify that the chosen agent has a key for its provider + and inspect the module logs for the upstream status. +- **OAuth redirects to the wrong origin:** set `AUTH_ISSUER_URL` to the exact + browser-visible origin, including scheme and port. +- **A stale browser identity follows a fresh publish:** clear the example's site + data and sign in again. + +## Important files + +- `spacetimedb/src/index.ts` - schema, views, auth integration, and agent procedures. +- `spacetimedb/src/loop.ts` - provider-independent agent loop. +- `spacetimedb/src/agents/` - registered agent definitions. +- `spacetimedb/src/tools/` - typed tools available to agents. +- `spacetimedb/scripts/test-loop.ts` - model-mocked loop tests. +- `server.ts` - startup configuration and same-origin HTTP proxy. +- `src/app.ts` - browser connection, subscriptions, and UI bridge. +- `public/index.html` - application structure. +- `public/ui.js` - DOM state, rendering, and interaction handling. +- `public/styles.css` - application presentation. diff --git a/spacetime-agents-ts/example/package.json b/spacetime-agents-ts/example/package.json new file mode 100644 index 00000000000..ae88eded0cc --- /dev/null +++ b/spacetime-agents-ts/example/package.json @@ -0,0 +1,29 @@ +{ + "name": "spacetime-agents-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "spacetime publish --server local --yes --module-path ./spacetimedb spacetime-agents-example && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "spacetime publish --server local --yes --delete-data=always --module-path ./spacetimedb spacetime-agents-example && pnpm run spacetime:generate && pnpm run build:app", + "check": "tsc --noEmit", + "test": "node scripts/test-markdown.mjs", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run spacetime:generate && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "@spacetimedb/submodule-shared": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-agents-ts/example/public/index.html b/spacetime-agents-ts/example/public/index.html new file mode 100644 index 00000000000..067f473b2fa --- /dev/null +++ b/spacetime-agents-ts/example/public/index.html @@ -0,0 +1,403 @@ + + + + + + + SpacetimeDB Agents + + + + +
+ + SpacetimeDB Agents +
+ +
+
+
+ + + + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ + + + + diff --git a/spacetime-agents-ts/example/public/markdown.js b/spacetime-agents-ts/example/public/markdown.js new file mode 100644 index 00000000000..e6c0a54a646 --- /dev/null +++ b/spacetime-agents-ts/example/public/markdown.js @@ -0,0 +1,46 @@ +export function escapeHtml(value) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function renderMarkdown(source) { + if (!source) return ''; + + const codeBlocks = []; + let rendered = source.replace(/```(?:[\w-]*)\n([\s\S]*?)```/g, (_, code) => { + const index = codeBlocks.length; + codeBlocks.push(code); + return `\n\n\uE000CODE_BLOCK_${index}\uE001\n\n`; + }); + + rendered = escapeHtml(rendered); + rendered = rendered.replace( + /`([^`\n]+)`/g, + (_, code) => `${code}` + ); + rendered = rendered.replace(/\*\*([^*]+)\*\*/g, '$1'); + rendered = rendered.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2'); + rendered = rendered.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, + '$1' + ); + rendered = rendered + .split(/\n{2,}/) + .filter(Boolean) + .map(paragraph => { + const codeBlockMatch = paragraph.match(/^\uE000CODE_BLOCK_(\d+)\uE001$/); + if (codeBlockMatch) { + const code = codeBlocks[Number(codeBlockMatch[1])]; + if (code !== undefined) { + return `
${escapeHtml(code)}
`; + } + } + return `

${paragraph.replace(/\n/g, '
')}

`; + }) + .join(''); + return rendered; +} diff --git a/spacetime-agents-ts/example/public/styles.css b/spacetime-agents-ts/example/public/styles.css new file mode 100644 index 00000000000..11485ab4b77 --- /dev/null +++ b/spacetime-agents-ts/example/public/styles.css @@ -0,0 +1,1218 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&family=Source+Code+Pro:wght@400;500;600&display=swap'); + +:root { + /* Tokens match spacetime-web/spacetimedb.com/app/styles/variables.css */ + --font-inter: 'Inter Variable', 'Inter', sans-serif; + --font-source: 'Source Code Pro Variable', 'Source Code Pro', monospace; + --font-ibm: 'IBM Plex Mono', monospace; + + --color-green: #4cf490; + --color-green-10: #4cf4901a; + --color-green-20: #4cf49033; + --color-green-25: #4cf49040; + --color-green-50: #4cf49080; + --color-green-75: #4cf490bf; + --color-white: #d7d8d9; + --color-yellow: #fbdc8e; + --color-yellow-10: #fbdc8e1a; + --color-yellow-20: #fbdc8e33; + --color-purple: #a880ff; + --color-purple-2: #8a38f5; + --color-orange: #ff9e9e; + --color-blue: #02befa; + --color-blue-10: #02befa1a; + --color-blue-20: #02befa33; + --color-pink: #ff80fb; + --color-teal: #00ccb4; + --color-red: #ff4c4c; + --color-brown: #3b3b3b; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n5: #363840; + --color-n6: #202126; + --color-n7: #050505; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade2: #122530; + --color-shade3: #122129; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + --color-shade8: #0b0e12; + + --color-border: var(--color-shade4); + --color-text: var(--color-n1); + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; + + /* Aliases retained for this app's existing rules. */ + --color-fg: var(--color-white); + --color-muted: var(--color-n4); +} +* { + box-sizing: border-box; +} +[hidden] { + display: none !important; +} +html, +body { + margin: 0; + height: 100%; +} +body { + font-family: var(--font-inter); + color: var(--color-fg); + background: var(--color-shade7); + overflow: hidden; +} +button { + font: inherit; +} +input, +textarea, +select { + font: inherit; + color: var(--color-fg); + background: var(--color-shade6); + border: 1px solid #1a2a35; + border-radius: var(--radius-sm); + padding: 8px 10px; +} +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-20); +} +::selection { + background: var(--color-green); + color: var(--color-n8); +} + +/* Compact scrollbars matching the SpacetimeDB dashboard. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-shade4) var(--color-shade7); +} +*::-webkit-scrollbar { + width: 4px; + height: 4px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background: var(--color-shade4); + border-radius: 2px; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--color-shade3); +} +*::-webkit-scrollbar-corner { + background: var(--color-shade7); +} + +.shell { + width: min(1320px, calc(100% - 32px)); + margin: 14px auto; + height: calc(100dvh - 28px); + display: flex; + gap: 14px; + min-height: 0; +} +.sidebar { + width: 280px; + flex: 0 0 280px; + transition: + flex-basis 0.18s ease, + width 0.18s ease; +} +.sidebar.collapsed { + width: 48px; + flex: 0 0 48px; +} +.sidebar.collapsed .brand, +.sidebar.collapsed .sidebar-new, +.sidebar.collapsed .threads, +.sidebar.collapsed .conn { + display: none; +} +.sidebar.collapsed .sidebar-head { + padding: 8px; + justify-content: center; +} +.sidebar.collapsed .sidebar-foot { + padding: 8px; + margin-top: auto; + justify-content: center; +} +.sidebar-head { + padding: 10px 12px; + border-bottom: 1px solid #142732; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.sidebar-new { + padding: 10px; + border-bottom: 1px solid #142732; +} +.sidebar-new .btn { + width: 100%; +} +.sidebar-foot { + padding: 8px 10px; + border-top: 1px solid #142732; + display: flex; + align-items: center; + gap: 10px; + font-family: var(--font-ibm); + font-size: 12px; + color: var(--color-muted); +} +.sidebar-foot .conn { + flex: 1; +} +.brand { + display: flex; + align-items: center; + gap: 10px; + font-weight: 700; + font-size: 14px; + min-width: 0; +} +.brand-logo { + height: 22px; + width: auto; + flex: 0 0 auto; + display: block; +} +.brand-sub { + padding: 2px 7px; + border: 1px solid #2a4250; + border-radius: 999px; + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 500; + color: #9cb1cb; + letter-spacing: 0.07em; + text-transform: uppercase; + flex: 0 0 auto; +} +.brand-name { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.empty-hero .hero-logo { + width: 96px; + height: auto; + margin: 0 auto 8px; + display: block; +} +.conn { + font-family: var(--font-ibm); + font-size: 12px; + color: var(--color-muted); +} +.conn.ok { + color: var(--color-green); +} +.conn.err { + color: var(--color-red); +} /* connection broken = vivid red */ +.conn.warn { + color: var(--color-yellow); +} +.btn.toggle { + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + color: var(--color-muted); + font-family: var(--font-ibm); + cursor: pointer; +} +.btn.toggle:hover { + color: var(--color-fg); +} +.btn { + padding: 7px 12px; + border-radius: var(--radius-sm); + border: 1px solid #1f3947; + background: #0f1a22; + color: var(--color-fg); + cursor: pointer; +} +.btn:hover { + background: #142433; +} +.btn:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +/* Buttons match spacetimedb.com Button.module.css: + primary = n3 bg, n8 text, white hover, green active, green focus outline + danger = bordered, soft orange text, soft orange hover wash */ +.btn.primary { + background: var(--color-n3); + border: 2px solid var(--color-n3); + color: var(--color-n8); + font-weight: 600; +} +.btn.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); + color: var(--color-n8); +} +.btn.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); +} +.btn.danger { + background: transparent; + color: var(--color-orange); + border-color: #5a2222; +} +.btn.danger:hover:not(:disabled) { + background: rgba(255, 158, 158, 0.08); + border-color: var(--color-orange); +} +.btn.small { + font-size: 12px; + padding: 4px 8px; +} +.btn.icon { + padding: 0; + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--color-muted); +} +.btn.icon:hover:not(:disabled) { + color: var(--color-fg); +} + +.sidebar, +.chat { + border: 1px solid #17303b; + border-radius: var(--radius-lg); + background: var(--color-shade6); + display: flex; + flex-direction: column; + min-height: 0; +} +.chat { + flex: 1; + min-width: 0; +} +.chat .head { + padding: 10px 14px; + border-bottom: 1px solid #142732; + display: flex; + align-items: center; + gap: 10px; + font-family: var(--font-ibm); + font-size: 12px; + color: var(--color-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.chat .head .agent-tag { + background: #14303d; + border: 1px solid #1f4555; + color: var(--color-blue); + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; + text-transform: none; + letter-spacing: 0; +} +.empty-hero { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 18px; + padding: 32px; + text-align: center; +} +.empty-hero h1 { + margin: 0; + font-size: 24px; + font-weight: 600; + color: var(--color-fg); +} +.empty-hero p { + margin: 0; + color: var(--color-muted); + font-size: 14px; + max-width: 480px; +} + +.threads { + flex: 1; + overflow-y: auto; + padding: 6px; +} +.thread-item { + padding: 8px 32px 8px 10px; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 14px; + line-height: 1.3; + position: relative; + display: flex; + align-items: center; + gap: 8px; +} +.thread-item:hover { + background: #122029; +} +.thread-item.active { + background: #101d24; + color: var(--color-fg); +} +.thread-item .row-menu-btn { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 24px; + padding: 0; + background: transparent; + border: none; + color: var(--color-muted); + cursor: pointer; + border-radius: 3px; + display: none; + align-items: center; + justify-content: center; +} +.thread-item:hover .row-menu-btn, +.thread-item.active .row-menu-btn, +.thread-item.menu-open .row-menu-btn { + display: inline-flex; +} +.thread-item .row-menu-btn:hover { + background: #1a3848; + color: var(--color-fg); +} +.row-menu { + position: absolute; + right: 0; + top: calc(100% + 2px); + min-width: 130px; + background: var(--color-shade5); + border: 1px solid #17303b; + border-radius: var(--radius-sm); + padding: 4px; + z-index: 50; + box-shadow: 0 4px 16px #0006; +} +.row-menu button { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: left; + background: transparent; + border: none; + color: var(--color-fg); + font-family: var(--font-inter); + font-size: 13px; + padding: 6px 8px; + border-radius: 3px; + cursor: pointer; +} +.row-menu button:hover { + background: #122029; +} +.row-menu button.danger { + color: var(--color-orange); +} +.row-menu button.danger:hover { + background: #2a1212; +} +.thread-item .title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.thread-item .badge { + background: #14303d; + border: 1px solid #1f4555; + color: var(--color-blue); + padding: 1px 6px; + border-radius: 3px; + font-size: 10px; +} +.thread-item .busy-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-yellow); + animation: pulse 1.4s ease-in-out infinite; +} +@keyframes pulse { + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 1; + } +} +.empty { + color: var(--color-muted); + font-size: 13px; + padding: 14px; + text-align: center; +} + +.chat .head .title { + font-weight: 600; + font-size: 15px; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chat .head .agent-tag { + background: #14303d; + border: 1px solid #1f4555; + color: var(--color-blue); + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-family: var(--font-ibm); +} +.chat .head .actions { + display: flex; + gap: 6px; +} +.chat .messages { + flex: 1; + overflow-y: auto; + padding: 14px 18px; + display: flex; + flex-direction: column; + gap: 12px; +} +.msg { + max-width: 78%; + border: 1px solid #142732; + background: #0e1a22; + border-radius: var(--radius); + padding: 10px 12px; + font-size: 14px; + line-height: 1.45; + position: relative; +} +.msg .who { + font-size: 11px; + color: var(--color-muted); + font-family: var(--font-ibm); + text-transform: uppercase; + margin-bottom: 4px; + letter-spacing: 0.04em; +} +.msg .body { + word-break: break-word; +} +.msg .body p { + margin: 0 0 8px; +} +.msg .body p:last-child { + margin-bottom: 0; +} +.msg .body code { + background: #061015; + border-radius: 3px; + padding: 1px 5px; + font-family: var(--font-ibm); + font-size: 13px; +} +.msg .body pre { + background: #061015; + border-radius: var(--radius-sm); + padding: 10px 12px; + overflow-x: auto; + margin: 6px 0; +} +.msg .body pre code { + background: none; + padding: 0; +} +.msg .body a { + color: var(--color-green); +} +.msg .body strong { + font-weight: 600; +} +.msg .body em { + font-style: italic; +} +.msg.user { + align-self: flex-end; + background: #0f2530; + border-color: #1f4555; +} +.msg.assistant { + align-self: flex-start; +} +.msg.tool { + align-self: flex-start; + background: #1a1a0d; + border-color: #3b3520; + font-family: var(--font-ibm); + font-size: 13px; +} +.msg.tool .who { + color: var(--color-yellow); +} +.msg.error { + border-color: #5a2222; + background: #2a1212; +} +.msg.error .who { + color: var(--color-orange); +} +.msg.error .body { + font-family: var(--font-ibm); + font-size: 12px; +} +.msg .toolcalls { + margin-top: 6px; + background: #0a1418; + border-radius: var(--radius-sm); + font-family: var(--font-ibm); + font-size: 12px; + color: var(--color-purple); +} +.msg .toolcalls summary { + padding: 6px 8px; + cursor: pointer; + color: var(--color-muted); + user-select: none; +} +.msg .toolcalls[open] summary { + color: var(--color-purple); +} +.msg .toolcalls pre { + margin: 0; + padding: 0 8px 8px; + white-space: pre-wrap; + word-break: break-word; +} +.msg-footer { + margin-top: 8px; + display: flex; + align-items: center; + gap: 4px; + font-family: var(--font-ibm); + font-size: 11px; + color: var(--color-muted); + opacity: 0; + transition: opacity 0.1s; +} +.msg:hover .msg-footer, +.msg.error .msg-footer { + opacity: 1; +} +.msg-footer .spacer { + flex: 1; +} +.msg-footer .usage { + white-space: nowrap; +} +.msg-footer button { + background: transparent; + border: none; + color: var(--color-muted); + cursor: pointer; + border-radius: 3px; + width: 22px; + height: 22px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} +.msg-footer button:hover { + color: var(--color-fg); + background: #142732; +} +.msg-footer button.danger:hover { + color: var(--color-orange); +} +.msg-footer .label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-left: 2px; +} +.code-block { + position: relative; +} +.code-block .copy { + position: absolute; + top: 4px; + right: 4px; + background: #0a1418; + border: 1px solid #1f3947; + color: var(--color-muted); + font-family: var(--font-ibm); + font-size: 10px; + padding: 2px 6px; + border-radius: 3px; + cursor: pointer; + opacity: 0; + transition: opacity 0.1s; +} +.code-block:hover .copy { + opacity: 1; +} +.code-block .copy:hover { + color: var(--color-fg); +} +.msg .usage { + margin-top: 6px; + font-family: var(--font-ibm); + font-size: 11px; + color: var(--color-muted); +} + +.composer-wrap { + padding: 12px 14px 14px; + border-top: 1px solid #142732; +} +.composer-meta { + font-family: var(--font-ibm); + font-size: 11px; + color: var(--color-muted); + margin-bottom: 6px; + display: flex; + align-items: center; + gap: 6px; +} +.composer-meta #composer-model-label { + color: var(--color-green); + cursor: pointer; + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; +} +.composer-meta #composer-model-label:hover { + color: var(--color-white); +} +.model-popover { + position: absolute; + background: var(--color-shade6); + border: 1px solid #1f4555; + border-radius: var(--radius-sm); + padding: 4px; + z-index: 1000; + min-width: 320px; + max-width: 440px; + max-height: 420px; + display: flex; + flex-direction: column; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4); +} +.model-popover input.search { + background: var(--color-shade7); + border: 1px solid #1a2a35; + color: var(--color-fg); + font-family: var(--font-ibm); + font-size: 12px; + padding: 6px 8px; + border-radius: 2px; + margin-bottom: 4px; + outline: none; +} +.model-popover input.search:focus { + border-color: var(--color-green); +} +.model-popover .list { + overflow-y: auto; + flex: 1; +} +.model-popover .empty { + font-family: var(--font-ibm); + font-size: 11px; + color: var(--color-muted); + padding: 10px; + text-align: center; +} +.model-popover button { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: none; + color: var(--color-shade1); + font-family: var(--font-ibm); + font-size: 12px; + padding: 6px 10px; + border-radius: 2px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.model-popover button:hover { + background: var(--color-shade5); +} +.model-popover button.active { + color: var(--color-green); +} +.model-popover button.active::before { + content: '✓ '; +} +.composer { + display: flex; + gap: 8px; + align-items: flex-end; + background: var(--color-shade5); + border: 1px solid #1a2a35; + border-radius: var(--radius); + padding: 8px; +} +.composer:focus-within { + border-color: var(--color-green); +} +.composer textarea { + flex: 1; + resize: none; + min-height: 36px; + max-height: 160px; + background: transparent; + border: none; + outline: none; + padding: 6px 4px; +} +.composer textarea:focus { + border: none; + outline: none; + box-shadow: none; +} +.pending-thumb { + position: relative; + width: 64px; + height: 64px; + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid #1f4555; +} +.pending-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} +.pending-thumb .x { + position: absolute; + top: 2px; + right: 2px; + width: 18px; + height: 18px; + background: #000a; + color: #fff; + border-radius: 50%; + border: none; + font-size: 11px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.msg-image { + margin-top: 6px; + max-width: 280px; + max-height: 280px; + border-radius: var(--radius-sm); + display: block; + border: 1px solid #1f4555; + background: #071116; + cursor: zoom-in; +} +.msg-file-link { + margin-top: 6px; + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 280px; + min-height: 28px; + padding: 6px 8px; + border: 1px solid #1f4555; + border-radius: var(--radius-sm); + color: var(--color-blue); + background: #071116; + font-size: 12px; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.typing { + align-self: flex-start; + display: flex; + align-items: baseline; + gap: 6px; + padding: 12px 14px; + background: #0e1a22; + border: 1px solid #142732; + border-radius: var(--radius); + color: var(--color-muted); + font-size: 12px; + font-family: var(--font-ibm); +} +.typing .dots { + display: inline-flex; + align-items: baseline; + gap: 3px; +} +.typing .dots span { + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--color-muted); + display: inline-block; + animation: typing-bounce 1.2s ease-in-out infinite; +} +.typing .dots span:nth-child(2) { + animation-delay: 0.15s; +} +.typing .dots span:nth-child(3) { + animation-delay: 0.3s; +} +@keyframes typing-bounce { + 0%, + 70%, + 100% { + transform: translateY(0); + opacity: 0.35; + } + 35% { + transform: translateY(-4px); + opacity: 1; + } +} + +.backdrop { + position: fixed; + inset: 0; + background: #04080a99; + display: none; + align-items: center; + justify-content: center; + z-index: 100; +} +.backdrop.open { + display: flex; +} +.modal { + width: min(560px, calc(100% - 32px)); + background: var(--color-shade5); + border: 1px solid #17303b; + border-radius: var(--radius-lg); + padding: 20px; + max-height: calc(100dvh - 64px); + overflow-y: auto; +} +.modal h2 { + margin: 0 0 6px; + font-size: 18px; +} +.modal p { + margin: 0 0 14px; + color: var(--color-muted); + font-size: 13px; +} +.field { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 12px; +} +.field label { + font-size: 12px; + color: var(--color-muted); + font-family: var(--font-ibm); +} +.field input, +.field textarea, +.field select { + width: 100%; +} +.field-row { + display: flex; + gap: 12px; +} +.field-row .field { + flex: 1; +} +.modal .actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} +.image-viewer { + width: min(1120px, calc(100% - 32px)); + max-height: calc(100dvh - 32px); + padding: 0; + overflow: hidden; + background: #071116; + border: 1px solid #214858; +} +.image-viewer:focus { + outline: none; +} +.image-viewer-head { + height: 44px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 10px 0 14px; + border-bottom: 1px solid #17303b; + background: #0b151b; +} +.image-viewer-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-muted); + font-size: 12px; + font-family: var(--font-ibm); +} +.image-viewer-actions { + flex: none; + display: flex; + align-items: center; + gap: 6px; +} +.image-viewer-body { + height: min(760px, calc(100dvh - 78px)); + display: flex; + align-items: center; + justify-content: center; + background: #05090c; +} +.image-viewer-body img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + display: block; +} + +.toast { + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + padding: 10px 16px; + border-radius: var(--radius); + font-size: 13px; + background: #0e1a22; + border: 1px solid #1f4555; + color: var(--color-fg); + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + z-index: 200; +} +.toast.show { + opacity: 1; +} +.toast.err { + border-color: #5a2222; + background: #2a1212; +} +.toast.ok { + border-color: #1f5a32; + background: #102a17; +} + +.boot-splash { + position: fixed; + inset: 0; + z-index: 9999; + background: var(--color-shade7); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + color: var(--color-green); + transition: opacity 200ms ease; +} +.boot-splash svg { + animation: boot-pulse 1.4s ease-in-out infinite; +} +.boot-splash-label { + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-n4); +} +.boot-splash.fading { + opacity: 0; + pointer-events: none; +} +@keyframes boot-pulse { + 0%, + 100% { + opacity: 0.4; + transform: scale(0.95); + } + 50% { + opacity: 1; + transform: scale(1); + } +} + +.auth-shell { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + z-index: 50; + background: + radial-gradient( + ellipse 80% 50% at 50% 0%, + var(--color-green-20), + transparent 60% + ), + var(--color-shade7); +} +/* ============================================================ + User panel + ============================================================ */ +.user-panel { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: var(--color-shade7); + border-top: 1px solid #1a2a35; + margin-top: auto; + min-width: 0; +} +.user-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--color-blue); + color: var(--color-shade7); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 13px; + flex: 0 0 32px; + text-transform: uppercase; + font-family: var(--font-ibm); + position: relative; + overflow: visible; +} +.user-avatar.has-image { + background: var(--color-shade5); +} +.user-avatar img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + display: block; +} +.user-avatar::after { + content: ''; + position: absolute; + bottom: -1px; + right: -1px; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color-red); + border: 2px solid var(--color-shade7); +} +.user-avatar.online::after { + background: var(--color-green); +} +.user-avatar.warn::after { + background: var(--color-yellow); +} +.user-meta { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + line-height: 1.15; +} +.user-name { + font-size: 13px; + font-weight: 600; + color: var(--color-fg); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.user-status { + font-family: var(--font-ibm); + font-size: 10px; + color: var(--color-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sidebar.collapsed .user-meta { + display: none; +} +.sidebar.collapsed .user-panel { + justify-content: center; +} +.sidebar.collapsed #btn-logout { + display: none; +} + +/* Inherit existing icon button styling for #btn-logout/#btn-settings */ +.user-panel .btn.icon { + width: 28px; + height: 28px; + flex: 0 0 28px; +} +.user-panel #btn-logout:hover { + color: var(--color-red); +} diff --git a/spacetime-agents-ts/example/public/ui.js b/spacetime-agents-ts/example/public/ui.js new file mode 100644 index 00000000000..72bb43de0ff --- /dev/null +++ b/spacetime-agents-ts/example/public/ui.js @@ -0,0 +1,1085 @@ +import { renderMarkdown } from './markdown.js'; + +const $ = id => document.getElementById(id); +let activeThreadId = null; +function selectThread(newId) { + activeThreadId = newId; + window.stdb?.setActiveThread(newId); +} +let allThreads = []; +let allMessages = []; +let allAttachments = {}; // messageId -> attachment metadata +let pendingAttachments = []; // {mimeType, filename, bytes} not yet sent +let lockedThreads = new Map(); // threadId -> cancelRequested +let overrides = new Map(); // agentName -> AgentOverride row + +let inFlightSend = new Set(); +let configState = { kind: 'unknown' }; +let connState = 'connecting'; + +let confirmResolver = null; +function confirmDialog({ + title = 'Confirm', + body = '', + confirmText = 'OK', + danger = false, +} = {}) { + $('confirm-title').textContent = title; + $('confirm-body').textContent = body; + const ok = $('confirm-ok'); + ok.textContent = confirmText; + ok.className = 'btn ' + (danger ? 'danger' : 'primary'); + $('confirm-backdrop').classList.add('open'); + setTimeout(() => ok.focus(), 50); + return new Promise(resolve => { + confirmResolver = resolve; + }); +} +function closeConfirm(result) { + $('confirm-backdrop').classList.remove('open'); + if (confirmResolver) { + confirmResolver(result); + confirmResolver = null; + } +} +$('confirm-ok').addEventListener('click', () => closeConfirm(true)); +$('confirm-cancel').addEventListener('click', () => closeConfirm(false)); +document.addEventListener('keydown', e => { + if (!$('confirm-backdrop').classList.contains('open')) return; + if (e.key === 'Escape') { + e.preventDefault(); + closeConfirm(false); + } else if (e.key === 'Enter') { + e.preventDefault(); + closeConfirm(true); + } +}); +$('confirm-backdrop').addEventListener('click', e => { + if (e.target === $('confirm-backdrop')) closeConfirm(false); +}); + +function openImageViewer(src, title) { + $('image-full').src = src; + $('image-full').alt = title; + $('image-title').textContent = title; + $('image-open').href = src; + $('image-backdrop').classList.add('open'); + $('image-dialog').focus({ preventScroll: true }); +} +function closeImageViewer() { + $('image-backdrop').classList.remove('open'); + $('image-full').removeAttribute('src'); + $('image-open').href = '#'; +} +$('image-close').addEventListener('click', closeImageViewer); +$('image-backdrop').addEventListener('click', e => { + if (e.target === $('image-backdrop')) closeImageViewer(); +}); +document.addEventListener('keydown', e => { + if (!$('image-backdrop').classList.contains('open')) return; + if (e.key === 'Escape') { + e.preventDefault(); + closeImageViewer(); + } +}); + +let toastTimer = null; +function toast(kind, text) { + const el = $('toast'); + el.className = 'toast ' + kind; + el.textContent = text; + void el.offsetWidth; + el.classList.add('show'); + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.classList.remove('show'), 2400); +} + +window.addEventListener('stdb:connState', e => { + const { state, detail } = e.detail; + connState = state; + const text = $('user-status'); + if (text) { + if (state === 'connected') { + text.className = 'user-status ok'; + text.textContent = 'online'; + } else if (state === 'connecting') { + text.className = 'user-status warn'; + text.textContent = 'connecting…'; + } else if (state === 'idle') { + text.className = 'user-status'; + text.textContent = 'idle'; + } else { + text.className = 'user-status err'; + text.textContent = detail ? `error: ${detail}` : 'error'; + } + } + updateButtons(); +}); + +window.addEventListener('stdb:ready', () => { + $('btn-settings').disabled = false; + updateButtons(); +}); + +$('btn-toggle-sidebar').addEventListener('click', () => { + const sb = $('sidebar'); + sb.classList.toggle('collapsed'); + $('btn-toggle-sidebar').textContent = sb.classList.contains('collapsed') + ? '»' + : '«'; +}); + +$('btn-new-thread-hero').addEventListener('click', () => + $('btn-new-thread').click() +); + +window.addEventListener('stdb:config', e => { + configState = e.detail.state; + if (configState.kind === 'unconfigured') { + openSetup(); + } else if (configState.kind === 'configured') { + closeSetup(); + } + updateButtons(); +}); + +function openSetup() { + const isFirst = configState.kind !== 'configured'; + $('setup-cancel').style.display = isFirst ? 'none' : ''; + if (configState.kind === 'configured') { + $('cfg-stalelock').value = String( + configState.status.staleLockThresholdSecs + ); + $('cfg-rl-tokens').value = + configState.status.rateLimitTokensPerWindow != null + ? String(configState.status.rateLimitTokensPerWindow) + : ''; + $('cfg-rl-window').value = + configState.status.rateLimitWindowSecs != null + ? String(configState.status.rateLimitWindowSecs) + : ''; + const providers = configState.status.configuredProviders; + $('cfg-configured').textContent = + providers.length === 0 ? 'none' : providers.join(', '); + } else { + $('cfg-configured').textContent = 'none'; + } + $('setup-backdrop').classList.add('open'); + setTimeout(() => $('cfg-apikey').focus(), 50); +} +function closeSetup() { + $('setup-backdrop').classList.remove('open'); +} + +$('setup-cancel').addEventListener('click', closeSetup); +$('setup-backdrop').addEventListener('click', e => { + if (e.target === $('setup-backdrop')) closeSetup(); +}); + +$('setup-form').addEventListener('submit', async e => { + e.preventDefault(); + if (!window.stdb) return toast('err', 'STDB not ready'); + + const provider = $('cfg-provider').value; + const apiKey = $('cfg-apikey').value.trim(); + const staleLockThresholdSecs = Number.parseInt($('cfg-stalelock').value, 10); + const rlTokensRaw = $('cfg-rl-tokens').value.trim(); + const rlWindowRaw = $('cfg-rl-window').value.trim(); + const rateLimitTokensPerWindow = + rlTokensRaw === '' ? undefined : Number.parseInt(rlTokensRaw, 10); + const rateLimitWindowSecs = + rlWindowRaw === '' ? undefined : Number.parseInt(rlWindowRaw, 10); + + if (!Number.isFinite(staleLockThresholdSecs) || staleLockThresholdSecs < 1) { + return toast('err', 'Stale-lock threshold must be ≥ 1'); + } + const haveTokens = rateLimitTokensPerWindow !== undefined; + const haveWindow = rateLimitWindowSecs !== undefined; + if (haveTokens !== haveWindow) { + return toast('err', 'Set both rate-limit fields, or leave both blank'); + } + if ( + haveTokens && + (!Number.isFinite(rateLimitTokensPerWindow) || rateLimitTokensPerWindow < 1) + ) { + return toast('err', 'Rate-limit token cap must be ≥ 1'); + } + if ( + haveWindow && + (!Number.isFinite(rateLimitWindowSecs) || rateLimitWindowSecs < 1) + ) { + return toast('err', 'Rate-limit window must be ≥ 1'); + } + + const btn = $('setup-save'); + btn.disabled = true; + btn.textContent = 'Saving…'; + try { + await window.stdb.setAgentSecret({ + staleLockThresholdSecs, + rateLimitTokensPerWindow, + rateLimitWindowSecs, + }); + if (apiKey) { + await window.stdb.setApiKey(provider, apiKey); + $('cfg-apikey').value = ''; + } + toast('ok', 'Saved'); + } catch (err) { + toast('err', err.message ?? String(err)); + } finally { + btn.disabled = false; + btn.textContent = 'Save'; + } +}); + +$('btn-settings').addEventListener('click', openSetup); + +window.addEventListener('stdb:overrides', e => { + overrides = new Map((e.detail.overrides ?? []).map(o => [o.agentName, o])); + renderMessages(); +}); + +window.addEventListener('stdb:locks', e => { + lockedThreads = new Map(e.detail.locks); + renderThreads(); + renderMessages(); + updateButtons(); +}); + +window.addEventListener('stdb:threads', e => { + allThreads = e.detail.threads; + renderThreads(); + if (activeThreadId === null && allThreads.length > 0) { + selectThread(allThreads[0].id); + renderThreads(); + } + if ( + activeThreadId !== null && + !allThreads.some(t => t.id === activeThreadId) + ) { + selectThread(allThreads[0]?.id ?? null); + } + if (activeThreadId !== null) renderMessages(); + updateButtons(); +}); + +function renderThreads() { + const list = $('thread-list'); + if (allThreads.length === 0) { + list.innerHTML = '
no threads yet
'; + return; + } + list.innerHTML = ''; + for (const t of allThreads) { + const item = document.createElement('div'); + item.className = 'thread-item' + (t.id === activeThreadId ? ' active' : ''); + item.dataset.threadId = String(t.id); + const titleEl = document.createElement('span'); + titleEl.className = 'title'; + titleEl.textContent = t.title ?? `Thread #${t.id}`; + if (lockedThreads.has(t.id)) { + const dot = document.createElement('span'); + dot.className = 'busy-dot'; + dot.title = lockedThreads.get(t.id) ? 'stopping' : 'thinking'; + item.appendChild(dot); + } + item.appendChild(titleEl); + + const menuBtn = document.createElement('button'); + menuBtn.className = 'row-menu-btn'; + menuBtn.title = 'More'; + menuBtn.setAttribute('aria-label', 'More actions'); + menuBtn.innerHTML = + ''; + menuBtn.addEventListener('click', e => { + e.stopPropagation(); + openRowMenu(item, t.id); + }); + item.appendChild(menuBtn); + + item.addEventListener('click', () => { + selectThread(t.id); + renderThreads(); + renderMessages(); + updateButtons(); + }); + list.appendChild(item); + } +} + +const DEFAULT_AGENT_PREFERENCE = ['chat']; +function pickDefaultAgent() { + const agents = + configState.kind === 'configured' ? configState.status.agents : []; + const names = agents.map(a => a.name); + for (const pref of DEFAULT_AGENT_PREFERENCE) { + if (names.includes(pref)) return pref; + } + return names[0]; +} + +// thread → effective model (thread.modelOverride ?? agent override ?? agent code default) +function effectiveModelFor(thread) { + if (!thread) return ''; + if (thread.modelOverride) return thread.modelOverride; + const ov = overrides.get(thread.agentName); + if (ov?.model != null) return ov.model; + if (configState.kind !== 'configured') return ''; + const ai = configState.status.agents.find(a => a.name === thread.agentName); + return ai?.defaultModel ?? ''; +} +$('btn-new-thread').addEventListener('click', async () => { + if (configState.kind !== 'configured' || !window.stdb) return; + const agentName = pickDefaultAgent(); + if (!agentName) return toast('err', 'no agents registered'); + try { + const id = await window.stdb.startThread({ + agentName, + title: undefined, + systemPromptOverride: undefined, + metadata: undefined, + }); + selectThread(id); + renderThreads(); + renderMessages(); + updateButtons(); + $('composer-input').focus(); + } catch (err) { + toast('err', err.message ?? String(err)); + } +}); + +let openMenuEl = null; +function closeRowMenu() { + if (openMenuEl) { + openMenuEl.parentElement?.classList.remove('menu-open'); + openMenuEl.remove(); + openMenuEl = null; + } +} +function openRowMenu(itemEl, threadId) { + if (openMenuEl && openMenuEl.dataset.threadId === String(threadId)) { + closeRowMenu(); + return; + } + closeRowMenu(); + const menu = document.createElement('div'); + menu.className = 'row-menu'; + menu.dataset.threadId = String(threadId); + const renameBtn = document.createElement('button'); + renameBtn.innerHTML = + 'Rename'; + renameBtn.addEventListener('click', e => { + e.stopPropagation(); + closeRowMenu(); + openRenameFor(threadId); + }); + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'danger'; + deleteBtn.innerHTML = + 'Delete'; + deleteBtn.addEventListener('click', e => { + e.stopPropagation(); + closeRowMenu(); + deleteThreadConfirmed(threadId); + }); + menu.appendChild(renameBtn); + menu.appendChild(deleteBtn); + itemEl.appendChild(menu); + itemEl.classList.add('menu-open'); + openMenuEl = menu; +} +document.addEventListener('click', () => closeRowMenu()); +document.addEventListener('keydown', e => { + if (e.key === 'Escape') closeRowMenu(); +}); + +let renameTargetId = null; +function openRenameFor(threadId) { + const t = allThreads.find(x => x.id === threadId); + if (!t) return; + renameTargetId = threadId; + $('rename-title').value = t.title ?? ''; + $('rename-prompt').value = t.systemPromptOverride ?? ''; + $('rename-backdrop').classList.add('open'); + setTimeout(() => $('rename-title').focus(), 50); +} +async function deleteThreadConfirmed(threadId) { + if (!window.stdb) return; + const t = allThreads.find(x => x.id === threadId); + const label = t?.title ?? `Thread #${threadId}`; + const ok = await confirmDialog({ + title: 'Delete chat', + body: `Delete "${label}" and all its messages? This can't be undone.`, + confirmText: 'Delete', + danger: true, + }); + if (!ok) return; + try { + await window.stdb.deleteThread(threadId); + if (activeThreadId === threadId) { + selectThread(null); + renderMessages(); + } + renderThreads(); + updateButtons(); + } catch (err) { + toast('err', err.message ?? String(err)); + } +} +$('rename-cancel').addEventListener('click', () => + $('rename-backdrop').classList.remove('open') +); +$('rename-backdrop').addEventListener('click', e => { + if (e.target === $('rename-backdrop')) + $('rename-backdrop').classList.remove('open'); +}); +$('rename-form').addEventListener('submit', async e => { + e.preventDefault(); + if (!window.stdb || renameTargetId === null) return; + const titleRaw = $('rename-title').value.trim(); + const promptRaw = $('rename-prompt').value.trim(); + try { + await window.stdb.updateThread({ + threadId: renameTargetId, + title: titleRaw ? titleRaw : undefined, + systemPromptOverride: promptRaw ? promptRaw : undefined, + modelOverride: undefined, + metadata: undefined, + clearTitle: !titleRaw, + clearSystemPromptOverride: !promptRaw, + clearModelOverride: false, + clearMetadata: false, + }); + $('rename-backdrop').classList.remove('open'); + renameTargetId = null; + } catch (err) { + toast('err', err.message ?? String(err)); + } +}); + +window.addEventListener('stdb:messages', e => { + allMessages = e.detail.messages; + allAttachments = e.detail.attachments ?? {}; + renderMessages(); +}); + +function renderMessages() { + const wrap = $('msg-list'); + const head = $('chat-head'); + const headLabel = $('chat-agent-label'); + const composer = $('composer'); + const hero = $('empty-hero'); + + if (activeThreadId === null) { + head.hidden = true; + hero.style.display = 'flex'; + wrap.style.display = 'none'; + composer.hidden = true; + return; + } + hero.style.display = 'none'; + wrap.style.display = 'flex'; + composer.hidden = false; + + const t = allThreads.find(x => x.id === activeThreadId); + head.hidden = false; + const titleStr = t?.title ?? `Thread #${activeThreadId}`; + headLabel.textContent = titleStr; + if (t) { + const model = effectiveModelFor(t) || t.agentName || 'Unavailable'; + $('composer-model-label').textContent = model; + } + + const msgs = allMessages.filter( + m => m.id !== undefined && m.threadId === activeThreadId + ); + if (msgs.length === 0 && !lockedThreads.has(activeThreadId)) { + wrap.innerHTML = '
no messages yet - say hello
'; + return; + } + wrap.innerHTML = ''; + const wasNearBottom = + wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight < 100; + let lastUserMessage = null; + let lastAssistantIdx = -1; + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i].role === 'assistant') { + lastAssistantIdx = i; + break; + } + } + for (let i = 0; i < msgs.length; i++) { + const m = msgs[i]; + if (m.role === 'user') lastUserMessage = m.content; + + const node = document.createElement('div'); + const errCls = m.isError ? ' error' : ''; + node.className = `msg ${m.role}${errCls}`; + const who = document.createElement('div'); + who.className = 'who'; + who.textContent = m.role + (m.isError ? ' · error' : ''); + const body = document.createElement('div'); + body.className = 'body'; + if (m.role === 'assistant' && !m.isError) { + body.innerHTML = renderMarkdown(m.content) || '(empty)'; + body.querySelectorAll('pre').forEach(pre => { + const wrap = document.createElement('div'); + wrap.className = 'code-block'; + pre.parentNode.insertBefore(wrap, pre); + wrap.appendChild(pre); + const btn = document.createElement('button'); + btn.className = 'copy'; + btn.textContent = 'copy'; + btn.addEventListener('click', () => { + const code = pre.textContent ?? ''; + navigator.clipboard.writeText(code).then( + () => { + btn.textContent = 'copied'; + setTimeout(() => (btn.textContent = 'copy'), 1200); + }, + () => {} + ); + }); + wrap.appendChild(btn); + }); + } else { + body.textContent = m.content; + } + node.appendChild(who); + node.appendChild(body); + + const msgAtts = allAttachments[String(m.id)] ?? allAttachments[m.id] ?? []; + for (const att of msgAtts) { + const fileUrl = `/files?id=${encodeURIComponent(att.fileId.toString())}&v=${encodeURIComponent(att.sha256Hex ?? '')}`; + const filename = att.filename ?? 'attachment'; + if (String(att.mimeType ?? '').startsWith('image/')) { + const img = document.createElement('img'); + img.className = 'msg-image'; + img.src = fileUrl; + img.alt = filename; + img.loading = 'lazy'; + img.title = filename; + img.addEventListener('click', () => openImageViewer(fileUrl, filename)); + img.addEventListener( + 'error', + () => { + const link = document.createElement('a'); + link.className = 'msg-file-link'; + link.href = fileUrl; + link.target = '_blank'; + link.rel = 'noopener'; + link.textContent = filename; + img.replaceWith(link); + }, + { once: true } + ); + node.appendChild(img); + } else { + const link = document.createElement('a'); + link.className = 'msg-file-link'; + link.href = fileUrl; + link.target = '_blank'; + link.rel = 'noopener'; + link.textContent = filename; + node.appendChild(link); + } + } + + if (m.toolCallsJson) { + const tc = document.createElement('details'); + tc.className = 'toolcalls'; + const summary = document.createElement('summary'); + let callList; + try { + callList = JSON.parse(m.toolCallsJson); + } catch { + callList = []; + } + summary.textContent = `▸ ${callList.length} tool call${callList.length === 1 ? '' : 's'}`; + const pre = document.createElement('pre'); + pre.textContent = formatToolCalls(callList); + tc.appendChild(summary); + tc.appendChild(pre); + node.appendChild(tc); + } + + if (m.role === 'assistant') { + const footer = document.createElement('div'); + footer.className = 'msg-footer'; + + if (m.content) { + const copyBtn = document.createElement('button'); + copyBtn.title = 'Copy'; + copyBtn.setAttribute('aria-label', 'Copy'); + copyBtn.innerHTML = + ''; + copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(m.content).then( + () => { + copyBtn.title = 'Copied'; + setTimeout(() => (copyBtn.title = 'Copy'), 1200); + }, + () => toast('err', 'clipboard write failed') + ); + }); + footer.appendChild(copyBtn); + } + + if (i === lastAssistantIdx && !lockedThreads.has(activeThreadId)) { + const regen = document.createElement('button'); + regen.title = 'Regenerate'; + regen.setAttribute('aria-label', 'Regenerate'); + regen.innerHTML = + ''; + regen.addEventListener('click', async () => { + if (!window.stdb || activeThreadId === null) return; + regen.disabled = true; + try { + await window.stdb.regenerateResponse(activeThreadId); + } catch (err) { + toast('err', err.message ?? String(err)); + } finally { + regen.disabled = false; + } + }); + footer.appendChild(regen); + } + + if (m.isError && lastUserMessage !== null) { + const retry = document.createElement('button'); + retry.title = 'Retry'; + retry.setAttribute('aria-label', 'Retry'); + retry.innerHTML = + ''; + const userMsg = lastUserMessage; + retry.addEventListener('click', async () => { + if (!window.stdb || activeThreadId === null) return; + retry.disabled = true; + try { + await window.stdb.sendMessage(activeThreadId, userMsg); + } catch (err) { + toast('err', err.message ?? String(err)); + } finally { + retry.disabled = false; + } + }); + footer.appendChild(retry); + } + + const spacer = document.createElement('span'); + spacer.className = 'spacer'; + footer.appendChild(spacer); + + if ( + !m.isError && + (m.promptTokens !== undefined || m.completionTokens !== undefined) + ) { + const u = document.createElement('span'); + u.className = 'usage'; + const pt = m.promptTokens ?? '?'; + const ct = m.completionTokens ?? '?'; + u.textContent = `${pt} in · ${ct} out`; + footer.appendChild(u); + } + + node.appendChild(footer); + } + + wrap.appendChild(node); + } + + if (lockedThreads.has(activeThreadId)) { + const t = document.createElement('div'); + t.className = 'typing'; + t.innerHTML = + 'agent is thinking'; + wrap.appendChild(t); + } + + if (wasNearBottom) wrap.scrollTop = wrap.scrollHeight; +} + +function formatToolCalls(arr) { + return arr + .map(c => { + const args = c.function?.arguments ?? ''; + return `→ ${c.function?.name ?? '?'}(${args})`; + }) + .join('\n'); +} + +const MAX_ATTACH_BYTES = 4_000_000; +const MAX_ATTACH_COUNT = 4; +const MAX_ATTACH_TOTAL_BYTES = 12_000_000; +function renderPendingAttachments() { + const wrap = $('pending-attachments'); + if (pendingAttachments.length === 0) { + wrap.style.display = 'none'; + wrap.innerHTML = ''; + return; + } + wrap.style.display = 'flex'; + wrap.innerHTML = ''; + pendingAttachments.forEach((a, idx) => { + const t = document.createElement('div'); + t.className = 'pending-thumb'; + const blob = new Blob([a.bytes], { type: a.mimeType }); + const url = URL.createObjectURL(blob); + t.innerHTML = ``; + t.querySelector('.x').addEventListener('click', () => { + pendingAttachments.splice(idx, 1); + renderPendingAttachments(); + }); + wrap.appendChild(t); + }); +} + +// Model list comes from OpenRouter's /api/v1/models so it's always +// current. Cached per page load. +let modelListCache = null; +let modelListPromise = null; +async function getModelList() { + if (modelListCache) return modelListCache; + if (modelListPromise) return modelListPromise; + modelListPromise = (async () => { + const res = await fetch('https://openrouter.ai/api/v1/models'); + if (!res.ok) throw new Error(`openrouter /models -> ${res.status}`); + const body = await res.json(); + const ids = (body?.data ?? []) + .map(m => m?.id) + .filter(id => typeof id === 'string') + .sort(); + modelListCache = ids; + return ids; + })(); + try { + return await modelListPromise; + } finally { + modelListPromise = null; + } +} + +let modelPopover = null; +function closeModelPopover() { + if (modelPopover) { + modelPopover.remove(); + modelPopover = null; + } +} +async function pickModel(model) { + closeModelPopover(); + if (!window.stdb || activeThreadId === null) return; + try { + await window.stdb.updateThread({ + threadId: activeThreadId, + title: undefined, + systemPromptOverride: undefined, + modelOverride: model, + metadata: undefined, + clearTitle: false, + clearSystemPromptOverride: false, + clearModelOverride: false, + clearMetadata: false, + }); + } catch (err) { + toast('err', err.message ?? String(err)); + } +} +function renderModelList(listEl, ids, current, filter) { + listEl.innerHTML = ''; + const q = filter.trim().toLowerCase(); + const filtered = q ? ids.filter(id => id.toLowerCase().includes(q)) : ids; + if (filtered.length === 0) { + const empty = document.createElement('div'); + empty.className = 'empty'; + empty.textContent = 'no matches'; + listEl.appendChild(empty); + return; + } + // Keep the active model at the top if it's in the filtered set. + const ordered = filtered.includes(current) + ? [current, ...filtered.filter(id => id !== current)] + : filtered; + // Cap the rendered count for performance; search to find the rest. + const SHOW_LIMIT = 200; + for (const id of ordered.slice(0, SHOW_LIMIT)) { + const btn = document.createElement('button'); + btn.textContent = id; + btn.title = id; + if (id === current) btn.classList.add('active'); + btn.addEventListener('click', () => pickModel(id)); + listEl.appendChild(btn); + } + if (ordered.length > SHOW_LIMIT) { + const more = document.createElement('div'); + more.className = 'empty'; + more.textContent = `…and ${ordered.length - SHOW_LIMIT} more - refine the search`; + listEl.appendChild(more); + } +} +async function openModelPopover(anchor) { + closeModelPopover(); + const t = allThreads.find(x => x.id === activeThreadId); + if (!t) return; + const current = effectiveModelFor(t); + modelPopover = document.createElement('div'); + modelPopover.className = 'model-popover'; + const search = document.createElement('input'); + search.type = 'text'; + search.className = 'search'; + search.placeholder = 'filter models…'; + const list = document.createElement('div'); + list.className = 'list'; + const loading = document.createElement('div'); + loading.className = 'empty'; + loading.textContent = 'loading models…'; + list.appendChild(loading); + modelPopover.appendChild(search); + modelPopover.appendChild(list); + document.body.appendChild(modelPopover); + // Anchor by bottom edge so the popover grows upward. + const r = anchor.getBoundingClientRect(); + modelPopover.style.left = `${r.left}px`; + modelPopover.style.bottom = `${window.innerHeight - r.top + 4}px`; + search.focus(); + + let ids; + try { + ids = await getModelList(); + } catch (err) { + loading.textContent = `couldn't load: ${err.message ?? err}`; + return; + } + // Popover may have been closed during the await. + if (!modelPopover) return; + renderModelList(list, ids, current, ''); + search.addEventListener('input', () => + renderModelList(list, ids, current, search.value) + ); +} +$('composer-model-label').addEventListener('click', e => { + e.stopPropagation(); + if (modelPopover) closeModelPopover(); + else openModelPopover(e.currentTarget); +}); +document.addEventListener('click', e => { + if (modelPopover && !modelPopover.contains(e.target)) closeModelPopover(); +}); +document.addEventListener('keydown', e => { + if (e.key === 'Escape') closeModelPopover(); +}); + +$('btn-attach').addEventListener('click', () => $('file-input').click()); +$('file-input').addEventListener('change', async e => { + const file = e.target.files?.[0]; + e.target.value = ''; + if (!file) return; + if ( + !['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(file.type) + ) { + return toast('err', `unsupported image type: ${file.type}`); + } + if (pendingAttachments.length >= MAX_ATTACH_COUNT) { + return toast('err', `at most ${MAX_ATTACH_COUNT} attachments are allowed`); + } + if (file.size > MAX_ATTACH_BYTES) { + return toast( + 'err', + `attachment too large (${file.size} > ${MAX_ATTACH_BYTES})` + ); + } + const totalBytes = + pendingAttachments.reduce((sum, item) => sum + item.bytes.length, 0) + + file.size; + if (totalBytes > MAX_ATTACH_TOTAL_BYTES) { + return toast( + 'err', + `attachments exceed the ${MAX_ATTACH_TOTAL_BYTES / 1_000_000} MB total limit` + ); + } + const bytes = new Uint8Array(await file.arrayBuffer()); + pendingAttachments.push({ + mimeType: file.type, + filename: file.name, + bytes, + }); + renderPendingAttachments(); +}); + +const composer = $('composer-input'); +composer.addEventListener('input', () => { + composer.style.height = 'auto'; + composer.style.height = Math.min(composer.scrollHeight, 160) + 'px'; +}); +composer.addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + $('btn-send').click(); + } +}); + +$('btn-stop').addEventListener('click', async () => { + if (!window.stdb || activeThreadId === null) return; + const tid = activeThreadId; + if (!lockedThreads.has(tid) || lockedThreads.get(tid) === true) return; + try { + await window.stdb.requestCancel(tid); + } catch (err) { + toast('err', err.message ?? String(err)); + } +}); + +$('btn-send').addEventListener('click', async () => { + if (!window.stdb || activeThreadId === null) return; + const tid = activeThreadId; + if (lockedThreads.has(tid) || inFlightSend.has(tid)) return; + + const text = composer.value.trim(); + const atts = pendingAttachments.slice(); + if (!text && atts.length === 0) return; + + inFlightSend.add(tid); + composer.value = ''; + composer.style.height = 'auto'; + pendingAttachments = []; + renderPendingAttachments(); + updateButtons(); + + const threadRow = allThreads.find(x => x.id === tid); + const noTitleYet = + threadRow && (threadRow.title == null || threadRow.title === ''); + const noPriorUser = !allMessages.some( + m => m.threadId === tid && m.role === 'user' + ); + + try { + await window.stdb.sendMessage(tid, text, atts); + } catch (err) { + toast('err', err.message ?? String(err)); + } finally { + inFlightSend.delete(tid); + updateButtons(); + if (activeThreadId === tid) composer.focus(); + } + + // After the first message lands, ask the summarizer to title the + // thread. Idempotent server-side; failures are silently ignored. + if (noTitleYet && noPriorUser && window.stdb) { + window.stdb.generateThreadTitle(tid).catch(() => {}); + } +}); + +function updateButtons() { + const ready = + connState === 'connected' && + configState.kind === 'configured' && + !!window.stdb; + $('btn-new-thread').disabled = !ready; + $('btn-new-thread-hero').disabled = !ready; + const tid = activeThreadId; + const locked = tid !== null && lockedThreads.has(tid); + const busy = tid !== null && (locked || inFlightSend.has(tid)); + const canSend = ready && tid !== null && !busy; + $('composer-input').disabled = !canSend; + $('btn-send').disabled = !canSend; + $('btn-attach').disabled = !canSend; + $('btn-send').textContent = busy ? 'thinking…' : 'Send'; + $('btn-send').hidden = locked; + $('btn-stop').hidden = !locked; + $('btn-stop').disabled = !locked || lockedThreads.get(tid) === true; + $('btn-stop').textContent = lockedThreads.get(tid) ? 'stopping…' : 'Stop'; +} + +let currentUserState = null; + +$('btn-logout').addEventListener('click', async () => { + if (!window.auth) return; + try { + await window.auth.logout(); + } catch (err) { + toast('err', err.message ?? String(err)); + } +}); + +function renderUserPanel() { + const u = currentUserState; + const av = $('user-avatar'); + const setAvatarInitial = () => { + av.classList.remove('has-image'); + const initial = u + ? (u.name?.trim() || u.email || '?').slice(0, 1).toUpperCase() + : '?'; + av.replaceChildren(document.createTextNode(initial)); + }; + if (!u) { + setAvatarInitial(); + $('user-name').textContent = 'Unavailable'; + return; + } + const imageUrl = typeof u.image === 'string' ? u.image.trim() : ''; + if (imageUrl) { + av.classList.add('has-image'); + const img = document.createElement('img'); + img.alt = ''; + img.referrerPolicy = 'no-referrer'; + img.addEventListener('error', setAvatarInitial, { once: true }); + img.src = imageUrl; + av.replaceChildren(img); + } else { + setAvatarInitial(); + } + $('user-name').textContent = u.name?.trim() || u.email; +} + +function applyConnStateToAvatar(state) { + const av = $('user-avatar'); + av.classList.toggle('online', state === 'connected'); + av.classList.toggle('warn', state === 'connecting' || state === 'idle'); + $('user-status').textContent = + state === 'connected' + ? 'online' + : state === 'connecting' + ? 'connecting…' + : state === 'idle' + ? 'idle' + : 'offline'; +} + +function showAuthView() { + $('auth-shell').hidden = false; + $('shell').hidden = true; +} +function showChatView() { + $('auth-shell').hidden = true; + $('shell').hidden = false; +} + +function dismissBootSplash() { + const splash = document.getElementById('bootSplash'); + if (!splash) return; + splash.classList.add('fading'); + setTimeout(() => splash.remove(), 250); +} +window.addEventListener('auth:ready', () => { + if (!currentUserState) showAuthView(); + dismissBootSplash(); +}); +setTimeout(dismissBootSplash, 4000); +window.addEventListener('auth:state', e => { + currentUserState = e.detail.user; + if (currentUserState) { + showChatView(); + renderUserPanel(); + } else { + showAuthView(); + } +}); + +window.addEventListener('stdb:connState', e => { + applyConnStateToAvatar(e.detail.state); +}); + +// Initial view: assume auth-shell until auth:state proves otherwise. +showAuthView(); diff --git a/spacetime-agents-ts/example/scripts/test-markdown.mjs b/spacetime-agents-ts/example/scripts/test-markdown.mjs new file mode 100644 index 00000000000..e48cd954953 --- /dev/null +++ b/spacetime-agents-ts/example/scripts/test-markdown.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { escapeHtml, renderMarkdown } from '../public/markdown.js'; + +assert.equal( + escapeHtml(``), + '<script data-x="'">&</script>' +); +assert.equal(renderMarkdown(''), ''); +assert.equal( + renderMarkdown('**bold** and *italic* and `code`'), + '

bold and italic and code

' +); +assert.equal( + renderMarkdown('[docs](https://example.com/path)'), + '

docs

' +); +assert.equal( + renderMarkdown('[unsafe](javascript:alert(1))'), + '

[unsafe](javascript:alert(1))

' +); +assert.equal( + renderMarkdown(''), + '

<img src=x onerror=alert(1)>

' +); +assert.equal( + renderMarkdown('```html\nnot HTML\n```'), + '
<strong>not HTML</strong>\n
' +); +assert.equal( + renderMarkdown('before\n```text\ninside\n```\nafter'), + '

before

inside\n

after

' +); +assert.equal(renderMarkdown(' BLOCK0 '), '

BLOCK0

'); + +console.log('agents markdown tests passed'); diff --git a/spacetime-agents-ts/example/server.ts b/spacetime-agents-ts/example/server.ts new file mode 100644 index 00000000000..b4a10447390 --- /dev/null +++ b/spacetime-agents-ts/example/server.ts @@ -0,0 +1,263 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; +import { exampleUiAssetsDir } from '@spacetimedb/submodule-shared/server'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const PUBLIC_DIR = path.join(__dirname, 'public'); +const SPA_HTML = readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8'); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +// Shared env supplies secrets; example-local env supplies app defaults. +// Blank placeholders in the example .env should not erase shared secrets. +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8789', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-agents-example'; +const AUTH_ISSUER_URL = + process.env.AUTH_ISSUER_URL ?? `http://localhost:${PORT}`; +const AUTH_BASE_URL = process.env.AUTH_BASE_URL ?? AUTH_ISSUER_URL; +const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME ?? 'stdb_auth'; +const AUTH_SESSION_TTL_SECONDS = Number.parseInt( + process.env.AUTH_SESSION_TTL_SECONDS ?? `${60 * 60 * 24 * 7}`, + 10 +); +if ( + !Number.isInteger(AUTH_SESSION_TTL_SECONDS) || + AUTH_SESSION_TTL_SECONDS <= 0 +) { + throw new Error('AUTH_SESSION_TTL_SECONDS must be a positive integer'); +} +const GOOGLE_OAUTH_ENABLED = Boolean( + process.env.GOOGLE_CLIENT_ID?.trim() && + process.env.GOOGLE_CLIENT_SECRET?.trim() +); +const GITHUB_OAUTH_ENABLED = Boolean( + process.env.GITHUB_CLIENT_ID?.trim() && + process.env.GITHUB_CLIENT_SECRET?.trim() +); +const STDB_SERVER = process.env.STDB_SERVER ?? STDB_HTTP; +const SPACETIME_BIN = 'spacetime'; + +function configuredValue(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function configuredPem(value: string | undefined): string | undefined { + return configuredValue(value)?.replace(/\\n/g, '\n'); +} + +const opt = (value: string | undefined) => + value === undefined ? JSON.stringify([1, []]) : JSON.stringify([0, value]); + +function configureAuthFromEnv(): void { + const args = [ + JSON.stringify(AUTH_ISSUER_URL), + opt(AUTH_BASE_URL), + opt(AUTH_COOKIE_NAME), + JSON.stringify([0, AUTH_SESSION_TTL_SECONDS]), + opt(configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM)), + opt(configuredValue(process.env.GOOGLE_CLIENT_ID)), + opt(configuredValue(process.env.GOOGLE_CLIENT_SECRET)), + opt(configuredValue(process.env.GITHUB_CLIENT_ID)), + opt(configuredValue(process.env.GITHUB_CLIENT_SECRET)), + ]; + + const result = spawnSync( + SPACETIME_BIN, + ['call', '--server', STDB_SERVER, DB_NAME, 'set_auth_config', ...args], + { stdio: 'inherit', shell: false } + ); + if (result.status !== 0) { + throw new Error(`auth config bootstrap failed (exit ${result.status})`); + } +} + +function optU32(value: string | undefined): string { + const trimmed = value?.trim(); + if (!trimmed) return JSON.stringify([1, []]); + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 0xffff_ffff) { + throw new Error(`invalid u32 env value: ${trimmed}`); + } + return JSON.stringify([0, parsed]); +} + +function callReducer(name: string, args: string[]): void { + const result = spawnSync( + SPACETIME_BIN, + ['call', '--server', STDB_SERVER, DB_NAME, name, ...args], + { stdio: 'inherit', shell: false } + ); + if (result.status !== 0) { + throw new Error(`${name} bootstrap failed (exit ${result.status})`); + } +} + +function seedApiKey(provider: string, key: string | undefined): boolean { + const configured = configuredValue(key); + if (!configured) return false; + callReducer('set_api_key', [ + JSON.stringify(provider), + JSON.stringify(configured), + ]); + return true; +} + +function configureAgentsFromEnv(): void { + callReducer('set_agent_secret', [ + optU32(process.env.STALE_LOCK_THRESHOLD_SECS), + optU32(process.env.RATE_LIMIT_TOKENS_PER_WINDOW), + optU32(process.env.RATE_LIMIT_WINDOW_SECS), + ]); + + const seededProviders = [ + seedApiKey('openrouter', process.env.OPENROUTER_API_KEY) + ? 'openrouter' + : undefined, + seedApiKey('openai', process.env.OPENAI_API_KEY) ? 'openai' : undefined, + seedApiKey('anthropic', process.env.ANTHROPIC_API_KEY) + ? 'anthropic' + : undefined, + ].filter((provider): provider is string => Boolean(provider)); + + if (seededProviders.length > 0) { + console.log(`[agents] seeded provider keys: ${seededProviders.join(', ')}`); + } else { + console.log('[agents] no provider keys found in env'); + } +} + +const app = express(); +app.use(express.json({ limit: '256kb' })); + +// Register this before the /auth proxy so reset links reach the SPA. +app.get('/auth/password/reset', (_req: Request, res: Response) => { + res.type('html').send(SPA_HTML); +}); + +function proxyStdbRoute(prefix: string) { + return async (req: Request, res: Response) => { + const mountedUrl = req.url.startsWith('/?') ? req.url.slice(1) : req.url; + const fullPath = `${prefix}${mountedUrl}`; + const qIdx = fullPath.indexOf('?'); + const subpath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); + const query = qIdx < 0 ? '' : fullPath.slice(qIdx); + const upstreamUrl = `${STDB_HTTP}/v1/database/${DB_NAME}/route${subpath}${query}`; + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === 'string') headers[k] = v; + else if (Array.isArray(v)) headers[k] = v.join(', '); + } + delete headers.host; + delete headers['content-length']; + headers['x-forwarded-proto'] = headers['x-forwarded-proto'] ?? req.protocol; + + const init: RequestInit = { + method: req.method, + headers, + redirect: 'manual', + }; + if (req.method !== 'GET' && req.method !== 'HEAD') { + init.body = JSON.stringify(req.body); + headers['content-type'] = 'application/json'; + } + + try { + const upstream = await fetch(upstreamUrl, init); + res.status(upstream.status); + upstream.headers.forEach((val, key) => { + const lower = key.toLowerCase(); + if ( + lower === 'transfer-encoding' || + lower === 'content-encoding' || + lower === 'content-length' + ) + return; + res.setHeader(key, val); + }); + const buf = Buffer.from(await upstream.arrayBuffer()); + res.send(buf); + } catch (err) { + res.status(502).json({ + error: 'upstream_unreachable', + detail: (err as Error).message, + }); + } + }; +} + +app.use('/auth', proxyStdbRoute('/auth')); +app.use('/files', proxyStdbRoute('/files')); + +app.use('/assets', express.static(exampleUiAssetsDir)); +app.use(express.static(PUBLIC_DIR)); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, databaseName: DB_NAME }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ + spacetimeUri: STDB_URI, + databaseName: DB_NAME, + auth: { + issuerUrl: AUTH_ISSUER_URL, + baseUrl: AUTH_BASE_URL, + cookieName: AUTH_COOKIE_NAME, + sessionTtlSeconds: AUTH_SESSION_TTL_SECONDS, + hasEs256PrivateKeyPem: Boolean( + configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM) + ), + }, + oauth: { + google: GOOGLE_OAUTH_ENABLED, + github: GITHUB_OAUTH_ENABLED, + }, + }); +}); + +try { + console.log(`[auth] bootstrapping env config via ${SPACETIME_BIN}`); + configureAuthFromEnv(); + console.log(`[auth] bootstrapped env config issuer=${AUTH_ISSUER_URL}`); + configureAgentsFromEnv(); +} catch (err) { + console.error( + `[auth] env config bootstrap failed: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + '[auth] is the SpacetimeDB host running and the agents example module published?' + ); + process.exit(1); +} + +app.listen(PORT, HOST, () => { + console.log(`Agents example running at http://${HOST}:${PORT}`); + console.log(` STDB ws -> ${STDB_URI}`); + console.log(` STDB http-> ${STDB_HTTP} (proxying /auth/*, /files)`); + console.log(` Database -> ${DB_NAME}`); +}); diff --git a/spacetime-agents-ts/example/spacetimedb/.npmrc b/spacetime-agents-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-agents-ts/example/spacetimedb/package.json b/spacetime-agents-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..596846d2fa2 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/package.json @@ -0,0 +1,23 @@ +{ + "name": "spacetime-agents-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-agents-example", + "test": "tsx scripts/test-loop.ts" + }, + "dependencies": { + "@spacetimedb/agents": "workspace:*", + "@spacetimedb/auth": "workspace:*", + "@spacetimedb/files": "workspace:*", + "@spacetimedb/rate-limit": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts b/spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts new file mode 100644 index 00000000000..c143e1f415c --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts @@ -0,0 +1,1557 @@ +// Pure-Node tests for the extracted agent loop. Mocks HTTP + LoopTx. + +import { + runAgentLoop, + buildLlmMessages, + type LoopTx, + type LoopMessage, + type LoopConfig, +} from '../src/loop.ts'; +import { isStaleLock } from '../src/sweeper.ts'; +import { + ATTACHMENT_COUNT_MAX, + ATTACHMENT_TOTAL_BYTES_MAX, + attachmentValidationError, +} from '../src/attachments.ts'; +import { + pickSummarizationCandidates, + buildSummarizerUserContent, + augmentSystemWithSummary, + formatMessagesForSummarizer, +} from '../src/summarize.ts'; +import type { HttpLike } from '@spacetimedb/agents/openrouter'; +import type { InvokeResult } from '@spacetimedb/agents'; + +let failures = 0; +function assert(cond: boolean, msg: string): void { + if (!cond) { + process.stderr.write(` FAIL: ${msg}\n`); + failures++; + } else { + process.stdout.write(` ${msg} OK\n`); + } +} + +const eq = (a: unknown, b: unknown): boolean => + JSON.stringify(a) === JSON.stringify(b); + +const png = (length: number) => ({ mimeType: 'image/png', bytes: { length } }); +assert( + attachmentValidationError([png(10)]) === undefined, + 'accepts a supported attachment' +); +assert( + attachmentValidationError([ + { mimeType: 'text/plain', bytes: { length: 10 } }, + ]) === 'agent.unsupported_attachment_mime:text/plain', + 'rejects an unsupported attachment type' +); +assert( + attachmentValidationError( + Array.from({ length: ATTACHMENT_COUNT_MAX + 1 }, () => png(1)) + ) === + `agent.too_many_attachments:${ATTACHMENT_COUNT_MAX + 1}/${ATTACHMENT_COUNT_MAX}`, + 'rejects too many attachments' +); +assert( + attachmentValidationError([ + png(3_000_000), + png(3_000_000), + png(3_000_000), + png(ATTACHMENT_TOTAL_BYTES_MAX - 9_000_000 + 1), + ]) === + `agent.attachments_too_large:${ATTACHMENT_TOTAL_BYTES_MAX + 1}/${ATTACHMENT_TOTAL_BYTES_MAX}`, + 'rejects excessive aggregate attachment bytes' +); + +function makeFakeStore(): { + tx: LoopTx; + messages: LoopMessage[]; + toolInvocations: Array<{ name: string; input: string }>; + toolHandlers: Map InvokeResult>; + withTxCalls: number; + bumpedThreads: bigint[]; + cancelledThreads: Set; + setTool(name: string, handler: (input: string) => InvokeResult): void; + cancel(threadId: bigint): void; +} { + const messages: LoopMessage[] = []; + const toolInvocations: Array<{ name: string; input: string }> = []; + const toolHandlers = new Map InvokeResult>(); + const bumpedThreads: bigint[] = []; + const cancelledThreads = new Set(); + let nextId = 1n; + + const tx: LoopTx = { + listMessages(threadId) { + return messages + .filter(m => m.threadId === threadId) + .slice() + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + }, + appendMessage(row) { + messages.push({ id: nextId++, attachments: [], ...row }); + }, + bumpThread(threadId) { + bumpedThreads.push(threadId); + }, + invokeTool(name, inputJson) { + toolInvocations.push({ name, input: inputJson }); + const h = toolHandlers.get(name); + if (!h) return { result: `unknown tool: ${name}`, isError: true }; + return h(inputJson); + }, + isCancelRequested(threadId) { + return cancelledThreads.has(threadId); + }, + }; + + return { + tx, + messages, + toolInvocations, + toolHandlers, + bumpedThreads, + cancelledThreads, + withTxCalls: 0, + setTool(name, handler) { + toolHandlers.set(name, handler); + }, + cancel(threadId) { + cancelledThreads.add(threadId); + }, + }; +} + +type FakeHttpResponse = { status: number; body: string } | { throws: Error }; +type FakeRequestBody = { + model?: unknown; + messages: Array<{ + role: string; + content?: unknown; + tool_calls?: Array<{ id: string }>; + tool_call_id?: string; + }>; + max_tokens?: unknown; + response_format?: unknown; + [key: string]: unknown; +}; + +function makeFakeHttp(responses: FakeHttpResponse[]): { + http: HttpLike; + requests: Array<{ + url: string; + method: string; + headers: Record; + body: FakeRequestBody; + }>; +} { + const requests: Array<{ + url: string; + method: string; + headers: Record; + body: FakeRequestBody; + }> = []; + let i = 0; + const http: HttpLike = { + fetch(url, init) { + const next = responses[i++]; + requests.push({ + url, + method: init.method, + headers: init.headers, + body: init.body + ? (JSON.parse(init.body) as FakeRequestBody) + : { messages: [] }, + }); + if (!next) + throw new Error(`fake http: no more canned responses (call #${i})`); + if ('throws' in next) throw next.throws; + return { status: next.status, text: () => next.body }; + }, + }; + return { http, requests }; +} + +function llmReply(opts: { + content?: string | null; + toolCalls?: Array<{ id: string; name: string; args: object }>; + finish?: string; + usage?: { prompt: number; completion: number }; +}): FakeHttpResponse { + const finish = + opts.finish ?? + (opts.toolCalls && opts.toolCalls.length > 0 ? 'tool_calls' : 'stop'); + const toolCalls = opts.toolCalls?.map(c => ({ + id: c.id, + type: 'function', + function: { name: c.name, arguments: JSON.stringify(c.args) }, + })); + const u = opts.usage ?? { prompt: 10, completion: 5 }; + return { + status: 200, + body: JSON.stringify({ + model: 'fake/model', + choices: [ + { + finish_reason: finish, + message: { + content: opts.content ?? null, + tool_calls: toolCalls, + }, + }, + ], + usage: { + prompt_tokens: u.prompt, + completion_tokens: u.completion, + total_tokens: u.prompt + u.completion, + }, + }), + }; +} + +function withTxAdapter(tx: LoopTx): (fn: (lt: LoopTx) => R) => R { + return fn => fn(tx); +} + +import { openRouterProvider } from '@spacetimedb/agents/providers'; + +const baseCfg: LoopConfig = { + provider: openRouterProvider, + apiKey: 'sk-test', + model: 'anthropic/claude-3.5-sonnet', + systemPrompt: 'you are a test assistant', + maxTurns: 5, + maxHistoryMessages: 50, + maxTokens: undefined, + retries: 2, + responseFormat: undefined, +}; + +process.stdout.write('agent loop tests\n'); + +// 1. Single-turn text reply +{ + const store = makeFakeStore(); + // Seed with a user message so buildLlmMessages includes it. + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + + const { http } = makeFakeHttp([ + llmReply({ content: 'hello!', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const assistantMsgs = store.messages.filter(m => m.role === 'assistant'); + assert( + assistantMsgs.length === 1, + `single-turn: 1 assistant message inserted` + ); + assert( + assistantMsgs[0].content === 'hello!', + `single-turn: assistant content is 'hello!'` + ); + assert( + assistantMsgs[0].isError === false, + `single-turn: not flagged as error` + ); + assert( + assistantMsgs[0].toolCallsJson === undefined, + `single-turn: no toolCallsJson` + ); +} + +// 2. Tool call -> tool result -> follow-up text (2 turns) +{ + const store = makeFakeStore(); + store.setTool('echo', inp => { + const args = JSON.parse(inp); + return { result: `echoed: ${args.message}`, isError: false }; + }); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'echo hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + + const { http, requests } = makeFakeHttp([ + llmReply({ + content: '', + toolCalls: [{ id: 'call_1', name: 'echo', args: { message: 'hi' } }], + }), + llmReply({ content: 'I echoed it.', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + assert(requests.length === 2, `tool-call flow: 2 LLM calls made`); + + const inserted = store.messages.filter(m => m.id > 1n); // skip seed user msg + assert( + inserted.length === 3, + `tool-call flow: assistant + tool + assistant inserted (got ${inserted.length})` + ); + assert( + inserted[0].role === 'assistant' && inserted[0].toolCallsJson !== undefined, + `tool-call flow: 1st insert is assistant w/ tool_calls` + ); + assert( + inserted[1].role === 'tool' && + inserted[1].content === 'echoed: hi' && + inserted[1].toolCallId === 'call_1', + `tool-call flow: 2nd insert is tool result, correctly linked to call_1` + ); + assert( + inserted[2].role === 'assistant' && + inserted[2].content === 'I echoed it.' && + inserted[2].toolCallsJson === undefined, + `tool-call flow: 3rd insert is final assistant text` + ); + assert( + store.toolInvocations.length === 1 && + store.toolInvocations[0].name === 'echo', + `tool-call flow: echo invoked once` + ); + + // 2nd LLM call must carry the assistant-with-tool_calls + tool result. + const secondReqMsgs = requests[1].body.messages; + const lastTwo = secondReqMsgs.slice(-2); + assert( + lastTwo[0].role === 'assistant' && + Array.isArray(lastTwo[0].tool_calls) && + lastTwo[0].tool_calls.length === 1, + `tool-call flow: 2nd request includes assistant-with-tool_calls` + ); + assert( + lastTwo[1].role === 'tool' && + lastTwo[1].tool_call_id === 'call_1' && + lastTwo[1].content === 'echoed: hi', + `tool-call flow: 2nd request includes tool result message` + ); +} + +// 3. Multiple tool calls in one turn +{ + const store = makeFakeStore(); + store.setTool('echo', inp => ({ + result: `echoed: ${JSON.parse(inp).message}`, + isError: false, + })); + store.setTool('upper', inp => ({ + result: String(JSON.parse(inp).text).toUpperCase(), + isError: false, + })); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'do both', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + + const { http } = makeFakeHttp([ + llmReply({ + content: null, + toolCalls: [ + { id: 'a', name: 'echo', args: { message: 'one' } }, + { id: 'b', name: 'upper', args: { text: 'two' } }, + ], + }), + llmReply({ content: 'done', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const tools = store.messages.filter(m => m.role === 'tool'); + assert(tools.length === 2, `multi-tool: 2 tool result messages`); + assert( + tools[0].toolCallId === 'a' && tools[0].content === 'echoed: one', + `multi-tool: a -> echo result` + ); + assert( + tools[1].toolCallId === 'b' && tools[1].content === 'TWO', + `multi-tool: b -> upper result` + ); + assert(store.toolInvocations.length === 2, `multi-tool: 2 invocations`); +} + +// 4. HTTP error path +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + + const { http } = makeFakeHttp([ + { status: 401, body: '{"error":"unauthorized"}' }, + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const assistants = store.messages.filter(m => m.role === 'assistant'); + assert( + assistants.length === 1 && assistants[0].isError === true, + `http-error: 1 error assistant message` + ); + assert( + assistants[0].content.includes('agent.provider_http:401'), + `http-error: error string includes status 401` + ); + assert( + assistants[0].content.includes('unauthorized'), + `http-error: error string includes body excerpt` + ); +} + +// 5. Transport error (fetch throws every attempt) +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + + // retries=2 -> 3 attempts total. + const { http, requests } = makeFakeHttp([ + { throws: new Error('connection refused') }, + { throws: new Error('connection refused') }, + { throws: new Error('connection refused') }, + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + assert( + requests.length === 3, + `transport-error: 3 attempts (initial + 2 retries)` + ); + const assistants = store.messages.filter(m => m.role === 'assistant'); + assert( + assistants.length === 1 && assistants[0].isError === true, + `transport-error: 1 error assistant message` + ); + assert( + assistants[0].content.includes( + 'agent.provider_transport:connection refused' + ), + `transport-error: error string identifies cause` + ); +} + +// 6. Parse error path +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + + const { http } = makeFakeHttp([{ status: 200, body: '{not json' }]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const assistants = store.messages.filter(m => m.role === 'assistant'); + assert( + assistants.length === 1 && + assistants[0].isError === true && + assistants[0].content.includes('agent.provider_parse'), + `parse-error: 1 error assistant message with parse kind` + ); +} + +// 7. Max turns exceeded +{ + const store = makeFakeStore(); + store.setTool('loop_tool', () => ({ result: 'still going', isError: false })); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'go', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + + const cfgSmall: LoopConfig = { ...baseCfg, maxTurns: 3 }; + const { http, requests } = makeFakeHttp([ + llmReply({ + content: '', + toolCalls: [{ id: 'a', name: 'loop_tool', args: {} }], + }), + llmReply({ + content: '', + toolCalls: [{ id: 'b', name: 'loop_tool', args: {} }], + }), + llmReply({ + content: '', + toolCalls: [{ id: 'c', name: 'loop_tool', args: {} }], + }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: cfgSmall, + threadId: 1n, + }); + + assert( + requests.length === 3, + `max-turns: exactly maxTurns LLM calls (got ${requests.length})` + ); + const errMsg = store.messages.find(m => m.isError === true); + assert( + errMsg !== undefined && errMsg.content === 'agent.max_turns_exceeded:3', + `max-turns: final error message inserted` + ); + assert( + store.toolInvocations.length === 3, + `max-turns: 3 tool invocations (one per turn)` + ); +} + +// 8. Failing tool (isError=true). Loop continues if LLM asks again, ends if not. +{ + const store = makeFakeStore(); + store.setTool('flaky', () => ({ result: 'tool exploded', isError: true })); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'try', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + + const { http } = makeFakeHttp([ + llmReply({ + content: '', + toolCalls: [{ id: 'x', name: 'flaky', args: {} }], + }), + llmReply({ content: 'sorry, that tool broke', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const toolMsg = store.messages.find(m => m.role === 'tool'); + assert( + toolMsg !== undefined && + toolMsg.isError === true && + toolMsg.content === 'tool exploded', + `failing-tool: tool message recorded with isError=true` + ); + const finalAssistant = [...store.messages] + .reverse() + .find(m => m.role === 'assistant' && !m.isError); + assert( + finalAssistant !== undefined && + finalAssistant.content === 'sorry, that tool broke', + `failing-tool: LLM's recovery reply recorded` + ); +} + +// 9. System prompt + message history forwarded correctly +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'first', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + store.tx.appendMessage({ + threadId: 1n, + role: 'assistant', + content: 'reply', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'second', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + // Different thread; must not leak. + store.tx.appendMessage({ + threadId: 99n, + role: 'user', + content: 'OTHER THREAD', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + + const { http, requests } = makeFakeHttp([ + llmReply({ content: 'ack', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const req = requests[0]; + assert( + req.url === 'https://openrouter.ai/api/v1/chat/completions', + `forward: posts to OpenRouter URL` + ); + assert( + req.headers.Authorization === 'Bearer sk-test', + `forward: auth header set` + ); + assert( + req.body.model === 'anthropic/claude-3.5-sonnet', + `forward: model included` + ); + assert( + req.body.messages[0].role === 'system' && + req.body.messages[0].content === 'you are a test assistant', + `forward: system prompt prepended` + ); + assert( + eq(req.body.messages.slice(1), [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'reply' }, + { role: 'user', content: 'second' }, + ]), + `forward: thread-1 history in correct order, no thread-99 leak` + ); +} + +// 10. buildLlmMessages: assistant-with-tool-calls round-trips through history +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 7n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + store.tx.appendMessage({ + threadId: 7n, + role: 'assistant', + content: '', + toolCallsJson: JSON.stringify([ + { + id: 'c1', + type: 'function', + function: { name: 'echo', arguments: '{"x":1}' }, + }, + ]), + toolCallId: undefined, + isError: false, + }); + store.tx.appendMessage({ + threadId: 7n, + role: 'tool', + content: 'result', + toolCallsJson: undefined, + toolCallId: 'c1', + isError: false, + }); + + const out = buildLlmMessages(store.tx, 7n, 50); + assert(out.length === 3, `roundtrip: 3 messages built`); + const first = out[0]; + const second = out[1]; + const third = out[2]; + assert(first?.role === 'user' && first.content === 'hi', `roundtrip: user`); + assert( + second?.role === 'assistant' && + Array.isArray(second.tool_calls) && + second.tool_calls[0]?.id === 'c1', + `roundtrip: assistant carries tool_calls array reconstructed from JSON` + ); + assert( + third?.role === 'tool' && third.tool_call_id === 'c1', + `roundtrip: tool message links via tool_call_id` + ); +} + +// 11. Malformed toolCallsJson is dropped, not thrown +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 5n, + role: 'assistant', + content: 'hello', + toolCallsJson: '{not json', + toolCallId: undefined, + isError: false, + }); + const out = buildLlmMessages(store.tx, 5n, 50); + const first = out[0]; + assert( + out.length === 1 && + first?.role === 'assistant' && + first.tool_calls === undefined, + `malformed-json: tool_calls dropped, message preserved` + ); +} + +// 12. History window slides over last N messages +{ + const store = makeFakeStore(); + for (let i = 0; i < 20; i++) { + store.tx.appendMessage({ + threadId: 1n, + role: i % 2 === 0 ? 'user' : 'assistant', + content: `msg-${i}`, + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + } + const out = buildLlmMessages(store.tx, 1n, 5); + assert( + out.length === 5, + `history-window: only last 5 messages emitted (got ${out.length})` + ); + assert( + out[0]?.content === 'msg-15' && out[4]?.content === 'msg-19', + `history-window: emits the most recent slice` + ); +} + +// 13. History window drops orphan tool messages (assistant turn evicted) +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 2n, + role: 'assistant', + content: '', + toolCallsJson: JSON.stringify([ + { + id: 'old', + type: 'function', + function: { name: 'echo', arguments: '{}' }, + }, + ]), + toolCallId: undefined, + isError: false, + }); + store.tx.appendMessage({ + threadId: 2n, + role: 'tool', + content: 'orphan', + toolCallsJson: undefined, + toolCallId: 'old', + isError: false, + }); + // Filler so the window cuts off the assistant + tool above. + for (let i = 0; i < 10; i++) { + store.tx.appendMessage({ + threadId: 2n, + role: 'user', + content: `keep-${i}`, + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + } + const out = buildLlmMessages(store.tx, 2n, 5); + const hasOrphan = out.some( + m => m.role === 'tool' && m.tool_call_id === 'old' + ); + assert(!hasOrphan, `history-window: orphan tool message dropped`); + assert( + out.every(m => m.role !== 'tool'), + `history-window: only user/assistant survive` + ); +} + +// 14. Tool result truncated at TOOL_RESULT_MAX +{ + const store = makeFakeStore(); + const big = 'A'.repeat(200_000); + store.setTool('big_tool', () => ({ result: big, isError: false })); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'go', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + + const { http } = makeFakeHttp([ + llmReply({ + content: '', + toolCalls: [{ id: 'a', name: 'big_tool', args: {} }], + }), + llmReply({ content: 'done', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const toolMsg = store.messages.find(m => m.role === 'tool'); + assert(toolMsg !== undefined, `tool-truncation: tool message present`); + assert( + toolMsg!.content.length < big.length, + `tool-truncation: clipped (${toolMsg!.content.length} < ${big.length})` + ); + assert( + toolMsg!.content.endsWith('…[truncated]'), + `tool-truncation: ends with truncation marker` + ); +} + +// 15. max_tokens forwarded to OpenRouter request body +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + const { http, requests } = makeFakeHttp([ + llmReply({ content: 'ok', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: { ...baseCfg, maxTokens: 256 }, + threadId: 1n, + }); + + assert( + requests[0].body.max_tokens === 256, + `max-tokens: forwarded as max_tokens=256` + ); +} + +// 15a. responseFormat forwarded to the request body +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + const { http, requests } = makeFakeHttp([ + llmReply({ content: '{}', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: { ...baseCfg, responseFormat: { type: 'json_object' } }, + threadId: 1n, + }); + + assert( + JSON.stringify(requests[0].body.response_format) === + '{"type":"json_object"}', + `response-format: forwarded as response_format=json_object` + ); +} + +// 15b. responseFormat omitted when undefined +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + const { http, requests } = makeFakeHttp([ + llmReply({ content: 'ok', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + assert( + !('response_format' in requests[0].body), + `response-format-omit: field absent when undefined` + ); +} + +// 16. max_tokens omitted when undefined +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + const { http, requests } = makeFakeHttp([ + llmReply({ content: 'ok', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, // maxTokens: undefined + threadId: 1n, + }); + + assert( + !('max_tokens' in requests[0].body), + `max-tokens-omit: field absent when undefined` + ); +} + +// 17. Retry succeeds after 503/503/200 +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + const { http, requests } = makeFakeHttp([ + { status: 503, body: 'unavailable' }, + { status: 503, body: 'unavailable' }, + llmReply({ content: 'finally', finish: 'stop' }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + assert(requests.length === 3, `retry-recovery: 3 attempts made`); + const assistant = store.messages.find(m => m.role === 'assistant'); + assert( + assistant !== undefined && + !assistant.isError && + assistant.content === 'finally', + `retry-recovery: success after retries` + ); +} + +// 18. Retry exhausted after 3x 429 +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + const { http, requests } = makeFakeHttp([ + { status: 429, body: 'rate limited' }, + { status: 429, body: 'rate limited' }, + { status: 429, body: 'rate limited' }, + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + assert(requests.length === 3, `retry-exhaust: 3 attempts then give up`); + const errAssistant = store.messages.find( + m => m.role === 'assistant' && m.isError + ); + assert( + errAssistant !== undefined && + errAssistant.content.includes('agent.provider_http:429'), + `retry-exhaust: final error includes 429` + ); +} + +// 19a. retries=0 disables retries even on 503 +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + const { http, requests } = makeFakeHttp([ + { status: 503, body: 'unavailable' }, + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: { ...baseCfg, retries: 0 }, + threadId: 1n, + }); + + assert( + requests.length === 1, + `retries=0: exactly 1 attempt on 503 (no retry)` + ); +} + +// 19. No retry on non-retryable status (401) +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + }); + const { http, requests } = makeFakeHttp([ + { status: 401, body: 'unauthorized' }, + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + assert( + requests.length === 1, + `no-retry-401: exactly 1 attempt (no retries on 401)` + ); +} + +// 21. Usage round-trip: assistant message captures promptTokens + completionTokens +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + const { http } = makeFakeHttp([ + llmReply({ + content: 'hi back', + finish: 'stop', + usage: { prompt: 47, completion: 13 }, + }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const assistant = store.messages.find(m => m.role === 'assistant'); + assert(assistant !== undefined, `usage: assistant message present`); + assert( + assistant!.promptTokens === 47, + `usage: promptTokens = 47 (got ${assistant?.promptTokens})` + ); + assert( + assistant!.completionTokens === 13, + `usage: completionTokens = 13 (got ${assistant?.completionTokens})` + ); +} + +// 22. Usage of 0 (e.g. billing not yet reported) -> stored as undefined +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + const { http } = makeFakeHttp([ + llmReply({ + content: 'ok', + finish: 'stop', + usage: { prompt: 0, completion: 0 }, + }), + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const assistant = store.messages.find(m => m.role === 'assistant'); + assert( + assistant!.promptTokens === undefined, + `usage-zero: promptTokens=0 mapped to undefined` + ); + assert( + assistant!.completionTokens === undefined, + `usage-zero: completionTokens=0 mapped to undefined` + ); +} + +// 23. Tool messages and error assistants have no usage +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + store.setTool('echo', () => ({ result: 'r', isError: false })); + const { http } = makeFakeHttp([ + llmReply({ toolCalls: [{ id: 'a', name: 'echo', args: {} }] }), + { status: 401, body: 'unauthorized' }, + ]); + + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + const tool = store.messages.find(m => m.role === 'tool'); + assert( + tool!.promptTokens === undefined && tool!.completionTokens === undefined, + `usage: tool message has no usage` + ); + const err = store.messages.find(m => m.role === 'assistant' && m.isError); + assert( + err!.promptTokens === undefined && err!.completionTokens === undefined, + `usage: error assistant has no usage` + ); +} + +// 24. Cancel before turn 1: loop bails immediately, no LLM call +{ + const store = makeFakeStore(); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'hi', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + store.cancel(1n); + + const { http, requests } = makeFakeHttp([]); + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + assert(requests.length === 0, `cancel-before: 0 LLM calls`); + const cancelMsg = store.messages.find( + m => m.role === 'assistant' && m.content === 'agent.cancelled' + ); + assert( + cancelMsg !== undefined && cancelMsg.isError === true, + `cancel-before: cancelled message inserted` + ); +} + +// 25. Cancel between turns: loop completes turn 1, sees cancel, bails before turn 2 +{ + const store = makeFakeStore(); + store.setTool('echo', () => ({ result: 'r', isError: false })); + store.tx.appendMessage({ + threadId: 1n, + role: 'user', + content: 'go', + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + }); + + // Trigger cancel inside the appendMessage hook of turn 1. + let triggeredCancel = false; + const originalAppend = store.tx.appendMessage.bind(store.tx); + store.tx.appendMessage = row => { + originalAppend(row); + if (!triggeredCancel && row.role === 'assistant' && row.toolCallsJson) { + triggeredCancel = true; + store.cancel(1n); + } + }; + + const { http, requests } = makeFakeHttp([ + llmReply({ content: '', toolCalls: [{ id: 'a', name: 'echo', args: {} }] }), + ]); + runAgentLoop({ + http, + withTx: withTxAdapter(store.tx), + llmToolDefs: [], + cfg: baseCfg, + threadId: 1n, + }); + + assert( + requests.length === 1, + `cancel-between: only 1 LLM call (turn 2 skipped)` + ); + const cancelMsg = store.messages.find( + m => m.role === 'assistant' && m.content === 'agent.cancelled' + ); + assert( + cancelMsg !== undefined && cancelMsg.isError === true, + `cancel-between: cancelled message inserted on turn 2 entry` + ); + assert( + store.toolInvocations.length === 1, + `cancel-between: turn 1's tool call completed before cancel` + ); +} + +// 20. Stale-lock predicate (threshold is operator-tunable, passed as arg) +{ + const ONE_MIN = 60n * 1_000_000n; + const FIFTEEN_MIN = 15n * ONE_MIN; + const now = 1_000_000_000_000_000n; + assert( + isStaleLock(now, now - 16n * ONE_MIN, FIFTEEN_MIN) === true, + `stale-lock: 16-min-old lock is stale at 15-min threshold` + ); + assert( + isStaleLock(now, now - 14n * ONE_MIN, FIFTEEN_MIN) === false, + `stale-lock: 14-min-old lock is fresh at 15-min threshold` + ); + // exactly threshold -> not stale (strict <) + assert( + isStaleLock(now, now - FIFTEEN_MIN, FIFTEEN_MIN) === false, + `stale-lock: lock at threshold boundary is fresh (strict <)` + ); + assert( + isStaleLock(now, now, FIFTEEN_MIN) === false, + `stale-lock: brand-new lock is fresh` + ); + // A future timestamp caused by clock skew remains fresh. + assert( + isStaleLock(now, now + ONE_MIN, FIFTEEN_MIN) === false, + `stale-lock: future-dated lock is fresh` + ); + assert( + isStaleLock(now, now - 16n * ONE_MIN, 30n * ONE_MIN) === false, + `stale-lock: 16-min-old lock is fresh at 30-min threshold (operator tuned)` + ); +} + +process.stdout.write('\nsummarization helper tests\n'); + +function mkMsg( + id: bigint, + role: string, + content: string, + extras: Partial = {} +): LoopMessage { + return { + id, + threadId: 1n, + role, + content, + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + attachments: [], + ...extras, + }; +} + +// 26. pickSummarizationCandidates: history fits window -> null +{ + const messages = [mkMsg(1n, 'user', 'a'), mkMsg(2n, 'assistant', 'b')]; + assert( + pickSummarizationCandidates(messages, 5, null) === null, + `pickCandidates: history within window -> null` + ); +} + +// 27. pickSummarizationCandidates: 10 messages, window=4 -> 6 dropped +{ + const messages = Array.from({ length: 10 }, (_, i) => + mkMsg(BigInt(i + 1), 'user', `m${i}`) + ); + const result = pickSummarizationCandidates(messages, 4, null); + assert( + result !== null && result.newDropped.length === 6, + `pickCandidates: 10 msgs, window=4 -> 6 dropped` + ); + assert( + result!.lastNewId === 6n, + `pickCandidates: lastNewId is the last dropped id` + ); +} + +// 28. pickSummarizationCandidates: respects summarizedThroughId +{ + // 10 msgs, window=4 -> ids 1-6 dropped; summary covers through 4 -> only 5,6 new. + const messages = Array.from({ length: 10 }, (_, i) => + mkMsg(BigInt(i + 1), 'user', `m${i}`) + ); + const result = pickSummarizationCandidates(messages, 4, 4n); + assert( + result !== null && result.newDropped.length === 2, + `pickCandidates: respects summarizedThroughId -> only NEW dropped (got ${result?.newDropped.length})` + ); + assert( + result!.newDropped[0].id === 5n && result!.lastNewId === 6n, + `pickCandidates: newDropped starts at first uncovered id` + ); +} + +// 29. pickSummarizationCandidates: summary already covers all dropped -> null +{ + const messages = Array.from({ length: 10 }, (_, i) => + mkMsg(BigInt(i + 1), 'user', `m${i}`) + ); + // summary covers 1-7 (>= window-cutoff at 6) -> nothing new + assert( + pickSummarizationCandidates(messages, 4, 7n) === null, + `pickCandidates: summary covers all dropped -> null` + ); +} + +// 30. formatMessagesForSummarizer: roles render distinctly +{ + const m = [ + mkMsg(1n, 'user', 'hello'), + mkMsg(2n, 'assistant', 'hi back'), + mkMsg(3n, 'tool', 'tool_result_text', { toolCallId: 'c1' }), + ]; + const out = formatMessagesForSummarizer(m); + assert(out.includes('User: hello'), `format: user line`); + assert(out.includes('Assistant: hi back'), `format: assistant line`); + assert( + out.includes('[Tool result: tool_result_text]'), + `format: tool result line` + ); +} + +// 31. formatMessagesForSummarizer: assistant tool_calls render +{ + const m = [ + mkMsg(1n, 'assistant', '', { + toolCallsJson: JSON.stringify([ + { function: { name: 'echo', arguments: '{"x":1}' } }, + ]), + }), + ]; + const out = formatMessagesForSummarizer(m); + assert( + out.includes('[Assistant called tool echo({"x":1})]'), + `format: assistant tool call rendered` + ); +} + +// 32. buildSummarizerUserContent: with existing summary +{ + const m = [mkMsg(1n, 'user', 'hi')]; + const out = buildSummarizerUserContent('prior summary text', m); + assert( + out.includes('Existing summary:\nprior summary text'), + `buildContent: includes existing summary` + ); + assert( + out.includes('Additional messages'), + `buildContent: asks for extension when summary exists` + ); +} + +// 33. buildSummarizerUserContent: without existing summary +{ + const m = [mkMsg(1n, 'user', 'hi')]; + const out = buildSummarizerUserContent(null, m); + assert( + !out.includes('Existing summary'), + `buildContent: no existing summary header` + ); + assert( + out.includes('Messages to summarize'), + `buildContent: from-scratch header` + ); +} + +// 34. augmentSystemWithSummary: no summary -> unchanged +{ + assert( + augmentSystemWithSummary('be helpful', null) === 'be helpful', + `augment: null summary returns base unchanged` + ); + assert( + augmentSystemWithSummary('be helpful', '') === 'be helpful', + `augment: empty summary returns base unchanged` + ); + assert( + augmentSystemWithSummary(undefined, null) === undefined, + `augment: undefined base + null summary stays undefined` + ); +} + +// 35. augmentSystemWithSummary: summary appended under divider +{ + const out = augmentSystemWithSummary('be helpful', 'we discussed APIs'); + assert(out!.includes('be helpful'), `augment: includes base`); + assert( + out!.includes('## Summary of earlier conversation'), + `augment: divider header present` + ); + assert(out!.includes('we discussed APIs'), `augment: includes summary`); +} + +// 36. augmentSystemWithSummary: undefined base + summary +{ + const out = augmentSystemWithSummary(undefined, 'context'); + assert( + out !== undefined && out.includes('context'), + `augment: produces summary-only system prompt when base is undefined` + ); +} + +if (failures > 0) { + process.stderr.write(`\n${failures} test(s) failed.\n`); + process.exit(1); +} +process.stdout.write('\nall agent loop tests passed.\n'); diff --git a/spacetime-agents-ts/example/spacetimedb/scripts/tsconfig.json b/spacetime-agents-ts/example/spacetimedb/scripts/tsconfig.json new file mode 100644 index 00000000000..5197ce2769f --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/scripts/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts"] +} diff --git a/spacetime-agents-ts/example/spacetimedb/src/agent-runner.ts b/spacetime-agents-ts/example/spacetimedb/src/agent-runner.ts new file mode 100644 index 00000000000..f318e7672c2 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/agent-runner.ts @@ -0,0 +1,383 @@ +import { makeAgentRegistry } from '@spacetimedb/agents'; +import { + callChat, + type ChatMessage, + type HttpLike, +} from '@spacetimedb/agents/openrouter'; +import { BUILT_IN_PROVIDERS } from '@spacetimedb/agents/providers'; +import { + BUILT_IN_EMBEDDING_PROVIDERS, + cosineSimilarity, + topKByScore, +} from '@spacetimedb/agents/embeddings'; +import { agents } from './agents'; +import { + runAgentLoop, + type LoopConfig, + type LoopMessage, + type LoopTx, +} from './loop'; +import { + augmentSystemWithSummary, + buildSummarizerUserContent, + pickSummarizationCandidates, +} from './summarize'; +import type { Tx } from './types'; + +type WriteCtx = Tx; + +export const registry = makeAgentRegistry(agents); + +export interface AgentProcedureContext { + http: HttpLike; + withTx: (fn: (tx: WriteCtx) => R) => R; +} + +function threadMessagesAscending(tx: WriteCtx, threadId: bigint) { + const rows = [...tx.db.message.threadId.filter(threadId)]; + rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return rows; +} + +export function maybeEmbedMessage( + ctx: AgentProcedureContext, + threadId: bigint, + messageId: bigint +): void { + const job = ctx.withTx(tx => { + if (tx.db.messageEmbedding.messageId.find(messageId) != null) return null; + const message = tx.db.message.id.find(messageId); + if (!message) return null; + const thread = tx.db.thread.id.find(threadId); + if (!thread) return null; + const definition = registry.agentDef(thread.agentName); + if (!definition?.embeddingsProvider || !definition.embeddingsModel) + return null; + const provider = + BUILT_IN_EMBEDDING_PROVIDERS[definition.embeddingsProvider]; + if (!provider) return null; + const key = tx.db.apiKey.provider.find(definition.embeddingsProvider); + if (!key) return null; + return { + provider, + apiKey: key.key, + model: definition.embeddingsModel, + content: message.content, + userId: message.userId, + }; + }); + if (!job) return; + + const result = job.provider.embed(ctx.http, job.apiKey, job.model, [ + job.content, + ]); + if (!result.ok || result.vectors.length === 0) { + console.warn( + `embedding failed: ${result.ok ? 'no vectors' : result.error.kind}` + ); + return; + } + ctx.withTx(tx => { + if (tx.db.messageEmbedding.messageId.find(messageId) != null) return; + tx.db.messageEmbedding.insert({ + messageId, + threadId, + userId: job.userId, + model: job.model, + vector: result.vectors[0]!, + createdAt: tx.timestamp, + }); + }); +} + +function retrieveRag(ctx: AgentProcedureContext, threadId: bigint): string[] { + return ctx.withTx(tx => { + const thread = tx.db.thread.id.find(threadId); + if (!thread) return []; + const definition = registry.agentDef(thread.agentName); + if (!definition || definition.ragTopK <= 0) return []; + + const messages = threadMessagesAscending(tx, threadId); + let queryMessage: (typeof messages)[number] | undefined; + for (let index = messages.length - 1; index >= 0; index--) { + if (messages[index]!.role === 'user') { + queryMessage = messages[index]; + break; + } + } + if (!queryMessage) return []; + const queryEmbedding = tx.db.messageEmbedding.messageId.find( + queryMessage.id + ); + if (!queryEmbedding) return []; + + const override = tx.db.agentOverride.agentName.find(thread.agentName); + const maxHistory = + override?.maxHistoryMessages ?? definition.defaultMaxHistoryMessages; + const windowStart = Math.max(0, messages.length - maxHistory); + const inWindowIds = new Set( + messages.slice(windowStart).map(message => message.id) + ); + const candidates = [ + ...tx.db.messageEmbedding.threadId.filter(threadId), + ].filter( + embedding => + !inWindowIds.has(embedding.messageId) && + embedding.messageId !== queryMessage!.id + ); + const top = topKByScore( + candidates, + embedding => cosineSimilarity(queryEmbedding.vector, embedding.vector), + definition.ragTopK + ).filter(result => result.score > 0); + + const snippets: string[] = []; + for (const { item } of top) { + const message = tx.db.message.id.find(item.messageId); + if (message) snippets.push(`[${message.role}] ${message.content}`); + } + return snippets; + }); +} + +function augmentSystemWithRag( + base: string | undefined, + snippets: string[] +): string | undefined { + if (snippets.length === 0) return base; + return `${base ?? ''}\n\n## Relevant earlier messages\n${snippets.join('\n---\n')}`.trim(); +} + +function runSummarization(ctx: AgentProcedureContext, threadId: bigint): void { + const decision = ctx.withTx(tx => { + const thread = tx.db.thread.id.find(threadId); + if (!thread) return null; + const definition = registry.agentDef(thread.agentName); + if (!definition?.summarizerAgentName) return null; + const summarizer = registry.agentDef(definition.summarizerAgentName); + if (!summarizer) return null; + + const override = tx.db.agentOverride.agentName.find(thread.agentName); + const maxHistory = + override?.maxHistoryMessages ?? definition.defaultMaxHistoryMessages; + const loopMessages: LoopMessage[] = threadMessagesAscending( + tx, + threadId + ).map(message => ({ + id: message.id, + threadId: message.threadId, + role: message.role, + content: message.content, + toolCallsJson: message.toolCallsJson, + toolCallId: message.toolCallId, + isError: message.isError, + promptTokens: message.promptTokens, + completionTokens: message.completionTokens, + attachments: [], + })); + const candidates = pickSummarizationCandidates( + loopMessages, + maxHistory, + thread.summarizedThroughId ?? null + ); + if (!candidates) return null; + + const summarizerOverride = tx.db.agentOverride.agentName.find( + definition.summarizerAgentName + ); + const providerName = + summarizerOverride?.provider ?? summarizer.defaultProvider; + const provider = BUILT_IN_PROVIDERS[providerName]; + const key = tx.db.apiKey.provider.find(providerName); + if (!provider || !key) return null; + return { + provider, + apiKey: key.key, + model: summarizerOverride?.model ?? summarizer.defaultModel, + systemPrompt: + summarizerOverride?.systemPrompt ?? summarizer.defaultSystemPrompt, + maxTokens: summarizerOverride?.maxTokens ?? summarizer.defaultMaxTokens, + retries: summarizerOverride?.retries ?? summarizer.defaultRetries, + existingSummary: thread.summary ?? null, + newDropped: candidates.newDropped, + lastNewId: candidates.lastNewId, + }; + }); + if (!decision) return; + + const messages: ChatMessage[] = [ + { + role: 'user', + content: buildSummarizerUserContent( + decision.existingSummary, + decision.newDropped + ), + }, + ]; + const result = callChat(ctx.http, decision.provider, { + apiKey: decision.apiKey, + model: decision.model, + system: decision.systemPrompt, + messages, + maxTokens: decision.maxTokens, + retries: decision.retries, + }); + if (!result.ok || !result.response.text) { + console.warn( + `summarization failed: ${result.ok ? 'no text in response' : result.error.kind}` + ); + return; + } + + ctx.withTx(tx => { + const thread = tx.db.thread.id.find(threadId); + if (!thread) return; + tx.db.thread.id.update({ + ...thread, + summary: result.response.text!, + summarizedThroughId: decision.lastNewId, + updatedAt: tx.timestamp, + }); + }); +} + +const BASE64_ALPHABET = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + +function bytesToBase64(bytes: ArrayLike): string { + let output = ''; + for (let index = 0; index < bytes.length; index += 3) { + const first = bytes[index]!; + const second = index + 1 < bytes.length ? bytes[index + 1]! : 0; + const third = index + 2 < bytes.length ? bytes[index + 2]! : 0; + output += BASE64_ALPHABET[first >> 2]; + output += BASE64_ALPHABET[((first & 0x03) << 4) | (second >> 4)]; + output += + index + 1 < bytes.length + ? BASE64_ALPHABET[((second & 0x0f) << 2) | (third >> 6)] + : '='; + output += index + 2 < bytes.length ? BASE64_ALPHABET[third & 0x3f] : '='; + } + return output; +} + +function loadAttachments( + tx: WriteCtx, + messageId: bigint +): Array<{ mimeType: string; data: string }> { + const rows = [...tx.db.messageAttachment.messageId.filter(messageId)]; + rows.sort((a, b) => a.ordinal - b.ordinal); + const attachments: Array<{ mimeType: string; data: string }> = []; + for (const row of rows) { + const file = tx.db.files.file.id.find(row.fileId); + const blob = tx.db.files.fileBlob.fileId.find(row.fileId); + if (file && blob) { + attachments.push({ + mimeType: file.mimeType, + data: bytesToBase64(blob.bytes), + }); + } + } + return attachments; +} + +function createLoopContext( + tx: WriteCtx, + agentName: string, + userId: string, + recordTokens: (tx: WriteCtx, userId: string, tokens: bigint) => void +): LoopTx { + return { + listMessages(threadId): LoopMessage[] { + return threadMessagesAscending(tx, threadId).map(message => ({ + id: message.id, + threadId: message.threadId, + role: message.role, + content: message.content, + toolCallsJson: message.toolCallsJson, + toolCallId: message.toolCallId, + isError: message.isError, + promptTokens: message.promptTokens, + completionTokens: message.completionTokens, + attachments: + message.role === 'user' ? loadAttachments(tx, message.id) : [], + })); + }, + appendMessage(row): void { + tx.db.message.insert({ + id: 0n, + threadId: row.threadId, + userId, + role: row.role, + content: row.content, + toolCallsJson: row.toolCallsJson, + toolCallId: row.toolCallId, + isError: row.isError, + promptTokens: row.promptTokens, + completionTokens: row.completionTokens, + createdAt: tx.timestamp, + }); + if ( + row.role === 'assistant' && + (row.promptTokens != null || row.completionTokens != null) + ) { + const tokens = BigInt( + (row.promptTokens ?? 0) + (row.completionTokens ?? 0) + ); + if (tokens > 0n) recordTokens(tx, userId, tokens); + } + }, + bumpThread(threadId): void { + const thread = tx.db.thread.id.find(threadId); + if (thread) + tx.db.thread.id.update({ ...thread, updatedAt: tx.timestamp }); + }, + invokeTool(name, inputJson) { + return registry.invoke(agentName, tx, name, inputJson); + }, + isCancelRequested(threadId): boolean { + return tx.db.threadLock.threadId.find(threadId)?.cancelRequested ?? false; + }, + }; +} + +export function runAgentForThread( + ctx: AgentProcedureContext, + cfg: LoopConfig, + agentName: string, + userId: string, + threadId: bigint, + recordTokens: (tx: WriteCtx, userId: string, tokens: bigint) => void +): void { + try { + runSummarization(ctx, threadId); + const ragSnippets = retrieveRag(ctx, threadId); + const finalConfig = ctx.withTx(tx => { + const thread = tx.db.thread.id.find(threadId); + if (!thread) return cfg; + const withSummary = augmentSystemWithSummary( + cfg.systemPrompt, + thread.summary ?? null + ); + return { + ...cfg, + systemPrompt: augmentSystemWithRag(withSummary, ragSnippets), + }; + }); + runAgentLoop({ + http: ctx.http, + withTx: (fn: (loopTx: LoopTx) => R): R => + ctx.withTx(tx => + fn(createLoopContext(tx, agentName, userId, recordTokens)) + ), + llmToolDefs: registry.llmToolDefsFor(agentName), + cfg: finalConfig, + threadId, + }); + } finally { + ctx.withTx(tx => { + const lock = tx.db.threadLock.threadId.find(threadId); + if (lock) tx.db.threadLock.delete(lock); + }); + } +} diff --git a/spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts b/spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts new file mode 100644 index 00000000000..54953340be8 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/agents/chat.ts @@ -0,0 +1,18 @@ +import { defineAgent } from '@spacetimedb/agents'; +import getTime from '../tools/getTime'; + +export default defineAgent({ + defaultModel: 'anthropic/claude-haiku-4.5', + defaultSystemPrompt: + 'You are a helpful assistant. Use tools when they make the answer better.', + defaultMaxTurns: 10, + defaultMaxHistoryMessages: 50, + defaultRetries: 2, + summarizerAgentName: 'summarizer', + embeddingsProvider: 'openai', + embeddingsModel: 'text-embedding-3-small', + ragTopK: 4, + tools: { + get_time: getTime, + }, +}); diff --git a/spacetime-agents-ts/example/spacetimedb/src/agents/index.ts b/spacetime-agents-ts/example/spacetimedb/src/agents/index.ts new file mode 100644 index 00000000000..0ceb62ee649 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/agents/index.ts @@ -0,0 +1,7 @@ +import chat from './chat'; +import summarizer from './summarizer'; + +export const agents = { + chat, + summarizer, +}; diff --git a/spacetime-agents-ts/example/spacetimedb/src/agents/summarizer.ts b/spacetime-agents-ts/example/spacetimedb/src/agents/summarizer.ts new file mode 100644 index 00000000000..039589fade7 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/agents/summarizer.ts @@ -0,0 +1,17 @@ +import { defineAgent } from '@spacetimedb/agents'; + +export default defineAgent({ + defaultModel: 'anthropic/claude-haiku-4.5', + defaultSystemPrompt: + 'You produce concise running summaries of chat conversations. ' + + 'Capture facts, decisions, names, numbers, and ongoing tasks the ' + + 'main assistant must remember. Skip pleasantries. If the user ' + + 'provides an existing summary, EXTEND it with the new content. ' + + 'Do not restart from scratch and do not duplicate prior facts. ' + + 'Reply with the updated summary as plain prose, no preamble.', + defaultMaxTurns: 1, + defaultMaxHistoryMessages: 100, + defaultMaxTokens: 600, + defaultRetries: 2, + tools: {}, +}); diff --git a/spacetime-agents-ts/example/spacetimedb/src/attachments.ts b/spacetime-agents-ts/example/spacetimedb/src/attachments.ts new file mode 100644 index 00000000000..82121eba7ab --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/attachments.ts @@ -0,0 +1,39 @@ +import { FILE_BYTES_MAX } from '@spacetimedb/files/constants'; + +export const ATTACHMENT_ALLOWED_MIMES = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', + 'image/gif', +]); + +export const ATTACHMENT_COUNT_MAX = 4; +export const ATTACHMENT_TOTAL_BYTES_MAX = 12_000_000; + +export interface AttachmentInput { + mimeType: string; + bytes: ArrayLike; +} + +export function attachmentValidationError( + attachments: readonly AttachmentInput[] +): string | undefined { + if (attachments.length > ATTACHMENT_COUNT_MAX) { + return `agent.too_many_attachments:${attachments.length}/${ATTACHMENT_COUNT_MAX}`; + } + + let totalBytes = 0; + for (const attachment of attachments) { + if (!ATTACHMENT_ALLOWED_MIMES.has(attachment.mimeType)) { + return `agent.unsupported_attachment_mime:${attachment.mimeType}`; + } + if (attachment.bytes.length > FILE_BYTES_MAX) { + return `agent.attachment_too_large:${attachment.bytes.length}/${FILE_BYTES_MAX}`; + } + totalBytes += attachment.bytes.length; + if (totalBytes > ATTACHMENT_TOTAL_BYTES_MAX) { + return `agent.attachments_too_large:${totalBytes}/${ATTACHMENT_TOTAL_BYTES_MAX}`; + } + } + return undefined; +} diff --git a/spacetime-agents-ts/example/spacetimedb/src/index.ts b/spacetime-agents-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..56d98e2f875 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,1065 @@ +import { + schema, + table, + t, + Range, + Router, + SenderError, + type TransactionCtx, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, +} from 'spacetimedb/server'; +import { ScheduleAt, Timestamp } from 'spacetimedb'; +import { + deleteStaleThreadLocks, + staleLockCutoffMicros, +} from '@spacetimedb/agents/stale-locks'; +import * as auth from '@spacetimedb/auth/submodule'; +import { + setAuthConfigParams, + getPublicKeyPemParams, + linkConnectionParams, + linkConnection, + unlinkConnectionParams, + updateProfileParams, + revokeSessionParams, + listMySessionsParams, + revokeMySessionParams, + passwordSignupHandler, + parseCookies, + passwordLoginHandler, + meHandler, + logoutHandler, + refreshHandler, + googleStartHandler, + googleCallbackHandler, + githubStartHandler, + githubCallbackHandler, + makeForgotPasswordHandler, + resetPasswordHandler, + makeEmailVerifyRequestHandler, + makeEmailVerifyHandler, + getCallerUserId, + publicKeyFromPem, + verifyJwt, + type SendMailFn, + type MailParams, +} from '@spacetimedb/auth/submodule'; +import { consumeRateLimit } from '@spacetimedb/rate-limit/submodule'; +import * as agentRateLimit from '@spacetimedb/rate-limit/submodule'; +import { callChat, type Provider } from '@spacetimedb/agents/openrouter'; +import { BUILT_IN_PROVIDERS } from '@spacetimedb/agents/providers'; +import { + FILE_VISIBILITY_OWNER, + fileSha256Hex, +} from '@spacetimedb/files/submodule'; +import * as files from '@spacetimedb/files/submodule'; +import { USER_CONTENT_MAX, type LoopConfig } from './loop'; +import { SWEEPER_INTERVAL_MICROS } from './sweeper'; +import { attachmentValidationError } from './attachments'; +import { registerAgentViews } from './views'; +import { maybeEmbedMessage, registry, runAgentForThread } from './agent-runner'; + +const ONE_SECOND_MICROS = 1_000_000n; +const DEFAULT_STALE_LOCK_THRESHOLD_SECS = 15 * 60; +const U32_MAX = 0xffff_ffff; +const AGENT_TOKEN_RATE_LIMIT_SCOPE = 'agents.tokens'; + +function throwSenderError(msg: string): never { + throw new SenderError(msg); +} + +// Development mailer that logs messages. Configure a delivery provider in production. +const consoleSendMail: SendMailFn = (_ctx, params: MailParams) => { + console.log( + `[mail] to=${params.to} subject=${params.subject}\n${params.text}` + ); +}; + +// Ownership is keyed by userId so the same user works across devices. +import { + apiKey, + agentSecret, + agentAdminIdentity, + agentOverride, + thread, + message, + threadLock, + messageAttachment, + messageEmbedding, +} from './model'; + +const threadLockSweeperTick = table( + { name: 'thread_lock_sweeper_tick' }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +const spacetimedb = schema({ + auth, + files, + agentRateLimit, + agentSecret, + agentAdminIdentity, + agentOverride, + apiKey, + thread, + message, + messageAttachment, + threadLock, + threadLockSweeperTick, + messageEmbedding, +}); +export default spacetimedb; + +type Schema = InferSchema; +type WriteCtx = TransactionCtx; + +export const { + myThreads, + myMessages, + myThreadLocks, + myMessageEmbeddings, + myFiles, + myAuthUser, +} = registerAgentViews(spacetimedb); + +function requireAdmin(tx: WriteCtx): void { + if (tx.db.agentAdminIdentity.identity.find(tx.sender) == null) { + throwSenderError('agent.not_authorized'); + } +} + +// Procedures and reducers both expose sender and db. +type CallerCtx = ProcedureCtx | ReducerCtx; + +function requireUserId(ctx: CallerCtx): string { + const userId = getCallerUserId(ctx.as.auth); + if (!userId) throwSenderError('agent.not_authenticated'); + return userId; +} + +function requireOwnedThread(tx: WriteCtx, threadId: bigint, userId: string) { + const row = tx.db.thread.id.find(threadId); + if (!row) throwSenderError(`agent.thread_not_found:${threadId}`); + if (row.userId !== userId) + throwSenderError(`agent.not_thread_owner:${threadId}`); + return row; +} + +function toU32OrThrow(name: string, value: bigint): number { + if (value <= 0n || value > BigInt(U32_MAX)) { + throwSenderError(`agent.invalid_${name}`); + } + return Number(value); +} + +function rateLimitKey(userId: string): string { + return `${AGENT_TOKEN_RATE_LIMIT_SCOPE}:${userId}`; +} + +function isExpired(nowMicros: bigint, expiresAtMicros: bigint): boolean { + return expiresAtMicros <= nowMicros; +} + +function checkRateLimit(tx: WriteCtx, userId: string): void { + const secret = tx.db.agentSecret.singleton.find(true); + if ( + secret == null || + secret.rateLimitTokensPerWindow == null || + secret.rateLimitWindowSecs == null + ) + return; + + const cap = Number(secret.rateLimitTokensPerWindow); + const key = rateLimitKey(userId); + const existing = tx.db.agentRateLimit.rateLimitBucket.key.find(key); + if (existing == null) return; + + const nowMicros = tx.timestamp.microsSinceUnixEpoch as bigint; + const expiresAtMicros = existing.expiresAt.microsSinceUnixEpoch as bigint; + if (isExpired(nowMicros, expiresAtMicros)) return; + + if (existing.count >= cap) { + throwSenderError(`agent.rate_limited:${existing.count}/${cap}`); + } +} + +function bumpRateLimit(tx: WriteCtx, userId: string, tokens: bigint): void { + if (tokens <= 0n) return; + + const secret = tx.db.agentSecret.singleton.find(true); + if ( + secret == null || + secret.rateLimitTokensPerWindow == null || + secret.rateLimitWindowSecs == null + ) + return; + + const cost = toU32OrThrow('rate_limit_tokens', tokens); + const windowSeconds = Number(secret.rateLimitWindowSecs); + + const result = consumeRateLimit(tx.as.agentRateLimit, { + key: rateLimitKey(userId), + scope: AGENT_TOKEN_RATE_LIMIT_SCOPE, + // Cap enforced in checkRateLimit; this only increments usage. + limit: U32_MAX, + windowSeconds, + cost, + }); + + if (!result.allowed) { + throwSenderError('agent.rate_limit_counter_overflow'); + } +} + +export const init = spacetimedb.init(ctx => { + auth.installAuth(ctx.as.auth); + files.installFiles(ctx.as.files); + agentRateLimit.installRateLimit(ctx.as.agentRateLimit); + ctx.db.agentAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + ctx.db.threadLockSweeperTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval(SWEEPER_INTERVAL_MICROS), + }); +}); + +export const set_auth_config = spacetimedb.reducer( + setAuthConfigParams, + (ctx, args) => { + auth.set_auth_config(ctx.as.auth, args); + } +); + +export const get_auth_public_key = spacetimedb.procedure( + getPublicKeyPemParams, + t.object('AuthPubKey', { + publicKeyPem: t.string(), + keyId: t.string(), + issuerUrl: t.string(), + }), + (ctx, args) => + auth.get_auth_public_key(ctx.as.auth, args) as { + publicKeyPem: string; + keyId: string; + issuerUrl: string; + } +); + +// Procedure (not reducer) so the client can await commit before subscribing. +export const link_connection = spacetimedb.procedure( + linkConnectionParams, + t.object('LinkConnectionResult', { userId: t.string() }), + (ctx, args) => linkConnection(ctx.as.auth, args) +); + +export const unlink_connection = spacetimedb.reducer( + unlinkConnectionParams, + (ctx, args) => { + auth.unlink_connection(ctx.as.auth, args); + } +); + +export const update_profile = spacetimedb.reducer( + updateProfileParams, + (ctx, args) => { + auth.update_profile(ctx.as.auth, args); + } +); + +export const revoke_session = spacetimedb.reducer( + revokeSessionParams, + (ctx, args) => { + auth.revoke_session(ctx.as.auth, args); + } +); + +export const list_my_sessions = spacetimedb.procedure( + listMySessionsParams, + t.object('MySessions', { + sessions: t.array( + t.object('MySession', { + sessionId: t.string(), + expiresAt: t.timestamp(), + createdAt: t.timestamp(), + ipAddress: t.option(t.string()), + userAgent: t.option(t.string()), + isCurrent: t.bool(), + }) + ), + }), + (ctx, args) => + auth.list_my_sessions(ctx.as.auth, args) as { + sessions: Array<{ + sessionId: string; + expiresAt: Timestamp; + createdAt: Timestamp; + ipAddress: string | undefined; + userAgent: string | undefined; + isCurrent: boolean; + }>; + } +); + +export const revoke_my_session = spacetimedb.reducer( + revokeMySessionParams, + (ctx, args) => { + auth.revoke_my_session(ctx.as.auth, args); + } +); + +const forgotHandler = makeForgotPasswordHandler({ + sendMail: consoleSendMail, + appName: 'Agents', +}); +const verifyRequestHandler = makeEmailVerifyRequestHandler({ + sendMail: consoleSendMail, + appName: 'Agents', +}); +const verifyHandler = makeEmailVerifyHandler({ + successRedirect: '/?verified=1', +}); +export const authPasswordSignup = spacetimedb.httpHandler((ctx, req) => + passwordSignupHandler(ctx.as.auth, req) +); +export const authPasswordLogin = spacetimedb.httpHandler((ctx, req) => + passwordLoginHandler(ctx.as.auth, req) +); +export const authMe = spacetimedb.httpHandler((ctx, req) => + meHandler(ctx.as.auth, req) +); +export const authLogout = spacetimedb.httpHandler((ctx, req) => + logoutHandler(ctx.as.auth, req) +); +export const authRefresh = spacetimedb.httpHandler((ctx, req) => + refreshHandler(ctx.as.auth, req) +); +export const authGoogleStart = spacetimedb.httpHandler((ctx, req) => + googleStartHandler(ctx.as.auth, req) +); +export const authGoogleCallback = spacetimedb.httpHandler((ctx, req) => + googleCallbackHandler(ctx.as.auth, req) +); +export const authGithubStart = spacetimedb.httpHandler((ctx, req) => + githubStartHandler(ctx.as.auth, req) +); +export const authGithubCallback = spacetimedb.httpHandler((ctx, req) => + githubCallbackHandler(ctx.as.auth, req) +); +export const authPasswordForgot = spacetimedb.httpHandler((ctx, req) => + forgotHandler(ctx.as.auth, req) +); +export const authPasswordReset = spacetimedb.httpHandler((ctx, req) => + resetPasswordHandler(ctx.as.auth, req) +); +export const authEmailVerifyRequest = spacetimedb.httpHandler((ctx, req) => + verifyRequestHandler(ctx.as.auth, req) +); +export const authEmailVerify = spacetimedb.httpHandler((ctx, req) => + verifyHandler(ctx.as.auth, req) +); + +const fileServeHandler = files.createFileHttpHandler({ + getOwner: (ctx, req) => + ctx.withTx((tx: TransactionCtx) => { + const binding = tx.db.auth.authConnectionBinding.stdbIdentity.find( + tx.sender + ); + if (binding) return binding.userId; + + const cfg = tx.db.auth.authConfig.singleton.find(true); + if (!cfg) return undefined; + const bearer = req.headers.get('authorization'); + const cookies = parseCookies(req.headers.get('cookie')); + const tokens = [ + bearer && bearer.toLowerCase().startsWith('bearer ') + ? bearer.slice(7).trim() + : undefined, + cookies[cfg.cookieName], + ].filter((token): token is string => Boolean(token)); + for (const token of tokens) { + const verified = verifyJwt( + publicKeyFromPem(cfg.es256PublicKeyPem), + token, + { + issuer: cfg.issuerUrl, + nowSeconds: Number( + (tx.timestamp.microsSinceUnixEpoch as bigint) / 1_000_000n + ), + } + ); + if (!verified.ok || !verified.claims.jti) continue; + + const session = tx.db.auth.authSession.sessionId.find( + verified.claims.jti + ); + if (!session) continue; + if ( + (session.expiresAt.microsSinceUnixEpoch as bigint) <= + (tx.timestamp.microsSinceUnixEpoch as bigint) + ) { + continue; + } + if (session.userId === verified.claims.sub) return session.userId; + } + return undefined; + }), + canAccess: (ctx, _req, file, userId) => + ctx.withTx((tx: TransactionCtx) => { + if (!userId) return false; + if (file.ownerUserId === userId) return true; + for (const a of tx.db.messageAttachment.fileId.filter(file.id)) { + if (a.ownerUserId === userId) return true; + } + return false; + }), +}); +export const fileServe = spacetimedb.httpHandler(fileServeHandler); + +export const router = spacetimedb.httpRouter( + new Router() + .post('/auth/password/signup', authPasswordSignup) + .post('/auth/password/login', authPasswordLogin) + .post('/auth/session/refresh', authRefresh) + .get('/auth/me', authMe) + .post('/auth/logout', authLogout) + .get('/auth/google/start', authGoogleStart) + .get('/auth/google/callback', authGoogleCallback) + .get('/auth/github/start', authGithubStart) + .get('/auth/github/callback', authGithubCallback) + .post('/auth/password/forgot', authPasswordForgot) + .post('/auth/password/reset', authPasswordReset) + .post('/auth/email/verify-request', authEmailVerifyRequest) + .get('/auth/email/verify', authEmailVerify) + .get('/files', fileServe) + .get('/files/', fileServe) + .head('/files/', fileServe) + .head('/files', fileServe) +); + +// Admin-gated tuning; API keys go through set_api_key. +export const set_agent_secret = spacetimedb.reducer( + { + staleLockThresholdSecs: t.option(t.u32()), + rateLimitTokensPerWindow: t.option(t.u32()), + rateLimitWindowSecs: t.option(t.u32()), + }, + (ctx, args) => { + const staleLockThresholdSecs = + args.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS; + if (staleLockThresholdSecs === 0) { + throwSenderError('agent.invalid_stale_lock_threshold:must be > 0'); + } + if ( + args.rateLimitTokensPerWindow !== undefined && + args.rateLimitTokensPerWindow === 0 + ) { + throwSenderError('agent.invalid_rate_limit_tokens:must be > 0'); + } + if ( + args.rateLimitWindowSecs !== undefined && + args.rateLimitWindowSecs === 0 + ) { + throwSenderError('agent.invalid_rate_limit_window:must be > 0'); + } + + const tx = ctx; + requireAdmin(tx); + + const existing = tx.db.agentSecret.singleton.find(true); + const row = { + singleton: true, + staleLockThresholdSecs, + rateLimitTokensPerWindow: args.rateLimitTokensPerWindow, + rateLimitWindowSecs: args.rateLimitWindowSecs, + updatedAt: tx.timestamp, + }; + if (existing) { + tx.db.agentSecret.singleton.update(row); + } else { + tx.db.agentSecret.insert(row); + } + } +); + +export const set_api_key = spacetimedb.reducer( + { provider: t.string(), key: t.string() }, + (ctx, args) => { + if (args.provider.length === 0) + throwSenderError('agent.invalid_provider:empty'); + if (args.key.length === 0) throwSenderError('agent.invalid_api_key:empty'); + if (!Object.hasOwn(BUILT_IN_PROVIDERS, args.provider)) { + throwSenderError(`agent.unknown_provider:${args.provider}`); + } + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.apiKey.provider.find(args.provider); + const row = { + provider: args.provider, + key: args.key, + updatedAt: tx.timestamp, + }; + if (existing) { + tx.db.apiKey.provider.update(row); + } else { + tx.db.apiKey.insert(row); + } + } +); + +export const clear_api_key = spacetimedb.reducer( + { provider: t.string() }, + (ctx, { provider }) => { + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.apiKey.provider.find(provider); + if (existing) tx.db.apiKey.delete(existing); + } +); + +export const set_agent_override = spacetimedb.reducer( + { + agentName: t.string(), + provider: t.option(t.string()), + model: t.option(t.string()), + systemPrompt: t.option(t.string()), + maxTurns: t.option(t.u32()), + maxHistoryMessages: t.option(t.u32()), + maxTokens: t.option(t.u32()), + retries: t.option(t.u32()), + }, + (ctx, args) => { + if (!registry.has(args.agentName)) { + throwSenderError(`agent.unknown:${args.agentName}`); + } + if ( + args.provider !== undefined && + !Object.hasOwn(BUILT_IN_PROVIDERS, args.provider) + ) { + throwSenderError(`agent.unknown_provider:${args.provider}`); + } + if (args.maxTurns !== undefined && args.maxTurns === 0) { + throwSenderError('agent.invalid_max_turns:must be > 0'); + } + if ( + args.maxHistoryMessages !== undefined && + args.maxHistoryMessages === 0 + ) { + throwSenderError('agent.invalid_max_history:must be > 0'); + } + + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.agentOverride.agentName.find(args.agentName); + const row = { + agentName: args.agentName, + provider: args.provider, + model: args.model, + systemPrompt: args.systemPrompt, + maxTurns: args.maxTurns, + maxHistoryMessages: args.maxHistoryMessages, + maxTokens: args.maxTokens, + retries: args.retries, + updatedAt: tx.timestamp, + }; + if (existing) { + tx.db.agentOverride.agentName.update(row); + } else { + tx.db.agentOverride.insert(row); + } + } +); + +export const clear_agent_override = spacetimedb.reducer( + { agentName: t.string() }, + (ctx, { agentName }) => { + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.agentOverride.agentName.find(agentName); + if (existing) tx.db.agentOverride.delete(existing); + } +); + +export const get_agent_config_status = spacetimedb.procedure( + {}, + t.object('AgentConfigStatus', { + isConfigured: t.bool(), + staleLockThresholdSecs: t.u32(), + rateLimitTokensPerWindow: t.option(t.u32()), + rateLimitWindowSecs: t.option(t.u32()), + agents: t.array( + t.object('AgentInfo', { + name: t.string(), + defaultProvider: t.string(), + defaultModel: t.string(), + }) + ), + configuredProviders: t.array(t.string()), + }), + ctx => + ctx.withTx(tx => { + const secret = tx.db.agentSecret.singleton.find(true); + const configuredProviders = [...tx.db.apiKey.iter()] + .map(r => r.provider) + .sort(); + const agents = registry.names().map(name => { + const def = registry.agentDef(name)!; + return { + name, + defaultProvider: def.defaultProvider, + defaultModel: def.defaultModel, + }; + }); + return { + isConfigured: secret != null, + staleLockThresholdSecs: + secret?.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS, + rateLimitTokensPerWindow: secret?.rateLimitTokensPerWindow, + rateLimitWindowSecs: secret?.rateLimitWindowSecs, + agents, + configuredProviders, + }; + }) +); + +export const add_agent_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, { identity }) => { + const tx = ctx; + requireAdmin(tx); + if (tx.db.agentAdminIdentity.identity.find(identity) == null) { + tx.db.agentAdminIdentity.insert({ + identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + } +); + +export const remove_agent_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, { identity }) => { + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.agentAdminIdentity.identity.find(identity); + if (existing) tx.db.agentAdminIdentity.delete(existing); + } +); + +export const start_thread = spacetimedb.procedure( + { + agentName: t.string(), + title: t.option(t.string()), + systemPromptOverride: t.option(t.string()), + metadata: t.option(t.string()), + }, + t.u64(), + (ctx, args) => { + const userId = requireUserId(ctx); + if (!registry.has(args.agentName)) { + throwSenderError(`agent.unknown:${args.agentName}`); + } + return ctx.withTx(tx => { + const inserted = tx.db.thread.insert({ + id: 0n, + userId, + agentName: args.agentName, + title: args.title, + systemPromptOverride: args.systemPromptOverride, + modelOverride: undefined, + metadata: args.metadata, + summary: undefined, + summarizedThroughId: undefined, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + return inserted.id; + }); + } +); + +export const update_thread = spacetimedb.reducer( + { + threadId: t.u64(), + title: t.option(t.string()), + systemPromptOverride: t.option(t.string()), + modelOverride: t.option(t.string()), + metadata: t.option(t.string()), + clearTitle: t.bool(), + clearSystemPromptOverride: t.bool(), + clearModelOverride: t.bool(), + clearMetadata: t.bool(), + }, + (ctx, args) => { + const userId = requireUserId(ctx); + const tx = ctx; + const row = requireOwnedThread(tx, args.threadId, userId); + tx.db.thread.id.update({ + ...row, + title: args.clearTitle ? undefined : (args.title ?? row.title), + systemPromptOverride: args.clearSystemPromptOverride + ? undefined + : (args.systemPromptOverride ?? row.systemPromptOverride), + modelOverride: args.clearModelOverride + ? undefined + : (args.modelOverride ?? row.modelOverride), + metadata: args.clearMetadata + ? undefined + : (args.metadata ?? row.metadata), + updatedAt: tx.timestamp, + }); + } +); + +export const delete_thread = spacetimedb.reducer( + { threadId: t.u64() }, + (ctx, { threadId }) => { + const userId = requireUserId(ctx); + const tx = ctx; + const row = requireOwnedThread(tx, threadId, userId); + if (tx.db.threadLock.threadId.find(threadId) != null) { + throwSenderError(`agent.thread_busy:${threadId}`); + } + for (const e of [...tx.db.messageEmbedding.threadId.filter(threadId)]) { + tx.db.messageEmbedding.delete(e); + } + for (const a of [...tx.db.messageAttachment.threadId.filter(threadId)]) { + const blob = tx.db.files.fileBlob.fileId.find(a.fileId); + if (blob) tx.db.files.fileBlob.delete(blob); + const file = tx.db.files.file.id.find(a.fileId); + if (file) tx.db.files.file.delete(file); + tx.db.messageAttachment.delete(a); + } + for (const m of [...tx.db.message.threadId.filter(threadId)]) { + tx.db.message.delete(m); + } + tx.db.thread.delete(row); + } +); + +// Admin-gated; bypasses ownership. +export const clear_thread_lock = spacetimedb.reducer( + { threadId: t.u64() }, + (ctx, { threadId }) => { + const tx = ctx; + requireAdmin(tx); + const lock = tx.db.threadLock.threadId.find(threadId); + if (lock) tx.db.threadLock.delete(lock); + } +); + +// No-op if the thread already has a title. +export const generate_thread_title = spacetimedb.procedure( + { threadId: t.u64() }, + t.unit(), + (ctx, { threadId }) => { + const userId = requireUserId(ctx); + const job = ctx.withTx(tx => { + const thread = tx.db.thread.id.find(threadId); + if (!thread) return null; + if (thread.userId !== userId) { + throwSenderError(`agent.not_thread_owner:${threadId}`); + } + if (thread.title != null && thread.title.length > 0) return null; + + const def = registry.agentDef(thread.agentName); + if (!def) return null; + const sumName = def.summarizerAgentName ?? thread.agentName; + const sumDef = registry.agentDef(sumName); + if (!sumDef) return null; + + const override = tx.db.agentOverride.agentName.find(sumName); + const providerName = override?.provider ?? sumDef.defaultProvider; + const provider = BUILT_IN_PROVIDERS[providerName]; + if (!provider) return null; + const keyRow = tx.db.apiKey.provider.find(providerName); + if (!keyRow) return null; + + const msgs = [...tx.db.message.threadId.filter(threadId)]; + msgs.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const firstUser = msgs.find(m => m.role === 'user'); + if (!firstUser) return null; + + return { + provider, + apiKey: keyRow.key, + model: override?.model ?? sumDef.defaultModel, + retries: override?.retries ?? sumDef.defaultRetries, + firstMessage: firstUser.content, + }; + }); + if (!job) return {}; + + const result = callChat(ctx.http, job.provider, { + apiKey: job.apiKey, + model: job.model, + system: + 'You title chat conversations. The user will paste the opening message of ' + + 'a chat. You output a 3-5 word title describing the topic. ' + + 'CRITICAL: do not answer or respond to the message. Do not greet. ' + + 'Output the title and only the title. No quotes, no punctuation at the end.', + messages: [ + { + role: 'user', + content: `Title for a chat that starts with this message:\n\n\n${job.firstMessage}\n`, + }, + ], + maxTokens: 30, + retries: job.retries, + }); + if (!result.ok || !result.response.text) { + console.warn( + `title gen failed: ${result.ok ? 'no text' : result.error.kind}` + ); + return {}; + } + + const cleaned = result.response.text + .trim() + .replace(/^["']|["']$/g, '') + .replace(/[.!?]+$/g, '') + .slice(0, 80); + + ctx.withTx(tx => { + const t2 = tx.db.thread.id.find(threadId); + if (!t2 || (t2.title != null && t2.title.length > 0)) return; + tx.db.thread.id.update({ + ...t2, + title: cleaned, + updatedAt: tx.timestamp, + }); + }); + return {}; + } +); + +export const request_cancel = spacetimedb.reducer( + { threadId: t.u64() }, + (ctx, { threadId }) => { + const userId = requireUserId(ctx); + const tx = ctx; + requireOwnedThread(tx, threadId, userId); + const lock = tx.db.threadLock.threadId.find(threadId); + if (!lock) throwSenderError(`agent.thread_not_running:${threadId}`); + if (lock.cancelRequested) return; + tx.db.threadLock.threadId.update({ ...lock, cancelRequested: true }); + } +); + +function resolveProvider(name: string): Provider { + const p = BUILT_IN_PROVIDERS[name]; + if (!p) throwSenderError(`agent.unknown_provider:${name}`); + return p; +} + +function loadLoopConfigOrThrow( + tx: WriteCtx, + threadId: bigint, + userId: string +): { cfg: LoopConfig; agentName: string; userId: string } { + const threadRow = requireOwnedThread(tx, threadId, userId); + + const def = registry.agentDef(threadRow.agentName); + if (!def) { + throwSenderError(`agent.unknown:${threadRow.agentName}`); + } + + if (tx.db.threadLock.threadId.find(threadId) != null) { + throwSenderError(`agent.thread_busy:${threadId}`); + } + if (tx.db.agentSecret.singleton.find(true) == null) { + throwSenderError('agent.not_configured'); + } + + checkRateLimit(tx, userId); + + const override = tx.db.agentOverride.agentName.find(threadRow.agentName); + const providerName = override?.provider ?? def.defaultProvider; + const provider = resolveProvider(providerName); + + const keyRow = tx.db.apiKey.provider.find(providerName); + if (!keyRow) throwSenderError(`agent.no_api_key:${providerName}`); + + return { + cfg: { + provider, + apiKey: keyRow.key, + model: threadRow.modelOverride ?? override?.model ?? def.defaultModel, + systemPrompt: + threadRow.systemPromptOverride ?? + override?.systemPrompt ?? + def.defaultSystemPrompt, + maxTurns: override?.maxTurns ?? def.defaultMaxTurns, + maxHistoryMessages: + override?.maxHistoryMessages ?? def.defaultMaxHistoryMessages, + maxTokens: override?.maxTokens ?? def.defaultMaxTokens, + retries: override?.retries ?? def.defaultRetries, + responseFormat: def.defaultResponseFormat, + } satisfies LoopConfig, + agentName: threadRow.agentName, + userId: threadRow.userId, + }; +} + +export const send_message = spacetimedb.procedure( + { + threadId: t.u64(), + content: t.string(), + attachments: t.array( + t.object('SendAttachment', { + mimeType: t.string(), + filename: t.option(t.string()), + bytes: t.array(t.u8()), + }) + ), + }, + t.unit(), + (ctx, args) => { + if (args.content.length === 0 && args.attachments.length === 0) { + throwSenderError('agent.empty_message'); + } + const attachmentError = attachmentValidationError(args.attachments); + if (attachmentError) throwSenderError(attachmentError); + const content = + args.content.length > USER_CONTENT_MAX + ? args.content.slice(0, USER_CONTENT_MAX) + '...[truncated]' + : args.content; + + const callerUserId = requireUserId(ctx); + const { cfg, agentName, userId, userMessageId } = ctx.withTx(tx => { + const loaded = loadLoopConfigOrThrow(tx, args.threadId, callerUserId); + tx.db.threadLock.insert({ + threadId: args.threadId, + userId: loaded.userId, + lockedAt: tx.timestamp, + cancelRequested: false, + }); + const inserted = tx.db.message.insert({ + id: 0n, + threadId: args.threadId, + userId: loaded.userId, + role: 'user', + content, + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + createdAt: tx.timestamp, + }); + for (let i = 0; i < args.attachments.length; i++) { + const a = args.attachments[i]; + const path = `/msg/${inserted.id}/${i}`; + const file = tx.db.files.file.insert({ + id: 0n, + ownerPathKey: files.ownerPathKey(loaded.userId, path), + path, + ownerUserId: loaded.userId, + mimeType: a.mimeType, + size: BigInt(a.bytes.length), + sha256Hex: fileSha256Hex(a.bytes), + visibility: FILE_VISIBILITY_OWNER, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + tx.db.files.fileBlob.insert({ fileId: file.id, bytes: a.bytes }); + tx.db.messageAttachment.insert({ + id: 0n, + fileId: file.id, + messageId: inserted.id, + threadId: args.threadId, + ownerUserId: loaded.userId, + ordinal: i, + filename: a.filename, + createdAt: tx.timestamp, + }); + } + const threadRow = tx.db.thread.id.find(args.threadId); + if (threadRow) + tx.db.thread.id.update({ ...threadRow, updatedAt: tx.timestamp }); + return { ...loaded, userMessageId: inserted.id }; + }); + + maybeEmbedMessage(ctx, args.threadId, userMessageId); + runAgentForThread( + ctx, + cfg, + agentName, + userId, + args.threadId, + bumpRateLimit + ); + return {}; + } +); + +export const regenerate_response = spacetimedb.procedure( + { threadId: t.u64() }, + t.unit(), + (ctx, { threadId }) => { + const callerUserId = requireUserId(ctx); + const { cfg, agentName, userId } = ctx.withTx(tx => { + const loaded = loadLoopConfigOrThrow(tx, threadId, callerUserId); + + const rows = [...tx.db.message.threadId.filter(threadId)]; + rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + let lastUserMsgId: bigint | undefined; + for (const r of rows) { + if (r.role === 'user') lastUserMsgId = r.id; + } + if (lastUserMsgId === undefined) { + throwSenderError(`agent.regenerate_no_user_message:${threadId}`); + } + + for (const r of rows) { + if (r.id > lastUserMsgId!) tx.db.message.delete(r); + } + + tx.db.threadLock.insert({ + threadId, + userId: loaded.userId, + lockedAt: tx.timestamp, + cancelRequested: false, + }); + const threadRow = tx.db.thread.id.find(threadId); + if (threadRow) + tx.db.thread.id.update({ ...threadRow, updatedAt: tx.timestamp }); + return loaded; + }); + + runAgentForThread(ctx, cfg, agentName, userId, threadId, bumpRateLimit); + return {}; + } +); + +export const thread_lock_sweep = spacetimedb.reducer( + { onSchedule: threadLockSweeperTick }, + { arg: threadLockSweeperTick.rowType }, + (ctx, _arg) => { + const secret = ctx.db.agentSecret.singleton.find(true); + const thresholdSecs = + secret?.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS; + const thresholdMicros = BigInt(thresholdSecs) * ONE_SECOND_MICROS; + + const cutoffMicros = staleLockCutoffMicros( + ctx.timestamp.microsSinceUnixEpoch as bigint, + thresholdMicros + ); + deleteStaleThreadLocks( + ctx.db.threadLock.lockedAt.filter( + new Range(undefined, { + tag: 'excluded', + value: new Timestamp(cutoffMicros), + }) + ), + cutoffMicros, + lock => ctx.db.threadLock.delete(lock) + ); + } +); diff --git a/spacetime-agents-ts/example/spacetimedb/src/loop.ts b/spacetime-agents-ts/example/spacetimedb/src/loop.ts new file mode 100644 index 00000000000..691a5621805 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/loop.ts @@ -0,0 +1,252 @@ +import { + callChat, + type ChatMessage, + type ContentBlock, + type ToolCall, + type HttpLike, + type ToolDefinition, + type ResponseFormat, + type Provider, +} from '@spacetimedb/agents/openrouter'; +import type { InvokeResult } from '@spacetimedb/agents'; + +export const USER_CONTENT_MAX = 32_000; +export const TOOL_RESULT_MAX = 64_000; + +export interface LoopConfig { + provider: Provider; + apiKey: string; + model: string; + systemPrompt: string | undefined; + maxTurns: number; + maxHistoryMessages: number; + maxTokens: number | undefined; + retries: number; + responseFormat: ResponseFormat | undefined; +} + +export interface LoopAttachment { + mimeType: string; + data: string; // base64 (provider HTTP APIs want strings) +} + +export interface LoopMessage { + id: bigint; + threadId: bigint; + role: string; + content: string; + toolCallsJson: string | undefined; + toolCallId: string | undefined; + isError: boolean; + promptTokens: number | undefined; + completionTokens: number | undefined; + attachments: LoopAttachment[]; +} + +// Only user messages carry attachments; the loop itself never appends them. +export type AppendMessageRow = Omit; + +export interface LoopTx { + listMessages(threadId: bigint): LoopMessage[]; + appendMessage(row: AppendMessageRow): void; + bumpThread(threadId: bigint): void; + invokeTool(name: string, inputJson: string): InvokeResult; + isCancelRequested(threadId: bigint): boolean; +} + +export type WithTx = (fn: (tx: LoopTx) => R) => R; + +export interface RunAgentLoopOpts { + http: HttpLike; + withTx: WithTx; + llmToolDefs: ToolDefinition[]; + cfg: LoopConfig; + threadId: bigint; +} + +function runOneTurn(opts: RunAgentLoopOpts): boolean { + const { http, withTx, llmToolDefs, cfg, threadId } = opts; + + const cancelled = withTx(tx => { + if (tx.isCancelRequested(threadId)) { + tx.appendMessage({ + threadId, + role: 'assistant', + content: 'agent.cancelled', + toolCallsJson: undefined, + toolCallId: undefined, + isError: true, + promptTokens: undefined, + completionTokens: undefined, + }); + tx.bumpThread(threadId); + return true; + } + return false; + }); + if (cancelled) return false; + + const llmMessages = withTx(tx => + buildLlmMessages(tx, threadId, cfg.maxHistoryMessages) + ); + + const result = callChat(http, cfg.provider, { + apiKey: cfg.apiKey, + model: cfg.model, + system: cfg.systemPrompt, + messages: llmMessages, + tools: llmToolDefs, + maxTokens: cfg.maxTokens, + responseFormat: cfg.responseFormat, + retries: cfg.retries, + }); + + if (!result.ok) { + withTx(tx => + tx.appendMessage({ + threadId, + role: 'assistant', + content: formatChatError(result.error), + toolCallsJson: undefined, + toolCallId: undefined, + isError: true, + promptTokens: undefined, + completionTokens: undefined, + }) + ); + return false; + } + + const { text, toolCalls, finishReason, usage } = result.response; + const hasToolCalls = toolCalls.length > 0; + + withTx(tx => { + tx.appendMessage({ + threadId, + role: 'assistant', + content: text ?? '', + toolCallsJson: hasToolCalls ? JSON.stringify(toolCalls) : undefined, + toolCallId: undefined, + isError: false, + promptTokens: usage.promptTokens > 0 ? usage.promptTokens : undefined, + completionTokens: + usage.completionTokens > 0 ? usage.completionTokens : undefined, + }); + + if (hasToolCalls) { + for (const call of toolCalls) { + const inv = tx.invokeTool(call.function.name, call.function.arguments); + tx.appendMessage({ + threadId, + role: 'tool', + content: clip(inv.result, TOOL_RESULT_MAX), + toolCallsJson: undefined, + toolCallId: call.id, + isError: inv.isError, + promptTokens: undefined, + completionTokens: undefined, + }); + } + } + + tx.bumpThread(threadId); + }); + + return hasToolCalls && finishReason === 'tool_calls'; +} + +export function runAgentLoop(opts: RunAgentLoopOpts): void { + for (let turn = 0; turn < opts.cfg.maxTurns; turn++) { + if (!runOneTurn(opts)) return; + } + opts.withTx(tx => + tx.appendMessage({ + threadId: opts.threadId, + role: 'assistant', + content: `agent.max_turns_exceeded:${opts.cfg.maxTurns}`, + toolCallsJson: undefined, + toolCallId: undefined, + isError: true, + promptTokens: undefined, + completionTokens: undefined, + }) + ); +} + +// Drops orphan tool rows whose assistant tool_call fell outside the window. +export function buildLlmMessages( + tx: LoopTx, + threadId: bigint, + maxHistoryMessages: number +): ChatMessage[] { + const all = tx.listMessages(threadId); + const window = + maxHistoryMessages > 0 && all.length > maxHistoryMessages + ? all.slice(all.length - maxHistoryMessages) + : all; + + const out: ChatMessage[] = []; + const knownToolCallIds = new Set(); + + for (const row of window) { + if (row.role === 'user') { + out.push({ role: 'user', content: userContent(row) }); + } else if (row.role === 'assistant') { + let toolCalls: ToolCall[] | undefined; + if (row.toolCallsJson != null) { + try { + toolCalls = JSON.parse(row.toolCallsJson) as ToolCall[]; + } catch { + toolCalls = undefined; + } + } + const msg: ChatMessage = { role: 'assistant', content: row.content }; + if (toolCalls && toolCalls.length > 0) { + msg.tool_calls = toolCalls; + for (const c of toolCalls) knownToolCallIds.add(c.id); + } + out.push(msg); + } else if (row.role === 'tool') { + const tcid = row.toolCallId ?? ''; + if (!knownToolCallIds.has(tcid)) continue; + out.push({ role: 'tool', tool_call_id: tcid, content: row.content }); + } + } + return out; +} + +function userContent(row: LoopMessage): string | ContentBlock[] { + if (row.attachments.length === 0) return row.content; + const blocks: ContentBlock[] = []; + if (row.content) blocks.push({ type: 'text', text: row.content }); + for (const a of row.attachments) { + blocks.push({ type: 'image', mimeType: a.mimeType, data: a.data }); + } + return blocks; +} + +export function formatChatError(err: { + kind: string; + status?: number; + message?: string; + body?: string; +}): string { + switch (err.kind) { + case 'http': + return `agent.provider_http:${err.status}:${truncate(err.body ?? '', 500)}`; + case 'transport': + return `agent.provider_transport:${err.message ?? 'unknown'}`; + case 'parse': + return `agent.provider_parse:${err.message ?? 'unknown'}`; + default: + return `agent.provider_error:${err.kind}`; + } +} + +function truncate(s: string, max: number): string { + return s.length <= max ? s : s.slice(0, max) + '…'; +} + +function clip(s: string, max: number): string { + return s.length <= max ? s : s.slice(0, max) + '…[truncated]'; +} diff --git a/spacetime-agents-ts/example/spacetimedb/src/model.ts b/spacetime-agents-ts/example/spacetimedb/src/model.ts new file mode 100644 index 00000000000..9fb469cff37 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/model.ts @@ -0,0 +1,132 @@ +import { table, t } from 'spacetimedb/server'; + +export const apiKey = table( + { name: 'api_key', public: false }, + { + provider: t.string().primaryKey(), + key: t.string(), + updatedAt: t.timestamp(), + } +); + +// rateLimit fields are paired: both set or both null. +export const agentSecret = table( + { name: 'agent_secret', public: false }, + { + singleton: t.bool().primaryKey(), + staleLockThresholdSecs: t.u32(), + rateLimitTokensPerWindow: t.option(t.u32()), + rateLimitWindowSecs: t.option(t.u32()), + updatedAt: t.timestamp(), + } +); + +export const agentAdminIdentity = table( + { name: 'agent_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } +); + +export const agentOverride = table( + { name: 'agent_override', public: true }, + { + agentName: t.string().primaryKey(), + provider: t.option(t.string()), + model: t.option(t.string()), + systemPrompt: t.option(t.string()), + maxTurns: t.option(t.u32()), + maxHistoryMessages: t.option(t.u32()), + maxTokens: t.option(t.u32()), + retries: t.option(t.u32()), + updatedAt: t.timestamp(), + } +); + +export const thread = table( + { name: 'thread', public: false }, + { + id: t.u64().primaryKey().autoInc(), + userId: t.string().index(), + agentName: t.string().index(), + title: t.option(t.string()), + systemPromptOverride: t.option(t.string()), + modelOverride: t.option(t.string()), + metadata: t.option(t.string()), + summary: t.option(t.string()), + summarizedThroughId: t.option(t.u64()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +// userId denormalized from thread for the visibility filter. +export const message = table( + { name: 'message', public: false }, + { + id: t.u64().primaryKey().autoInc(), + threadId: t.u64().index(), + userId: t.string().index(), + role: t.string(), + content: t.string(), + toolCallsJson: t.option(t.string()), + toolCallId: t.option(t.string()), + isError: t.bool(), + promptTokens: t.option(t.u32()), + completionTokens: t.option(t.u32()), + createdAt: t.timestamp(), + } +); +export const threadLock = table( + { name: 'thread_lock', public: false }, + { + threadId: t.u64().primaryKey(), + userId: t.string().index(), + lockedAt: t.timestamp().index('btree'), + cancelRequested: t.bool(), + } +); + +export const messageAttachment = table( + { name: 'message_attachment', public: false }, + { + id: t.u64().primaryKey().autoInc(), + fileId: t.u64().index(), + messageId: t.u64().index(), + threadId: t.u64().index(), + ownerUserId: t.string().index(), + ordinal: t.u32(), + filename: t.option(t.string()), + createdAt: t.timestamp(), + } +); + +export const fileViewRow = t.object('File', { + id: t.u64(), + fileId: t.u64(), + path: t.string(), + ownerUserId: t.string(), + mimeType: t.string(), + size: t.u64(), + sha256Hex: t.string(), + visibility: t.string(), + filename: t.option(t.string()), + messageId: t.option(t.u64()), + threadId: t.option(t.u64()), + ordinal: t.u32(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}); + +export const messageEmbedding = table( + { name: 'message_embedding', public: false }, + { + messageId: t.u64().primaryKey(), + threadId: t.u64().index(), + userId: t.string().index(), + model: t.string(), + vector: t.array(t.f32()), + createdAt: t.timestamp(), + } +); diff --git a/spacetime-agents-ts/example/spacetimedb/src/summarize.ts b/spacetime-agents-ts/example/spacetimedb/src/summarize.ts new file mode 100644 index 00000000000..0da8ebdbb13 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/summarize.ts @@ -0,0 +1,73 @@ +import type { LoopMessage } from './loop'; + +export function pickSummarizationCandidates( + messages: LoopMessage[], // ascending by id + maxHistoryMessages: number, + summarizedThroughId: bigint | null +): { newDropped: LoopMessage[]; lastNewId: bigint } | null { + if (messages.length <= maxHistoryMessages) return null; + + const dropCount = messages.length - maxHistoryMessages; + const dropped = messages.slice(0, dropCount); + + const newDropped = + summarizedThroughId == null + ? dropped + : dropped.filter(m => m.id > summarizedThroughId); + + if (newDropped.length === 0) return null; + return { newDropped, lastNewId: newDropped[newDropped.length - 1].id }; +} + +export function formatMessagesForSummarizer(messages: LoopMessage[]): string { + const lines: string[] = []; + for (const m of messages) { + if (m.role === 'user') { + lines.push(`User: ${m.content}`); + } else if (m.role === 'assistant') { + if (m.toolCallsJson != null) { + try { + const calls = JSON.parse(m.toolCallsJson) as Array<{ + function?: { name?: string; arguments?: string }; + }>; + for (const c of calls) { + const name = c.function?.name ?? '?'; + const args = c.function?.arguments ?? ''; + lines.push(`[Assistant called tool ${name}(${args})]`); + } + } catch { + /* malformed */ + } + if (m.content) lines.push(`Assistant: ${m.content}`); + } else { + lines.push(`Assistant: ${m.content}`); + } + } else if (m.role === 'tool') { + lines.push(`[Tool result: ${m.content}]`); + } + } + return lines.join('\n'); +} + +export function buildSummarizerUserContent( + existingSummary: string | null, + newDropped: LoopMessage[] +): string { + const formatted = formatMessagesForSummarizer(newDropped); + if (existingSummary) { + return ( + `Existing summary:\n${existingSummary}\n\n` + + `Additional messages to fold into the summary:\n${formatted}` + ); + } + return `Messages to summarize:\n${formatted}`; +} + +export function augmentSystemWithSummary( + baseSystem: string | undefined, + summary: string | null +): string | undefined { + if (summary == null || summary.length === 0) return baseSystem; + const base = baseSystem ?? ''; + return `${base}\n\n## Summary of earlier conversation\n${summary}`.trim(); +} diff --git a/spacetime-agents-ts/example/spacetimedb/src/sweeper.ts b/spacetime-agents-ts/example/spacetimedb/src/sweeper.ts new file mode 100644 index 00000000000..d4cab2280f9 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/sweeper.ts @@ -0,0 +1,12 @@ +const ONE_SECOND_MICROS = 1_000_000n; +const ONE_MINUTE_MICROS = 60n * ONE_SECOND_MICROS; + +export const SWEEPER_INTERVAL_MICROS = ONE_MINUTE_MICROS; + +export function isStaleLock( + nowMicros: bigint, + lockedAtMicros: bigint, + thresholdMicros: bigint +): boolean { + return lockedAtMicros < nowMicros - thresholdMicros; +} diff --git a/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts b/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts new file mode 100644 index 00000000000..33f2df50904 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/tools/getTime.ts @@ -0,0 +1,14 @@ +import { t } from 'spacetimedb/server'; +import { agentTool } from '@spacetimedb/agents'; +import type { Tx } from '../types'; + +export default agentTool( + 'returns the current server time as an ISO-8601 string', + t.unit(), + ctx => { + // The Tx cast avoids a circular type reference between the tool and schema. + const tx = ctx as Tx; + const micros = tx.timestamp.microsSinceUnixEpoch as bigint; + return new Date(Number(micros / 1000n)).toISOString(); + } +); diff --git a/spacetime-agents-ts/example/spacetimedb/src/types.ts b/spacetime-agents-ts/example/spacetimedb/src/types.ts new file mode 100644 index 00000000000..3a9586279b3 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/types.ts @@ -0,0 +1,6 @@ +import type { ReducerCtx, InferSchema } from 'spacetimedb/server'; +import type spacetimedb from './index'; + +export type Schema = InferSchema; +export type Tx = ReducerCtx; +export type Db = Tx['db']; diff --git a/spacetime-agents-ts/example/spacetimedb/src/views.ts b/spacetime-agents-ts/example/spacetimedb/src/views.ts new file mode 100644 index 00000000000..4ade0f00d49 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/src/views.ts @@ -0,0 +1,115 @@ +import { t, type InferSchema, type ViewCtx } from 'spacetimedb/server'; +import { + fileViewRow, + message, + messageEmbedding, + thread, + threadLock, +} from './model'; + +const authUserViewRow = t.object('AgentAuthUser', { + userId: t.string(), + email: t.string(), + emailVerified: t.bool(), + name: t.option(t.string()), + image: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}); + +export function registerAgentViews( + spacetimedb: typeof import('./index').default +) { + type Schema = InferSchema; + const callerUserId = (ctx: ViewCtx) => + ctx.db.auth.authConnectionBinding.stdbIdentity.find(ctx.sender)?.userId; + + const myThreads = spacetimedb.view( + { name: 'my_threads', public: true }, + t.array(thread.rowType), + ctx => { + const userId = callerUserId(ctx); + return userId ? [...ctx.db.thread.userId.filter(userId)] : []; + } + ); + + const myMessages = spacetimedb.view( + { name: 'my_messages', public: true }, + t.array(message.rowType), + ctx => { + const userId = callerUserId(ctx); + return userId ? [...ctx.db.message.userId.filter(userId)] : []; + } + ); + + const myThreadLocks = spacetimedb.view( + { name: 'my_thread_locks', public: true }, + t.array(threadLock.rowType), + ctx => { + const userId = callerUserId(ctx); + return userId ? [...ctx.db.threadLock.userId.filter(userId)] : []; + } + ); + + const myMessageEmbeddings = spacetimedb.view( + { name: 'my_message_embeddings', public: true }, + t.array(messageEmbedding.rowType), + ctx => { + const userId = callerUserId(ctx); + return userId ? [...ctx.db.messageEmbedding.userId.filter(userId)] : []; + } + ); + + const myFiles = spacetimedb.view( + { name: 'my_files', public: true }, + t.array(fileViewRow), + ctx => { + const userId = callerUserId(ctx); + if (!userId) return []; + const rows = []; + for (const attachment of ctx.db.messageAttachment.ownerUserId.filter( + userId + )) { + const file = ctx.db.files.file.id.find(attachment.fileId); + if (!file) continue; + rows.push({ + id: attachment.id, + fileId: attachment.fileId, + path: file.path, + ownerUserId: attachment.ownerUserId, + mimeType: file.mimeType, + size: file.size, + sha256Hex: file.sha256Hex, + visibility: file.visibility, + filename: attachment.filename, + messageId: attachment.messageId, + threadId: attachment.threadId, + ordinal: attachment.ordinal, + createdAt: attachment.createdAt, + updatedAt: file.updatedAt, + }); + } + return rows; + } + ); + + const myAuthUser = spacetimedb.view( + { name: 'my_auth_user', public: true }, + t.array(authUserViewRow), + ctx => { + const userId = callerUserId(ctx); + if (!userId) return []; + const row = ctx.db.auth.authUser.userId.find(userId); + return row ? [row] : []; + } + ); + + return { + myThreads, + myMessages, + myThreadLocks, + myMessageEmbeddings, + myFiles, + myAuthUser, + }; +} diff --git a/spacetime-agents-ts/example/spacetimedb/tsconfig.json b/spacetime-agents-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..c18065b7cb8 --- /dev/null +++ b/spacetime-agents-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-agents-ts/example/src/app.ts b/spacetime-agents-ts/example/src/app.ts new file mode 100644 index 00000000000..ccfc3cd69ca --- /dev/null +++ b/spacetime-agents-ts/example/src/app.ts @@ -0,0 +1,644 @@ +import { + authUrlState, + clearAuthResultParams, + mountAuthPanel, +} from '@spacetimedb/submodule-shared'; +import '@spacetimedb/submodule-shared/styles.css'; +import { + DbConnection, + tables, + type ErrorContext, + type EventContext, + type SubscriptionHandle, +} from './module_bindings/app'; +import type { + File as FileRow, + AgentAuthUser as AuthUserRow, + AgentConfigStatus, + MySessions, +} from './module_bindings/app/types'; + +interface AuthUser { + userId: string; + email: string; + emailVerified: boolean; + name?: string; + image?: string; +} +interface AuthMe { + user: AuthUser; + sessionExpiresAt: number; +} +interface ServerConfig { + spacetimeUri: string; + databaseName: string; + oauth?: { + google?: boolean; + github?: boolean; + }; +} +declare global { + interface Window { + auth?: { + signup: (args: { + email: string; + password: string; + name?: string; + }) => Promise; + login: (args: { email: string; password: string }) => Promise; + logout: () => Promise; + oauthStart: (provider: 'google' | 'github') => void; + forgotPassword: (email: string) => Promise; + resetPassword: (token: string, newPassword: string) => Promise; + requestEmailVerify: () => Promise; + listMySessions: () => Promise; + revokeMySession: (sessionId: string) => Promise; + setProfile: (args: { name?: string; image?: string }) => void; + }; + stdb?: { + setAgentSecret: (args: { + staleLockThresholdSecs: number | undefined; + rateLimitTokensPerWindow: number | undefined; + rateLimitWindowSecs: number | undefined; + }) => Promise; + setApiKey: (provider: string, key: string) => Promise; + clearApiKey: (provider: string) => Promise; + setAgentOverride: (args: { + agentName: string; + provider: string | undefined; + model: string | undefined; + systemPrompt: string | undefined; + maxTurns: number | undefined; + maxHistoryMessages: number | undefined; + maxTokens: number | undefined; + retries: number | undefined; + }) => Promise; + clearAgentOverride: (agentName: string) => Promise; + getAgentConfigStatus: () => Promise; + setActiveThread: (threadId: bigint | null) => void; + startThread: (args: { + agentName: string; + title: string | undefined; + systemPromptOverride: string | undefined; + metadata: string | undefined; + }) => Promise; + updateThread: (args: { + threadId: bigint; + title: string | undefined; + systemPromptOverride: string | undefined; + modelOverride: string | undefined; + metadata: string | undefined; + clearTitle: boolean; + clearSystemPromptOverride: boolean; + clearModelOverride: boolean; + clearMetadata: boolean; + }) => Promise; + deleteThread: (threadId: bigint) => Promise; + sendMessage: ( + threadId: bigint, + content: string, + attachments?: Array<{ + mimeType: string; + filename: string | undefined; + bytes: Uint8Array; + }> + ) => Promise; + regenerateResponse: (threadId: bigint) => Promise; + requestCancel: (threadId: bigint) => Promise; + generateThreadTitle: (threadId: bigint) => Promise; + clearThreadLock: (threadId: bigint) => Promise; + }; + } +} + +type ConfigState = + | { kind: 'unknown' } + | { kind: 'unconfigured' } + | { kind: 'configured'; status: AgentConfigStatus }; +type ConnState = 'idle' | 'connecting' | 'connected' | 'error'; + +let configState: ConfigState = { kind: 'unknown' }; + +let currentConn: DbConnection | null = null; +let globalSub: SubscriptionHandle | null = null; +let messageSub: SubscriptionHandle | null = null; +let activeThreadId: bigint | null = null; +let serverCfg: ServerConfig | null = null; + +let currentUser: AuthUser | null = null; +let currentExp: number | undefined; + +let reconnectAttempt = 0; +let reconnectTimer: ReturnType | null = null; +const RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 15000]; + +function emitAppEvent(name: string, detail: unknown): void { + window.dispatchEvent(new CustomEvent(name, { detail })); +} +function emitThreads(): void { + if (!currentConn) { + emitAppEvent('stdb:threads', { threads: [] }); + return; + } + const sorted = [...currentConn.db.myThreads.iter()].sort((a, b) => { + const av = a.updatedAt.microsSinceUnixEpoch as bigint; + const bv = b.updatedAt.microsSinceUnixEpoch as bigint; + return av < bv ? 1 : av > bv ? -1 : 0; + }); + emitAppEvent('stdb:threads', { threads: sorted }); +} +function emitMessages(): void { + if (!currentConn) { + emitAppEvent('stdb:messages', { messages: [], attachments: {} }); + return; + } + const sorted = [...currentConn.db.myMessages.iter()].sort((a, b) => + a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + ); + const atts: Record = {}; + for (const f of currentConn.db.myFiles.iter()) { + if (f.messageId === undefined) continue; + const key = f.messageId.toString(); + (atts[key] ??= []).push(f); + } + emitAppEvent('stdb:messages', { messages: sorted, attachments: atts }); +} +function emitThreadLocks(): void { + if (!currentConn) { + emitAppEvent('stdb:locks', { locks: [] }); + return; + } + const entries: Array<[bigint, boolean]> = []; + for (const l of currentConn.db.myThreadLocks.iter()) + entries.push([l.threadId, l.cancelRequested]); + emitAppEvent('stdb:locks', { locks: entries }); +} +function emitAgentOverrides(): void { + if (!currentConn) { + emitAppEvent('stdb:overrides', { overrides: [] }); + return; + } + emitAppEvent('stdb:overrides', { + overrides: [...currentConn.db.agentOverride.iter()], + }); +} +function emitConfigState(): void { + emitAppEvent('stdb:config', { state: configState }); +} +function emitConnectionState(state: ConnState, detail?: string): void { + emitAppEvent('stdb:connState', { state, detail }); +} +function emitAuthState(): void { + emitAppEvent('auth:state', { + user: currentUser, + sessionExpiresAt: currentExp, + }); +} + +function syncUserFromRow(row: AuthUserRow): void { + if (!currentUser || row.userId !== currentUser.userId) return; + currentUser = { + userId: row.userId, + email: row.email, + emailVerified: row.emailVerified, + name: row.name ?? undefined, + image: row.image ?? undefined, + }; + emitAuthState(); +} + +function requireConn(): DbConnection { + if (!currentConn) throw new Error('STDB not connected'); + return currentConn; +} + +// Authentication requests proxied to SpacetimeDB by the Express server +async function callJson(path: string, body?: unknown): Promise { + const r = await fetch(path, { + method: body !== undefined ? 'POST' : 'GET', + headers: body !== undefined ? { 'content-type': 'application/json' } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + credentials: 'same-origin', + }); + let data: unknown = null; + try { + data = await r.json(); + } catch { + /* empty body */ + } + if (!r.ok) { + const err = + data && typeof data === 'object' && 'error' in data + ? String((data as { error: unknown }).error) + : `http_${r.status}`; + throw new Error(err); + } + return data as T; +} + +async function loadServerConfig(): Promise { + const res = await fetch('/api/config', { credentials: 'same-origin' }); + if (!res.ok) throw new Error(`/api/config returned ${res.status}`); + const nextConfig = (await res.json()) as ServerConfig; + authPanel.setProviders({ + google: Boolean(nextConfig.oauth?.google), + github: Boolean(nextConfig.oauth?.github), + }); + return nextConfig; +} + +// Persist the STDB identity token so refresh reuses the same identity. +const STDB_TOKEN_KEY = 'agents:stdb_token'; +function loadStdbToken(): string | undefined { + try { + return localStorage.getItem(STDB_TOKEN_KEY) ?? undefined; + } catch { + return undefined; + } +} +function saveStdbToken(token: string): void { + try { + localStorage.setItem(STDB_TOKEN_KEY, token); + } catch { + /* Storage can be unavailable. */ + } +} + +function connect(uri: string, databaseName: string): Promise { + return new Promise((resolve, reject) => { + DbConnection.builder() + .withUri(uri) + .withDatabaseName(databaseName) + .withToken(loadStdbToken()) + .onConnect((connection, _identity, token) => { + if (token) saveStdbToken(token); + resolve(connection); + }) + .onDisconnect((_ctx, err) => { + emitConnectionState('error', err?.message ?? 'disconnected'); + currentConn = null; + globalSub = null; + messageSub = null; + if (currentUser) scheduleReconnect(); + }) + .onConnectError((_ctx, err) => { + emitConnectionState('error', err?.message ?? 'connect failed'); + reject(err); + }) + .build(); + }); +} + +function scheduleReconnect(): void { + if (reconnectTimer) return; + const delay = + RECONNECT_DELAYS_MS[ + Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1) + ]; + console.warn( + `STDB reconnect in ${delay}ms (attempt ${reconnectAttempt + 1})` + ); + reconnectTimer = setTimeout(async () => { + reconnectTimer = null; + reconnectAttempt++; + if (!currentUser) return; + try { + const r = await callJson<{ + user: AuthUser; + token: string; + sessionExpiresAt: number; + }>('/auth/session/refresh', {}); + await bindSession(r.token, r.user, r.sessionExpiresAt); + reconnectAttempt = 0; + } catch (err) { + console.error('Reconnect failed:', err); + scheduleReconnect(); + } + }, delay); +} + +function setActiveThread(threadId: bigint | null): void { + if (activeThreadId === threadId) return; + activeThreadId = threadId; + + if (messageSub) { + messageSub.unsubscribe(); + messageSub = null; + } + emitMessages(); + + if (threadId === null || !currentConn) return; + + messageSub = currentConn + .subscriptionBuilder() + .onApplied(() => emitMessages()) + .onError((ctx: ErrorContext) => + console.error('message sub error', ctx.event) + ) + .subscribe([tables.myMessages.where(row => row.threadId.eq(threadId))]); +} + +function registerRowCallbacks(connection: DbConnection): void { + connection.db.myThreads.onInsert(() => emitThreads()); + connection.db.myThreads.onUpdate(() => emitThreads()); + connection.db.myThreads.onDelete(() => emitThreads()); + + connection.db.myMessages.onInsert(() => emitMessages()); + connection.db.myMessages.onUpdate(() => emitMessages()); + connection.db.myMessages.onDelete(() => emitMessages()); + + connection.db.myFiles.onInsert(() => emitMessages()); + connection.db.myFiles.onUpdate(() => emitMessages()); + connection.db.myFiles.onDelete(() => emitMessages()); + + connection.db.myThreadLocks.onInsert(() => emitThreadLocks()); + connection.db.myThreadLocks.onUpdate(() => emitThreadLocks()); + connection.db.myThreadLocks.onDelete(() => emitThreadLocks()); + + connection.db.agentOverride.onInsert(() => emitAgentOverrides()); + connection.db.agentOverride.onUpdate(() => emitAgentOverrides()); + connection.db.agentOverride.onDelete(() => emitAgentOverrides()); + + connection.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) => + syncUserFromRow(row) + ); + connection.db.myAuthUser.onUpdate( + (_ctx: EventContext, _o: AuthUserRow, n: AuthUserRow) => syncUserFromRow(n) + ); + connection.db.myAuthUser.onDelete((_ctx: EventContext, row: AuthUserRow) => { + if (!currentUser || row.userId !== currentUser.userId) return; + currentUser = null; + currentExp = undefined; + emitAuthState(); + }); +} + +function subscribeToTables(connection: DbConnection): SubscriptionHandle { + return connection + .subscriptionBuilder() + .onApplied(() => { + emitThreads(); + emitThreadLocks(); + emitAgentOverrides(); + emitMessages(); + }) + .onError((ctx: ErrorContext) => + console.error('global sub error', ctx.event) + ) + .subscribe([ + tables.myThreads, + tables.myThreadLocks, + tables.agentOverride, + tables.myFiles, + tables.myAuthUser, + ]); +} + +async function refreshConfigStatus(): Promise { + const status = await requireConn().procedures.getAgentConfigStatus({}); + configState = status.isConfigured + ? { kind: 'configured', status } + : { kind: 'unconfigured' }; + emitConfigState(); + return status; +} + +async function bindSession( + token: string, + user: AuthUser, + exp: number +): Promise { + currentUser = user; + currentExp = exp; + + if (!serverCfg) serverCfg = await loadServerConfig(); + + if (!currentConn) { + emitConnectionState('connecting'); + try { + const conn = await connect( + serverCfg.spacetimeUri, + serverCfg.databaseName + ); + currentConn = conn; + reconnectAttempt = 0; + emitConnectionState('connected'); + + emitThreads(); + emitMessages(); + emitThreadLocks(); + emitAgentOverrides(); + + registerRowCallbacks(conn); + } catch (err) { + emitConnectionState( + 'error', + err instanceof Error ? err.message : String(err) + ); + return; + } + } + + // Link the connection before subscribing because views read the binding. + try { + await currentConn.procedures.linkConnection({ sessionToken: token }); + } catch (err) { + console.warn('link_connection failed', err); + } + + if (!globalSub) { + globalSub = subscribeToTables(currentConn); + + const previousActive = activeThreadId; + activeThreadId = null; + messageSub = null; + if (previousActive !== null) setActiveThread(previousActive); + } + + await refreshConfigStatus(); + emitAuthState(); +} + +async function restoreSession(): Promise { + try { + const r = await callJson<{ + user: AuthUser; + token: string; + sessionExpiresAt: number; + }>('/auth/session/refresh', {}); + await bindSession(r.token, r.user, r.sessionExpiresAt); + return true; + } catch { + return false; + } +} + +async function signup(args: { + email: string; + password: string; + name?: string; +}): Promise { + const r = await callJson<{ token: string }>('/auth/password/signup', args); + const me = await callJson('/auth/me'); + await bindSession(r.token, me.user, me.sessionExpiresAt); +} + +async function login(args: { email: string; password: string }): Promise { + const r = await callJson<{ token: string }>('/auth/password/login', args); + const me = await callJson('/auth/me'); + await bindSession(r.token, me.user, me.sessionExpiresAt); +} + +async function logout(): Promise { + if (currentConn) { + try { + currentConn.reducers.unlinkConnection({}); + } catch { + /* ignore */ + } + } + try { + await callJson('/auth/logout', {}); + } catch { + /* ignore */ + } + currentUser = null; + currentExp = undefined; + emitThreads(); + emitMessages(); + emitThreadLocks(); + emitAgentOverrides(); + emitAuthState(); +} + +function oauthStart(provider: 'google' | 'github'): void { + window.location.href = `/auth/${provider}/start?redirectTo=/`; +} + +async function forgotPassword(email: string): Promise { + await callJson('/auth/password/forgot', { email }); +} +async function resetPassword( + token: string, + newPassword: string +): Promise { + await callJson('/auth/password/reset', { token, newPassword }); +} +async function requestEmailVerify(): Promise { + await callJson('/auth/email/verify-request', {}); +} + +async function listMySessions(): Promise { + return await requireConn().procedures.listMySessions({}); +} +async function revokeMySession(sessionId: string): Promise { + requireConn().reducers.revokeMySession({ sessionId }); +} + +const authResult = authUrlState(window.location); +const authPanelRoot = document.getElementById('auth-panel'); +if (!authPanelRoot) throw new Error('missing_auth_panel'); +const authPanel = mountAuthPanel(authPanelRoot, { + productName: 'Agents', + actions: { + login, + signup, + forgotPassword, + resetPassword, + oauthStart, + }, + initialMode: authResult.mode, + resetToken: authResult.resetToken, +}); +if (authResult.oauthError) { + authPanel.showMessage('error', `OAuth: ${authResult.oauthError}`); +} +if (authResult.verified) { + authPanel.showMessage('success', 'Email verified.'); +} +clearAuthResultParams(window.location, window.history); + +async function main(): Promise { + window.auth = { + signup, + login, + logout, + oauthStart, + forgotPassword, + resetPassword, + requestEmailVerify, + listMySessions, + revokeMySession, + setProfile: args => { + requireConn().reducers.updateProfile({ + name: args.name, + image: args.image, + }); + }, + }; + + window.stdb = { + setAgentSecret: async args => { + requireConn().reducers.setAgentSecret(args); + await refreshConfigStatus(); + }, + setApiKey: async (provider, key) => { + requireConn().reducers.setApiKey({ provider, key }); + await refreshConfigStatus(); + }, + clearApiKey: async provider => { + requireConn().reducers.clearApiKey({ provider }); + await refreshConfigStatus(); + }, + setAgentOverride: async args => { + requireConn().reducers.setAgentOverride(args); + }, + clearAgentOverride: async agentName => { + requireConn().reducers.clearAgentOverride({ agentName }); + }, + getAgentConfigStatus: () => refreshConfigStatus(), + setActiveThread, + startThread: async args => { + return await requireConn().procedures.startThread(args); + }, + updateThread: async args => { + requireConn().reducers.updateThread(args); + }, + deleteThread: async threadId => { + requireConn().reducers.deleteThread({ threadId }); + }, + sendMessage: async (threadId, content, atts) => { + await requireConn().procedures.sendMessage({ + threadId, + content, + attachments: atts ?? [], + }); + }, + regenerateResponse: async threadId => { + await requireConn().procedures.regenerateResponse({ threadId }); + }, + requestCancel: async threadId => { + requireConn().reducers.requestCancel({ threadId }); + }, + generateThreadTitle: async threadId => { + await requireConn().procedures.generateThreadTitle({ threadId }); + }, + clearThreadLock: async threadId => { + requireConn().reducers.clearThreadLock({ threadId }); + }, + }; + + emitConnectionState('idle'); + serverCfg = await loadServerConfig(); + emitAppEvent('stdb:ready', {}); + await restoreSession(); + emitAppEvent('auth:ready', {}); +} + +main().catch(err => { + console.error(err); + emitConnectionState( + 'error', + err instanceof Error ? err.message : String(err) + ); +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/add_agent_admin_identity_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/add_agent_admin_identity_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/add_agent_admin_identity_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + identity: __t.identity(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/add_rate_limit_admin_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/add_rate_limit_admin_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + identity: __t.identity(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/admin_rate_limit_buckets_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/admin_rate_limit_buckets_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/consume_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/consume_procedure.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/rate_limit_config_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/reset_buckets_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/reset_buckets_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/run_sweep_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/types.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/types.ts @@ -0,0 +1,56 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/update_config_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agentRateLimit/update_config_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/agent_override_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/agent_override_table.ts new file mode 100644 index 00000000000..e960774db2d --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/agent_override_table.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + agentName: __t.string().primaryKey().name("agent_name"), + provider: __t.option(__t.string()), + model: __t.option(__t.string()), + systemPrompt: __t.option(__t.string()).name("system_prompt"), + maxTurns: __t.option(__t.u32()).name("max_turns"), + maxHistoryMessages: __t.option(__t.u32()).name("max_history_messages"), + maxTokens: __t.option(__t.u32()).name("max_tokens"), + retries: __t.option(__t.u32()), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionToken: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts new file mode 100644 index 00000000000..6573c3fe132 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + userId: __t.string().primaryKey().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + identity: __t.identity(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/types.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/types.ts @@ -0,0 +1,56 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/types.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/types.ts new file mode 100644 index 00000000000..8df99e3d1b4 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/types.ts @@ -0,0 +1,137 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AuthAccount = __t.object("AuthAccount", { + accountId: __t.string(), + userId: __t.string(), + providerId: __t.string(), + providerAccountId: __t.string(), + passwordHash: __t.option(__t.string()), + accessToken: __t.option(__t.string()), + refreshToken: __t.option(__t.string()), + accessTokenExpiresAt: __t.option(__t.timestamp()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthAccount = __Infer; + +export const AuthAdminIdentity = __t.object("AuthAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type AuthAdminIdentity = __Infer; + +export const AuthConfig = __t.object("AuthConfig", { + singleton: __t.bool(), + issuerUrl: __t.string(), + baseUrl: __t.string(), + cookieName: __t.string(), + sessionTtlSeconds: __t.u64(), + es256PrivateKeyPem: __t.string(), + es256PublicKeyPem: __t.string(), + keyId: __t.string(), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type AuthConfig = __Infer; + +export const AuthConnectionBinding = __t.object("AuthConnectionBinding", { + stdbIdentity: __t.identity(), + userId: __t.string(), + linkedAt: __t.timestamp(), +}); +export type AuthConnectionBinding = __Infer; + +export const AuthOauthState = __t.object("AuthOauthState", { + state: __t.string(), + provider: __t.string(), + codeVerifier: __t.string(), + redirectTo: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthOauthState = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const AuthSession = __t.object("AuthSession", { + sessionId: __t.string(), + userId: __t.string(), + token: __t.string(), + expiresAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type AuthSession = __Infer; + +export const AuthSweeperTick = __t.object("AuthSweeperTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type AuthSweeperTick = __Infer; + +export const AuthUser = __t.object("AuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthUser = __Infer; + +export const AuthVerification = __t.object("AuthVerification", { + verificationId: __t.string(), + identifier: __t.string(), + value: __t.string(), + purpose: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthVerification = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/auth/whoami_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/auth/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/auth/whoami_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/clear_agent_override_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/clear_agent_override_reducer.ts new file mode 100644 index 00000000000..9d9d7df0ddd --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/clear_agent_override_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + agentName: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/clear_api_key_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/clear_api_key_reducer.ts new file mode 100644 index 00000000000..ce029b88adf --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/clear_api_key_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + provider: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/clear_thread_lock_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/clear_thread_lock_reducer.ts new file mode 100644 index 00000000000..452ed4063c6 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/clear_thread_lock_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + threadId: __t.u64(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/delete_thread_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/delete_thread_reducer.ts new file mode 100644 index 00000000000..452ed4063c6 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/delete_thread_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + threadId: __t.u64(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/files/types.ts b/spacetime-agents-ts/example/src/module_bindings/app/files/types.ts new file mode 100644 index 00000000000..a8336b9566f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/files/types.ts @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const File = __t.object("File", { + id: __t.u64(), + ownerPathKey: __t.string(), + path: __t.string(), + ownerUserId: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type File = __Infer; + +export const FileBlob = __t.object("FileBlob", { + fileId: __t.u64(), + bytes: __t.byteArray(), +}); +export type FileBlob = __Infer; + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/generate_thread_title_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/generate_thread_title_procedure.ts new file mode 100644 index 00000000000..398618113fb --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/generate_thread_title_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + threadId: __t.u64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/get_agent_config_status_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/get_agent_config_status_procedure.ts new file mode 100644 index 00000000000..359fe0fafab --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/get_agent_config_status_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AgentConfigStatus, +} from "./types"; + +export const params = { +}; +export const returnType = AgentConfigStatus \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/index.ts b/spacetime-agents-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..bd996e4cce9 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,387 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from "spacetimedb"; + +// Import all reducer arg schemas +import AddAgentAdminIdentityReducer from "./add_agent_admin_identity_reducer"; +import ClearAgentOverrideReducer from "./clear_agent_override_reducer"; +import ClearApiKeyReducer from "./clear_api_key_reducer"; +import ClearThreadLockReducer from "./clear_thread_lock_reducer"; +import DeleteThreadReducer from "./delete_thread_reducer"; +import RemoveAgentAdminIdentityReducer from "./remove_agent_admin_identity_reducer"; +import RequestCancelReducer from "./request_cancel_reducer"; +import RevokeMySessionReducer from "./revoke_my_session_reducer"; +import RevokeSessionReducer from "./revoke_session_reducer"; +import SetAgentOverrideReducer from "./set_agent_override_reducer"; +import SetAgentSecretReducer from "./set_agent_secret_reducer"; +import SetApiKeyReducer from "./set_api_key_reducer"; +import SetAuthConfigReducer from "./set_auth_config_reducer"; +import UnlinkConnectionReducer from "./unlink_connection_reducer"; +import UpdateProfileReducer from "./update_profile_reducer"; +import UpdateThreadReducer from "./update_thread_reducer"; + +// Import all procedure arg schemas +import * as GenerateThreadTitleProcedure from "./generate_thread_title_procedure"; +import * as GetAgentConfigStatusProcedure from "./get_agent_config_status_procedure"; +import * as GetAuthPublicKeyProcedure from "./get_auth_public_key_procedure"; +import * as LinkConnectionProcedure from "./link_connection_procedure"; +import * as ListMySessionsProcedure from "./list_my_sessions_procedure"; +import * as RegenerateResponseProcedure from "./regenerate_response_procedure"; +import * as SendMessageProcedure from "./send_message_procedure"; +import * as StartThreadProcedure from "./start_thread_procedure"; + +// Import all table schema definitions +import AgentOverrideRow from "./agent_override_table"; +import MyAuthUserRow from "./my_auth_user_table"; +import MyFilesRow from "./my_files_table"; +import MyMessageEmbeddingsRow from "./my_message_embeddings_table"; +import MyMessagesRow from "./my_messages_table"; +import MyThreadLocksRow from "./my_thread_locks_table"; +import MyThreadsRow from "./my_threads_table"; + +// Import namespace table schema definitions +import AuthRateLimit_RateLimitConfigRow from "./auth/rateLimit/rate_limit_config_table"; +import AgentRateLimit_RateLimitConfigRow from "./agentRateLimit/rate_limit_config_table"; +import Auth_MyAuthUserRow from "./auth/my_auth_user_table"; +import AuthRateLimit_AdminRateLimitBucketsRow from "./auth/rateLimit/admin_rate_limit_buckets_table"; +import AgentRateLimit_AdminRateLimitBucketsRow from "./agentRateLimit/admin_rate_limit_buckets_table"; + +// Import namespace reducer arg schemas +import Auth_LinkConnectionReducer from "./auth/link_connection_reducer"; +import Auth_RevokeMySessionReducer from "./auth/revoke_my_session_reducer"; +import Auth_RevokeSessionReducer from "./auth/revoke_session_reducer"; +import Auth_SetAuthConfigReducer from "./auth/set_auth_config_reducer"; +import Auth_UnlinkConnectionReducer from "./auth/unlink_connection_reducer"; +import Auth_UpdateProfileReducer from "./auth/update_profile_reducer"; +import AuthRateLimit_AddRateLimitAdminReducer from "./auth/rateLimit/add_rate_limit_admin_reducer"; +import AuthRateLimit_ResetBucketsReducer from "./auth/rateLimit/reset_buckets_reducer"; +import AuthRateLimit_UpdateConfigReducer from "./auth/rateLimit/update_config_reducer"; +import AgentRateLimit_AddRateLimitAdminReducer from "./agentRateLimit/add_rate_limit_admin_reducer"; +import AgentRateLimit_ResetBucketsReducer from "./agentRateLimit/reset_buckets_reducer"; +import AgentRateLimit_UpdateConfigReducer from "./agentRateLimit/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as Auth_GetAuthPublicKeyProcedure from "./auth/get_auth_public_key_procedure"; +import * as Auth_ListMySessionsProcedure from "./auth/list_my_sessions_procedure"; +import * as Auth_WhoamiProcedure from "./auth/whoami_procedure"; +import * as AuthRateLimit_ConsumeProcedure from "./auth/rateLimit/consume_procedure"; +import * as AuthRateLimit_RunSweepProcedure from "./auth/rateLimit/run_sweep_procedure"; +import * as AgentRateLimit_ConsumeProcedure from "./agentRateLimit/consume_procedure"; +import * as AgentRateLimit_RunSweepProcedure from "./agentRateLimit/run_sweep_procedure"; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + agentOverride: __table({ + name: 'agent_override', + indexes: [ + { accessor: 'agentName', name: 'agent_override_agent_name_idx_btree', algorithm: 'btree', columns: [ + 'agentName', + ] }, + ], + constraints: [ + { name: 'agent_override_agent_name_key', constraint: 'unique', columns: ['agentName'] }, + ], + }, AgentOverrideRow), + myAuthUser: __table({ + name: 'my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, MyAuthUserRow), + myFiles: __table({ + name: 'my_files', + indexes: [ + ], + constraints: [ + ], + }, MyFilesRow), + myMessageEmbeddings: __table({ + name: 'my_message_embeddings', + indexes: [ + ], + constraints: [ + ], + }, MyMessageEmbeddingsRow), + myMessages: __table({ + name: 'my_messages', + indexes: [ + ], + constraints: [ + ], + }, MyMessagesRow), + myThreadLocks: __table({ + name: 'my_thread_locks', + indexes: [ + ], + constraints: [ + ], + }, MyThreadLocksRow), + myThreads: __table({ + name: 'my_threads', + indexes: [ + ], + constraints: [ + ], + }, MyThreadsRow), + "auth.rateLimit.rate_limit_config": __table({ + name: 'auth.rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, AuthRateLimit_RateLimitConfigRow), + "agentRateLimit.rate_limit_config": __table({ + name: 'agentRateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, AgentRateLimit_RateLimitConfigRow), + "auth.my_auth_user": __table({ + name: 'auth.my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, Auth_MyAuthUserRow), + "auth.rateLimit.admin_rate_limit_buckets": __table({ + name: 'auth.rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, AuthRateLimit_AdminRateLimitBucketsRow), + "agentRateLimit.admin_rate_limit_buckets": __table({ + name: 'agentRateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, AgentRateLimit_AdminRateLimitBucketsRow), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema("add_agent_admin_identity", AddAgentAdminIdentityReducer), + __reducerSchema("clear_agent_override", ClearAgentOverrideReducer), + __reducerSchema("clear_api_key", ClearApiKeyReducer), + __reducerSchema("clear_thread_lock", ClearThreadLockReducer), + __reducerSchema("delete_thread", DeleteThreadReducer), + __reducerSchema("remove_agent_admin_identity", RemoveAgentAdminIdentityReducer), + __reducerSchema("request_cancel", RequestCancelReducer), + __reducerSchema("revoke_my_session", RevokeMySessionReducer), + __reducerSchema("revoke_session", RevokeSessionReducer), + __reducerSchema("set_agent_override", SetAgentOverrideReducer), + __reducerSchema("set_agent_secret", SetAgentSecretReducer), + __reducerSchema("set_api_key", SetApiKeyReducer), + __reducerSchema("set_auth_config", SetAuthConfigReducer), + __reducerSchema("unlink_connection", UnlinkConnectionReducer), + __reducerSchema("update_profile", UpdateProfileReducer), + __reducerSchema("update_thread", UpdateThreadReducer), + __reducerSchema("auth.link_connection", Auth_LinkConnectionReducer), + __reducerSchema("auth.revoke_my_session", Auth_RevokeMySessionReducer), + __reducerSchema("auth.revoke_session", Auth_RevokeSessionReducer), + __reducerSchema("auth.set_auth_config", Auth_SetAuthConfigReducer), + __reducerSchema("auth.unlink_connection", Auth_UnlinkConnectionReducer), + __reducerSchema("auth.update_profile", Auth_UpdateProfileReducer), + __reducerSchema("auth.rateLimit.add_rate_limit_admin", AuthRateLimit_AddRateLimitAdminReducer), + __reducerSchema("auth.rateLimit.reset_buckets", AuthRateLimit_ResetBucketsReducer), + __reducerSchema("auth.rateLimit.update_config", AuthRateLimit_UpdateConfigReducer), + __reducerSchema("agentRateLimit.add_rate_limit_admin", AgentRateLimit_AddRateLimitAdminReducer), + __reducerSchema("agentRateLimit.reset_buckets", AgentRateLimit_ResetBucketsReducer), + __reducerSchema("agentRateLimit.update_config", AgentRateLimit_UpdateConfigReducer), +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( + __procedureSchema("generate_thread_title", GenerateThreadTitleProcedure.params, GenerateThreadTitleProcedure.returnType), + __procedureSchema("get_agent_config_status", GetAgentConfigStatusProcedure.params, GetAgentConfigStatusProcedure.returnType), + __procedureSchema("get_auth_public_key", GetAuthPublicKeyProcedure.params, GetAuthPublicKeyProcedure.returnType), + __procedureSchema("link_connection", LinkConnectionProcedure.params, LinkConnectionProcedure.returnType), + __procedureSchema("list_my_sessions", ListMySessionsProcedure.params, ListMySessionsProcedure.returnType), + __procedureSchema("regenerate_response", RegenerateResponseProcedure.params, RegenerateResponseProcedure.returnType), + __procedureSchema("send_message", SendMessageProcedure.params, SendMessageProcedure.returnType), + __procedureSchema("start_thread", StartThreadProcedure.params, StartThreadProcedure.returnType), + __procedureSchema("auth.get_auth_public_key", Auth_GetAuthPublicKeyProcedure.params, Auth_GetAuthPublicKeyProcedure.returnType), + __procedureSchema("auth.list_my_sessions", Auth_ListMySessionsProcedure.params, Auth_ListMySessionsProcedure.returnType), + __procedureSchema("auth.whoami", Auth_WhoamiProcedure.params, Auth_WhoamiProcedure.returnType), + __procedureSchema("auth.rateLimit.consume", AuthRateLimit_ConsumeProcedure.params, AuthRateLimit_ConsumeProcedure.returnType), + __procedureSchema("auth.rateLimit.run_sweep", AuthRateLimit_RunSweepProcedure.params, AuthRateLimit_RunSweepProcedure.returnType), + __procedureSchema("agentRateLimit.consume", AgentRateLimit_ConsumeProcedure.params, AgentRateLimit_ConsumeProcedure.returnType), + __procedureSchema("agentRateLimit.run_sweep", AgentRateLimit_RunSweepProcedure.params, AgentRateLimit_RunSweepProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + agentOverride: __qb.agentOverride, + myAuthUser: __qb.myAuthUser, + myFiles: __qb.myFiles, + myMessageEmbeddings: __qb.myMessageEmbeddings, + myMessages: __qb.myMessages, + myThreadLocks: __qb.myThreadLocks, + myThreads: __qb.myThreads, + agentRateLimit: { + rateLimitConfig: __qb["agentRateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["agentRateLimit.admin_rate_limit_buckets"], + }, + auth: { + myAuthUser: __qb["auth.my_auth_user"], + rateLimit: { + rateLimitConfig: __qb["auth.rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["auth.rateLimit.admin_rate_limit_buckets"], + }, + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + addAgentAdminIdentity: __reducerAccessors.addAgentAdminIdentity, + clearAgentOverride: __reducerAccessors.clearAgentOverride, + clearApiKey: __reducerAccessors.clearApiKey, + clearThreadLock: __reducerAccessors.clearThreadLock, + deleteThread: __reducerAccessors.deleteThread, + removeAgentAdminIdentity: __reducerAccessors.removeAgentAdminIdentity, + requestCancel: __reducerAccessors.requestCancel, + revokeMySession: __reducerAccessors.revokeMySession, + revokeSession: __reducerAccessors.revokeSession, + setAgentOverride: __reducerAccessors.setAgentOverride, + setAgentSecret: __reducerAccessors.setAgentSecret, + setApiKey: __reducerAccessors.setApiKey, + setAuthConfig: __reducerAccessors.setAuthConfig, + unlinkConnection: __reducerAccessors.unlinkConnection, + updateProfile: __reducerAccessors.updateProfile, + updateThread: __reducerAccessors.updateThread, + agentRateLimit: { + addRateLimitAdmin: __reducerAccessors["agentRateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["agentRateLimit.resetBuckets"], + updateConfig: __reducerAccessors["agentRateLimit.updateConfig"], + }, + auth: { + linkConnection: __reducerAccessors["auth.linkConnection"], + revokeMySession: __reducerAccessors["auth.revokeMySession"], + revokeSession: __reducerAccessors["auth.revokeSession"], + setAuthConfig: __reducerAccessors["auth.setAuthConfig"], + unlinkConnection: __reducerAccessors["auth.unlinkConnection"], + updateProfile: __reducerAccessors["auth.updateProfile"], + rateLimit: { + addRateLimitAdmin: __reducerAccessors["auth.rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["auth.rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["auth.rateLimit.updateConfig"], + }, + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + generateThreadTitle: __procedureAccessors.generateThreadTitle, + getAgentConfigStatus: __procedureAccessors.getAgentConfigStatus, + getAuthPublicKey: __procedureAccessors.getAuthPublicKey, + linkConnection: __procedureAccessors.linkConnection, + listMySessions: __procedureAccessors.listMySessions, + regenerateResponse: __procedureAccessors.regenerateResponse, + sendMessage: __procedureAccessors.sendMessage, + startThread: __procedureAccessors.startThread, + agentRateLimit: { + consume: __procedureAccessors["agentRateLimit.consume"], + runSweep: __procedureAccessors["agentRateLimit.runSweep"], + }, + auth: { + getAuthPublicKey: __procedureAccessors["auth.getAuthPublicKey"], + listMySessions: __procedureAccessors["auth.listMySessions"], + whoami: __procedureAccessors["auth.whoami"], + rateLimit: { + consume: __procedureAccessors["auth.rateLimit.consume"], + runSweep: __procedureAccessors["auth.rateLimit.runSweep"], + }, + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/link_connection_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/link_connection_procedure.ts new file mode 100644 index 00000000000..9c7f85a8337 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/link_connection_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + LinkConnectionResult, +} from "./types"; + +export const params = { + sessionToken: __t.string(), +}; +export const returnType = LinkConnectionResult \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/my_auth_user_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/my_auth_user_table.ts new file mode 100644 index 00000000000..5966b094061 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + userId: __t.string().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/my_files_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/my_files_table.ts new file mode 100644 index 00000000000..90659027bc4 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/my_files_table.ts @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64(), + fileId: __t.u64().name("file_id"), + path: __t.string(), + ownerUserId: __t.string().name("owner_user_id"), + mimeType: __t.string().name("mime_type"), + size: __t.u64(), + sha256Hex: __t.string().name("sha_256_hex"), + visibility: __t.string(), + filename: __t.option(__t.string()), + messageId: __t.option(__t.u64()).name("message_id"), + threadId: __t.option(__t.u64()).name("thread_id"), + ordinal: __t.u32(), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/my_message_embeddings_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/my_message_embeddings_table.ts new file mode 100644 index 00000000000..ad69d2a6150 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/my_message_embeddings_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + messageId: __t.u64().primaryKey().name("message_id"), + threadId: __t.u64().name("thread_id"), + userId: __t.string().name("user_id"), + model: __t.string(), + vector: __t.array(__t.f32()), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/my_messages_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/my_messages_table.ts new file mode 100644 index 00000000000..88c46d4549d --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/my_messages_table.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + threadId: __t.u64().name("thread_id"), + userId: __t.string().name("user_id"), + role: __t.string(), + content: __t.string(), + toolCallsJson: __t.option(__t.string()).name("tool_calls_json"), + toolCallId: __t.option(__t.string()).name("tool_call_id"), + isError: __t.bool().name("is_error"), + promptTokens: __t.option(__t.u32()).name("prompt_tokens"), + completionTokens: __t.option(__t.u32()).name("completion_tokens"), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/my_thread_locks_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/my_thread_locks_table.ts new file mode 100644 index 00000000000..b4070961f81 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/my_thread_locks_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + threadId: __t.u64().primaryKey().name("thread_id"), + userId: __t.string().name("user_id"), + lockedAt: __t.timestamp().name("locked_at"), + cancelRequested: __t.bool().name("cancel_requested"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/my_threads_table.ts b/spacetime-agents-ts/example/src/module_bindings/app/my_threads_table.ts new file mode 100644 index 00000000000..525ad19bd94 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/my_threads_table.ts @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + userId: __t.string().name("user_id"), + agentName: __t.string().name("agent_name"), + title: __t.option(__t.string()), + systemPromptOverride: __t.option(__t.string()).name("system_prompt_override"), + modelOverride: __t.option(__t.string()).name("model_override"), + metadata: __t.option(__t.string()), + summary: __t.option(__t.string()), + summarizedThroughId: __t.option(__t.u64()).name("summarized_through_id"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-agents-ts/example/src/module_bindings/app/regenerate_response_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/regenerate_response_procedure.ts new file mode 100644 index 00000000000..398618113fb --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/regenerate_response_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + threadId: __t.u64(), +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/remove_agent_admin_identity_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/remove_agent_admin_identity_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/remove_agent_admin_identity_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + identity: __t.identity(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/request_cancel_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/request_cancel_reducer.ts new file mode 100644 index 00000000000..452ed4063c6 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/request_cancel_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + threadId: __t.u64(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/revoke_session_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/revoke_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/send_message_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/send_message_procedure.ts new file mode 100644 index 00000000000..70c7ce3d362 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/send_message_procedure.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + SendAttachment, +} from "./types"; + +export const params = { + threadId: __t.u64(), + content: __t.string(), + get attachments() { + return __t.array(SendAttachment); + }, +}; +export const returnType = __t.unit() \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/set_agent_override_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/set_agent_override_reducer.ts new file mode 100644 index 00000000000..68dbd1ce936 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/set_agent_override_reducer.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + agentName: __t.string(), + provider: __t.option(__t.string()), + model: __t.option(__t.string()), + systemPrompt: __t.option(__t.string()), + maxTurns: __t.option(__t.u32()), + maxHistoryMessages: __t.option(__t.u32()), + maxTokens: __t.option(__t.u32()), + retries: __t.option(__t.u32()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/set_agent_secret_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/set_agent_secret_reducer.ts new file mode 100644 index 00000000000..cf609ee4331 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/set_agent_secret_reducer.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + staleLockThresholdSecs: __t.option(__t.u32()), + rateLimitTokensPerWindow: __t.option(__t.u32()), + rateLimitWindowSecs: __t.option(__t.u32()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/set_api_key_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/set_api_key_reducer.ts new file mode 100644 index 00000000000..fd621323a70 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/set_api_key_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + provider: __t.string(), + key: __t.string(), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/set_auth_config_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/set_auth_config_reducer.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/start_thread_procedure.ts b/spacetime-agents-ts/example/src/module_bindings/app/start_thread_procedure.ts new file mode 100644 index 00000000000..aa89af1fa45 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/start_thread_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + agentName: __t.string(), + title: __t.option(__t.string()), + systemPromptOverride: __t.option(__t.string()), + metadata: __t.option(__t.string()), +}; +export const returnType = __t.u64() \ No newline at end of file diff --git a/spacetime-agents-ts/example/src/module_bindings/app/types.ts b/spacetime-agents-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..8806a043a06 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,215 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AgentAdminIdentity = __t.object("AgentAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type AgentAdminIdentity = __Infer; + +export const AgentAuthUser = __t.object("AgentAuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AgentAuthUser = __Infer; + +export const AgentConfigStatus = __t.object("AgentConfigStatus", { + isConfigured: __t.bool(), + staleLockThresholdSecs: __t.u32(), + rateLimitTokensPerWindow: __t.option(__t.u32()), + rateLimitWindowSecs: __t.option(__t.u32()), + get agents() { + return __t.array(AgentInfo); + }, + configuredProviders: __t.array(__t.string()), +}); +export type AgentConfigStatus = __Infer; + +export const AgentInfo = __t.object("AgentInfo", { + name: __t.string(), + defaultProvider: __t.string(), + defaultModel: __t.string(), +}); +export type AgentInfo = __Infer; + +export const AgentOverride = __t.object("AgentOverride", { + agentName: __t.string(), + provider: __t.option(__t.string()), + model: __t.option(__t.string()), + systemPrompt: __t.option(__t.string()), + maxTurns: __t.option(__t.u32()), + maxHistoryMessages: __t.option(__t.u32()), + maxTokens: __t.option(__t.u32()), + retries: __t.option(__t.u32()), + updatedAt: __t.timestamp(), +}); +export type AgentOverride = __Infer; + +export const AgentSecret = __t.object("AgentSecret", { + singleton: __t.bool(), + staleLockThresholdSecs: __t.u32(), + rateLimitTokensPerWindow: __t.option(__t.u32()), + rateLimitWindowSecs: __t.option(__t.u32()), + updatedAt: __t.timestamp(), +}); +export type AgentSecret = __Infer; + +export const ApiKey = __t.object("ApiKey", { + provider: __t.string(), + key: __t.string(), + updatedAt: __t.timestamp(), +}); +export type ApiKey = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const File = __t.object("File", { + id: __t.u64(), + fileId: __t.u64(), + path: __t.string(), + ownerUserId: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + filename: __t.option(__t.string()), + messageId: __t.option(__t.u64()), + threadId: __t.option(__t.u64()), + ordinal: __t.u32(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type File = __Infer; + +export const LinkConnectionResult = __t.object("LinkConnectionResult", { + userId: __t.string(), +}); +export type LinkConnectionResult = __Infer; + +export const Message = __t.object("Message", { + id: __t.u64(), + threadId: __t.u64(), + userId: __t.string(), + role: __t.string(), + content: __t.string(), + toolCallsJson: __t.option(__t.string()), + toolCallId: __t.option(__t.string()), + isError: __t.bool(), + promptTokens: __t.option(__t.u32()), + completionTokens: __t.option(__t.u32()), + createdAt: __t.timestamp(), +}); +export type Message = __Infer; + +export const MessageAttachment = __t.object("MessageAttachment", { + id: __t.u64(), + fileId: __t.u64(), + messageId: __t.u64(), + threadId: __t.u64(), + ownerUserId: __t.string(), + ordinal: __t.u32(), + filename: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type MessageAttachment = __Infer; + +export const MessageEmbedding = __t.object("MessageEmbedding", { + messageId: __t.u64(), + threadId: __t.u64(), + userId: __t.string(), + model: __t.string(), + vector: __t.array(__t.f32()), + createdAt: __t.timestamp(), +}); +export type MessageEmbedding = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MyFiles = __t.object("MyFiles", {}); +export type MyFiles = __Infer; + +export const MyMessageEmbeddings = __t.object("MyMessageEmbeddings", {}); +export type MyMessageEmbeddings = __Infer; + +export const MyMessages = __t.object("MyMessages", {}); +export type MyMessages = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const MyThreadLocks = __t.object("MyThreadLocks", {}); +export type MyThreadLocks = __Infer; + +export const MyThreads = __t.object("MyThreads", {}); +export type MyThreads = __Infer; + +export const SendAttachment = __t.object("SendAttachment", { + mimeType: __t.string(), + filename: __t.option(__t.string()), + bytes: __t.byteArray(), +}); +export type SendAttachment = __Infer; + +export const Thread = __t.object("Thread", { + id: __t.u64(), + userId: __t.string(), + agentName: __t.string(), + title: __t.option(__t.string()), + systemPromptOverride: __t.option(__t.string()), + modelOverride: __t.option(__t.string()), + metadata: __t.option(__t.string()), + summary: __t.option(__t.string()), + summarizedThroughId: __t.option(__t.u64()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Thread = __Infer; + +export const ThreadLock = __t.object("ThreadLock", { + threadId: __t.u64(), + userId: __t.string(), + lockedAt: __t.timestamp(), + cancelRequested: __t.bool(), +}); +export type ThreadLock = __Infer; + +export const ThreadLockSweeperTick = __t.object("ThreadLockSweeperTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type ThreadLockSweeperTick = __Infer; + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-agents-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..4c464d921f0 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/types/procedures.ts @@ -0,0 +1,34 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as GenerateThreadTitleProcedure from "../generate_thread_title_procedure"; +import * as GetAgentConfigStatusProcedure from "../get_agent_config_status_procedure"; +import * as GetAuthPublicKeyProcedure from "../get_auth_public_key_procedure"; +import * as LinkConnectionProcedure from "../link_connection_procedure"; +import * as ListMySessionsProcedure from "../list_my_sessions_procedure"; +import * as RegenerateResponseProcedure from "../regenerate_response_procedure"; +import * as SendMessageProcedure from "../send_message_procedure"; +import * as StartThreadProcedure from "../start_thread_procedure"; + +export type GenerateThreadTitleArgs = __Infer; +export type GenerateThreadTitleResult = __Infer; +export type GetAgentConfigStatusArgs = __Infer; +export type GetAgentConfigStatusResult = __Infer; +export type GetAuthPublicKeyArgs = __Infer; +export type GetAuthPublicKeyResult = __Infer; +export type LinkConnectionArgs = __Infer; +export type LinkConnectionResult = __Infer; +export type ListMySessionsArgs = __Infer; +export type ListMySessionsResult = __Infer; +export type RegenerateResponseArgs = __Infer; +export type RegenerateResponseResult = __Infer; +export type SendMessageArgs = __Infer; +export type SendMessageResult = __Infer; +export type StartThreadArgs = __Infer; +export type StartThreadResult = __Infer; + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-agents-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..d4aed5c9af0 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/types/reducers.ts @@ -0,0 +1,42 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import AddAgentAdminIdentityReducer from "../add_agent_admin_identity_reducer"; +import ClearAgentOverrideReducer from "../clear_agent_override_reducer"; +import ClearApiKeyReducer from "../clear_api_key_reducer"; +import ClearThreadLockReducer from "../clear_thread_lock_reducer"; +import DeleteThreadReducer from "../delete_thread_reducer"; +import RemoveAgentAdminIdentityReducer from "../remove_agent_admin_identity_reducer"; +import RequestCancelReducer from "../request_cancel_reducer"; +import RevokeMySessionReducer from "../revoke_my_session_reducer"; +import RevokeSessionReducer from "../revoke_session_reducer"; +import SetAgentOverrideReducer from "../set_agent_override_reducer"; +import SetAgentSecretReducer from "../set_agent_secret_reducer"; +import SetApiKeyReducer from "../set_api_key_reducer"; +import SetAuthConfigReducer from "../set_auth_config_reducer"; +import UnlinkConnectionReducer from "../unlink_connection_reducer"; +import UpdateProfileReducer from "../update_profile_reducer"; +import UpdateThreadReducer from "../update_thread_reducer"; + +export type AddAgentAdminIdentityParams = __Infer; +export type ClearAgentOverrideParams = __Infer; +export type ClearApiKeyParams = __Infer; +export type ClearThreadLockParams = __Infer; +export type DeleteThreadParams = __Infer; +export type RemoveAgentAdminIdentityParams = __Infer; +export type RequestCancelParams = __Infer; +export type RevokeMySessionParams = __Infer; +export type RevokeSessionParams = __Infer; +export type SetAgentOverrideParams = __Infer; +export type SetAgentSecretParams = __Infer; +export type SetApiKeyParams = __Infer; +export type SetAuthConfigParams = __Infer; +export type UnlinkConnectionParams = __Infer; +export type UpdateProfileParams = __Infer; +export type UpdateThreadParams = __Infer; + diff --git a/spacetime-agents-ts/example/src/module_bindings/app/unlink_connection_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/update_profile_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-agents-ts/example/src/module_bindings/app/update_thread_reducer.ts b/spacetime-agents-ts/example/src/module_bindings/app/update_thread_reducer.ts new file mode 100644 index 00000000000..e00528bdcc9 --- /dev/null +++ b/spacetime-agents-ts/example/src/module_bindings/app/update_thread_reducer.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + threadId: __t.u64(), + title: __t.option(__t.string()), + systemPromptOverride: __t.option(__t.string()), + modelOverride: __t.option(__t.string()), + metadata: __t.option(__t.string()), + clearTitle: __t.bool(), + clearSystemPromptOverride: __t.bool(), + clearModelOverride: __t.bool(), + clearMetadata: __t.bool(), +}; diff --git a/spacetime-agents-ts/example/tsconfig.json b/spacetime-agents-ts/example/tsconfig.json new file mode 100644 index 00000000000..419028c7af7 --- /dev/null +++ b/spacetime-agents-ts/example/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "server.ts", "scripts/**/*.ts"], + "exclude": ["node_modules", "spacetimedb"] +} diff --git a/spacetime-agents-ts/package.json b/spacetime-agents-ts/package.json new file mode 100644 index 00000000000..2358b75aac1 --- /dev/null +++ b/spacetime-agents-ts/package.json @@ -0,0 +1,78 @@ +{ + "name": "@spacetimedb/agents", + "description": "Reusable agent orchestration, tool calling, and provider adapters for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./openrouter": { + "types": "./src/openrouter.ts", + "default": "./src/openrouter.ts" + }, + "./providers": { + "types": "./src/providers.ts", + "default": "./src/providers.ts" + }, + "./embeddings": { + "types": "./src/embeddings.ts", + "default": "./src/embeddings.ts" + }, + "./stale-locks": { + "types": "./src/stale-locks.ts", + "default": "./src/stale-locks.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-agents-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-agents-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "agents", + "llm", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "pnpm run test:unit", + "test:unit": "tsx scripts/test.ts" + }, + "dependencies": {}, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^22.10.2", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-agents-ts/scripts/test.ts b/spacetime-agents-ts/scripts/test.ts new file mode 100644 index 00000000000..cd9553d8e51 --- /dev/null +++ b/spacetime-agents-ts/scripts/test.ts @@ -0,0 +1,1199 @@ +// Pure-Node tests for the Agents package. +// Avoids importing 'spacetimedb/server' (Node 22 ESM can't parse its `using` decls); +// builds minimal AlgebraicType fixtures matching what t.object(...) would produce. + +import { + agentTool, + makeAgentDispatch, + defineAgent, + makeAgentRegistry, + typeBuilderToJsonSchema, +} from '../src/agent.ts'; +import { + openRouterProvider, + openAiProvider, + anthropicProvider, +} from '../src/providers.ts'; +import { + callChat, + isRetryableError, + type HttpLike, + type ChatRequest, + type ToolDefinition, +} from '../src/openrouter.ts'; +import { + cosineSimilarity, + topKByScore, + openAiEmbeddingsProvider, + openRouterEmbeddingsProvider, +} from '../src/embeddings.ts'; +import { + deleteStaleThreadLocks, + staleLockCutoffMicros, +} from '../src/stale-locks.ts'; + +type AT = { tag: string; value?: unknown }; + +import type { AlgebraicType } from 'spacetimedb'; +import type { TypeBuilder } from 'spacetimedb/server'; + +const fake = (at: AT): TypeBuilder => + ({ algebraicType: at }) as unknown as TypeBuilder; + +const _bool = (): AT => ({ tag: 'Bool' }); +const _string = (): AT => ({ tag: 'String' }); +const _i8 = (): AT => ({ tag: 'I8' }); +const _u8 = (): AT => ({ tag: 'U8' }); +const _i16 = (): AT => ({ tag: 'I16' }); +const _u16 = (): AT => ({ tag: 'U16' }); +const _i32 = (): AT => ({ tag: 'I32' }); +const _u32 = (): AT => ({ tag: 'U32' }); +const _i64 = (): AT => ({ tag: 'I64' }); +const _u64 = (): AT => ({ tag: 'U64' }); +const _u128 = (): AT => ({ tag: 'U128' }); +const _f64 = (): AT => ({ tag: 'F64' }); +const _array = (e: AT): AT => ({ tag: 'Array', value: e }); +const _object = (props: Record): AT => ({ + tag: 'Product', + value: { + elements: Object.entries(props).map(([name, at]) => ({ + name, + algebraicType: at, + })), + }, +}); +const _unit = (): AT => ({ tag: 'Product', value: { elements: [] } }); +const _option = (inner: AT): AT => ({ + tag: 'Sum', + value: { + variants: [ + { name: 'some', algebraicType: inner }, + { name: 'none', algebraicType: _unit() }, + ], + }, +}); + +let failures = 0; +function assert(cond: boolean, msg: string): void { + if (!cond) { + process.stderr.write(` FAIL: ${msg}\n`); + failures++; + } else { + process.stdout.write(` ${msg} OK\n`); + } +} + +process.stdout.write('\nstale lock sweep tests\n'); + +{ + const nowMicros = 20_000n; + const cutoffMicros = staleLockCutoffMicros(nowMicros, 1_000n); + const locks = Array.from({ length: 600 }, (_, id) => ({ + id, + lockedAt: { microsSinceUnixEpoch: nowMicros }, + })); + locks.push({ id: 600, lockedAt: { microsSinceUnixEpoch: 1_000n } }); + + const expiredIndexRows = locks + .filter(lock => lock.lockedAt.microsSinceUnixEpoch < cutoffMicros) + .sort((a, b) => + a.lockedAt.microsSinceUnixEpoch < b.lockedAt.microsSinceUnixEpoch ? -1 : 1 + ); + const deleted = new Set(); + const count = deleteStaleThreadLocks(expiredIndexRows, cutoffMicros, lock => + deleted.add(lock.id) + ); + + assert(count === 1, 'sweep reaches an expired lock after 600 fresh inserts'); + assert(deleted.has(600), 'sweep deletes the expired lock'); + assert( + [...deleted].every(id => id === 600), + 'sweep preserves every fresh lock' + ); +} + +const eq = (a: unknown, b: unknown): boolean => + JSON.stringify(a) === JSON.stringify(b); + +process.stdout.write('typeBuilderToJsonSchema tests\n'); + +// 1. unit -> empty object schema +{ + const schema = typeBuilderToJsonSchema(fake(_unit())); + assert( + eq(schema, { type: 'object', properties: {} }), + `unit() -> empty object` + ); +} + +// 2. object with required primitives +{ + const tb = fake( + _object({ + name: _string(), + count: _i32(), + ratio: _f64(), + on: _bool(), + }) + ); + const schema = typeBuilderToJsonSchema(tb); + assert( + eq(schema, { + type: 'object', + properties: { + name: { type: 'string' }, + count: { type: 'integer' }, + ratio: { type: 'number' }, + on: { type: 'boolean' }, + }, + required: ['name', 'count', 'ratio', 'on'], + }), + `object with primitives -> all required` + ); +} + +// 3. option fields excluded from required, unwrapped to inner schema +{ + const tb = fake( + _object({ + must: _string(), + maybe: _option(_string()), + }) + ); + const schema = typeBuilderToJsonSchema(tb); + assert( + schema.required !== undefined && + schema.required.includes('must') && + !schema.required.includes('maybe'), + `option fields excluded from required` + ); + assert( + eq(schema.properties.maybe, { type: 'string' }), + `option unwraps to plain string schema` + ); +} + +// 4. nested object +{ + const tb = fake( + _object({ + inner: _object({ a: _i64() }), + }) + ); + const schema = typeBuilderToJsonSchema(tb); + assert( + eq(schema.properties.inner, { + type: 'object', + properties: { a: { type: 'integer' } }, + required: ['a'], + }), + `nested object inlined recursively` + ); +} + +// 5. array of strings +{ + const tb = fake(_object({ tags: _array(_string()) })); + const schema = typeBuilderToJsonSchema(tb); + assert( + eq(schema.properties.tags, { type: 'array', items: { type: 'string' } }), + `array -> {type:array, items:{type:string}}` + ); +} + +// 6. all i*/u* through 64 bits roll up to integer +{ + const tb = fake( + _object({ + a: _i8(), + b: _u8(), + c: _i16(), + d: _u16(), + e: _i32(), + f: _u32(), + g: _i64(), + h: _u64(), + }) + ); + const schema = typeBuilderToJsonSchema(tb); + const allInt = Object.values(schema.properties).every( + v => + typeof v === 'object' && + v !== null && + (v as { type?: unknown }).type === 'integer' + ); + assert(allInt, `all i*/u* up to 64 bits -> integer`); +} + +// 7. 128/256-bit integers throw (not JSON-representable) +{ + let threw = false; + try { + typeBuilderToJsonSchema(fake(_object({ x: _u128() }))); + } catch (err) { + threw = err instanceof Error && err.message.includes('U128'); + } + assert(threw, `u128 field rejected with helpful error`); +} + +// 8. non-product top-level rejected +{ + let threw = false; + try { + typeBuilderToJsonSchema(fake(_string())); + } catch (err) { + threw = err instanceof Error && err.message.includes('object'); + } + assert(threw, `top-level non-object rejected`); +} + +// 9. true sum (non-option) -> oneOf +{ + const sumAt: AT = { + tag: 'Sum', + value: { + variants: [ + { name: 'a', algebraicType: _string() }, + { name: 'b', algebraicType: _i32() }, + { name: 'c', algebraicType: _bool() }, + ], + }, + }; + const tb = fake(_object({ kind: sumAt })); + const schema = typeBuilderToJsonSchema(tb); + const kind = schema.properties.kind as { oneOf?: unknown[] }; + const oneOf = Array.isArray(kind.oneOf) ? kind.oneOf : []; + assert(oneOf.length === 3, `3-variant sum -> oneOf with 3 entries`); + assert( + eq(oneOf[0], { + type: 'object', + properties: { + tag: { type: 'string', enum: ['a'] }, + value: { type: 'string' }, + }, + required: ['tag'], + }), + `sum variant shape: {tag, value}` + ); +} + +process.stdout.write('\nagentTool + makeAgentDispatch tests\n'); + +// 10. agentTool retains TypeBuilder algebraicType +{ + const tool = agentTool( + 'echoes the message back', + fake<{ msg: string }>(_object({ msg: _string() })), + (_ctx, args) => `echo: ${args.msg}` + ); + const at = tool.algebraicType; + assert(at?.tag === 'Product', `agentTool retains algebraicType`); +} + +// 11. dispatch builds llmToolDefs in expected shape + invoke paths +{ + const echo = agentTool( + 'echoes the message back', + fake<{ msg: string }>(_object({ msg: _string() })), + (_ctx, args) => `echo: ${args.msg}` + ); + const noop = agentTool( + 'does nothing, takes no args', + fake(_unit()), + _ctx => 'ok' + ); + const { llmToolDefs, invoke } = makeAgentDispatch< + unknown, + { echo: typeof echo; noop: typeof noop } + >({ echo, noop }); + + assert(llmToolDefs.length === 2, `2 tool defs emitted`); + assert( + llmToolDefs[0].function.name === 'echo' && + llmToolDefs[0].function.description === 'echoes the message back', + `echo tool def name + description` + ); + assert( + eq(llmToolDefs[0].function.parameters, { + type: 'object', + properties: { msg: { type: 'string' } }, + required: ['msg'], + }), + `echo tool def parameters schema` + ); + assert( + eq(llmToolDefs[1].function.parameters, { type: 'object', properties: {} }), + `unit-arg tool def has empty properties` + ); + + const r1 = invoke({}, 'echo', JSON.stringify({ msg: 'hi' })); + assert(r1.isError === false && r1.result === 'echo: hi', `invoke echo OK`); + + const r2 = invoke({}, 'noop', ''); + assert( + r2.isError === false && r2.result === 'ok', + `invoke noop with empty input string` + ); + + const r3 = invoke({}, 'nope', '{}'); + assert( + r3.isError === true && r3.result.includes('unknown tool'), + `unknown tool returns isError` + ); + + const r4 = invoke({}, 'echo', '{not json'); + assert( + r4.isError === true && r4.result.includes('invalid JSON'), + `bad JSON returns isError` + ); + + const boom = agentTool('throws', fake(_unit()), _ctx => { + throw new Error('kaboom'); + }); + const d2 = makeAgentDispatch({ boom }); + const r5 = d2.invoke({}, 'boom', ''); + assert( + r5.isError === true && r5.result === 'kaboom', + `tool throw becomes isError result` + ); + + const missing = invoke({}, 'echo', '{}'); + assert( + missing.isError === true && missing.result.includes('msg is required'), + `missing required field rejected before handler` + ); + const wrongType = invoke({}, 'echo', '{"msg":42}'); + assert( + wrongType.isError === true && wrongType.result.includes('must be a string'), + `wrong field type rejected before handler` + ); + const unknown = invoke({}, 'echo', '{"msg":"hi","extra":true}'); + assert( + unknown.isError === true && unknown.result.includes('extra is not allowed'), + `unknown field rejected before handler` + ); + const oversized = invoke( + {}, + 'echo', + JSON.stringify({ msg: 'x'.repeat(70_000) }) + ); + assert( + oversized.isError === true && oversized.result.includes('exceeds'), + `oversized tool input rejected before parsing` + ); +} + +// 12. invalid tool name rejected at dispatch construction +{ + const bad = agentTool('x', fake(_unit()), () => 'ok'); + let threw = false; + try { + makeAgentDispatch>({ + 'has spaces': bad, + }); + } catch (err) { + threw = err instanceof Error && err.message.includes('must match'); + } + assert(threw, `invalid tool name 'has spaces' rejected`); +} + +// 13. valid tool names accepted (a-z, A-Z, 0-9, _, -) +{ + const ok = agentTool('x', fake(_unit()), () => 'ok'); + let threw = false; + try { + makeAgentDispatch>({ + send_message: ok, + 'get-time': ok, + tool42: ok, + }); + } catch { + threw = true; + } + assert(!threw, `valid tool names accepted`); +} + +// 14. Prototype-key lookup reported as 'unknown tool', not dispatched. +{ + const ok = agentTool('x', fake(_unit()), () => 'ok'); + const { invoke } = makeAgentDispatch>({ + real: ok, + }); + for (const name of [ + 'toString', + 'constructor', + '__proto__', + 'hasOwnProperty', + ]) { + const r = invoke({}, name, '{}'); + assert( + r.isError === true && r.result === `unknown tool: ${name}`, + `prototype-key '${name}' rejected as unknown tool` + ); + } +} + +process.stdout.write('\ndefineAgent + makeAgentRegistry tests\n'); + +// 15. defineAgent fills in defaults for omitted optional fields +{ + const echo = agentTool( + 'echo back', + fake<{ message: string }>(_object({ message: _string() })), + (_ctx, args) => `echo: ${args.message}` + ); + const a = defineAgent({ + defaultModel: 'm/x', + tools: { echo }, + }); + assert(a.defaultModel === 'm/x', `defineAgent: defaultModel set`); + assert( + a.defaultMaxTurns === 10, + `defineAgent: defaultMaxTurns falls back to 10` + ); + assert( + a.defaultMaxHistoryMessages === 50, + `defineAgent: defaultMaxHistoryMessages falls back to 50` + ); + assert(a.defaultRetries === 2, `defineAgent: defaultRetries falls back to 2`); + assert( + a.defaultSystemPrompt === undefined, + `defineAgent: systemPrompt remains undefined when omitted` + ); + assert( + a.defaultMaxTokens === undefined, + `defineAgent: maxTokens remains undefined when omitted` + ); + assert( + a.defaultResponseFormat === undefined, + `defineAgent: responseFormat remains undefined when omitted` + ); +} + +// 15b. Invalid agent limits fail during definition. +{ + let threw = false; + try { + defineAgent({ defaultModel: 'm/x', defaultMaxTurns: 0, tools: {} }); + } catch (err) { + threw = err instanceof Error && err.message.includes('defaultMaxTurns'); + } + assert(threw, `defineAgent rejects an invalid turn limit`); +} + +// 16. makeAgentRegistry exposes names() / has() / agentDef() +{ + const echo = agentTool( + 'echo back', + fake<{ message: string }>(_object({ message: _string() })), + (_ctx, args) => `echo: ${args.message}` + ); + const noop = agentTool('does nothing', fake(_unit()), _ctx => 'ok'); + const chat = defineAgent({ defaultModel: 'm/chat', tools: { echo, noop } }); + const summary = defineAgent({ + defaultModel: 'm/summary', + defaultMaxTurns: 1, + defaultResponseFormat: { type: 'json_object' }, + tools: {}, + }); + const reg = makeAgentRegistry< + unknown, + { chat: typeof chat; summary: typeof summary } + >({ chat, summary }); + + assert( + eq(reg.names().sort(), ['chat', 'summary']), + `registry: names() returns all agent names` + ); + assert(reg.has('chat') === true, `registry: has('chat') = true`); + assert( + reg.has('does-not-exist') === false, + `registry: has('does-not-exist') = false` + ); + assert( + reg.agentDef('chat')?.defaultModel === 'm/chat', + `registry: agentDef returns the right def` + ); + assert( + reg.agentDef('summary')?.defaultResponseFormat !== undefined, + `registry: per-agent responseFormat preserved` + ); +} + +// 17. Registry routes tool dispatch by agent name +{ + const echo = agentTool( + 'echo', + fake<{ msg: string }>(_object({ msg: _string() })), + (_ctx, args) => `chat-echo: ${args.msg}` + ); + const otherEcho = agentTool( + 'echo', + fake<{ msg: string }>(_object({ msg: _string() })), + (_ctx, args) => `summary-echo: ${args.msg}` + ); + const chat = defineAgent({ defaultModel: 'm/chat', tools: { echo } }); + const summary = defineAgent({ + defaultModel: 'm/sum', + tools: { echo: otherEcho }, + }); + const reg = makeAgentRegistry< + unknown, + { chat: typeof chat; summary: typeof summary } + >({ chat, summary }); + + const r1 = reg.invoke('chat', {}, 'echo', JSON.stringify({ msg: 'hi' })); + assert( + r1.isError === false && r1.result === 'chat-echo: hi', + `registry: invoke('chat', echo) hits chat's tool` + ); + const r2 = reg.invoke('summary', {}, 'echo', JSON.stringify({ msg: 'hi' })); + assert( + r2.isError === false && r2.result === 'summary-echo: hi', + `registry: invoke('summary', echo) hits summary's tool (same name, different agent)` + ); + const r3 = reg.invoke('does-not-exist', {}, 'echo', '{}'); + assert( + r3.isError === true && r3.result.includes('unknown agent'), + `registry: invoke on unknown agent returns isError` + ); + const r4 = reg.invoke('summary', {}, 'echo_typo', '{}'); + assert( + r4.isError === true && r4.result.includes('unknown tool'), + `registry: invoke with unknown tool name in valid agent returns isError` + ); +} + +// 18. Per-agent llmToolDefs differs by agent +{ + const echo = agentTool( + 'echo', + fake<{ msg: string }>(_object({ msg: _string() })), + (_ctx, args) => `e: ${args.msg}` + ); + const chat = defineAgent({ defaultModel: 'm/chat', tools: { echo } }); + const summary = defineAgent({ defaultModel: 'm/sum', tools: {} }); + const reg = makeAgentRegistry< + unknown, + { chat: typeof chat; summary: typeof summary } + >({ chat, summary }); + + assert(reg.llmToolDefsFor('chat').length === 1, `tool-defs: chat has 1 tool`); + assert( + reg.llmToolDefsFor('summary').length === 0, + `tool-defs: summary has 0 tools` + ); + assert( + reg.llmToolDefsFor('does-not-exist').length === 0, + `tool-defs: unknown agent returns empty list` + ); +} + +// 19. Invalid agent name rejected at registry construction +{ + const a = defineAgent({ defaultModel: 'm/x', tools: {} }); + let threw = false; + try { + makeAgentRegistry>({ 'has spaces': a }); + } catch (err) { + threw = err instanceof Error && err.message.includes('must match'); + } + assert(threw, `agent-name: invalid name rejected`); +} + +// Provider adapters + +process.stdout.write('\nprovider adapter tests\n'); + +const sampleTools: ToolDefinition[] = [ + { + type: 'function', + function: { + name: 'echo', + description: 'echo back', + parameters: { + type: 'object', + properties: { msg: { type: 'string' } }, + required: ['msg'], + }, + }, + }, +]; + +const baseReq: ChatRequest = { + apiKey: 'sk-test', + model: 'some/model', + system: 'you are helpful', + messages: [{ role: 'user', content: 'hi' }], + tools: sampleTools, + maxTokens: 256, +}; + +// 20. openRouterProvider builds OpenAI-shape body, posts to OpenRouter URL. +{ + const { url, headers, body } = openRouterProvider.buildRequest(baseReq); + assert( + url === 'https://openrouter.ai/api/v1/chat/completions', + `openrouter: OpenRouter URL` + ); + assert( + headers['Authorization'] === 'Bearer sk-test', + `openrouter: Bearer auth` + ); + const b = JSON.parse(body); + assert(b.model === 'some/model', `openrouter: model carried`); + assert( + b.messages[0].role === 'system' && + b.messages[0].content === 'you are helpful', + `openrouter: system prepended as first message` + ); + assert(b.messages[1].role === 'user', `openrouter: user follows system`); + assert( + Array.isArray(b.tools) && b.tools[0].function.name === 'echo', + `openrouter: tools in OpenAI shape` + ); + assert(b.tool_choice === 'auto', `openrouter: tool_choice='auto'`); + assert(b.max_tokens === 256, `openrouter: max_tokens carried`); +} + +// 21. openAiProvider differs only in URL (same wire format). +{ + const { url, headers } = openAiProvider.buildRequest(baseReq); + assert( + url === 'https://api.openai.com/v1/chat/completions', + `openai: native OpenAI URL` + ); + assert(headers['Authorization'] === 'Bearer sk-test', `openai: Bearer auth`); +} + +// 22. anthropicProvider translates: system separate, tools renamed, max_tokens required. +{ + const { url, headers, body } = anthropicProvider.buildRequest(baseReq); + assert( + url === 'https://api.anthropic.com/v1/messages', + `anthropic: messages URL` + ); + assert(headers['x-api-key'] === 'sk-test', `anthropic: x-api-key auth`); + assert( + headers['anthropic-version'] === '2023-06-01', + `anthropic: version header` + ); + const b = JSON.parse(body); + assert( + b.system === 'you are helpful', + `anthropic: system as top-level field` + ); + assert( + b.messages.length === 1 && b.messages[0].role === 'user', + `anthropic: system NOT in messages array` + ); + assert(b.max_tokens === 256, `anthropic: max_tokens carried`); + assert( + b.tools[0].name === 'echo' && b.tools[0].input_schema !== undefined, + `anthropic: tools renamed (function.name -> name, parameters -> input_schema)` + ); + assert( + eq(b.tool_choice, { type: 'auto' }), + `anthropic: tool_choice is object {type:'auto'}` + ); +} + +// 23. anthropicProvider: tool_calls in assistant messages become content blocks. +{ + const req: ChatRequest = { + apiKey: 'sk-test', + model: 'claude-3-5-sonnet', + messages: [ + { role: 'user', content: 'do it' }, + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'tc_1', + type: 'function', + function: { name: 'echo', arguments: '{"msg":"hi"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'tc_1', content: 'echoed' }, + ], + }; + const { body } = anthropicProvider.buildRequest(req); + const b = JSON.parse(body); + assert(b.messages.length === 3, `anthropic: 3 messages`); + const asst = b.messages[1]; + assert( + asst.role === 'assistant' && Array.isArray(asst.content), + `anthropic: assistant content is content-block array` + ); + assert( + asst.content.some( + (block: unknown) => + typeof block === 'object' && + block !== null && + (block as { type?: unknown }).type === 'tool_use' && + (block as { id?: unknown }).id === 'tc_1' + ), + `anthropic: tool_call -> tool_use block` + ); + const tr = b.messages[2]; + assert( + tr.role === 'user' && + tr.content[0].type === 'tool_result' && + tr.content[0].tool_use_id === 'tc_1', + `anthropic: tool result wrapped in user/tool_result block` + ); +} + +// 24. anthropicProvider: default max_tokens when caller omits. +{ + const req: ChatRequest = { + apiKey: 'k', + model: 'm', + messages: [{ role: 'user', content: 'x' }], + }; + const { body } = anthropicProvider.buildRequest(req); + assert( + JSON.parse(body).max_tokens > 0, + `anthropic: max_tokens always present` + ); +} + +// 25. parseResponse: OpenAI shape. +{ + const r = openRouterProvider.parseResponse( + JSON.stringify({ + model: 'gpt-4', + choices: [ + { + finish_reason: 'stop', + message: { content: 'hello back' }, + }, + ], + usage: { prompt_tokens: 12, completion_tokens: 5, total_tokens: 17 }, + }), + 'some/model' + ); + assert( + r.text === 'hello back' && r.finishReason === 'stop', + `parse-openai: text + finishReason` + ); + assert( + r.usage.promptTokens === 12 && r.usage.completionTokens === 5, + `parse-openai: usage` + ); + assert(r.toolCalls.length === 0, `parse-openai: no tool calls`); +} + +// 26. parseResponse: OpenAI tool calls. +{ + const r = openRouterProvider.parseResponse( + JSON.stringify({ + model: 'gpt-4', + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: null, + tool_calls: [ + { + id: 'tc_a', + type: 'function', + function: { name: 'echo', arguments: '{"msg":"hi"}' }, + }, + ], + }, + }, + ], + usage: {}, + }), + 'some/model' + ); + assert( + r.text === null && + r.toolCalls.length === 1 && + r.toolCalls[0].function.name === 'echo', + `parse-openai: tool_calls extracted` + ); + assert(r.finishReason === 'tool_calls', `parse-openai: tool_calls finish`); +} + +// 27. parseResponse: Anthropic content blocks + stop_reason mapping. +{ + const r = anthropicProvider.parseResponse( + JSON.stringify({ + model: 'claude-3-5-sonnet', + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'hello back' }], + usage: { input_tokens: 12, output_tokens: 5 }, + }), + 'claude-3-5-sonnet' + ); + assert(r.text === 'hello back', `parse-anthropic: text extracted`); + assert(r.finishReason === 'stop', `parse-anthropic: end_turn -> stop`); + assert( + r.usage.promptTokens === 12 && r.usage.completionTokens === 5, + `parse-anthropic: usage mapped (input_tokens -> promptTokens)` + ); + assert(r.usage.totalTokens === 17, `parse-anthropic: total computed`); +} + +// 27b. Malformed provider usage cannot produce NaN or negative totals. +{ + const openAi = openAiProvider.parseResponse( + JSON.stringify({ + choices: [{ finish_reason: 'stop', message: { content: 'ok' } }], + usage: { + prompt_tokens: 'oops', + completion_tokens: -5, + total_tokens: null, + }, + }), + 'm' + ); + assert( + eq(openAi.usage, { promptTokens: 0, completionTokens: 0, totalTokens: 0 }), + `parse-openai: malformed usage normalizes to zero` + ); +} + +// 28. parseResponse: Anthropic tool_use blocks -> normalized tool calls. +{ + const r = anthropicProvider.parseResponse( + JSON.stringify({ + model: 'claude-3-5-sonnet', + stop_reason: 'tool_use', + content: [ + { type: 'text', text: 'using tool' }, + { type: 'tool_use', id: 'tu_1', name: 'echo', input: { msg: 'hi' } }, + ], + usage: {}, + }), + 'claude-3-5-sonnet' + ); + assert(r.text === 'using tool', `parse-anthropic: text from text block`); + assert( + r.finishReason === 'tool_calls', + `parse-anthropic: tool_use -> tool_calls` + ); + assert( + r.toolCalls.length === 1 && r.toolCalls[0].function.name === 'echo', + `parse-anthropic: tool_use -> toolCalls` + ); + assert( + r.toolCalls[0].function.arguments === '{"msg":"hi"}', + `parse-anthropic: input -> JSON-stringified arguments` + ); +} + +// 29. callChat dispatches through chosen provider. +{ + const http: HttpLike = { + fetch(url, init) { + // Verify Anthropic URL came through. + assert( + url === 'https://api.anthropic.com/v1/messages', + `callChat: routes to provider URL` + ); + assert( + (init.headers as Record)['x-api-key'] === 'sk-test', + `callChat: provider headers` + ); + return { + status: 200, + text: () => + JSON.stringify({ + model: 'claude-3-5-sonnet', + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'ok' }], + usage: { input_tokens: 1, output_tokens: 1 }, + }), + }; + }, + }; + const result = callChat(http, anthropicProvider, { + apiKey: 'sk-test', + model: 'claude-3-5-sonnet', + messages: [{ role: 'user', content: 'hi' }], + }); + assert( + result.ok && result.response.text === 'ok', + `callChat: returns parsed response from chosen provider` + ); +} + +assert( + isRetryableError({ kind: 'http', status: 500, body: '' }), + 'callChat: retries all 5xx responses' +); +assert( + !isRetryableError({ kind: 'http', status: 400, body: '' }), + 'callChat: does not retry 4xx responses other than 429' +); + +// 30. defineAgent: provider defaults to 'openrouter'. +{ + const a = defineAgent({ defaultModel: 'm', tools: {} }); + assert( + a.defaultProvider === 'openrouter', + `defineAgent: default provider is openrouter` + ); +} + +// 31. defineAgent: explicit provider preserved. +{ + const a = defineAgent({ + defaultProvider: 'anthropic', + defaultModel: 'm', + tools: {}, + }); + assert( + a.defaultProvider === 'anthropic', + `defineAgent: explicit provider preserved` + ); +} + +// Embeddings + RAG helpers + +process.stdout.write('\nembeddings + RAG tests\n'); + +// 32. Cosine: identical vectors -> 1.0 +{ + const v = [1, 2, 3]; + assert(Math.abs(cosineSimilarity(v, v) - 1) < 1e-9, `cosine: identical -> 1`); +} + +// 33. Cosine: orthogonal -> 0 +{ + assert( + Math.abs(cosineSimilarity([1, 0], [0, 1])) < 1e-9, + `cosine: orthogonal -> 0` + ); +} + +// 34. Cosine: anti-parallel -> -1 +{ + assert( + Math.abs(cosineSimilarity([1, 2, 3], [-1, -2, -3]) - -1) < 1e-9, + `cosine: anti-parallel -> -1` + ); +} + +// 35. Cosine: zero vector -> 0 (no NaN) +{ + assert( + cosineSimilarity([0, 0, 0], [1, 2, 3]) === 0, + `cosine: zero vector returns 0` + ); +} + +// 36. Cosine: length mismatch -> 0 +{ + assert( + cosineSimilarity([1, 2], [1, 2, 3]) === 0, + `cosine: length mismatch -> 0` + ); +} + +// 37. topKByScore picks the highest-scoring items in descending order +{ + const items = ['a', 'b', 'c', 'd']; + const scores: Record = { a: 0.1, b: 0.9, c: 0.5, d: 0.7 }; + const top = topKByScore(items, x => scores[x], 2); + assert(top.length === 2, `topK: count`); + assert(top[0].item === 'b' && top[1].item === 'd', `topK: descending`); + assert(top[0].score === 0.9, `topK: score carried`); +} + +// 38. topKByScore k=0 returns empty +{ + assert(topKByScore([1, 2, 3], x => x, 0).length === 0, `topK: k=0 -> empty`); +} + +// 39. topKByScore k > length returns all +{ + const out = topKByScore(['x', 'y'], () => 1, 10); + assert(out.length === 2, `topK: k > length returns all`); +} + +// 40. openAiEmbeddingsProvider builds correct request + parses response. +{ + let captured: { url?: string; body?: unknown } = {}; + const http: HttpLike = { + fetch(url, init) { + captured = { url, body: JSON.parse(init.body!) }; + return { + status: 200, + text: () => + JSON.stringify({ + model: 'text-embedding-3-small', + data: [ + { embedding: [0.1, 0.2, 0.3] }, + { embedding: [0.4, 0.5, 0.6] }, + ], + usage: { prompt_tokens: 8, total_tokens: 8 }, + }), + }; + }, + }; + const r = openAiEmbeddingsProvider.embed( + http, + 'sk-test', + 'text-embedding-3-small', + ['hello', 'world'] + ); + const capturedBody = captured.body as Record; + assert( + captured.url === 'https://api.openai.com/v1/embeddings', + `embeddings: openai URL` + ); + assert( + capturedBody.model === 'text-embedding-3-small', + `embeddings: model carried` + ); + assert( + eq(capturedBody.input, ['hello', 'world']), + `embeddings: inputs array` + ); + assert( + r.ok && r.vectors.length === 2 && eq(r.vectors[0], [0.1, 0.2, 0.3]), + `embeddings: vectors parsed in order` + ); + assert(r.ok && r.usage.promptTokens === 8, `embeddings: usage parsed`); +} + +// 41. openRouterEmbeddingsProvider hits OpenRouter URL. +{ + let capturedUrl = ''; + const http: HttpLike = { + fetch(url) { + capturedUrl = url; + return { + status: 200, + text: () => + JSON.stringify({ + model: 'm', + data: [{ embedding: [1, 2] }], + usage: {}, + }), + }; + }, + }; + openRouterEmbeddingsProvider.embed(http, 'k', 'm', ['t']); + assert( + capturedUrl === 'https://openrouter.ai/api/v1/embeddings', + `embeddings: openrouter URL` + ); +} + +// Multimodal content blocks + +// 42a. openRouter/openAi serialize image attachments as image_url data URIs. +{ + const req: ChatRequest = { + apiKey: 'k', + model: 'gpt-4o', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'caption this' }, + { type: 'image', mimeType: 'image/png', data: 'BASE64DATA' }, + ], + }, + ], + }; + const { body } = openAiProvider.buildRequest(req); + const b = JSON.parse(body); + assert( + Array.isArray(b.messages[0].content), + `multimodal-openai: content is array` + ); + assert( + b.messages[0].content[0].type === 'text' && + b.messages[0].content[0].text === 'caption this', + `multimodal-openai: text block` + ); + assert( + b.messages[0].content[1].type === 'image_url', + `multimodal-openai: image becomes image_url block` + ); + assert( + b.messages[0].content[1].image_url.url === + 'data:image/png;base64,BASE64DATA', + `multimodal-openai: data URI built correctly` + ); +} + +// 42b. Anthropic serializes image attachments as { type:'image', source:{base64,media_type,data} }. +{ + const req: ChatRequest = { + apiKey: 'k', + model: 'claude-3-5-sonnet', + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'caption this' }, + { type: 'image', mimeType: 'image/jpeg', data: 'BASE64DATA' }, + ], + }, + ], + }; + const { body } = anthropicProvider.buildRequest(req); + const b = JSON.parse(body); + assert( + Array.isArray(b.messages[0].content), + `multimodal-anthropic: content is array` + ); + assert( + b.messages[0].content[1].type === 'image', + `multimodal-anthropic: image block type='image'` + ); + assert( + eq(b.messages[0].content[1].source, { + type: 'base64', + media_type: 'image/jpeg', + data: 'BASE64DATA', + }), + `multimodal-anthropic: source structured correctly` + ); +} + +// 42c. String content unchanged through providers (backward compat). +{ + const req: ChatRequest = { + apiKey: 'k', + model: 'm', + messages: [{ role: 'user', content: 'plain text' }], + }; + const oa = JSON.parse(openAiProvider.buildRequest(req).body); + const an = JSON.parse(anthropicProvider.buildRequest(req).body); + assert( + oa.messages[0].content === 'plain text', + `compat: openai keeps string content` + ); + assert( + an.messages[0].content === 'plain text', + `compat: anthropic keeps string content` + ); +} + +// 42. Embedding HTTP errors return parseable error shape. +{ + const http: HttpLike = { + fetch: () => ({ status: 401, text: () => '{"error":"unauthorized"}' }), + }; + const r = openAiEmbeddingsProvider.embed(http, 'bad', 'm', ['x']); + assert( + !r.ok && r.error.kind === 'http' && r.error.status === 401, + `embeddings: http error parsed` + ); +} + +if (failures > 0) { + process.stderr.write(`\n${failures} test(s) failed.\n`); + process.exit(1); +} +process.stdout.write('\nall Agents tests passed.\n'); diff --git a/spacetime-agents-ts/spacetimedb/.npmrc b/spacetime-agents-ts/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-agents-ts/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-agents-ts/spacetimedb/package.json b/spacetime-agents-ts/spacetimedb/package.json new file mode 100644 index 00000000000..e450a177931 --- /dev/null +++ b/spacetime-agents-ts/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-agents-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-agents", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-agents" + }, + "dependencies": { + "@spacetimedb/agents": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-agents-ts/spacetimedb/src/index.ts b/spacetime-agents-ts/spacetimedb/src/index.ts new file mode 100644 index 00000000000..160a0b54719 --- /dev/null +++ b/spacetime-agents-ts/spacetimedb/src/index.ts @@ -0,0 +1,2 @@ +export { default } from '../../src/submodule/index'; +export * from '../../src/submodule/index'; diff --git a/spacetime-agents-ts/spacetimedb/tsconfig.json b/spacetime-agents-ts/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..8d8f9b03455 --- /dev/null +++ b/spacetime-agents-ts/spacetimedb/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src"] +} diff --git a/spacetime-agents-ts/src/agent.ts b/spacetime-agents-ts/src/agent.ts new file mode 100644 index 00000000000..5406472e5a5 --- /dev/null +++ b/spacetime-agents-ts/src/agent.ts @@ -0,0 +1,617 @@ +import type { ElementsObj, Infer as InferBuilder } from 'spacetimedb/server'; +import type { ToolDefinition, ResponseFormat } from './openrouter'; + +type TypeBuilderLike = ElementsObj[string]; +type IsUnit = [keyof T] extends [never] ? true : false; + +type RunFn = + IsUnit> extends true + ? (ctx: unknown) => string + : (ctx: unknown, args: InferBuilder) => string; + +const DESC_KEY = Symbol.for('agents-ts/description'); +const RUN_KEY = Symbol.for('agents-ts/run'); +const MAX_TOOLS = 64; +const MAX_TOOL_DESCRIPTION_LENGTH = 1024; +const MAX_TOOL_INPUT_JSON_LENGTH = 64 * 1024; +const MAX_TOOL_RESULT_LENGTH = 64 * 1024; +const MAX_TOOL_ARRAY_LENGTH = 1000; + +export type AgentTool = TB & { + [DESC_KEY]: string; + [RUN_KEY]: RunFn; +}; + +export function agentTool( + description: string, + args: TB, + run: RunFn +): AgentTool { + const normalizedDescription = description.trim(); + if ( + normalizedDescription.length === 0 || + normalizedDescription.length > MAX_TOOL_DESCRIPTION_LENGTH + ) { + throw new Error('agentTool description must contain 1 to 1024 characters'); + } + Object.defineProperty(args, DESC_KEY, { + value: normalizedDescription, + enumerable: false, + configurable: false, + writable: false, + }); + Object.defineProperty(args, RUN_KEY, { + value: run, + enumerable: false, + configurable: false, + writable: false, + }); + return args as AgentTool; +} + +export type InvokeResult = { result: string; isError: boolean }; + +export function makeAgentDispatch< + Tx, + T extends Record>, +>(tools: T) { + if (Object.keys(tools).length > MAX_TOOLS) { + throw new Error(`an agent may define at most ${MAX_TOOLS} tools`); + } + const llmToolDefs: ToolDefinition[] = []; + for (const [name, tool] of Object.entries(tools)) { + if (!isValidToolName(name)) { + throw new Error( + `agentTool name '${name}' must match /^[a-zA-Z0-9_-]{1,64}$/` + ); + } + llmToolDefs.push({ + type: 'function', + function: { + name, + description: tool[DESC_KEY], + parameters: typeBuilderToJsonSchema(tool), + }, + }); + } + + function invoke(ctx: Tx, name: string, inputJson: string): InvokeResult { + if (!Object.hasOwn(tools, name)) { + return { result: `unknown tool: ${name}`, isError: true }; + } + const tool = (tools as Record>)[name]; + if (!tool) return { result: `unknown tool: ${name}`, isError: true }; + + if (inputJson.length > MAX_TOOL_INPUT_JSON_LENGTH) { + return { result: 'tool input exceeds 65536 characters', isError: true }; + } + let parsed: unknown; + try { + const decoded: unknown = inputJson === '' ? {} : JSON.parse(inputJson); + parsed = validateToolValue(tool.algebraicType, decoded, '$'); + } catch (err) { + return { + result: `invalid JSON in tool input: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + + try { + const run = tool[RUN_KEY] as (ctx: Tx, args: unknown) => string; + const result = run(ctx, parsed); + if (typeof result !== 'string') { + return { result: 'tool returned a non-string result', isError: true }; + } + if (result.length > MAX_TOOL_RESULT_LENGTH) { + return { + result: 'tool result exceeds 65536 characters', + isError: true, + }; + } + return { result, isError: false }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + result: message.slice(0, MAX_TOOL_RESULT_LENGTH), + isError: true, + }; + } + } + + return { llmToolDefs, invoke }; +} + +function isValidToolName(name: string): boolean { + return /^[a-zA-Z0-9_-]{1,64}$/.test(name); +} + +type AlgebraicTypeLike = { tag: string; value?: unknown }; +type AlgebraicElement = { name: string; algebraicType: AlgebraicTypeLike }; +type AlgebraicVariant = { name: string; algebraicType: AlgebraicTypeLike }; + +function objectValue(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function productElements(at: AlgebraicTypeLike): AlgebraicElement[] { + const value = objectValue(at.value); + return Array.isArray(value?.elements) + ? (value.elements as AlgebraicElement[]) + : []; +} + +function sumVariants(at: AlgebraicTypeLike): AlgebraicVariant[] { + const value = objectValue(at.value); + return Array.isArray(value?.variants) + ? (value.variants as AlgebraicVariant[]) + : []; +} + +function isUnitType(at: AlgebraicTypeLike): boolean { + return at.tag === 'Product' && productElements(at).length === 0; +} + +function optionPayload(at: AlgebraicTypeLike): AlgebraicTypeLike | undefined { + if (at.tag !== 'Sum') return undefined; + const variants = sumVariants(at); + if (variants.length !== 2) return undefined; + const unitIndex = variants.findIndex(variant => + isUnitType(variant.algebraicType) + ); + return unitIndex < 0 + ? undefined + : variants[unitIndex === 0 ? 1 : 0]?.algebraicType; +} + +function invalidToolValue(path: string, expected: string): never { + throw new Error(`invalid tool input: ${path} must be ${expected}`); +} + +function validateInteger( + at: AlgebraicTypeLike, + value: unknown, + path: string +): number | bigint { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) { + return invalidToolValue(path, 'a safe integer'); + } + const bounds: Record = { + I8: [-128, 127], + U8: [0, 255], + I16: [-32768, 32767], + U16: [0, 65535], + I32: [-2147483648, 2147483647], + U32: [0, 4294967295], + }; + const bound = bounds[at.tag]; + if (bound && (value < bound[0] || value > bound[1])) { + return invalidToolValue(path, `within the ${at.tag} range`); + } + if (at.tag === 'I64' || at.tag === 'U64') { + if (at.tag === 'U64' && value < 0) + return invalidToolValue(path, 'a non-negative safe integer'); + return BigInt(value); + } + return value; +} + +function validateToolValue( + at: AlgebraicTypeLike, + value: unknown, + path: string +): unknown { + switch (at.tag) { + case 'Bool': + return typeof value === 'boolean' + ? value + : invalidToolValue(path, 'a boolean'); + case 'String': + return typeof value === 'string' + ? value + : invalidToolValue(path, 'a string'); + case 'F32': + case 'F64': + return typeof value === 'number' && Number.isFinite(value) + ? value + : invalidToolValue(path, 'a finite number'); + case 'I8': + case 'I16': + case 'I32': + case 'I64': + case 'U8': + case 'U16': + case 'U32': + case 'U64': + return validateInteger(at, value, path); + case 'Product': { + const input = objectValue(value); + if (!input) return invalidToolValue(path, 'an object'); + const elements = productElements(at); + const names = new Set(elements.map(element => element.name)); + for (const key of Object.keys(input)) { + if (!names.has(key)) + throw new Error(`invalid tool input: ${path}.${key} is not allowed`); + } + const output: Record = {}; + for (const element of elements) { + if (!Object.hasOwn(input, element.name)) { + if (optionPayload(element.algebraicType) !== undefined) continue; + throw new Error( + `invalid tool input: ${path}.${element.name} is required` + ); + } + const payload = optionPayload(element.algebraicType); + output[element.name] = validateToolValue( + payload ?? element.algebraicType, + input[element.name], + `${path}.${element.name}` + ); + } + return output; + } + case 'Array': { + if (!Array.isArray(value)) return invalidToolValue(path, 'an array'); + if (value.length > MAX_TOOL_ARRAY_LENGTH) { + throw new Error( + `invalid tool input: ${path} exceeds ${MAX_TOOL_ARRAY_LENGTH} items` + ); + } + const inner = at.value as AlgebraicTypeLike; + return value.map((item, index) => + validateToolValue(inner, item, `${path}[${index}]`) + ); + } + case 'Sum': { + const payload = optionPayload(at); + if (payload !== undefined) return validateToolValue(payload, value, path); + const input = objectValue(value); + if (!input || typeof input.tag !== 'string') { + return invalidToolValue(path, 'a tagged object'); + } + const variant = sumVariants(at).find( + candidate => candidate.name === input.tag + ); + if (!variant) + throw new Error(`invalid tool input: ${path}.tag is unknown`); + const allowed = isUnitType(variant.algebraicType) + ? new Set(['tag']) + : new Set(['tag', 'value']); + for (const key of Object.keys(input)) { + if (!allowed.has(key)) + throw new Error(`invalid tool input: ${path}.${key} is not allowed`); + } + if (isUnitType(variant.algebraicType)) return { tag: input.tag }; + if (!Object.hasOwn(input, 'value')) + throw new Error(`invalid tool input: ${path}.value is required`); + return { + tag: input.tag, + value: validateToolValue( + variant.algebraicType, + input.value, + `${path}.value` + ), + }; + } + case 'Ref': + throw new Error( + 'invalid tool input: referenced argument types are unsupported' + ); + default: + throw new Error(`invalid tool input: unsupported type ${at.tag}`); + } +} + +export type ToolMap = Record>; + +// Built-ins: 'openrouter' | 'openai' | 'anthropic'. +export type ProviderName = string; + +export interface AgentDefinition { + defaultProvider: ProviderName; + defaultModel: string; + defaultSystemPrompt: string | undefined; + defaultMaxTurns: number; + defaultMaxHistoryMessages: number; + defaultMaxTokens: number | undefined; + defaultRetries: number; + defaultResponseFormat: ResponseFormat | undefined; + summarizerAgentName: string | undefined; + embeddingsProvider: string | undefined; + embeddingsModel: string | undefined; + ragTopK: number; + tools: TM; +} + +export function defineAgent(config: { + defaultProvider?: ProviderName; + defaultModel: string; + defaultSystemPrompt?: string; + defaultMaxTurns?: number; + defaultMaxHistoryMessages?: number; + defaultMaxTokens?: number; + defaultRetries?: number; + defaultResponseFormat?: ResponseFormat; + summarizerAgentName?: string; + embeddingsProvider?: string; + embeddingsModel?: string; + ragTopK?: number; + tools: TM; +}): AgentDefinition { + const provider = validateConfigString( + config.defaultProvider ?? 'openrouter', + 'defaultProvider', + 64 + ); + const model = validateConfigString(config.defaultModel, 'defaultModel', 256); + if ( + config.defaultSystemPrompt !== undefined && + config.defaultSystemPrompt.length > 32 * 1024 + ) { + throw new Error('defaultSystemPrompt exceeds 32768 characters'); + } + const maxTurns = validateConfigInteger( + config.defaultMaxTurns ?? 10, + 'defaultMaxTurns', + 1, + 100 + ); + const maxHistory = validateConfigInteger( + config.defaultMaxHistoryMessages ?? 50, + 'defaultMaxHistoryMessages', + 0, + 1000 + ); + const maxTokens = + config.defaultMaxTokens === undefined + ? undefined + : validateConfigInteger( + config.defaultMaxTokens, + 'defaultMaxTokens', + 1, + 1_000_000 + ); + const retries = validateConfigInteger( + config.defaultRetries ?? 2, + 'defaultRetries', + 0, + 10 + ); + const ragTopK = validateConfigInteger(config.ragTopK ?? 0, 'ragTopK', 0, 100); + return { + defaultProvider: provider, + defaultModel: model, + defaultSystemPrompt: config.defaultSystemPrompt, + defaultMaxTurns: maxTurns, + defaultMaxHistoryMessages: maxHistory, + defaultMaxTokens: maxTokens, + defaultRetries: retries, + defaultResponseFormat: config.defaultResponseFormat, + summarizerAgentName: + config.summarizerAgentName === undefined + ? undefined + : validateConfigString( + config.summarizerAgentName, + 'summarizerAgentName', + 64 + ), + embeddingsProvider: + config.embeddingsProvider === undefined + ? undefined + : validateConfigString( + config.embeddingsProvider, + 'embeddingsProvider', + 64 + ), + embeddingsModel: + config.embeddingsModel === undefined + ? undefined + : validateConfigString(config.embeddingsModel, 'embeddingsModel', 256), + ragTopK, + tools: config.tools, + }; +} + +function validateConfigString( + value: string, + field: string, + maxLength: number +): string { + const normalized = value.trim(); + if (normalized.length === 0 || normalized.length > maxLength) { + throw new Error(`${field} must contain 1 to ${maxLength} characters`); + } + return normalized; +} + +function validateConfigInteger( + value: number, + field: string, + minimum: number, + maximum: number +): number { + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw new Error( + `${field} must be an integer from ${minimum} to ${maximum}` + ); + } + return value; +} + +export interface AgentRegistry { + has(agentName: string): boolean; + names(): string[]; + agentDef(agentName: string): AgentDefinition | undefined; + llmToolDefsFor(agentName: string): ToolDefinition[]; + invoke( + agentName: string, + ctx: Tx, + toolName: string, + inputJson: string + ): InvokeResult; +} + +export function makeAgentRegistry< + Tx, + Agents extends Record>, +>(agents: Agents): AgentRegistry { + const dispatches = new Map< + string, + ReturnType> + >(); + for (const [name, def] of Object.entries(agents)) { + if (!isValidAgentName(name)) { + throw new Error( + `agent name '${name}' must match /^[a-zA-Z0-9_-]{1,64}$/` + ); + } + dispatches.set(name, makeAgentDispatch(def.tools)); + } + + return { + has(agentName: string): boolean { + return Object.hasOwn(agents, agentName); + }, + names(): string[] { + return Object.keys(agents); + }, + agentDef(agentName: string): AgentDefinition | undefined { + return Object.hasOwn(agents, agentName) ? agents[agentName] : undefined; + }, + llmToolDefsFor(agentName: string): ToolDefinition[] { + const d = dispatches.get(agentName); + if (!d) return []; + return d.llmToolDefs; + }, + invoke( + agentName: string, + ctx: Tx, + toolName: string, + inputJson: string + ): InvokeResult { + const d = dispatches.get(agentName); + if (!d) return { result: `unknown agent: ${agentName}`, isError: true }; + return d.invoke(ctx, toolName, inputJson); + }, + }; +} + +function isValidAgentName(name: string): boolean { + return /^[a-zA-Z0-9_-]{1,64}$/.test(name); +} + +export function typeBuilderToJsonSchema(tb: TypeBuilderLike): { + type: 'object'; + properties: Record; + required?: string[]; +} { + const at = tb.algebraicType; + if (!at || at.tag !== 'Product') { + throw new Error( + `agentTool args must be a t.object(...) or t.unit(); got ${at?.tag ?? 'unknown'}` + ); + } + return algebraicProductToObjectSchema(at); +} + +function algebraicProductToObjectSchema(at: AlgebraicTypeLike): { + type: 'object'; + properties: Record; + required?: string[]; +} { + const properties: Record = {}; + const required: string[] = []; + const elements = productElements(at); + for (const el of elements) { + const inner = algebraicTypeToJsonSchema(el.algebraicType); + properties[el.name] = inner.schema; + if (inner.required) required.push(el.name); + } + const out: { + type: 'object'; + properties: Record; + required?: string[]; + } = { + type: 'object', + properties, + }; + if (required.length > 0) out.required = required; + return out; +} + +function algebraicTypeToJsonSchema(at: AlgebraicTypeLike): { + schema: unknown; + required: boolean; +} { + switch (at.tag) { + case 'Bool': + return { schema: { type: 'boolean' }, required: true }; + case 'String': + return { schema: { type: 'string' }, required: true }; + case 'F32': + case 'F64': + return { schema: { type: 'number' }, required: true }; + case 'I8': + case 'I16': + case 'I32': + case 'I64': + case 'U8': + case 'U16': + case 'U32': + case 'U64': + return { schema: { type: 'integer' }, required: true }; + case 'I128': + case 'U128': + case 'I256': + case 'U256': + throw new Error( + `tool arg type ${at.tag} is not representable in JSON Schema; use a smaller integer type or t.string()` + ); + case 'Product': + return { schema: algebraicProductToObjectSchema(at), required: true }; + case 'Array': { + const inner = algebraicTypeToJsonSchema(at.value as AlgebraicTypeLike); + return { schema: { type: 'array', items: inner.schema }, required: true }; + } + case 'Sum': { + const variants = sumVariants(at); + // Unwrap option = Sum { some: T, none: () }. + if (variants.length === 2) { + const unitVariantIdx = variants.findIndex(v => + isUnitType(v.algebraicType) + ); + const payloadIdx = + unitVariantIdx === 0 ? 1 : unitVariantIdx === 1 ? 0 : -1; + if (unitVariantIdx >= 0 && payloadIdx >= 0) { + const inner = algebraicTypeToJsonSchema( + variants[payloadIdx].algebraicType + ); + return { schema: inner.schema, required: false }; + } + } + return { + schema: { + oneOf: variants.map(v => { + const inner = algebraicTypeToJsonSchema(v.algebraicType); + return { + type: 'object', + properties: { + tag: { type: 'string', enum: [v.name] }, + value: inner.schema, + }, + required: ['tag'], + }; + }), + }, + required: true, + }; + } + case 'Ref': + throw new Error( + 'typespace Ref types are not supported in tool args; declare the type inline with t.object(...)' + ); + default: + throw new Error(`unsupported algebraic type tag: ${at.tag}`); + } +} diff --git a/spacetime-agents-ts/src/embeddings.ts b/spacetime-agents-ts/src/embeddings.ts new file mode 100644 index 00000000000..4541780ee45 --- /dev/null +++ b/spacetime-agents-ts/src/embeddings.ts @@ -0,0 +1,195 @@ +import type { HttpLike } from './openrouter.ts'; + +export interface EmbeddingProvider { + name: string; + embed( + http: HttpLike, + apiKey: string, + model: string, + texts: string[] + ): EmbeddingResult; +} + +export type EmbeddingResult = + | { + ok: true; + vectors: number[][]; + model: string; + usage: { promptTokens: number; totalTokens: number }; + } + | { + ok: false; + error: + | { kind: 'http'; status: number; body: string } + | { kind: 'transport'; message: string } + | { kind: 'parse'; message: string; body: string }; + }; + +function asObject(value: unknown): Record | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function tokenCount(value: unknown): number { + const count = typeof value === 'number' ? value : Number(value); + return Number.isFinite(count) && count >= 0 + ? Math.min(Math.trunc(count), Number.MAX_SAFE_INTEGER) + : 0; +} + +function postOpenAiEmbeddings( + http: HttpLike, + url: string, + apiKey: string, + model: string, + texts: string[] +): EmbeddingResult { + if ( + texts.length === 0 || + texts.length > 100 || + texts.some(text => text.length > 32_768) + ) { + return { + ok: false, + error: { kind: 'parse', message: 'invalid embedding input', body: '' }, + }; + } + if (texts.reduce((total, value) => total + value.length, 0) > 262_144) { + return { + ok: false, + error: { + kind: 'parse', + message: 'embedding input is too large', + body: '', + }, + }; + } + let res: { status: number; text(): string }; + try { + res = http.fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ model, input: texts }), + }); + } catch (err) { + return { + ok: false, + error: { + kind: 'transport', + message: err instanceof Error ? err.message : String(err), + }, + }; + } + const text = res.text(); + if (res.status < 200 || res.status >= 300) { + return { + ok: false, + error: { kind: 'http', status: res.status, body: text.slice(0, 65_536) }, + }; + } + try { + if (text.length > 4 * 1024 * 1024) + throw new Error('embedding response is too large'); + const parsed: unknown = JSON.parse(text); + const root = asObject(parsed); + if (!root) throw new Error('embedding response must be an object'); + const data = root?.data; + if (!Array.isArray(data)) throw new Error('no data array'); + const vectors: number[][] = data.map(value => { + const row = asObject(value); + if (!Array.isArray(row?.embedding)) throw new Error('missing embedding'); + if (row.embedding.length === 0 || row.embedding.length > 16_384) { + throw new Error('invalid embedding dimensions'); + } + if ( + !row.embedding.every( + value => typeof value === 'number' && Number.isFinite(value) + ) + ) { + throw new Error('embedding contains a non-finite value'); + } + return row.embedding as number[]; + }); + const usage = asObject(root.usage); + return { + ok: true, + vectors, + model: String(root.model ?? model), + usage: { + promptTokens: tokenCount(usage?.prompt_tokens), + totalTokens: tokenCount(usage?.total_tokens), + }, + }; + } catch (err) { + return { + ok: false, + error: { + kind: 'parse', + message: err instanceof Error ? err.message : String(err), + body: text.slice(0, 65_536), + }, + }; + } +} + +export const openAiEmbeddingsProvider: EmbeddingProvider = { + name: 'openai', + embed: (http, apiKey, model, texts) => + postOpenAiEmbeddings( + http, + 'https://api.openai.com/v1/embeddings', + apiKey, + model, + texts + ), +}; + +export const openRouterEmbeddingsProvider: EmbeddingProvider = { + name: 'openrouter', + embed: (http, apiKey, model, texts) => + postOpenAiEmbeddings( + http, + 'https://openrouter.ai/api/v1/embeddings', + apiKey, + model, + texts + ), +}; + +export const BUILT_IN_EMBEDDING_PROVIDERS: Record = { + openai: openAiEmbeddingsProvider, + openrouter: openRouterEmbeddingsProvider, +}; + +export function cosineSimilarity( + a: ArrayLike, + b: ArrayLike +): number { + if (a.length !== b.length || a.length === 0) return 0; + let dot = 0, + na = 0, + nb = 0; + for (let i = 0; i < a.length; i++) { + const x = a[i], + y = b[i]; + dot += x * y; + na += x * x; + nb += y * y; + } + const denom = Math.sqrt(na) * Math.sqrt(nb); + return denom === 0 ? 0 : dot / denom; +} + +export function topKByScore( + items: T[], + scoreFn: (t: T) => number, + k: number +): { item: T; score: number }[] { + const scored = items.map(item => ({ item, score: scoreFn(item) })); + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, Math.max(0, k)); +} diff --git a/spacetime-agents-ts/src/index.ts b/spacetime-agents-ts/src/index.ts new file mode 100644 index 00000000000..2176ea9e267 --- /dev/null +++ b/spacetime-agents-ts/src/index.ts @@ -0,0 +1,45 @@ +export { + agentTool, + makeAgentDispatch, + defineAgent, + makeAgentRegistry, + typeBuilderToJsonSchema, +} from './agent.ts'; +export type { + AgentTool, + AgentDefinition, + AgentRegistry, + InvokeResult, + ToolMap, +} from './agent.ts'; + +export { callChat, isRetryableError } from './openrouter.ts'; +export type { + HttpLike, + ChatMessage, + ContentBlock, + ToolCall, + ToolDefinition, + ChatRequest, + ChatResponse, + ChatError, + ChatResult, + ResponseFormat, + Provider, +} from './openrouter.ts'; + +export { + openRouterProvider, + openAiProvider, + anthropicProvider, + BUILT_IN_PROVIDERS, +} from './providers.ts'; + +export { + cosineSimilarity, + topKByScore, + openAiEmbeddingsProvider, + openRouterEmbeddingsProvider, + BUILT_IN_EMBEDDING_PROVIDERS, +} from './embeddings.ts'; +export type { EmbeddingProvider, EmbeddingResult } from './embeddings.ts'; diff --git a/spacetime-agents-ts/src/openrouter.ts b/spacetime-agents-ts/src/openrouter.ts new file mode 100644 index 00000000000..8806664e324 --- /dev/null +++ b/spacetime-agents-ts/src/openrouter.ts @@ -0,0 +1,155 @@ +export interface HttpLike { + fetch( + url: string, + init: { method: string; headers: Record; body?: string } + ): { + status: number; + text(): string; + }; +} + +export type ContentBlock = + | { type: 'text'; text: string } + | { type: 'image'; mimeType: string; data: string }; + +export type ChatMessage = + | { + role: 'system' | 'user' | 'assistant'; + content: string | ContentBlock[]; + tool_calls?: ToolCall[]; + } + | { role: 'tool'; tool_call_id: string; content: string }; + +export type ToolCall = { + id: string; + type: 'function'; + function: { name: string; arguments: string }; +}; + +export type ToolDefinition = { + type: 'function'; + function: { + name: string; + description: string; + parameters: { + type: 'object'; + properties: Record; + required?: string[]; + }; + }; +}; + +export type ResponseFormat = { type: string; [k: string]: unknown }; + +export type ChatRequest = { + apiKey: string; + model: string; + system?: string; + messages: ChatMessage[]; + tools?: ToolDefinition[]; + maxTokens?: number; + responseFormat?: ResponseFormat; + /** Back-to-back retries on 429/5xx/transport (STDB has no sleep). */ + retries?: number; +}; + +export type ChatResponse = { + text: string | null; + toolCalls: ToolCall[]; + finishReason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | string; + usage: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + }; + model: string; + raw: unknown; +}; + +export type ChatError = + | { kind: 'http'; status: number; body: string } + | { kind: 'transport'; message: string } + | { kind: 'parse'; message: string; body: string }; + +export type ChatResult = + | { ok: true; response: ChatResponse } + | { ok: false; error: ChatError }; + +export interface Provider { + name: string; + buildRequest(req: ChatRequest): { + url: string; + headers: Record; + body: string; + }; + parseResponse(text: string, requestedModel: string): ChatResponse; +} + +export function isRetryableError(err: ChatError): boolean { + if (err.kind === 'transport') return true; + if (err.kind === 'http') { + return err.status === 429 || (err.status >= 500 && err.status <= 599); + } + return false; +} + +export function callChat( + http: HttpLike, + provider: Provider, + req: ChatRequest +): ChatResult { + const retries = Math.max(0, req.retries ?? 0); + let lastError: ChatError | null = null; + for (let attempt = 0; attempt <= retries; attempt++) { + const result = callChatOnce(http, provider, req); + if (result.ok) return result; + lastError = result.error; + if (!isRetryableError(result.error)) return result; + } + return { + ok: false, + error: lastError ?? { kind: 'transport', message: 'no attempts made' }, + }; +} + +function callChatOnce( + http: HttpLike, + provider: Provider, + req: ChatRequest +): ChatResult { + const { url, headers, body } = provider.buildRequest(req); + + let res: { status: number; text(): string }; + try { + res = http.fetch(url, { method: 'POST', headers, body }); + } catch (err) { + return { + ok: false, + error: { + kind: 'transport', + message: err instanceof Error ? err.message : String(err), + }, + }; + } + + const text = res.text(); + if (res.status < 200 || res.status >= 300) { + return { + ok: false, + error: { kind: 'http', status: res.status, body: text }, + }; + } + + try { + return { ok: true, response: provider.parseResponse(text, req.model) }; + } catch (err) { + return { + ok: false, + error: { + kind: 'parse', + message: err instanceof Error ? err.message : String(err), + body: text, + }, + }; + } +} diff --git a/spacetime-agents-ts/src/providers.ts b/spacetime-agents-ts/src/providers.ts new file mode 100644 index 00000000000..5750db98e64 --- /dev/null +++ b/spacetime-agents-ts/src/providers.ts @@ -0,0 +1,290 @@ +import type { + ChatRequest, + ChatMessage, + ContentBlock, + ToolCall, + Provider, + ChatResponse, +} from './openrouter.ts'; + +type JsonObject = Record; + +function asObject(value: unknown): JsonObject | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as JsonObject) + : undefined; +} + +function tokenCount(value: unknown): number { + const count = typeof value === 'number' ? value : Number(value); + return Number.isFinite(count) && count >= 0 + ? Math.min(Math.trunc(count), Number.MAX_SAFE_INTEGER) + : 0; +} + +function toOpenAiContent(content: string | ContentBlock[]): unknown { + if (typeof content === 'string') return content; + return content.map(b => + b.type === 'text' + ? { type: 'text', text: b.text } + : { + type: 'image_url', + image_url: { url: `data:${b.mimeType};base64,${b.data}` }, + } + ); +} + +function toOpenAiMessage(m: ChatMessage): unknown { + if (m.role === 'tool') return m; + const out: JsonObject = { role: m.role, content: toOpenAiContent(m.content) }; + if (m.tool_calls) out.tool_calls = m.tool_calls; + return out; +} + +function buildOpenAiBody(req: ChatRequest): unknown { + const messages = req.messages.map(toOpenAiMessage); + const body: Record = { + model: req.model, + messages: req.system + ? [{ role: 'system', content: req.system }, ...messages] + : messages, + }; + if (req.tools && req.tools.length > 0) { + body.tools = req.tools; + body.tool_choice = 'auto'; + } + if (req.maxTokens !== undefined) body.max_tokens = req.maxTokens; + if (req.responseFormat !== undefined) + body.response_format = req.responseFormat; + return body; +} + +function parseOpenAiResponse( + text: string, + requestedModel: string +): ChatResponse { + const parsed: unknown = JSON.parse(text); + const root = asObject(parsed); + const choices = root?.choices; + const choice = Array.isArray(choices) ? asObject(choices[0]) : undefined; + if (!choice) throw new Error('no choices in response'); + const message = asObject(choice.message) ?? {}; + const toolCalls: ToolCall[] = Array.isArray(message.tool_calls) + ? message.tool_calls.map(value => { + const toolCall = asObject(value) ?? {}; + const fn = asObject(toolCall.function) ?? {}; + return { + id: String(toolCall.id ?? ''), + type: 'function', + function: { + name: String(fn.name ?? ''), + arguments: String(fn.arguments ?? '{}'), + }, + }; + }) + : []; + const usage = asObject(root?.usage) ?? {}; + return { + text: typeof message.content === 'string' ? message.content : null, + toolCalls, + finishReason: String(choice.finish_reason ?? 'stop'), + usage: { + promptTokens: tokenCount(usage.prompt_tokens), + completionTokens: tokenCount(usage.completion_tokens), + totalTokens: tokenCount(usage.total_tokens), + }, + model: String(root?.model ?? requestedModel), + raw: parsed, + }; +} + +export const openRouterProvider: Provider = { + name: 'openrouter', + buildRequest(req) { + return { + url: 'https://openrouter.ai/api/v1/chat/completions', + headers: { + Authorization: `Bearer ${req.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildOpenAiBody(req)), + }; + }, + parseResponse: parseOpenAiResponse, +}; + +export const openAiProvider: Provider = { + name: 'openai', + buildRequest(req) { + return { + url: 'https://api.openai.com/v1/chat/completions', + headers: { + Authorization: `Bearer ${req.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildOpenAiBody(req)), + }; + }, + parseResponse: parseOpenAiResponse, +}; + +type AnthropicContentBlock = + | { type: 'text'; text: string } + | { + type: 'image'; + source: { type: 'base64'; media_type: string; data: string }; + }; + +function toAnthropicBlocks( + content: string | ContentBlock[] +): AnthropicContentBlock[] { + if (typeof content === 'string') return [{ type: 'text', text: content }]; + return content.map(b => + b.type === 'text' + ? { type: 'text', text: b.text } + : { + type: 'image', + source: { type: 'base64', media_type: b.mimeType, data: b.data }, + } + ); +} + +export const anthropicProvider: Provider = { + name: 'anthropic', + buildRequest(req) { + const messages: JsonObject[] = []; + for (const m of req.messages) { + if (m.role === 'tool') { + messages.push({ + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: m.tool_call_id, + content: m.content, + }, + ], + }); + } else if ( + m.role === 'assistant' && + m.tool_calls && + m.tool_calls.length > 0 + ) { + const content: unknown[] = []; + const textBlocks = toAnthropicBlocks(m.content); + for (const b of textBlocks) { + if (b.type === 'text' && !b.text) continue; + content.push(b); + } + for (const tc of m.tool_calls) { + let input: unknown = {}; + try { + input = JSON.parse(tc.function.arguments); + } catch { + /* Preserve the empty fallback. */ + } + content.push({ + type: 'tool_use', + id: tc.id, + name: tc.function.name, + input, + }); + } + messages.push({ role: 'assistant', content }); + } else if (m.role === 'system') { + continue; + } else { + messages.push({ + role: m.role, + content: + typeof m.content === 'string' + ? m.content + : toAnthropicBlocks(m.content), + }); + } + } + + const body: Record = { + model: req.model, + messages, + max_tokens: req.maxTokens ?? 4096, + }; + if (req.system) body.system = req.system; + if (req.tools && req.tools.length > 0) { + body.tools = req.tools.map(t => ({ + name: t.function.name, + description: t.function.description, + input_schema: t.function.parameters, + })); + body.tool_choice = { type: 'auto' }; + } + return { + url: 'https://api.anthropic.com/v1/messages', + headers: { + 'x-api-key': req.apiKey, + 'anthropic-version': '2023-06-01', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }; + }, + parseResponse(text, requestedModel) { + const parsed: unknown = JSON.parse(text); + const root = asObject(parsed); + if (!Array.isArray(root?.content)) + throw new Error('no content in anthropic response'); + + let textContent: string | null = null; + const toolCalls: ToolCall[] = []; + for (const value of root.content) { + const block = asObject(value); + if (!block) continue; + if (block.type === 'text') { + textContent = (textContent ?? '') + String(block.text ?? ''); + } else if (block.type === 'tool_use') { + toolCalls.push({ + id: String(block.id ?? ''), + type: 'function', + function: { + name: String(block.name ?? ''), + arguments: JSON.stringify(block.input ?? {}), + }, + }); + } + } + + const stopReason = String(root.stop_reason ?? 'end_turn'); + const finishReason = + stopReason === 'end_turn' + ? 'stop' + : stopReason === 'tool_use' + ? 'tool_calls' + : stopReason === 'max_tokens' + ? 'length' + : stopReason === 'stop_sequence' + ? 'stop' + : stopReason; + + const usage = asObject(root.usage) ?? {}; + const inputTokens = tokenCount(usage.input_tokens); + const outputTokens = tokenCount(usage.output_tokens); + return { + text: textContent, + toolCalls, + finishReason, + usage: { + promptTokens: inputTokens, + completionTokens: outputTokens, + totalTokens: inputTokens + outputTokens, + }, + model: String(root.model ?? requestedModel), + raw: parsed, + }; + }, +}; + +export const BUILT_IN_PROVIDERS: Record = { + openrouter: openRouterProvider, + openai: openAiProvider, + anthropic: anthropicProvider, +}; diff --git a/spacetime-agents-ts/src/stale-locks.ts b/spacetime-agents-ts/src/stale-locks.ts new file mode 100644 index 00000000000..0e625c3b995 --- /dev/null +++ b/spacetime-agents-ts/src/stale-locks.ts @@ -0,0 +1,34 @@ +export const DEFAULT_STALE_LOCK_SWEEP_BATCH = 500; + +export interface ThreadLockLike { + lockedAt: { + microsSinceUnixEpoch: bigint; + }; +} + +export function staleLockCutoffMicros( + nowMicros: bigint, + thresholdMicros: bigint +): bigint { + return nowMicros - thresholdMicros; +} + +export function deleteStaleThreadLocks( + expiredLocks: Iterable, + cutoffMicros: bigint, + deleteLock: (lock: T) => void, + maxRows = DEFAULT_STALE_LOCK_SWEEP_BATCH +): number { + if (!Number.isInteger(maxRows) || maxRows <= 0) { + throw new Error('agents.invalid_stale_lock_sweep_batch'); + } + + let deleted = 0; + for (const lock of expiredLocks) { + if (deleted >= maxRows) break; + if (lock.lockedAt.microsSinceUnixEpoch >= cutoffMicros) break; + deleteLock(lock); + deleted++; + } + return deleted; +} diff --git a/spacetime-agents-ts/src/submodule.ts b/spacetime-agents-ts/src/submodule.ts new file mode 100644 index 00000000000..9bcd2213661 --- /dev/null +++ b/spacetime-agents-ts/src/submodule.ts @@ -0,0 +1,25 @@ +export { default } from './submodule/index'; +export { installAgents } from './submodule/install'; +export { + add_agent_admin_identity, + clear_agent_override, + clear_api_key, + clear_thread_lock, + delete_thread, + generate_thread_title, + get_agent_config_status, + myMessageEmbeddings, + myMessages, + myThreadLocks, + myThreads, + regenerate_response, + remove_agent_admin_identity, + request_cancel, + send_message, + set_agent_override, + set_agent_secret, + set_api_key, + start_thread, + thread_lock_sweep, + update_thread, +} from './submodule/index'; diff --git a/spacetime-agents-ts/src/submodule/index.ts b/spacetime-agents-ts/src/submodule/index.ts new file mode 100644 index 00000000000..e9b35336772 --- /dev/null +++ b/spacetime-agents-ts/src/submodule/index.ts @@ -0,0 +1,1024 @@ +import { + schema, + table, + t, + Range, + SenderError, + type TransactionCtx, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, +} from 'spacetimedb/server'; +import { Timestamp, type Identity } from 'spacetimedb'; +import { deleteStaleThreadLocks, staleLockCutoffMicros } from '../stale-locks'; +import { installAgents } from './install'; +import { agentTool, defineAgent, makeAgentRegistry } from '../agent'; +import { + callChat, + type ChatMessage, + type Provider, + type HttpLike, +} from '../openrouter'; +import { BUILT_IN_PROVIDERS } from '../providers'; +import { + BUILT_IN_EMBEDDING_PROVIDERS, + cosineSimilarity, + topKByScore, +} from '../embeddings'; +import { + runAgentLoop, + USER_CONTENT_MAX, + type LoopConfig, + type LoopMessage, + type LoopTx, +} from './loop'; +import { + augmentSystemWithSummary, + buildSummarizerUserContent, + pickSummarizationCandidates, +} from './summarize'; + +const ONE_SECOND_MICROS = 1_000_000n; +const DEFAULT_STALE_LOCK_THRESHOLD_SECS = 15 * 60; + +function throwSenderError(msg: string): never { + throw new SenderError(msg); +} + +const getTime = agentTool( + 'returns the current server time as an ISO-8601 string', + t.unit(), + ctx => { + const tx = ctx as { timestamp: { microsSinceUnixEpoch: bigint } }; + const micros = tx.timestamp.microsSinceUnixEpoch; + return new Date(Number(micros / 1000n)).toISOString(); + } +); + +const chatAgent = defineAgent({ + defaultModel: 'anthropic/claude-haiku-4.5', + defaultSystemPrompt: + 'You are a helpful assistant. Use tools when they make the answer better.', + defaultMaxTurns: 10, + defaultMaxHistoryMessages: 50, + defaultRetries: 2, + summarizerAgentName: 'summarizer', + embeddingsProvider: 'openai', + embeddingsModel: 'text-embedding-3-small', + ragTopK: 4, + tools: { + get_time: getTime, + }, +}); + +const summarizerAgent = defineAgent({ + defaultModel: 'anthropic/claude-haiku-4.5', + defaultSystemPrompt: + 'You produce concise running summaries of chat conversations. ' + + 'Capture facts, decisions, names, numbers, and ongoing tasks the ' + + 'main assistant must remember. Skip pleasantries. If the user ' + + 'provides an existing summary, EXTEND it with the new content. ' + + 'Do not restart from scratch and do not duplicate prior facts. ' + + 'Reply with the updated summary as plain prose, no preamble.', + defaultMaxTurns: 1, + defaultMaxHistoryMessages: 100, + defaultMaxTokens: 600, + defaultRetries: 2, + tools: {}, +}); + +const agents = { + chat: chatAgent, + summarizer: summarizerAgent, +}; + +import { + apiKey, + agentSecret, + agentAdminIdentity, + agentOverride, + thread, + message, + threadLock, + messageEmbedding, +} from './model'; + +const threadLockSweeperTick = table( + { name: 'thread_lock_sweeper_tick' }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +const spacetimedb = schema({ + apiKey, + agentSecret, + agentAdminIdentity, + agentOverride, + thread, + message, + threadLock, + threadLockSweeperTick, + messageEmbedding, +}); +export default spacetimedb; + +type Schema = InferSchema; +type WriteCtx = TransactionCtx; + +const registry = makeAgentRegistry(agents); + +export const myThreads = spacetimedb.view( + { name: 'my_threads', public: true }, + t.array(thread.rowType), + ctx => [...ctx.db.thread.owner.filter(ctx.sender)] +); + +export const myMessages = spacetimedb.view( + { name: 'my_messages', public: true }, + t.array(message.rowType), + ctx => [...ctx.db.message.owner.filter(ctx.sender)] +); + +export const myThreadLocks = spacetimedb.view( + { name: 'my_thread_locks', public: true }, + t.array(threadLock.rowType), + ctx => [...ctx.db.threadLock.owner.filter(ctx.sender)] +); + +export const myMessageEmbeddings = spacetimedb.view( + { name: 'my_message_embeddings', public: true }, + t.array(messageEmbedding.rowType), + ctx => [...ctx.db.messageEmbedding.owner.filter(ctx.sender)] +); + +function requireAdmin(tx: WriteCtx): void { + if (tx.db.agentAdminIdentity.identity.find(tx.sender) == null) { + throwSenderError('agent.not_authorized'); + } +} + +type CallerCtx = ProcedureCtx | ReducerCtx; + +function callerIdentity(ctx: CallerCtx): Identity { + return ctx.sender; +} + +function requireOwnedThread(tx: WriteCtx, threadId: bigint, owner: Identity) { + const row = tx.db.thread.id.find(threadId); + if (!row) throwSenderError(`agent.thread_not_found:${threadId}`); + if (!row.owner.isEqual(owner)) { + throwSenderError(`agent.not_thread_owner:${threadId}`); + } + return row; +} + +export const init = spacetimedb.init(ctx => { + installAgents(ctx); +}); + +export const set_agent_secret = spacetimedb.reducer( + { staleLockThresholdSecs: t.option(t.u32()) }, + (ctx, args) => { + const staleLockThresholdSecs = + args.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS; + if (staleLockThresholdSecs === 0) { + throwSenderError('agent.invalid_stale_lock_threshold:must be > 0'); + } + + const tx = ctx; + requireAdmin(tx); + + const existing = tx.db.agentSecret.singleton.find(true); + const row = { + singleton: true, + staleLockThresholdSecs, + updatedAt: tx.timestamp, + }; + if (existing) { + tx.db.agentSecret.singleton.update(row); + } else { + tx.db.agentSecret.insert(row); + } + } +); + +export const set_api_key = spacetimedb.reducer( + { provider: t.string(), key: t.string() }, + (ctx, args) => { + if (args.provider.length === 0) + throwSenderError('agent.invalid_provider:empty'); + if (args.key.length === 0) throwSenderError('agent.invalid_api_key:empty'); + if (!Object.hasOwn(BUILT_IN_PROVIDERS, args.provider)) { + throwSenderError(`agent.unknown_provider:${args.provider}`); + } + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.apiKey.provider.find(args.provider); + const row = { + provider: args.provider, + key: args.key, + updatedAt: tx.timestamp, + }; + if (existing) { + tx.db.apiKey.provider.update(row); + } else { + tx.db.apiKey.insert(row); + } + } +); + +export const clear_api_key = spacetimedb.reducer( + { provider: t.string() }, + (ctx, { provider }) => { + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.apiKey.provider.find(provider); + if (existing) tx.db.apiKey.delete(existing); + } +); + +export const set_agent_override = spacetimedb.reducer( + { + agentName: t.string(), + provider: t.option(t.string()), + model: t.option(t.string()), + systemPrompt: t.option(t.string()), + maxTurns: t.option(t.u32()), + maxHistoryMessages: t.option(t.u32()), + maxTokens: t.option(t.u32()), + retries: t.option(t.u32()), + }, + (ctx, args) => { + if (!registry.has(args.agentName)) { + throwSenderError(`agent.unknown:${args.agentName}`); + } + if ( + args.provider !== undefined && + !Object.hasOwn(BUILT_IN_PROVIDERS, args.provider) + ) { + throwSenderError(`agent.unknown_provider:${args.provider}`); + } + if (args.maxTurns !== undefined && args.maxTurns === 0) { + throwSenderError('agent.invalid_max_turns:must be > 0'); + } + if ( + args.maxHistoryMessages !== undefined && + args.maxHistoryMessages === 0 + ) { + throwSenderError('agent.invalid_max_history:must be > 0'); + } + + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.agentOverride.agentName.find(args.agentName); + const row = { + agentName: args.agentName, + provider: args.provider, + model: args.model, + systemPrompt: args.systemPrompt, + maxTurns: args.maxTurns, + maxHistoryMessages: args.maxHistoryMessages, + maxTokens: args.maxTokens, + retries: args.retries, + updatedAt: tx.timestamp, + }; + if (existing) { + tx.db.agentOverride.agentName.update(row); + } else { + tx.db.agentOverride.insert(row); + } + } +); + +export const clear_agent_override = spacetimedb.reducer( + { agentName: t.string() }, + (ctx, { agentName }) => { + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.agentOverride.agentName.find(agentName); + if (existing) tx.db.agentOverride.delete(existing); + } +); + +export const add_agent_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, { identity }) => { + const tx = ctx; + requireAdmin(tx); + if (tx.db.agentAdminIdentity.identity.find(identity) == null) { + tx.db.agentAdminIdentity.insert({ + identity, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + } +); + +export const remove_agent_admin_identity = spacetimedb.reducer( + { identity: t.identity() }, + (ctx, { identity }) => { + const tx = ctx; + requireAdmin(tx); + const existing = tx.db.agentAdminIdentity.identity.find(identity); + if (!existing) return; + if (tx.db.agentAdminIdentity.count() <= 1n) { + throwSenderError('agent.cannot_remove_last_admin'); + } + tx.db.agentAdminIdentity.delete(existing); + } +); + +export const get_agent_config_status = spacetimedb.procedure( + {}, + t.object('AgentConfigStatus', { + isConfigured: t.bool(), + staleLockThresholdSecs: t.u32(), + agents: t.array( + t.object('AgentInfo', { + name: t.string(), + defaultProvider: t.string(), + defaultModel: t.string(), + }) + ), + configuredProviders: t.array(t.string()), + }), + ctx => + ctx.withTx(tx => { + const secret = tx.db.agentSecret.singleton.find(true); + const configuredProviders = [...tx.db.apiKey.iter()] + .map(r => r.provider) + .sort(); + const agentInfos = registry.names().map(name => { + const def = registry.agentDef(name)!; + return { + name, + defaultProvider: def.defaultProvider, + defaultModel: def.defaultModel, + }; + }); + return { + isConfigured: secret != null, + staleLockThresholdSecs: + secret?.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS, + agents: agentInfos, + configuredProviders, + }; + }) +); + +export const start_thread = spacetimedb.procedure( + { + agentName: t.string(), + title: t.option(t.string()), + systemPromptOverride: t.option(t.string()), + metadata: t.option(t.string()), + }, + t.u64(), + (ctx, args) => { + const owner = callerIdentity(ctx); + if (!registry.has(args.agentName)) { + throwSenderError(`agent.unknown:${args.agentName}`); + } + return ctx.withTx(tx => { + const inserted = tx.db.thread.insert({ + id: 0n, + owner, + agentName: args.agentName, + title: args.title, + systemPromptOverride: args.systemPromptOverride, + modelOverride: undefined, + metadata: args.metadata, + summary: undefined, + summarizedThroughId: undefined, + createdAt: tx.timestamp, + updatedAt: tx.timestamp, + }); + return inserted.id; + }); + } +); + +export const update_thread = spacetimedb.reducer( + { + threadId: t.u64(), + title: t.option(t.string()), + systemPromptOverride: t.option(t.string()), + modelOverride: t.option(t.string()), + metadata: t.option(t.string()), + clearTitle: t.bool(), + clearSystemPromptOverride: t.bool(), + clearModelOverride: t.bool(), + clearMetadata: t.bool(), + }, + (ctx, args) => { + const owner = callerIdentity(ctx); + const tx = ctx; + const row = requireOwnedThread(tx, args.threadId, owner); + tx.db.thread.id.update({ + ...row, + title: args.clearTitle ? undefined : (args.title ?? row.title), + systemPromptOverride: args.clearSystemPromptOverride + ? undefined + : (args.systemPromptOverride ?? row.systemPromptOverride), + modelOverride: args.clearModelOverride + ? undefined + : (args.modelOverride ?? row.modelOverride), + metadata: args.clearMetadata + ? undefined + : (args.metadata ?? row.metadata), + updatedAt: tx.timestamp, + }); + } +); + +export const delete_thread = spacetimedb.reducer( + { threadId: t.u64() }, + (ctx, { threadId }) => { + const owner = callerIdentity(ctx); + const tx = ctx; + const row = requireOwnedThread(tx, threadId, owner); + if (tx.db.threadLock.threadId.find(threadId) != null) { + throwSenderError(`agent.thread_busy:${threadId}`); + } + for (const e of [...tx.db.messageEmbedding.threadId.filter(threadId)]) { + tx.db.messageEmbedding.delete(e); + } + for (const m of [...tx.db.message.threadId.filter(threadId)]) { + tx.db.message.delete(m); + } + tx.db.thread.delete(row); + } +); + +// Admin-gated and bypasses ownership, to clear a wedged lock. +export const clear_thread_lock = spacetimedb.reducer( + { threadId: t.u64() }, + (ctx, { threadId }) => { + const tx = ctx; + requireAdmin(tx); + const lock = tx.db.threadLock.threadId.find(threadId); + if (lock) tx.db.threadLock.delete(lock); + } +); + +export const request_cancel = spacetimedb.reducer( + { threadId: t.u64() }, + (ctx, { threadId }) => { + const owner = callerIdentity(ctx); + const tx = ctx; + requireOwnedThread(tx, threadId, owner); + const lock = tx.db.threadLock.threadId.find(threadId); + if (!lock) throwSenderError(`agent.thread_not_running:${threadId}`); + if (lock.cancelRequested) return; + tx.db.threadLock.threadId.update({ ...lock, cancelRequested: true }); + } +); + +function resolveProvider(name: string): Provider { + const p = BUILT_IN_PROVIDERS[name]; + if (!p) throwSenderError(`agent.unknown_provider:${name}`); + return p; +} + +function loadLoopConfigOrThrow( + tx: WriteCtx, + threadId: bigint, + owner: Identity +): { cfg: LoopConfig; agentName: string; owner: Identity } { + const threadRow = requireOwnedThread(tx, threadId, owner); + + const def = registry.agentDef(threadRow.agentName); + if (!def) { + throwSenderError(`agent.unknown:${threadRow.agentName}`); + } + + if (tx.db.threadLock.threadId.find(threadId) != null) { + throwSenderError(`agent.thread_busy:${threadId}`); + } + if (tx.db.agentSecret.singleton.find(true) == null) { + throwSenderError('agent.not_configured'); + } + + const override = tx.db.agentOverride.agentName.find(threadRow.agentName); + const providerName = override?.provider ?? def.defaultProvider; + const provider = resolveProvider(providerName); + + const keyRow = tx.db.apiKey.provider.find(providerName); + if (!keyRow) throwSenderError(`agent.no_api_key:${providerName}`); + + return { + cfg: { + provider, + apiKey: keyRow.key, + model: threadRow.modelOverride ?? override?.model ?? def.defaultModel, + systemPrompt: + threadRow.systemPromptOverride ?? + override?.systemPrompt ?? + def.defaultSystemPrompt, + maxTurns: override?.maxTurns ?? def.defaultMaxTurns, + maxHistoryMessages: + override?.maxHistoryMessages ?? def.defaultMaxHistoryMessages, + maxTokens: override?.maxTokens ?? def.defaultMaxTokens, + retries: override?.retries ?? def.defaultRetries, + responseFormat: def.defaultResponseFormat, + }, + agentName: threadRow.agentName, + owner: threadRow.owner, + }; +} + +function augmentSystemWithRag( + base: string | undefined, + snippets: string[] +): string | undefined { + if (snippets.length === 0) return base; + const b = base ?? ''; + return `${b}\n\n## Relevant earlier messages\n${snippets.join('\n---\n')}`.trim(); +} + +type ProcLikeCtx = { + http: HttpLike; + withTx: (fn: (tx: WriteCtx) => R) => R; +}; + +function threadMessagesAscending(tx: WriteCtx, threadId: bigint) { + const rows = [...tx.db.message.threadId.filter(threadId)]; + rows.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return rows; +} + +function toLoopMessage(r: { + id: bigint; + threadId: bigint; + role: string; + content: string; + toolCallsJson: string | undefined; + toolCallId: string | undefined; + isError: boolean; + promptTokens: number | undefined; + completionTokens: number | undefined; +}): LoopMessage { + return { + id: r.id, + threadId: r.threadId, + role: r.role, + content: r.content, + toolCallsJson: r.toolCallsJson, + toolCallId: r.toolCallId, + isError: r.isError, + promptTokens: r.promptTokens, + completionTokens: r.completionTokens, + }; +} + +function maybeEmbedMessage( + ctx: ProcLikeCtx, + threadId: bigint, + messageId: bigint +): void { + const job = ctx.withTx(tx => { + if (tx.db.messageEmbedding.messageId.find(messageId) != null) return null; + const msg = tx.db.message.id.find(messageId); + if (!msg) return null; + const threadRow = tx.db.thread.id.find(threadId); + if (!threadRow) return null; + const def = registry.agentDef(threadRow.agentName); + if (!def?.embeddingsProvider || !def.embeddingsModel) return null; + const provider = BUILT_IN_EMBEDDING_PROVIDERS[def.embeddingsProvider]; + if (!provider) return null; + const keyRow = tx.db.apiKey.provider.find(def.embeddingsProvider); + if (!keyRow) return null; + return { + provider, + apiKey: keyRow.key, + model: def.embeddingsModel, + content: msg.content, + owner: msg.owner, + }; + }); + if (!job) return; + + const result = job.provider.embed(ctx.http, job.apiKey, job.model, [ + job.content, + ]); + if (!result.ok || result.vectors.length === 0) { + console.warn( + `embedding failed: ${result.ok ? 'no vectors' : result.error.kind}` + ); + return; + } + ctx.withTx(tx => { + if (tx.db.messageEmbedding.messageId.find(messageId) != null) return; + tx.db.messageEmbedding.insert({ + messageId, + threadId, + owner: job.owner, + model: job.model, + vector: result.vectors[0], + createdAt: tx.timestamp, + }); + }); +} + +function maybeRetrieveRag(ctx: ProcLikeCtx, threadId: bigint): string[] { + return ctx.withTx(tx => { + const threadRow = tx.db.thread.id.find(threadId); + if (!threadRow) return []; + const def = registry.agentDef(threadRow.agentName); + if (!def || def.ragTopK <= 0) return []; + + const msgs = threadMessagesAscending(tx, threadId); + let queryMsg = undefined as (typeof msgs)[number] | undefined; + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i].role === 'user') { + queryMsg = msgs[i]; + break; + } + } + if (!queryMsg) return []; + const queryEmb = tx.db.messageEmbedding.messageId.find(queryMsg.id); + if (!queryEmb) return []; + + const override = tx.db.agentOverride.agentName.find(threadRow.agentName); + const maxHistory = + override?.maxHistoryMessages ?? def.defaultMaxHistoryMessages; + const windowStartIdx = Math.max(0, msgs.length - maxHistory); + const inWindowIds = new Set(msgs.slice(windowStartIdx).map(m => m.id)); + + const candidates = [ + ...tx.db.messageEmbedding.threadId.filter(threadId), + ].filter( + e => !inWindowIds.has(e.messageId) && e.messageId !== queryMsg!.id + ); + const top = topKByScore( + candidates, + e => cosineSimilarity(queryEmb.vector, e.vector), + def.ragTopK + ).filter(x => x.score > 0); + + const out: string[] = []; + for (const { item } of top) { + const m = tx.db.message.id.find(item.messageId); + if (m) out.push(`[${m.role}] ${m.content}`); + } + return out; + }); +} + +function maybeRunSummarization(ctx: ProcLikeCtx, threadId: bigint): void { + const decision = ctx.withTx(tx => { + const threadRow = tx.db.thread.id.find(threadId); + if (!threadRow) return null; + const def = registry.agentDef(threadRow.agentName); + if (!def?.summarizerAgentName) return null; + const sumDef = registry.agentDef(def.summarizerAgentName); + if (!sumDef) return null; + + const override = tx.db.agentOverride.agentName.find(threadRow.agentName); + const maxHistory = + override?.maxHistoryMessages ?? def.defaultMaxHistoryMessages; + + const rows = threadMessagesAscending(tx, threadId).map(toLoopMessage); + + const candidates = pickSummarizationCandidates( + rows, + maxHistory, + threadRow.summarizedThroughId ?? null + ); + if (!candidates) return null; + + const sumOverride = tx.db.agentOverride.agentName.find( + def.summarizerAgentName + ); + const sumProviderName = sumOverride?.provider ?? sumDef.defaultProvider; + const sumProvider = BUILT_IN_PROVIDERS[sumProviderName]; + if (!sumProvider) return null; + const keyRow = tx.db.apiKey.provider.find(sumProviderName); + if (!keyRow) return null; + + return { + provider: sumProvider, + apiKey: keyRow.key, + sumModel: sumOverride?.model ?? sumDef.defaultModel, + sumSystemPrompt: sumOverride?.systemPrompt ?? sumDef.defaultSystemPrompt, + sumMaxTokens: sumOverride?.maxTokens ?? sumDef.defaultMaxTokens, + sumRetries: sumOverride?.retries ?? sumDef.defaultRetries, + existingSummary: threadRow.summary ?? null, + newDropped: candidates.newDropped, + lastNewId: candidates.lastNewId, + }; + }); + if (!decision) return; + + const userContent = buildSummarizerUserContent( + decision.existingSummary, + decision.newDropped + ); + const messages: ChatMessage[] = [{ role: 'user', content: userContent }]; + const result = callChat(ctx.http, decision.provider, { + apiKey: decision.apiKey, + model: decision.sumModel, + system: decision.sumSystemPrompt, + messages, + maxTokens: decision.sumMaxTokens, + retries: decision.sumRetries, + }); + if (!result.ok || !result.response.text) { + console.warn( + `summarization failed: ${result.ok ? 'no text in response' : result.error.kind}` + ); + return; + } + + ctx.withTx(tx => { + const threadRow = tx.db.thread.id.find(threadId); + if (!threadRow) return; + tx.db.thread.id.update({ + ...threadRow, + summary: result.response.text!, + summarizedThroughId: decision.lastNewId, + updatedAt: tx.timestamp, + }); + }); +} + +function createLoopContext( + tx: WriteCtx, + agentName: string, + owner: Identity +): LoopTx { + return { + listMessages(threadId: bigint): LoopMessage[] { + return threadMessagesAscending(tx, threadId).map(toLoopMessage); + }, + appendMessage(row) { + tx.db.message.insert({ + id: 0n, + threadId: row.threadId, + owner, + role: row.role, + content: row.content, + toolCallsJson: row.toolCallsJson, + toolCallId: row.toolCallId, + isError: row.isError, + promptTokens: row.promptTokens, + completionTokens: row.completionTokens, + createdAt: tx.timestamp, + }); + }, + bumpThread(threadId: bigint): void { + const r = tx.db.thread.id.find(threadId); + if (r) tx.db.thread.id.update({ ...r, updatedAt: tx.timestamp }); + }, + invokeTool(name: string, inputJson: string) { + return registry.invoke(agentName, tx, name, inputJson); + }, + isCancelRequested(threadId: bigint): boolean { + const lock = tx.db.threadLock.threadId.find(threadId); + return lock != null && lock.cancelRequested; + }, + }; +} + +function runAgentForThread( + ctx: ProcLikeCtx, + cfg: LoopConfig, + agentName: string, + owner: Identity, + threadId: bigint +): void { + try { + maybeRunSummarization(ctx, threadId); + const ragSnippets = maybeRetrieveRag(ctx, threadId); + + const finalCfg = ctx.withTx(tx => { + const threadRow = tx.db.thread.id.find(threadId); + if (!threadRow) return cfg; + let systemPrompt = cfg.systemPrompt; + systemPrompt = augmentSystemWithSummary( + systemPrompt, + threadRow.summary ?? null + ); + systemPrompt = augmentSystemWithRag(systemPrompt, ragSnippets); + return { ...cfg, systemPrompt }; + }); + + runAgentLoop({ + http: ctx.http, + withTx: (fn: (lt: LoopTx) => R): R => + ctx.withTx(tx => fn(createLoopContext(tx, agentName, owner))), + llmToolDefs: registry.llmToolDefsFor(agentName), + cfg: finalCfg, + threadId, + }); + } finally { + ctx.withTx(tx => { + const lock = tx.db.threadLock.threadId.find(threadId); + if (lock) tx.db.threadLock.delete(lock); + }); + } +} + +export const send_message = spacetimedb.procedure( + { threadId: t.u64(), content: t.string() }, + t.unit(), + (ctx, args) => { + if (args.content.length === 0) { + throwSenderError('agent.empty_message'); + } + const content = + args.content.length > USER_CONTENT_MAX + ? args.content.slice(0, USER_CONTENT_MAX) + '...[truncated]' + : args.content; + + const owner = callerIdentity(ctx); + const { + cfg, + agentName, + owner: threadOwner, + userMessageId, + } = ctx.withTx(tx => { + const loaded = loadLoopConfigOrThrow(tx, args.threadId, owner); + tx.db.threadLock.insert({ + threadId: args.threadId, + owner: loaded.owner, + lockedAt: tx.timestamp, + cancelRequested: false, + }); + const inserted = tx.db.message.insert({ + id: 0n, + threadId: args.threadId, + owner: loaded.owner, + role: 'user', + content, + toolCallsJson: undefined, + toolCallId: undefined, + isError: false, + promptTokens: undefined, + completionTokens: undefined, + createdAt: tx.timestamp, + }); + const threadRow = tx.db.thread.id.find(args.threadId); + if (threadRow) + tx.db.thread.id.update({ ...threadRow, updatedAt: tx.timestamp }); + return { ...loaded, userMessageId: inserted.id }; + }); + + maybeEmbedMessage(ctx, args.threadId, userMessageId); + runAgentForThread(ctx, cfg, agentName, threadOwner, args.threadId); + return {}; + } +); + +export const regenerate_response = spacetimedb.procedure( + { threadId: t.u64() }, + t.unit(), + (ctx, { threadId }) => { + const owner = callerIdentity(ctx); + const { + cfg, + agentName, + owner: threadOwner, + } = ctx.withTx(tx => { + const loaded = loadLoopConfigOrThrow(tx, threadId, owner); + + const rows = threadMessagesAscending(tx, threadId); + let lastUserMsgId: bigint | undefined; + for (const r of rows) { + if (r.role === 'user') lastUserMsgId = r.id; + } + if (lastUserMsgId === undefined) { + throwSenderError(`agent.regenerate_no_user_message:${threadId}`); + } + + for (const r of rows) { + if (r.id > lastUserMsgId!) tx.db.message.delete(r); + } + + tx.db.threadLock.insert({ + threadId, + owner: loaded.owner, + lockedAt: tx.timestamp, + cancelRequested: false, + }); + const threadRow = tx.db.thread.id.find(threadId); + if (threadRow) + tx.db.thread.id.update({ ...threadRow, updatedAt: tx.timestamp }); + return loaded; + }); + + runAgentForThread(ctx, cfg, agentName, threadOwner, threadId); + return {}; + } +); + +export const generate_thread_title = spacetimedb.procedure( + { threadId: t.u64() }, + t.unit(), + (ctx, { threadId }) => { + const owner = callerIdentity(ctx); + const job = ctx.withTx(tx => { + const threadRow = tx.db.thread.id.find(threadId); + if (!threadRow) return null; + if (!threadRow.owner.isEqual(owner)) { + throwSenderError(`agent.not_thread_owner:${threadId}`); + } + if (threadRow.title != null && threadRow.title.length > 0) return null; + + const def = registry.agentDef(threadRow.agentName); + if (!def) return null; + const sumName = def.summarizerAgentName ?? threadRow.agentName; + const sumDef = registry.agentDef(sumName); + if (!sumDef) return null; + + const override = tx.db.agentOverride.agentName.find(sumName); + const providerName = override?.provider ?? sumDef.defaultProvider; + const provider = BUILT_IN_PROVIDERS[providerName]; + if (!provider) return null; + const keyRow = tx.db.apiKey.provider.find(providerName); + if (!keyRow) return null; + + const msgs = threadMessagesAscending(tx, threadId); + const firstUser = msgs.find(m => m.role === 'user'); + if (!firstUser) return null; + + return { + provider, + apiKey: keyRow.key, + model: override?.model ?? sumDef.defaultModel, + retries: override?.retries ?? sumDef.defaultRetries, + firstMessage: firstUser.content, + }; + }); + if (!job) return {}; + + const result = callChat(ctx.http, job.provider, { + apiKey: job.apiKey, + model: job.model, + system: + 'You title chat conversations. The user will paste the opening message of ' + + 'a chat. You output a 3-5 word title describing the topic. ' + + 'CRITICAL: do not answer or respond to the message. Do not greet. ' + + 'Output the title and only the title. No quotes, no punctuation at the end.', + messages: [ + { + role: 'user', + content: `Title for a chat that starts with this message:\n\n\n${job.firstMessage}\n`, + }, + ], + maxTokens: 30, + retries: job.retries, + }); + if (!result.ok || !result.response.text) { + console.warn( + `title gen failed: ${result.ok ? 'no text' : result.error.kind}` + ); + return {}; + } + + const cleaned = result.response.text + .trim() + .replace(/^["']|["']$/g, '') + .replace(/[.!?]+$/g, '') + .slice(0, 80); + + ctx.withTx(tx => { + const t2 = tx.db.thread.id.find(threadId); + if (!t2 || (t2.title != null && t2.title.length > 0)) return; + tx.db.thread.id.update({ + ...t2, + title: cleaned, + updatedAt: tx.timestamp, + }); + }); + return {}; + } +); + +export const thread_lock_sweep = spacetimedb.reducer( + { onSchedule: threadLockSweeperTick }, + { arg: threadLockSweeperTick.rowType }, + (ctx, _arg) => { + const secret = ctx.db.agentSecret.singleton.find(true); + const thresholdSecs = + secret?.staleLockThresholdSecs ?? DEFAULT_STALE_LOCK_THRESHOLD_SECS; + const thresholdMicros = BigInt(thresholdSecs) * ONE_SECOND_MICROS; + + const cutoffMicros = staleLockCutoffMicros( + ctx.timestamp.microsSinceUnixEpoch, + thresholdMicros + ); + deleteStaleThreadLocks( + ctx.db.threadLock.lockedAt.filter( + new Range(undefined, { + tag: 'excluded', + value: new Timestamp(cutoffMicros), + }) + ), + cutoffMicros, + lock => ctx.db.threadLock.delete(lock) + ); + } +); diff --git a/spacetime-agents-ts/src/submodule/install.ts b/spacetime-agents-ts/src/submodule/install.ts new file mode 100644 index 00000000000..148053285bb --- /dev/null +++ b/spacetime-agents-ts/src/submodule/install.ts @@ -0,0 +1,22 @@ +import { ScheduleAt } from 'spacetimedb'; +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; +import type spacetimedb from './index'; + +const ONE_SECOND_MICROS = 1_000_000n; +const SWEEPER_INTERVAL_MICROS = 60n * ONE_SECOND_MICROS; + +type Schema = InferSchema; +type InstallCtx = ReducerCtx; + +export function installAgents(ctx: InstallCtx) { + if (ctx.db.agentAdminIdentity.identity.find(ctx.sender) == null) { + ctx.db.agentAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + ctx.db.threadLockSweeperTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval(SWEEPER_INTERVAL_MICROS), + }); +} diff --git a/spacetime-agents-ts/src/submodule/loop.ts b/spacetime-agents-ts/src/submodule/loop.ts new file mode 100644 index 00000000000..217429c9f96 --- /dev/null +++ b/spacetime-agents-ts/src/submodule/loop.ts @@ -0,0 +1,231 @@ +import { + callChat, + type ChatMessage, + type HttpLike, + type Provider, + type ResponseFormat, + type ToolCall, + type ToolDefinition, +} from '../openrouter'; + +export const USER_CONTENT_MAX = 32_000; +export const TOOL_RESULT_MAX = 64_000; + +export interface LoopConfig { + provider: Provider; + apiKey: string; + model: string; + systemPrompt: string | undefined; + maxTurns: number; + maxHistoryMessages: number; + maxTokens: number | undefined; + retries: number; + responseFormat: ResponseFormat | undefined; +} + +export interface LoopMessage { + id: bigint; + threadId: bigint; + role: string; + content: string; + toolCallsJson: string | undefined; + toolCallId: string | undefined; + isError: boolean; + promptTokens: number | undefined; + completionTokens: number | undefined; +} + +export type AppendMessageRow = Omit; + +export interface LoopTx { + listMessages(threadId: bigint): LoopMessage[]; + appendMessage(row: AppendMessageRow): void; + bumpThread(threadId: bigint): void; + invokeTool( + name: string, + inputJson: string + ): { result: string; isError: boolean }; + isCancelRequested(threadId: bigint): boolean; +} + +export interface RunAgentLoopOptions { + http: HttpLike; + withTx: (fn: (tx: LoopTx) => R) => R; + llmToolDefs: ToolDefinition[]; + cfg: LoopConfig; + threadId: bigint; +} + +function clip(value: string, max: number): string { + return value.length <= max ? value : `${value.slice(0, max)}...[truncated]`; +} + +function truncate(value: string, max: number): string { + return value.length <= max ? value : `${value.slice(0, max)}...`; +} + +function formatChatError(error: { + kind: string; + status?: number; + message?: string; + body?: string; +}): string { + switch (error.kind) { + case 'http': + return `agent.provider_http:${error.status}:${truncate(error.body ?? '', 500)}`; + case 'transport': + return `agent.provider_transport:${error.message ?? 'unknown'}`; + case 'parse': + return `agent.provider_parse:${error.message ?? 'unknown'}`; + default: + return `agent.provider_error:${error.kind}`; + } +} + +function buildLlmMessages( + tx: LoopTx, + threadId: bigint, + maxHistoryMessages: number +): ChatMessage[] { + const all = tx.listMessages(threadId); + const window = + maxHistoryMessages > 0 && all.length > maxHistoryMessages + ? all.slice(all.length - maxHistoryMessages) + : all; + const messages: ChatMessage[] = []; + const knownToolCallIds = new Set(); + + for (const row of window) { + if (row.role === 'user') { + messages.push({ role: 'user', content: row.content }); + } else if (row.role === 'assistant') { + let toolCalls: ToolCall[] | undefined; + if (row.toolCallsJson != null) { + try { + toolCalls = JSON.parse(row.toolCallsJson) as ToolCall[]; + } catch { + toolCalls = undefined; + } + } + const message: ChatMessage = { role: 'assistant', content: row.content }; + if (toolCalls && toolCalls.length > 0) { + message.tool_calls = toolCalls; + for (const call of toolCalls) knownToolCallIds.add(call.id); + } + messages.push(message); + } else if (row.role === 'tool') { + const toolCallId = row.toolCallId ?? ''; + if (!knownToolCallIds.has(toolCallId)) continue; + messages.push({ + role: 'tool', + tool_call_id: toolCallId, + content: row.content, + }); + } + } + return messages; +} + +function runOneTurn(options: RunAgentLoopOptions): boolean { + const { http, withTx, llmToolDefs, cfg, threadId } = options; + const cancelled = withTx(tx => { + if (!tx.isCancelRequested(threadId)) return false; + tx.appendMessage({ + threadId, + role: 'assistant', + content: 'agent.cancelled', + toolCallsJson: undefined, + toolCallId: undefined, + isError: true, + promptTokens: undefined, + completionTokens: undefined, + }); + tx.bumpThread(threadId); + return true; + }); + if (cancelled) return false; + + const llmMessages = withTx(tx => + buildLlmMessages(tx, threadId, cfg.maxHistoryMessages) + ); + const result = callChat(http, cfg.provider, { + apiKey: cfg.apiKey, + model: cfg.model, + system: cfg.systemPrompt, + messages: llmMessages, + tools: llmToolDefs, + maxTokens: cfg.maxTokens, + responseFormat: cfg.responseFormat, + retries: cfg.retries, + }); + + if (!result.ok) { + withTx(tx => + tx.appendMessage({ + threadId, + role: 'assistant', + content: formatChatError(result.error), + toolCallsJson: undefined, + toolCallId: undefined, + isError: true, + promptTokens: undefined, + completionTokens: undefined, + }) + ); + return false; + } + + const { text, toolCalls, finishReason, usage } = result.response; + const hasToolCalls = toolCalls.length > 0; + withTx(tx => { + tx.appendMessage({ + threadId, + role: 'assistant', + content: text ?? '', + toolCallsJson: hasToolCalls ? JSON.stringify(toolCalls) : undefined, + toolCallId: undefined, + isError: false, + promptTokens: usage.promptTokens > 0 ? usage.promptTokens : undefined, + completionTokens: + usage.completionTokens > 0 ? usage.completionTokens : undefined, + }); + if (hasToolCalls) { + for (const call of toolCalls) { + const invocation = tx.invokeTool( + call.function.name, + call.function.arguments + ); + tx.appendMessage({ + threadId, + role: 'tool', + content: clip(invocation.result, TOOL_RESULT_MAX), + toolCallsJson: undefined, + toolCallId: call.id, + isError: invocation.isError, + promptTokens: undefined, + completionTokens: undefined, + }); + } + } + tx.bumpThread(threadId); + }); + return hasToolCalls && finishReason === 'tool_calls'; +} + +export function runAgentLoop(options: RunAgentLoopOptions): void { + for (let turn = 0; turn < options.cfg.maxTurns; turn++) { + if (!runOneTurn(options)) return; + } + options.withTx(tx => + tx.appendMessage({ + threadId: options.threadId, + role: 'assistant', + content: `agent.max_turns_exceeded:${options.cfg.maxTurns}`, + toolCallsJson: undefined, + toolCallId: undefined, + isError: true, + promptTokens: undefined, + completionTokens: undefined, + }) + ); +} diff --git a/spacetime-agents-ts/src/submodule/model.ts b/spacetime-agents-ts/src/submodule/model.ts new file mode 100644 index 00000000000..8d16d5d2200 --- /dev/null +++ b/spacetime-agents-ts/src/submodule/model.ts @@ -0,0 +1,100 @@ +import { table, t } from 'spacetimedb/server'; + +export const apiKey = table( + { name: 'api_key', public: false }, + { + provider: t.string().primaryKey(), + key: t.string(), + updatedAt: t.timestamp(), + } +); + +export const agentSecret = table( + { name: 'agent_secret', public: false }, + { + singleton: t.bool().primaryKey(), + staleLockThresholdSecs: t.u32(), + updatedAt: t.timestamp(), + } +); + +export const agentAdminIdentity = table( + { name: 'agent_admin_identity', public: false }, + { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), + } +); + +// Effective config precedence: thread > override > code default. +export const agentOverride = table( + { name: 'agent_override', public: true }, + { + agentName: t.string().primaryKey(), + provider: t.option(t.string()), + model: t.option(t.string()), + systemPrompt: t.option(t.string()), + maxTurns: t.option(t.u32()), + maxHistoryMessages: t.option(t.u32()), + maxTokens: t.option(t.u32()), + retries: t.option(t.u32()), + updatedAt: t.timestamp(), + } +); + +export const thread = table( + { name: 'thread', public: false }, + { + id: t.u64().primaryKey().autoInc(), + owner: t.identity().index(), + agentName: t.string().index(), + title: t.option(t.string()), + systemPromptOverride: t.option(t.string()), + modelOverride: t.option(t.string()), + metadata: t.option(t.string()), + summary: t.option(t.string()), + summarizedThroughId: t.option(t.u64()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +// owner denormalized from thread so the visibility view can filter on it. +export const message = table( + { name: 'message', public: false }, + { + id: t.u64().primaryKey().autoInc(), + threadId: t.u64().index(), + owner: t.identity().index(), + role: t.string(), + content: t.string(), + toolCallsJson: t.option(t.string()), + toolCallId: t.option(t.string()), + isError: t.bool(), + promptTokens: t.option(t.u32()), + completionTokens: t.option(t.u32()), + createdAt: t.timestamp(), + } +); +// Presence of a row is the per-thread mutex: a loop is running for it. +export const threadLock = table( + { name: 'thread_lock', public: false }, + { + threadId: t.u64().primaryKey(), + owner: t.identity().index(), + lockedAt: t.timestamp().index('btree'), + cancelRequested: t.bool(), + } +); + +export const messageEmbedding = table( + { name: 'message_embedding', public: false }, + { + messageId: t.u64().primaryKey(), + threadId: t.u64().index(), + owner: t.identity().index(), + model: t.string(), + vector: t.array(t.f32()), + createdAt: t.timestamp(), + } +); diff --git a/spacetime-agents-ts/src/submodule/summarize.ts b/spacetime-agents-ts/src/submodule/summarize.ts new file mode 100644 index 00000000000..c4814b3dd14 --- /dev/null +++ b/spacetime-agents-ts/src/submodule/summarize.ts @@ -0,0 +1,66 @@ +import type { LoopMessage } from './loop'; + +export function pickSummarizationCandidates( + messages: LoopMessage[], + maxHistoryMessages: number, + summarizedThroughId: bigint | null +): { newDropped: LoopMessage[]; lastNewId: bigint } | null { + if (messages.length <= maxHistoryMessages) return null; + const dropped = messages.slice(0, messages.length - maxHistoryMessages); + const newDropped = + summarizedThroughId == null + ? dropped + : dropped.filter(message => message.id > summarizedThroughId); + if (newDropped.length === 0) return null; + return { newDropped, lastNewId: newDropped[newDropped.length - 1]!.id }; +} + +export function formatMessagesForSummarizer(messages: LoopMessage[]): string { + const lines: string[] = []; + for (const message of messages) { + if (message.role === 'user') { + lines.push(`User: ${message.content}`); + } else if (message.role === 'assistant') { + if (message.toolCallsJson != null) { + try { + const calls = JSON.parse(message.toolCallsJson) as Array<{ + function?: { name?: string; arguments?: string }; + }>; + for (const call of calls) { + lines.push( + `[Assistant called tool ${call.function?.name ?? '?'}(${call.function?.arguments ?? ''})]` + ); + } + } catch { + // Preserve the assistant text when stored tool metadata is malformed. + } + } + if (message.content) lines.push(`Assistant: ${message.content}`); + } else if (message.role === 'tool') { + lines.push(`[Tool result: ${message.content}]`); + } + } + return lines.join('\n'); +} + +export function buildSummarizerUserContent( + existingSummary: string | null, + newDropped: LoopMessage[] +): string { + const formatted = formatMessagesForSummarizer(newDropped); + if (existingSummary) { + return ( + `Existing summary:\n${existingSummary}\n\n` + + `Additional messages to fold into the summary:\n${formatted}` + ); + } + return `Messages to summarize:\n${formatted}`; +} + +export function augmentSystemWithSummary( + baseSystem: string | undefined, + summary: string | null +): string | undefined { + if (!summary) return baseSystem; + return `${baseSystem ?? ''}\n\n## Summary of earlier conversation\n${summary}`.trim(); +} diff --git a/spacetime-agents-ts/tsconfig.json b/spacetime-agents-ts/tsconfig.json new file mode 100644 index 00000000000..b2b4ecdcdda --- /dev/null +++ b/spacetime-agents-ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-auth-ts/.npmrc b/spacetime-auth-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-auth-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-auth-ts/LICENSE.txt b/spacetime-auth-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-auth-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-auth-ts/README.md b/spacetime-auth-ts/README.md new file mode 100644 index 00000000000..974ee647d6f --- /dev/null +++ b/spacetime-auth-ts/README.md @@ -0,0 +1,178 @@ +# @spacetimedb/auth + +Authentication primitives for SpacetimeDB TypeScript modules. The package +provides password and OAuth handlers, ES256 sessions, connection binding, +profile management, and in-module rate limiting. + +## Install + +```bash +npm install @spacetimedb/auth spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +The host module owns HTTP route registration and any mail-delivery adapter. + +## Usage + +### Integrate into an application + +Import the submodule namespace, register the handlers your application needs, +then install Auth from the host `init` hook. Auth mounts and initializes its +Rate Limit dependency. + +```ts +import { schema } from 'spacetimedb/server'; +import * as auth from '@spacetimedb/auth/submodule'; + +const spacetimedb = schema({ auth }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + auth.installAuth(ctx.as.auth); +}); +``` + +Register only the HTTP handlers and connection procedures your application +uses. For example, a login route calls `auth.passwordLoginHandler` with +`ctx.as.auth`; the host owns its router and trusted-proxy policy. + +The complete wiring covers routes, connection binding, caller-scoped views, +and mail callbacks in the +[Auth example host module](./example/spacetimedb/). + +Handlers set `Secure` cookies by default. Local HTTP examples pass +`{ secureCookies: false }` explicitly. To apply IP-based limits behind a proxy, +pass an `AuthHttpOptions` value naming the header that the proxy overwrites: + +```ts +const authHttp = { + trustedProxyHeader: 'x-forwarded-for', +} satisfies auth.AuthHttpOptions; + +auth.passwordLoginHandler(ctx.as.auth, req, authHttp); +``` + +Register the handlers on the host router and expose authenticated application +operations through the connection binding: + +```ts +import { Router } from 'spacetimedb/server'; + +export const authPasswordSignup = spacetimedb.httpHandler((ctx, req) => + auth.passwordSignupHandler(ctx.as.auth, req) +); + +export const link_connection = spacetimedb.reducer( + auth.linkConnectionParams, + (ctx, args) => auth.link_connection(ctx.as.auth, args) +); + +export const update_profile = spacetimedb.reducer( + auth.updateProfileParams, + (ctx, args) => auth.update_profile(ctx.as.auth, args) +); + +export const router = spacetimedb.httpRouter( + new Router().post('/auth/password/signup', authPasswordSignup) +); +``` + +The browser obtains a session through HTTP, binds its SpacetimeDB connection, +then calls normal generated operations: + +```ts +const signup = await fetch('/auth/password/signup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password, name }), + credentials: 'same-origin', +}); +if (!signup.ok) throw new Error(`signup_failed:${signup.status}`); +const { token } = (await signup.json()) as { token: string }; + +await conn.reducers.linkConnection({ sessionToken: token }); +await conn.reducers.updateProfile({ name: 'Ada', image: undefined }); +``` + +## API + +The root entrypoint exports table builders, password and OAuth handlers, JWT +and key helpers, connection-binding procedures, and caller helpers. The +`./submodule` entrypoint exports the submodule schema, registered database +operations, views, handler factories, and `installAuth`. The host module owns +`init`, HTTP routing, cookie policy, and mail delivery. + +Supported flows: + +- Email/password signup and login +- ES256 JWT session cookies, refresh, logout, revoke, and sweep +- Google and GitHub OAuth through provider user-info endpoints +- Email verification and password-reset handler factories +- SpacetimeDB identity binding through `link_connection` +- Caller profile reads and updates +- Fixed-window limits for authentication endpoints + +Submodule operations: + +- Configuration and keys: `set_auth_config`, `get_auth_public_key`. +- Connection binding: `link_connection`, `unlink_connection`, and `whoami`. +- Profiles and sessions: `update_profile`, `list_my_sessions`, + `revoke_my_session`, and administrative `revoke_session`. +- Caller helpers: `getCallerUserId` and the `my_auth_user` scoped view. + +The handler exports are `passwordSignupHandler`, `passwordLoginHandler`, +`meHandler`, `refreshHandler`, `logoutHandler`, `googleStartHandler`, +`googleCallbackHandler`, `githubStartHandler`, `githubCallbackHandler`, +`makeForgotPasswordHandler`, `resetPasswordHandler`, +`makeEmailVerifyRequestHandler`, and `makeEmailVerifyHandler`. + +Package entrypoints: + +- `@spacetimedb/auth/submodule` is the normal host integration surface. +- `@spacetimedb/auth/handlers` exports HTTP handler factories. +- `@spacetimedb/auth/tables` exports lower-level table definitions. +- `@spacetimedb/auth/crypto`, `/jwt`, and `/keys` export focused helpers. +- `@spacetimedb/auth` re-exports the supported public surface. + +## Security guarantees + +- Passwords use scrypt with parameters encoded in the stored hash. +- Signing keys, OAuth secrets, and session state live in private tables. +- The publishing owner seeds the initial admin state during `init`. +- Authentication handlers use deterministic module context for time and + randomness when running inside SpacetimeDB. +- Default authentication limits are production-oriented. Email-based limits + work directly. IP-based limits and stored session IPs are + enabled only when the host explicitly selects a trusted proxy header. + +- Google honors the provider's `email_verified` claim. GitHub selects a + verified address from the `/user/emails` response. +- When a new OAuth identity has the same email as an existing user, the callback + returns `account_link_required`. The host can provide an authenticated account + linking flow. +- OAuth completion redirects accept application-relative paths up to 2,048 + characters. Unsafe absolute, protocol-relative, backslash, fragment, control + character, and encoded forms are rejected before state is stored. + +Applications remain responsible for route exposure, cookie policy, mail +delivery, and the user experience for explicit account linking. + +## Testing + +```bash +pnpm test +pnpm run typecheck +``` + +The unit suite covers key generation, JWT validation, password hashing, PKCE, +tokens, and UUID generation. Build the example module to validate submodule +schema integration. + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-auth-ts/example/.env.example b/spacetime-auth-ts/example/.env.example new file mode 100644 index 00000000000..31234a33528 --- /dev/null +++ b/spacetime-auth-ts/example/.env.example @@ -0,0 +1,30 @@ +# Copy to .env. The example server loads this on startup and bootstraps auth. + +# ---------------- Static server ---------------- +HOST=127.0.0.1 +PORT=8791 + +# ---------------- SpacetimeDB ---------------- +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +SPACETIMEDB_DB_NAME=spacetime-auth-example +STDB_SERVER=http://127.0.0.1:3000 + +# ---------------- Auth ---------------- +AUTH_ISSUER_URL=http://localhost:8791 +AUTH_BASE_URL=http://localhost:8791 +AUTH_COOKIE_NAME=stdb_auth +AUTH_SESSION_TTL_SECONDS=604800 + +# Optional. Leave blank to have the module generate an ES256 keypair on first startup bootstrap. +# Use \n escapes if putting a PEM on one line. +AUTH_ES256_PRIVATE_KEY_PEM= + +# OAuth (optional). Without these the corresponding buttons are disabled. +# Redirect URI to register with each provider: +# http://localhost:8791/auth/google/callback +# http://localhost:8791/auth/github/callback +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= diff --git a/spacetime-auth-ts/example/.npmrc b/spacetime-auth-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-auth-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-auth-ts/example/README.md b/spacetime-auth-ts/example/README.md new file mode 100644 index 00000000000..dd0f6e5fe2a --- /dev/null +++ b/spacetime-auth-ts/example/README.md @@ -0,0 +1,195 @@ +# Auth notes example + +This example is an end-to-end authentication application built with +[`@spacetimedb/auth`](../). A small realtime notes feature shows how an +authenticated application user is linked to a SpacetimeDB connection and used +for server-side authorization. + +## What this demonstrates + +- Password signup, login, logout, and session refresh. +- Optional Google and GitHub OAuth. +- ES256-signed application sessions stored in an HTTP-only cookie. +- Password reset and email-verification flows with a development mailer. +- Listing and revoking the current user's sessions. +- Linking an application session to a SpacetimeDB connection. +- Caller-scoped notes and profile views with realtime updates. +- Reconnecting the browser after a WebSocket interruption. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity. A fresh publish seeds the publisher as the initial auth + administrator. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-auth-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Open , create an account, and add a note. + +`build:module:fresh` deletes and recreates only the local `spacetime-auth-example` +database. Use `pnpm run build:module` when the existing local data must be +preserved. + +## Use in your project + +This workspace tests the submodule source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/auth spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Copy the HTTP +routes, connection binding, and caller-view patterns you use; replace the +console mailer and development server before production. + +## Configuration + +| Variable | Default | Purpose | +| ------------------------------ | ------------------------ | ----------------------------------------------------------------- | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8791` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | HTTP endpoint used by the auth proxy. | +| `STDB_SERVER` | `STDB_HTTP` | CLI target used during startup configuration. | +| `SPACETIMEDB_DB_NAME` | `spacetime-auth-example` | Published database name. | +| `AUTH_ISSUER_URL` | `http://localhost:8791` | JWT issuer and OAuth redirect origin. | +| `AUTH_BASE_URL` | `http://localhost:8791` | Browser-visible auth base URL. | +| `AUTH_COOKIE_NAME` | `stdb_auth` | Session-cookie name. | +| `AUTH_SESSION_TTL_SECONDS` | `604800` | Session lifetime in seconds. | +| `AUTH_ES256_PRIVATE_KEY_PEM` | generated by submodule | Optional persistent ES256 private key. | +| Google/GitHub client variables | empty | Enables the matching OAuth provider when both values are present. | + +The server loads `.env` and calls `set_auth_config` on every startup using the +logged-in CLI identity. Restart the server after changing auth or OAuth values. + +Keep `STDB_URI`, `STDB_HTTP`, and `STDB_SERVER` on the same SpacetimeDB instance. +Set `AUTH_ISSUER_URL` and `AUTH_BASE_URL` to the exact origin users load, including +scheme and port. + +## OAuth setup + +Register an application with each provider and add its client ID and secret to +`.env`. For the default local origin, register these callbacks: + +- Google: `http://localhost:8791/auth/google/callback` +- GitHub: `http://localhost:8791/auth/github/callback` + +The browser hides a provider button unless both corresponding values are present. +Do not put provider secrets in frontend code or `/api/config`. + +## Architecture + +```text +Browser + -> same-origin /auth/* requests -> Node proxy -> module HTTP router + -> SpacetimeDB WebSocket -> link_connection -> my_notes / my_auth_user + +SpacetimeDB module + -> private auth/session/account tables + -> application connection bindings + -> caller-scoped notes and profile views +``` + +The Node proxy exists so development cookies remain same-origin. After signup, +login, or refresh, the browser links the returned application token to its +SpacetimeDB connection before subscribing. The module's `my_notes` and +`my_auth_user` views derive the user from that binding and ignore user IDs sent +by the browser. + +## Development mailer + +The example uses a console mailer. Password-reset and +email-verification messages, including their one-time links, appear in the +SpacetimeDB module logs. Production deployments require a delivery provider. + +Production applications should send mail through a real provider, avoid logging +tokens, and apply appropriate retention and redaction to application logs. + +## Security and deployment boundaries + +- A fresh publish seeds the publishing owner in the private + `auth_admin_identity` table. +- Auth configuration can be changed only by an existing administrator. +- Password hashes, OAuth secrets, signing keys, session cookies, reset tokens, and + `.env` must not be committed or logged. +- `AUTH_ES256_PRIVATE_KEY_PEM` should come from durable secret storage in + production. Relying on a generated development key makes sessions dependent on + the database's retained state. +- The included Express process is a development server. Production deployment + needs TLS, explicit network binding, origin/host policy, trusted-proxy settings, + durable secrets, and process supervision. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run build +pnpm exec tsc -p tsconfig.json +``` + +For a release smoke test, use two accounts and verify: + +1. Signup, login, reload-based refresh, and logout. +2. Create, edit, and delete notes with realtime subscription updates. +3. One account cannot subscribe to or mutate the other account's notes. +4. Session listing and revocation invalidate the selected session. +5. Password reset and email verification complete using the one-time links in the + module log. +6. Each configured OAuth provider completes its callback and establishes a linked + SpacetimeDB session. + +Useful owner-only diagnostics: + +```powershell +spacetime sql --server http://127.0.0.1:3000 spacetime-auth-example "SELECT user_id, email FROM auth_user" +spacetime sql --server http://127.0.0.1:3000 spacetime-auth-example "SELECT * FROM auth_connection_binding" +``` + +## Troubleshooting + +- **Startup configuration fails:** verify the database is published and the CLI is + logged in as its owner or a registered auth administrator. +- **Cookies fail to restore:** use one consistent hostname. `localhost` and + `127.0.0.1` are different cookie hosts. +- **Scoped subscriptions are empty:** confirm `link_connection` succeeded before + the subscriptions were created. +- **OAuth reports a redirect mismatch:** compare the registered callback byte for + byte with the URL derived from `AUTH_ISSUER_URL`. +- **Sessions fail after a fresh publish:** clear site data and sign in again; + the database was replaced. + +## Important files + +- `spacetimedb/src/index.ts` - Auth registration, scoped views, notes, and HTTP handlers. +- `server.ts` - startup configuration, static serving, and auth proxy. +- `src/app.ts` - auth calls, connection linking, subscriptions, and reconnects. +- `public/index.html` - notes and account-management interface. +- `public/ui.js` - DOM state, rendering, and interaction handling. +- `public/styles.css` - application presentation. diff --git a/spacetime-auth-ts/example/package.json b/spacetime-auth-ts/example/package.json new file mode 100644 index 00000000000..709f999f1c9 --- /dev/null +++ b/spacetime-auth-ts/example/package.json @@ -0,0 +1,28 @@ +{ + "name": "spacetime-auth-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", + "check": "tsc --noEmit", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "build": "pnpm run spacetime:generate && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "@spacetimedb/submodule-shared": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-auth-ts/example/public/index.html b/spacetime-auth-ts/example/public/index.html new file mode 100644 index 00000000000..8cbb9d6ffd8 --- /dev/null +++ b/spacetime-auth-ts/example/public/index.html @@ -0,0 +1,194 @@ + + + + + + + SpacetimeDB Notes + + + + +
+ + SpacetimeDB Notes +
+
+
+
+ SpacetimeDB +

Notes Example

+
+
+ + + initializing… + + +
+
+ +
+
+
+
+
+ + + +
+ + + +
+ + + + + diff --git a/spacetime-auth-ts/example/public/styles.css b/spacetime-auth-ts/example/public/styles.css new file mode 100644 index 00000000000..b6a4b75c7c2 --- /dev/null +++ b/spacetime-auth-ts/example/public/styles.css @@ -0,0 +1,894 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&family=Source+Code+Pro:wght@400;500;600&display=swap'); + +:root { + /* Tokens copied from spacetime-web/spacetimedb.com/app/styles/variables.css */ + --font-inter: 'Inter Variable', 'Inter', sans-serif; + --font-source: 'Source Code Pro Variable', 'Source Code Pro', monospace; + --font-ibm: 'IBM Plex Mono', monospace; + + --color-green: #4cf490; + --color-green-10: #4cf4901a; + --color-green-20: #4cf49033; + --color-green-25: #4cf49040; + --color-green-50: #4cf49080; + --color-green-75: #4cf490bf; + --color-white: #d7d8d9; + --color-yellow: #fbdc8e; + --color-yellow-10: #fbdc8e1a; + --color-yellow-20: #fbdc8e33; + --color-purple: #a880ff; + --color-purple-2: #8a38f5; + --color-orange: #ff9e9e; + --color-blue: #02befa; + --color-blue-10: #02befa1a; + --color-blue-20: #02befa33; + --color-pink: #ff80fb; + --color-teal: #00ccb4; + --color-red: #ff4c4c; + --color-brown: #3b3b3b; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n5: #363840; + --color-n6: #202126; + --color-n7: #050505; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade2: #122530; + --color-shade3: #122129; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + --color-shade8: #0b0e12; + + --color-border: var(--color-shade4); + --color-text: var(--color-n1); + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; +} +* { + box-sizing: border-box; +} +[hidden] { + display: none !important; +} +html, +body { + margin: 0; + height: 100%; +} +body { + font-family: var(--font-inter); + color: var(--color-white); + background: var(--color-shade7); + overflow: hidden; +} +::selection { + background: var(--color-green); + color: var(--color-n8); +} +a { + color: var(--color-green); + text-decoration: none; +} +a:visited { + color: var(--color-purple); +} +a:hover { + text-decoration: underline; +} + +/* Compact scrollbars matching the SpacetimeDB dashboard. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--color-shade4) var(--color-shade7); +} +*::-webkit-scrollbar { + width: 4px; + height: 4px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background: var(--color-shade4); + border-radius: 2px; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--color-shade3); +} +*::-webkit-scrollbar-corner { + background: var(--color-shade7); +} + +/* shell + topnav */ +.shell { + width: min(1320px, calc(100% - 32px)); + margin: 14px auto; + height: calc(100dvh - 28px); + display: flex; + flex-direction: column; + gap: 14px; +} +.topnav { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid #17303b; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #0d1920, #0b1319); + padding: 9px 12px; + box-shadow: inset 0 1px 0 #26435166; +} +.brand { + display: inline-flex; + align-items: center; + gap: 10px; +} +.brand-wordmark { + display: block; + height: 28px; +} +.brand-sub { + margin: 0; + padding: 2px 7px; + border: 1px solid #2a4250; + border-radius: 999px; + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 500; + line-height: 1.2; + color: #9cb1cb; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.topnav-actions { + display: flex; + align-items: center; + gap: 8px; +} +.meta-pill { + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid #27414e; + border-radius: 999px; + padding: 4px 10px; + font-family: var(--font-ibm); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #c6d1e1; + background: transparent; +} +.meta-pill.good { + border-color: #31684c; + color: var(--color-green); +} +.meta-pill.err { + border-color: #6a2929; + color: var(--color-red); +} +.meta-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-n4); +} +.meta-pill.good .meta-dot { + background: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-20); +} +.meta-pill.err .meta-dot { + background: var(--color-red); +} + +/* main layout */ +.main { + flex: 1 1 auto; + min-height: 0; + display: grid; + grid-template-columns: 380px 1fr; + gap: 14px; + align-items: stretch; +} +.main.anon { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: + radial-gradient( + ellipse 80% 50% at 50% 0%, + var(--color-green-20), + transparent 60% + ), + var(--color-shade7); +} +.main.anon .auth-stack { + width: 100%; + max-width: 380px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.divider { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 10px; + align-items: center; + margin: 14px 0; + font-family: var(--font-ibm); + font-size: 10px; + color: var(--color-n4); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.divider::before, +.divider::after { + content: ''; + height: 1px; + background: #17303b; +} +.toggle-foot { + margin: 14px 0 0; + text-align: center; + font-size: 12px; + color: var(--color-n4); +} +.toggle-foot a { + color: var(--color-green); + text-decoration: none; + cursor: pointer; + font-weight: 600; +} +.toggle-foot a:hover { + text-decoration: underline; +} +@media (max-width: 960px) { + .main:not(.anon) { + grid-template-columns: 1fr; + } +} +.panel { + border: 1px solid #17303b; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + padding: 18px; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.panel h2 { + margin: 0 0 4px; + font-size: 16px; + font-weight: 700; + display: inline-flex; + align-items: baseline; + gap: 8px; +} +.panel h2 .count { + font-family: var(--font-ibm); + font-size: 11px; + font-weight: 500; + color: var(--color-n4); +} +.panel-sub { + margin: 0 0 14px; + color: var(--color-n4); + font-size: 12px; + line-height: 1.5; +} +.panel-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + margin: 0 -18px -18px; + padding: 0 18px 18px; +} + +/* form controls */ +label { + display: block; + font-size: 11px; + color: var(--color-n4); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; +} +.field { + margin-bottom: 12px; +} +input, +select, +textarea { + width: 100%; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + border-radius: var(--radius); + color: var(--color-text); + font-family: inherit; + font-size: 13px; + padding: 9px 11px; + outline: none; + transition: + border-color 0.2s, + box-shadow 0.2s; +} +input:focus, +select:focus, +textarea:focus { + border-color: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-20); +} +textarea { + font-family: var(--font-ibm); + font-size: 12px; + min-height: 60px; + resize: vertical; +} +input.mono { + font-family: var(--font-ibm); + font-size: 12px; +} + +/* Buttons match spacetimedb.com Button.module.css: + primary = n3 bg, n8 text, white hover, green active, green focus outline + tertiary = shade7 bg, n2 text, shade4 hover, green active + text = transparent, green text + danger = bordered, soft orange text, soft orange hover wash */ +.btn { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 8px 16px; + border-radius: 4px; + font-family: var(--font-inter); + font-size: 13px; + font-weight: 600; + border: 1px solid transparent; + background: var(--color-shade7); + color: var(--color-n2); + cursor: pointer; + transition: + background 0.2s, + border-color 0.2s, + color 0.2s; +} +.btn:hover:not(:disabled) { + background: var(--color-shade4); + color: var(--color-white); +} +.btn:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} + +.btn.primary { + background: var(--color-n3); + border: 2px solid var(--color-n3); + color: var(--color-n8); +} +.btn.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); + color: var(--color-n8); +} +.btn.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); +} + +.btn.danger { + background: transparent; + border: 1px solid var(--color-shade1); + color: var(--color-orange); +} +.btn.danger:hover:not(:disabled) { + background: rgba(255, 158, 158, 0.08); + border-color: var(--color-orange); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.btn.tiny { + height: auto; + font-size: 11px; + padding: 4px 8px; +} +.btn.block { + width: 100%; +} + +.row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +/* avatar + dropdown menu */ +.avatar-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + border: 1px solid var(--color-shade1); + background: var(--color-shade5); + color: var(--color-n2); + font-family: var(--font-inter); + font-size: 13px; + font-weight: 700; + cursor: pointer; + transition: + background 0.2s, + border-color 0.2s; +} +.avatar-btn:hover { + background: var(--color-shade4); + border-color: var(--color-white); +} +.avatar-btn:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} + +.avatar-wrap { + position: relative; +} +.avatar-menu { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 320px; + border: 1px solid var(--color-shade1); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.5); + padding: 16px; + z-index: 70; + opacity: 0; + transform: translateY(-6px); + pointer-events: none; + transition: + opacity 160ms ease, + transform 160ms ease; +} +.avatar-menu.is-open { + opacity: 1; + transform: none; + pointer-events: auto; +} +.avatar-menu .who { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 14px; +} +.avatar-menu .who .big-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--color-shade7); + border: 1px solid var(--color-shade1); + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: var(--color-white); +} +.avatar-menu .who .who-text { + min-width: 0; +} +.avatar-menu .who .name { + font-weight: 600; + font-size: 13px; + color: var(--color-white); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.avatar-menu .who .email { + font-size: 12px; + color: var(--color-n4); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-ibm); +} +.avatar-menu hr { + border: none; + border-top: 1px solid var(--color-shade1); + margin: 12px 0; +} +.avatar-menu .menu-actions { + display: flex; + flex-direction: column; + gap: 6px; +} +.avatar-menu details { + margin: 4px 0 8px; +} +.avatar-menu details summary { + cursor: pointer; + font-size: 11px; + color: var(--color-n4); + text-transform: uppercase; + letter-spacing: 0.06em; + font-weight: 600; + list-style: none; + padding: 4px 0; +} +.avatar-menu details summary::-webkit-details-marker { + display: none; +} +.avatar-menu details summary::after { + content: ' ▸'; + opacity: 0.5; +} +.avatar-menu details[open] summary::after { + content: ' ▾'; +} +.avatar-menu .id-row { + font-family: var(--font-ibm); + font-size: 11px; + padding: 8px 10px; + border-radius: var(--radius); + background: var(--color-shade7); + border: 1px solid var(--color-shade4); + margin-top: 6px; + word-break: break-all; + color: var(--color-n3); +} +.avatar-menu .id-row .lbl { + color: var(--color-n4); + text-transform: uppercase; + letter-spacing: 0.06em; + font-size: 10px; + font-weight: 600; + font-family: var(--font-inter); + margin-bottom: 2px; +} + +/* Compose + notes grid */ +.notes-view { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} +.notes-shell { + display: flex; + flex-direction: column; + gap: 18px; + width: 100%; + margin: 0 auto; + padding: 24px 0; +} +.compose-card { + background: var(--color-shade6); + border: 1px solid var(--color-shade1); + border-radius: var(--radius-lg); + padding: 12px 14px; + transition: + box-shadow 0.2s, + border-color 0.2s; +} +.compose-card:focus-within { + border-color: var(--color-white); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} +.compose-card .compose-title, +.compose-card .compose-body { + background: transparent; + border: none; + padding: 4px 0; + font-size: 14px; + color: var(--color-text); +} +.compose-card .compose-title { + font-weight: 600; + font-family: var(--font-inter); +} +.compose-card .compose-title::placeholder, +.compose-card .compose-body::placeholder { + color: var(--color-n4); +} +.compose-card .compose-body { + font-family: var(--font-inter); + font-size: 13px; + min-height: 24px; +} +.compose-card .compose-title:focus, +.compose-card .compose-body:focus { + box-shadow: none; + outline: none; +} +.compose-card.collapsed .compose-title, +.compose-card.collapsed .compose-actions { + display: none; +} +.compose-card .compose-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; + border-top: 1px solid var(--color-shade1); + padding-top: 8px; +} + +.notes-grid { + column-count: 4; + column-gap: 12px; +} +@media (max-width: 1200px) { + .notes-grid { + column-count: 3; + } +} +@media (max-width: 900px) { + .notes-grid { + column-count: 2; + } +} +@media (max-width: 560px) { + .notes-grid { + column-count: 1; + } +} + +.note-card { + break-inside: avoid; + margin-bottom: 12px; + background: var(--color-shade6); + border: 1px solid var(--color-shade1); + border-radius: var(--radius); + padding: 12px 14px; + position: relative; + transition: + border-color 0.2s, + box-shadow 0.2s; + cursor: default; + display: inline-block; + width: 100%; +} +.note-card:hover { + border-color: var(--color-white); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); +} +.note-card .nc-title { + font-weight: 600; + font-size: 14px; + color: var(--color-white); + margin-bottom: 4px; + word-break: break-word; + overflow-wrap: anywhere; +} +.note-card .nc-body { + font-size: 13px; + color: var(--color-n2); + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-inter); +} +.note-card .nc-meta { + margin-top: 10px; + font-family: var(--font-ibm); + font-size: 10px; + color: var(--color-n4); +} +.note-card .nc-del { + position: absolute; + top: 6px; + right: 6px; + width: 24px; + height: 24px; + border-radius: 50%; + background: transparent; + border: none; + color: var(--color-n4); + cursor: pointer; + opacity: 0; + transition: + opacity 0.15s, + background 0.15s, + color 0.15s; + font-size: 14px; + line-height: 1; +} +.note-card:hover .nc-del { + opacity: 1; +} +.note-card .nc-del:hover { + background: var(--color-shade4); + color: var(--color-orange); +} +.note-card { + cursor: pointer; +} +.note-card .nc-del { + cursor: pointer; +} + +/* edit modal */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 90; + display: none; + align-items: center; + justify-content: center; + padding: 24px; +} +.modal-backdrop.is-open { + display: flex; +} +.modal-card { + width: min(560px, 100%); + max-height: calc(100dvh - 48px); + background: var(--color-shade6); + border: 1px solid var(--color-shade1); + border-radius: var(--radius-lg); + padding: 16px 18px; + display: flex; + flex-direction: column; + gap: 4px; + box-shadow: 0 30px 80px rgba(0, 0, 0, 0.6); +} +.modal-card .modal-title, +.modal-card .modal-body { + background: transparent; + border: none; + padding: 6px 0; + font-size: 15px; + color: var(--color-text); + outline: none; + box-shadow: none; +} +.modal-card .modal-title { + font-weight: 600; +} +.modal-card .modal-body { + font-family: var(--font-inter); + font-size: 14px; + min-height: 120px; + flex: 1 1 auto; + resize: vertical; +} +.modal-card .modal-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--color-shade1); + gap: 8px; +} +.modal-card .modal-actions .right { + display: flex; + gap: 8px; +} + +.empty-hero { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 32px 16px; + color: var(--color-n4); +} +.empty-hero .glyph { + font-size: 22px; + opacity: 0.45; + margin-bottom: 4px; +} +.empty-hero .title { + font-size: 13px; + font-weight: 600; + color: var(--color-n3); +} +.empty-hero .sub { + font-size: 11px; + font-family: var(--font-ibm); + color: var(--color-n4); +} + +/* error toast */ +#toast { + position: fixed; + top: 18px; + left: 50%; + transform: translateX(-50%); + z-index: 80; + pointer-events: none; + max-width: min(420px, calc(100% - 44px)); +} +#toast:empty { + display: none; +} +.toast-msg { + pointer-events: auto; + padding: 10px 14px; + border-radius: var(--radius); + font-size: 13px; + font-family: var(--font-ibm); + background: var(--color-orange-08); + border: 1px solid var(--color-orange-45); + color: var(--color-orange); + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.5); + animation: slideIn 180ms ease; + cursor: pointer; +} +.toast-msg.ok { + background: rgba(76, 244, 144, 0.08); + border-color: rgba(76, 244, 144, 0.45); + color: var(--color-green); +} +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.boot-splash { + position: fixed; + inset: 0; + z-index: 9999; + background: var(--color-shade7); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + color: var(--color-green); + transition: opacity 200ms ease; +} +.boot-splash svg { + animation: boot-pulse 1.4s ease-in-out infinite; +} +.boot-splash-label { + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-n4); +} +.boot-splash.fading { + opacity: 0; + pointer-events: none; +} +@keyframes boot-pulse { + 0%, + 100% { + opacity: 0.4; + transform: scale(0.95); + } + 50% { + opacity: 1; + transform: scale(1); + } +} diff --git a/spacetime-auth-ts/example/public/ui.js b/spacetime-auth-ts/example/public/ui.js new file mode 100644 index 00000000000..218ed5272c1 --- /dev/null +++ b/spacetime-auth-ts/example/public/ui.js @@ -0,0 +1,317 @@ +const $ = id => document.getElementById(id); + +function fmtTimestamp(micros) { + if (micros == null) return '-'; + return new Date(Number(BigInt(micros) / 1000n)).toLocaleString(); +} +function fmtUnixSeconds(s) { + if (!s) return '-'; + return new Date(s * 1000).toLocaleString(); +} +function escapeHtml(s) { + return String(s ?? '').replace( + /[&<>"']/g, + c => + ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[c] + ); +} +function showToast(kind, msg, dur = 4500) { + const el = $('toast'); + el.innerHTML = `
${escapeHtml(msg)}
`; + const child = el.firstChild; + child.addEventListener('click', () => (el.innerHTML = '')); + setTimeout(() => { + if (el.firstChild === child) el.innerHTML = ''; + }, dur); +} + +window.addEventListener('auth:conn', e => { + const pill = $('conn-pill'); + const text = $('conn-text'); + pill.classList.remove('good', 'err'); + if (e.detail.state === 'connected') { + pill.classList.add('good'); + text.textContent = 'connected'; + } else if (e.detail.state === 'connecting' || e.detail.state === 'idle') { + text.textContent = e.detail.state; + } else { + pill.classList.add('err'); + text.textContent = e.detail.detail || 'disconnected'; + } +}); +function dismissBootSplash() { + const splash = document.getElementById('bootSplash'); + if (!splash) return; + splash.classList.add('fading'); + setTimeout(() => splash.remove(), 250); +} +window.addEventListener('auth:ready', dismissBootSplash); +setTimeout(dismissBootSplash, 4000); + +function initial(s) { + const t = String(s ?? '').trim(); + return t ? t[0].toUpperCase() : '?'; +} +window.addEventListener('auth:state', e => { + const user = e.detail.user; + const anon = $('anon-view'); + const view = $('user-view'); + const wrap = $('avatar-wrap'); + + if (user) { + anon.hidden = true; + view.hidden = false; + wrap.hidden = false; + const letter = initial(user.name || user.email); + $('avatar-btn').textContent = letter; + $('big-avatar').textContent = letter; + $('who-name').textContent = user.name || user.email; + $('who-email').textContent = user.email; + $('who-uid').textContent = user.userId; + $('who-stdb').textContent = e.detail.senderHex ?? '-'; + $('who-exp').textContent = fmtUnixSeconds(e.detail.sessionExpiresAt); + $('email-unverified').hidden = !!user.emailVerified; + } else { + anon.hidden = false; + view.hidden = true; + wrap.hidden = true; + $('avatar-menu').classList.remove('is-open'); + } +}); + +const avatarBtn = $('avatar-btn'); +const avatarMenu = $('avatar-menu'); +avatarBtn.addEventListener('click', e => { + e.stopPropagation(); + const open = avatarMenu.classList.toggle('is-open'); + avatarBtn.setAttribute('aria-expanded', String(open)); + if (open) refreshSessionsList(); +}); +async function refreshSessionsList() { + const box = $('sessions-list'); + try { + const r = await window.auth.listMySessions(); + if (!r.sessions.length) { + box.innerHTML = '
No active sessions.
'; + return; + } + box.innerHTML = r.sessions + .map( + s => ` +
+
${fmtTimestamp(s.createdAt.microsSinceUnixEpoch)}
+
${escapeHtml(s.userAgent ?? 'unknown UA')}
+ +
+ ` + ) + .join(''); + box.querySelectorAll('[data-revoke]').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Revoke this session?')) return; + try { + await window.auth.revokeMySession(btn.dataset.revoke); + refreshSessionsList(); + } catch (err) { + showToast('err', err.message ?? String(err)); + } + }); + }); + } catch (err) { + box.innerHTML = `
${escapeHtml(err.message ?? String(err))}
`; + } +} +$('resend-verify-btn').addEventListener('click', () => + tryCall('resend-verify-btn', async () => { + await window.auth.requestEmailVerify(); + showToast('ok', 'verification email sent (check STDB log in dev)', 7000); + }) +); +document.addEventListener('click', e => { + if (!avatarMenu.classList.contains('is-open')) return; + if (!avatarMenu.contains(e.target) && e.target !== avatarBtn) { + avatarMenu.classList.remove('is-open'); + avatarBtn.setAttribute('aria-expanded', 'false'); + } +}); + +const notesById = new Map(); +window.addEventListener('auth:notes', e => { + const notes = e.detail.notes; + notesById.clear(); + notes.forEach(n => notesById.set(n.noteId, n)); + + const list = $('notes-list'); + const empty = $('notes-empty'); + if (notes.length === 0) { + list.hidden = true; + empty.hidden = false; + return; + } + empty.hidden = true; + list.hidden = false; + list.innerHTML = notes + .map( + n => ` +
+ + ${n.title ? `
${escapeHtml(n.title)}
` : ''} +
${escapeHtml(n.body)}
+
${fmtTimestamp(n.createdAt.microsSinceUnixEpoch)}
+
+ ` + ) + .join(''); + list.querySelectorAll('[data-del]').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + if (!confirm('Delete this note?')) return; + try { + await window.auth.deleteNote(btn.dataset.del); + } catch (err) { + showToast('err', err.message ?? String(err)); + } + }); + }); + list.querySelectorAll('[data-edit]').forEach(card => { + card.addEventListener('click', () => openEdit(card.dataset.edit)); + }); + + // Refresh open modal if its note row changed underneath us. + if (editingId && notesById.has(editingId)) { + const updated = notesById.get(editingId); + if ( + $('edit-title').value === editOriginal.title && + $('edit-body').value === editOriginal.body + ) { + // user hasn't typed; pull the new values in + $('edit-title').value = updated.title; + $('edit-body').value = updated.body; + editOriginal = { title: updated.title, body: updated.body }; + } + } +}); + +let editingId = null; +let editOriginal = { title: '', body: '' }; +const editBackdrop = $('edit-backdrop'); +const editTitle = $('edit-title'); +const editBody = $('edit-body'); + +function openEdit(noteId) { + const n = notesById.get(noteId); + if (!n) return; + editingId = noteId; + editOriginal = { title: n.title, body: n.body }; + editTitle.value = n.title; + editBody.value = n.body; + editBackdrop.classList.add('is-open'); + setTimeout(() => editBody.focus(), 0); +} +function closeEdit() { + editBackdrop.classList.remove('is-open'); + editingId = null; +} +async function saveEdit() { + if (!editingId) return; + const id = editingId; + const title = editTitle.value; + const body = editBody.value; + if (title === editOriginal.title && body === editOriginal.body) { + closeEdit(); + return; + } + try { + await window.auth.updateNote({ noteId: id, title, body }); + closeEdit(); + } catch (err) { + showToast('err', err.message ?? String(err)); + } +} +async function deleteFromEdit() { + if (!editingId) return; + if (!confirm('Delete this note?')) return; + const id = editingId; + try { + await window.auth.deleteNote(id); + closeEdit(); + } catch (err) { + showToast('err', err.message ?? String(err)); + } +} +editBackdrop.addEventListener('click', e => { + if (e.target === editBackdrop) saveEdit(); +}); +$('edit-cancel').addEventListener('click', closeEdit); +$('edit-save').addEventListener('click', saveEdit); +$('edit-del').addEventListener('click', deleteFromEdit); +document.addEventListener('keydown', e => { + if (e.key === 'Escape' && editBackdrop.classList.contains('is-open')) + closeEdit(); +}); + +const compose = $('compose-card'); +const ntBody = $('nt-body'); +const ntTitle = $('nt-title'); +function expandCompose() { + compose.classList.remove('collapsed'); +} +function collapseCompose() { + compose.classList.add('collapsed'); + ntTitle.value = ''; + ntBody.value = ''; + ntBody.style.height = ''; +} +ntBody.addEventListener('focus', expandCompose); +ntTitle.addEventListener('focus', expandCompose); +ntBody.addEventListener('input', () => { + ntBody.style.height = 'auto'; + ntBody.style.height = ntBody.scrollHeight + 'px'; +}); +$('nt-cancel').addEventListener('click', collapseCompose); + +async function tryCall(btnId, fn, okMsg) { + const btn = $(btnId); + btn.disabled = true; + try { + await fn(); + if (okMsg) showToast('ok', okMsg); + } catch (err) { + showToast('err', err.message ?? String(err)); + } finally { + btn.disabled = false; + } +} + +$('logout-btn').addEventListener('click', () => + tryCall('logout-btn', () => window.auth.logout(), 'signed out') +); +$('whoami-btn').addEventListener('click', () => + tryCall('whoami-btn', async () => { + const r = await window.auth.whoami(); + showToast( + 'ok', + `userId=${r.userId ?? 'null'} sender=${r.senderIdentityHex.slice(0, 16)}…`, + 6000 + ); + }) +); +$('nt-btn').addEventListener('click', () => + tryCall( + 'nt-btn', + async () => { + const title = ntTitle.value.trim(); + const body = ntBody.value; + if (!title && !body.trim()) return; + await window.auth.createNote({ title: title || '', body }); + collapseCompose(); + }, + 'note saved' + ) +); diff --git a/spacetime-auth-ts/example/server.ts b/spacetime-auth-ts/example/server.ts new file mode 100644 index 00000000000..9653b5f3e92 --- /dev/null +++ b/spacetime-auth-ts/example/server.ts @@ -0,0 +1,200 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; +import { exampleUiAssetsDir } from '@spacetimedb/submodule-shared/server'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const PUBLIC_DIR = path.join(__dirname, 'public'); +const SPA_HTML = readFileSync(path.join(PUBLIC_DIR, 'index.html'), 'utf8'); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +// Shared/root env supplies secrets; example-local env supplies app defaults. +// Blank placeholders in the example .env should not erase shared secrets. +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8791', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-auth-example'; +const AUTH_ISSUER_URL = + process.env.AUTH_ISSUER_URL ?? `http://localhost:${PORT}`; +const AUTH_BASE_URL = process.env.AUTH_BASE_URL ?? AUTH_ISSUER_URL; +const AUTH_COOKIE_NAME = process.env.AUTH_COOKIE_NAME ?? 'stdb_auth'; +const AUTH_SESSION_TTL_SECONDS = Number.parseInt( + process.env.AUTH_SESSION_TTL_SECONDS ?? `${60 * 60 * 24 * 7}`, + 10 +); +if ( + !Number.isInteger(AUTH_SESSION_TTL_SECONDS) || + AUTH_SESSION_TTL_SECONDS <= 0 +) { + throw new Error('AUTH_SESSION_TTL_SECONDS must be a positive integer'); +} +const GOOGLE_OAUTH_ENABLED = Boolean( + process.env.GOOGLE_CLIENT_ID?.trim() && + process.env.GOOGLE_CLIENT_SECRET?.trim() +); +const GITHUB_OAUTH_ENABLED = Boolean( + process.env.GITHUB_CLIENT_ID?.trim() && + process.env.GITHUB_CLIENT_SECRET?.trim() +); +const STDB_SERVER = process.env.STDB_SERVER ?? STDB_HTTP; +const SPACETIME_BIN = 'spacetime'; + +function configuredValue(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function configuredPem(value: string | undefined): string | undefined { + return configuredValue(value)?.replace(/\\n/g, '\n'); +} + +const opt = (value: string | undefined) => + value === undefined ? JSON.stringify([1, []]) : JSON.stringify([0, value]); + +function configureAuthFromEnv(): void { + const args = [ + JSON.stringify(AUTH_ISSUER_URL), + opt(AUTH_BASE_URL), + opt(AUTH_COOKIE_NAME), + JSON.stringify([0, AUTH_SESSION_TTL_SECONDS]), + opt(configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM)), + opt(configuredValue(process.env.GOOGLE_CLIENT_ID)), + opt(configuredValue(process.env.GOOGLE_CLIENT_SECRET)), + opt(configuredValue(process.env.GITHUB_CLIENT_ID)), + opt(configuredValue(process.env.GITHUB_CLIENT_SECRET)), + ]; + + const result = spawnSync( + SPACETIME_BIN, + ['call', '--server', STDB_SERVER, DB_NAME, 'set_auth_config', ...args], + { stdio: 'inherit', shell: false } + ); + if (result.status !== 0) { + throw new Error(`auth config bootstrap failed (exit ${result.status})`); + } +} + +const app = express(); +app.use(express.json({ limit: '256kb' })); + +// Register this before the /auth proxy so reset links reach the SPA. +app.get('/auth/password/reset', (_req: Request, res: Response) => { + res.type('html').send(SPA_HTML); +}); + +// Using app.use as middleware since Express 4's `app.all('/auth/*', ...)` does +// not match nested paths reliably. +app.use('/auth', async (req, res) => { + const fullPath = `/auth${req.url}`; // req.url is relative to the /auth route prefix + const qIdx = fullPath.indexOf('?'); + const path = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); + const query = qIdx < 0 ? '' : fullPath.slice(qIdx); + const upstreamUrl = `${STDB_HTTP}/v1/database/${DB_NAME}/route${path}${query}`; + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === 'string') headers[k] = v; + else if (Array.isArray(v)) headers[k] = v.join(', '); + } + // Host must point at STDB or some setups 404. + delete headers.host; + delete headers['content-length']; + headers['x-forwarded-proto'] = headers['x-forwarded-proto'] ?? req.protocol; + + // redirect:manual so upstream 302s (e.g. OAuth start) pass through to the browser. + const init: RequestInit = { method: req.method, headers, redirect: 'manual' }; + if (req.method !== 'GET' && req.method !== 'HEAD') { + init.body = JSON.stringify(req.body); + headers['content-type'] = 'application/json'; + } + + try { + const upstream = await fetch(upstreamUrl, init); + res.status(upstream.status); + upstream.headers.forEach((val, key) => { + const lower = key.toLowerCase(); + if ( + lower === 'transfer-encoding' || + lower === 'content-encoding' || + lower === 'content-length' + ) + return; + res.setHeader(key, val); + }); + const buf = Buffer.from(await upstream.arrayBuffer()); + res.send(buf); + } catch (err) { + res + .status(502) + .json({ error: 'upstream_unreachable', detail: (err as Error).message }); + } +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ + spacetimeUri: STDB_URI, + databaseName: DB_NAME, + auth: { + issuerUrl: AUTH_ISSUER_URL, + baseUrl: AUTH_BASE_URL, + cookieName: AUTH_COOKIE_NAME, + sessionTtlSeconds: AUTH_SESSION_TTL_SECONDS, + hasEs256PrivateKeyPem: Boolean( + configuredPem(process.env.AUTH_ES256_PRIVATE_KEY_PEM) + ), + }, + oauth: { + google: GOOGLE_OAUTH_ENABLED, + github: GITHUB_OAUTH_ENABLED, + }, + }); +}); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, databaseName: DB_NAME }); +}); + +app.use('/assets', express.static(exampleUiAssetsDir)); +app.use(express.static(PUBLIC_DIR)); + +try { + console.log(`[auth] bootstrapping env config via ${SPACETIME_BIN}`); + configureAuthFromEnv(); + console.log(`[auth] bootstrapped env config issuer=${AUTH_ISSUER_URL}`); +} catch (err) { + console.error( + `[auth] env config bootstrap failed: ${err instanceof Error ? err.message : String(err)}` + ); + console.error( + '[auth] is the SpacetimeDB host running and the auth example module published?' + ); + process.exit(1); +} + +app.listen(PORT, HOST, () => { + console.log(`Notes example running at http://${HOST}:${PORT}`); + console.log(` STDB ws -> ${STDB_URI}`); + console.log(` STDB http-> ${STDB_HTTP} (proxying /auth/*)`); + console.log(` Database -> ${DB_NAME}`); +}); diff --git a/spacetime-auth-ts/example/spacetimedb/.npmrc b/spacetime-auth-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-auth-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-auth-ts/example/spacetimedb/package.json b/spacetime-auth-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..a6666973260 --- /dev/null +++ b/spacetime-auth-ts/example/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-auth-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-auth-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-auth-example" + }, + "dependencies": { + "@spacetimedb/auth": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-auth-ts/example/spacetimedb/src/index.ts b/spacetime-auth-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..e30e48b4a5d --- /dev/null +++ b/spacetime-auth-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,300 @@ +import { schema, t, table, Router, SenderError } from 'spacetimedb/server'; +import type { Timestamp } from 'spacetimedb'; +import * as auth from '@spacetimedb/auth/submodule'; +import { + setAuthConfigParams, + getPublicKeyPemParams, + linkConnectionParams, + unlinkConnectionParams, + updateProfileParams, + revokeSessionParams, + listMySessionsParams, + revokeMySessionParams, + passwordSignupHandler, + passwordLoginHandler, + meHandler, + logoutHandler, + refreshHandler, + googleStartHandler, + googleCallbackHandler, + githubStartHandler, + githubCallbackHandler, + makeForgotPasswordHandler, + resetPasswordHandler, + makeEmailVerifyRequestHandler, + makeEmailVerifyHandler, + getCallerUserId, + type SendMailFn, + type MailParams, +} from '@spacetimedb/auth/submodule'; + +const consoleSendMail: SendMailFn = (_ctx, params: MailParams) => { + console.log( + `[mail] to=${params.to} subject=${params.subject}\n${params.text}` + ); +}; + +const authUserViewRow = t.object('ExampleAuthUser', { + userId: t.string(), + email: t.string(), + emailVerified: t.bool(), + name: t.option(t.string()), + image: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}); + +const note = table( + { name: 'note', public: false }, + { + noteId: t.string().primaryKey(), + authorId: t.string().index(), + title: t.string(), + body: t.string(), + createdAt: t.timestamp().index(), + } +); + +const spacetimedb = schema({ + auth, + note, +}); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + auth.installAuth(ctx.as.auth); +}); + +export const set_auth_config = spacetimedb.reducer( + setAuthConfigParams, + (ctx, args) => { + auth.set_auth_config(ctx.as.auth, args); + } +); + +export const get_auth_public_key = spacetimedb.procedure( + getPublicKeyPemParams, + t.object('AuthPubKey', { + publicKeyPem: t.string(), + keyId: t.string(), + issuerUrl: t.string(), + }), + (ctx, args) => + auth.get_auth_public_key(ctx.as.auth, args) as { + publicKeyPem: string; + keyId: string; + issuerUrl: string; + } +); + +export const link_connection = spacetimedb.reducer( + linkConnectionParams, + (ctx, args) => { + auth.link_connection(ctx.as.auth, args); + } +); + +export const unlink_connection = spacetimedb.reducer( + unlinkConnectionParams, + (ctx, args) => { + auth.unlink_connection(ctx.as.auth, args); + } +); + +export const update_profile = spacetimedb.reducer( + updateProfileParams, + (ctx, args) => { + auth.update_profile(ctx.as.auth, args); + } +); + +export const revoke_session = spacetimedb.reducer( + revokeSessionParams, + (ctx, args) => { + auth.revoke_session(ctx.as.auth, args); + } +); + +export const list_my_sessions = spacetimedb.procedure( + listMySessionsParams, + t.object('MySessions', { + sessions: t.array( + t.object('MySession', { + sessionId: t.string(), + expiresAt: t.timestamp(), + createdAt: t.timestamp(), + ipAddress: t.option(t.string()), + userAgent: t.option(t.string()), + isCurrent: t.bool(), + }) + ), + }), + (ctx, args) => + auth.list_my_sessions(ctx.as.auth, args) as { + sessions: Array<{ + sessionId: string; + expiresAt: Timestamp; + createdAt: Timestamp; + ipAddress: string | undefined; + userAgent: string | undefined; + isCurrent: boolean; + }>; + } +); + +export const revoke_my_session = spacetimedb.reducer( + revokeMySessionParams, + (ctx, args) => { + auth.revoke_my_session(ctx.as.auth, args); + } +); + +export const myNotes = spacetimedb.view( + { name: 'my_notes', public: true }, + t.array(note.rowType), + ctx => { + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (!binding) return []; + return [...ctx.db.note.authorId.filter(binding.userId)]; + } +); + +export const myAuthUser = spacetimedb.view( + { name: 'my_auth_user', public: true }, + t.array(authUserViewRow), + ctx => { + const binding = ctx.db.auth.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (!binding) return []; + const row = ctx.db.auth.authUser.userId.find(binding.userId); + return row ? [row] : []; + } +); + +export const create_note = spacetimedb.reducer( + { title: t.string(), body: t.string() }, + (ctx, args) => { + const userId = getCallerUserId(ctx.as.auth); + if (!userId) throw new SenderError('auth.not_authenticated'); + const noteId = ctx.newUuidV7().toString(); + ctx.db.note.insert({ + noteId, + authorId: userId, + title: args.title, + body: args.body, + createdAt: ctx.timestamp, + }); + } +); + +export const delete_note = spacetimedb.reducer( + { noteId: t.string() }, + (ctx, args) => { + const userId = getCallerUserId(ctx.as.auth); + if (!userId) throw new SenderError('auth.not_authenticated'); + const row = ctx.db.note.noteId.find(args.noteId); + if (!row) throw new SenderError('note.not_found'); + if (row.authorId !== userId) throw new SenderError('note.not_owner'); + ctx.db.note.delete(row); + } +); + +export const update_note = spacetimedb.reducer( + { noteId: t.string(), title: t.string(), body: t.string() }, + (ctx, args) => { + const userId = getCallerUserId(ctx.as.auth); + if (!userId) throw new SenderError('auth.not_authenticated'); + const row = ctx.db.note.noteId.find(args.noteId); + if (!row) throw new SenderError('note.not_found'); + if (row.authorId !== userId) throw new SenderError('note.not_owner'); + ctx.db.note.noteId.update({ ...row, title: args.title, body: args.body }); + } +); + +export const whoami = spacetimedb.procedure( + {}, + t.object('WhoAmI', { + userId: t.option(t.string()), + senderIdentityHex: t.string(), + }), + (ctx, _args) => { + const userId = getCallerUserId(ctx.as.auth); + return { + userId: userId ?? undefined, + senderIdentityHex: ctx.sender.toHexString(), + }; + } +); + +export const authPasswordSignup = spacetimedb.httpHandler((ctx, req) => + passwordSignupHandler(ctx.as.auth, req) +); +export const authPasswordLogin = spacetimedb.httpHandler((ctx, req) => + passwordLoginHandler(ctx.as.auth, req) +); +export const authMe = spacetimedb.httpHandler((ctx, req) => + meHandler(ctx.as.auth, req) +); +export const authLogout = spacetimedb.httpHandler((ctx, req) => + logoutHandler(ctx.as.auth, req) +); +export const authRefresh = spacetimedb.httpHandler((ctx, req) => + refreshHandler(ctx.as.auth, req) +); +export const authGoogleStart = spacetimedb.httpHandler((ctx, req) => + googleStartHandler(ctx.as.auth, req) +); +export const authGoogleCallback = spacetimedb.httpHandler((ctx, req) => + googleCallbackHandler(ctx.as.auth, req) +); +export const authGithubStart = spacetimedb.httpHandler((ctx, req) => + githubStartHandler(ctx.as.auth, req) +); +export const authGithubCallback = spacetimedb.httpHandler((ctx, req) => + githubCallbackHandler(ctx.as.auth, req) +); + +const forgotHandler = makeForgotPasswordHandler({ + sendMail: consoleSendMail, + appName: 'Notes', +}); +const verifyRequestHandler = makeEmailVerifyRequestHandler({ + sendMail: consoleSendMail, + appName: 'Notes', +}); +const verifyHandler = makeEmailVerifyHandler({ + successRedirect: '/?verified=1', +}); + +export const authPasswordForgot = spacetimedb.httpHandler((ctx, req) => + forgotHandler(ctx.as.auth, req) +); +export const authPasswordReset = spacetimedb.httpHandler((ctx, req) => + resetPasswordHandler(ctx.as.auth, req) +); +export const authEmailVerifyRequest = spacetimedb.httpHandler((ctx, req) => + verifyRequestHandler(ctx.as.auth, req) +); +export const authEmailVerify = spacetimedb.httpHandler((ctx, req) => + verifyHandler(ctx.as.auth, req) +); + +export const router = spacetimedb.httpRouter( + new Router() + .post('/auth/password/signup', authPasswordSignup) + .post('/auth/password/login', authPasswordLogin) + .post('/auth/session/refresh', authRefresh) + .get('/auth/me', authMe) + .post('/auth/logout', authLogout) + .get('/auth/google/start', authGoogleStart) + .get('/auth/google/callback', authGoogleCallback) + .get('/auth/github/start', authGithubStart) + .get('/auth/github/callback', authGithubCallback) + .post('/auth/password/forgot', authPasswordForgot) + .post('/auth/password/reset', authPasswordReset) + .post('/auth/email/verify-request', authEmailVerifyRequest) + .get('/auth/email/verify', authEmailVerify) +); diff --git a/spacetime-auth-ts/example/spacetimedb/tsconfig.json b/spacetime-auth-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..c18065b7cb8 --- /dev/null +++ b/spacetime-auth-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-auth-ts/example/src/app.ts b/spacetime-auth-ts/example/src/app.ts new file mode 100644 index 00000000000..8f033adccc7 --- /dev/null +++ b/spacetime-auth-ts/example/src/app.ts @@ -0,0 +1,426 @@ +import { + authUrlState, + clearAuthResultParams, + mountAuthPanel, +} from '@spacetimedb/submodule-shared'; +import '@spacetimedb/submodule-shared/styles.css'; +import { + DbConnection, + tables, + type EventContext, + type ErrorContext, +} from './module_bindings/app'; +import type { + ExampleAuthUser as AuthUserRow, + MySessions, +} from './module_bindings/app/types'; + +declare global { + interface Window { + auth?: { + signup: (args: { + email: string; + password: string; + name?: string; + }) => Promise; + login: (args: { email: string; password: string }) => Promise; + logout: () => Promise; + createNote: (args: { title: string; body: string }) => void; + updateNote: (args: { + noteId: string; + title: string; + body: string; + }) => void; + deleteNote: (noteId: string) => void; + whoami: () => Promise<{ + userId: string | undefined; + senderIdentityHex: string; + }>; + oauthStart: (provider: 'google' | 'github') => void; + listMySessions: () => Promise; + revokeMySession: (sessionId: string) => void; + forgotPassword: (email: string) => Promise; + resetPassword: (token: string, newPassword: string) => Promise; + requestEmailVerify: () => Promise; + setProfile: (args: { name?: string; image?: string }) => void; + }; + } +} + +interface AuthMe { + user: { + userId: string; + email: string; + emailVerified: boolean; + name?: string; + image?: string; + }; + sessionExpiresAt: number; +} + +interface ServerConfig { + spacetimeUri: string; + databaseName: string; + oauth?: { + google?: boolean; + github?: boolean; + }; +} + +let conn: DbConnection | null = null; +let serverCfg: ServerConfig | null = null; +let currentUser: AuthMe['user'] | null = null; +let currentExp: number | undefined; +let currentSenderHex: string | undefined; + +function emitAppEvent(name: string, detail: unknown) { + window.dispatchEvent(new CustomEvent(name, { detail })); +} +function emitAuthState() { + emitAppEvent('auth:state', { + user: currentUser, + senderHex: currentSenderHex, + sessionExpiresAt: currentExp, + }); +} +let lastConnState: string = ''; +let lastConnDetail: string = ''; +function emitConnectionState( + state: 'idle' | 'connecting' | 'connected' | 'error', + detail?: string +) { + const d = detail ?? ''; + if (state === lastConnState && d === lastConnDetail) return; + lastConnState = state; + lastConnDetail = d; + emitAppEvent('auth:conn', { state, detail }); +} +function emitNotes() { + const sorted = conn + ? [...conn.db.myNotes.iter()].sort((a, b) => + Number( + b.createdAt.microsSinceUnixEpoch - a.createdAt.microsSinceUnixEpoch + ) + ) + : []; + emitAppEvent('auth:notes', { notes: sorted }); +} + +async function callJson(path: string, body?: unknown): Promise { + const r = await fetch(path, { + method: body !== undefined ? 'POST' : 'GET', + headers: body !== undefined ? { 'content-type': 'application/json' } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + credentials: 'same-origin', + }); + let data: unknown = null; + try { + data = await r.json(); + } catch { + /* empty or non-JSON response */ + } + if (!r.ok) { + const error = + data && typeof data === 'object' && 'error' in data + ? String((data as { error: unknown }).error) + : `http_${r.status}`; + throw new Error(error); + } + return data as T; +} + +async function loadServerConfig(): Promise { + const r = await fetch('/api/config', { credentials: 'same-origin' }); + if (!r.ok) throw new Error(`/api/config returned ${r.status}`); + const cfg = (await r.json()) as ServerConfig; + authPanel.setProviders({ + google: Boolean(cfg.oauth?.google), + github: Boolean(cfg.oauth?.github), + }); + return cfg; +} + +// Persist the STDB identity token so refresh reuses the same identity. +const STDB_TOKEN_KEY = 'notes:stdb_token'; +function loadStdbToken(): string | undefined { + try { + return localStorage.getItem(STDB_TOKEN_KEY) ?? undefined; + } catch { + return undefined; + } +} +function saveStdbToken(token: string): void { + try { + localStorage.setItem(STDB_TOKEN_KEY, token); + } catch { + /* Storage can be unavailable. */ + } +} + +function connect(): Promise { + if (!serverCfg) throw new Error('missing_server_config'); + const config = serverCfg; + return new Promise((resolve, reject) => { + DbConnection.builder() + .withUri(config.spacetimeUri) + .withDatabaseName(config.databaseName) + .withToken(loadStdbToken()) + .onConnect((connection, _identity, token) => { + if (token) saveStdbToken(token); + resolve(connection); + }) + .onDisconnect((_ctx, err) => { + emitConnectionState('error', err?.message ?? 'disconnected'); + conn = null; + if (currentUser) scheduleReconnect(); + }) + .onConnectError((_ctx, err) => { + emitConnectionState('error', 'connect failed'); + reject(err); + }) + .build(); + }); +} + +let reconnectAttempts = 0; +let reconnectTimer: number | null = null; +function scheduleReconnect() { + if (reconnectTimer != null) return; + const delay = Math.min(30000, 500 * Math.pow(2, reconnectAttempts)); + reconnectAttempts++; + reconnectTimer = window.setTimeout(async () => { + reconnectTimer = null; + if (!currentUser) return; + try { + const r = await callJson<{ + user: AuthMe['user']; + token: string; + sessionExpiresAt: number; + }>('/auth/session/refresh', {}); + await bindSession(r.token, r.user, r.sessionExpiresAt); + reconnectAttempts = 0; + } catch { + scheduleReconnect(); + } + }, delay); +} + +async function bindSession(token: string, user: AuthMe['user'], exp: number) { + currentUser = user; + currentExp = exp; + + if (!conn) { + emitConnectionState('connecting'); + try { + conn = await connect(); + registerRowCallbacks(conn); + subscribeToTables(conn); + emitConnectionState('connected'); + } catch (err) { + emitConnectionState('error', (err as Error).message); + return; + } + } + + try { + conn.reducers.linkConnection({ sessionToken: token }); + const w = await conn.procedures.whoami({}); + currentSenderHex = w.senderIdentityHex; + } catch (err) { + console.warn('link_connection failed', err); + } + emitAuthState(); +} + +function syncUserFromRow(row: AuthUserRow) { + if (!currentUser || row.userId !== currentUser.userId) return; + currentUser = { + userId: row.userId, + email: row.email, + emailVerified: row.emailVerified, + name: row.name ?? undefined, + image: row.image ?? undefined, + }; + emitAuthState(); +} + +function subscribeToTables(connection: DbConnection): void { + connection + .subscriptionBuilder() + .onApplied(() => emitNotes()) + .onError((ctx: ErrorContext) => console.error('sub error', ctx.event)) + .subscribe([tables.myNotes, tables.myAuthUser]); +} + +function registerRowCallbacks(connection: DbConnection): void { + connection.db.myNotes.onInsert(() => emitNotes()); + connection.db.myNotes.onUpdate(() => emitNotes()); + connection.db.myNotes.onDelete(() => emitNotes()); + + connection.db.myAuthUser.onInsert((_ctx: EventContext, row: AuthUserRow) => + syncUserFromRow(row) + ); + connection.db.myAuthUser.onUpdate( + (_ctx: EventContext, _o: AuthUserRow, n: AuthUserRow) => syncUserFromRow(n) + ); + connection.db.myAuthUser.onDelete((_ctx: EventContext, row: AuthUserRow) => { + if (!currentUser || row.userId !== currentUser.userId) return; + currentUser = null; + currentExp = undefined; + emitAuthState(); + }); +} + +async function signup(args: { + email: string; + password: string; + name?: string; +}) { + const r = await callJson<{ token: string }>('/auth/password/signup', args); + const me = await callJson('/auth/me'); + await bindSession(r.token, me.user, me.sessionExpiresAt); +} + +async function login(args: { email: string; password: string }) { + const r = await callJson<{ token: string }>('/auth/password/login', args); + const me = await callJson('/auth/me'); + await bindSession(r.token, me.user, me.sessionExpiresAt); +} + +async function restoreSession(): Promise { + try { + const r = await callJson<{ + user: AuthMe['user']; + token: string; + sessionExpiresAt: number; + }>('/auth/session/refresh', {}); + await bindSession(r.token, r.user, r.sessionExpiresAt); + return true; + } catch { + return false; + } +} + +async function logout() { + if (conn) { + try { + conn.reducers.unlinkConnection({}); + } catch { + /* best-effort disconnect cleanup */ + } + } + await callJson('/auth/logout', {}); + currentUser = null; + currentExp = undefined; + currentSenderHex = undefined; + emitAuthState(); + emitNotes(); +} + +function createNote(args: { title: string; body: string }) { + if (!conn) throw new Error('not_connected'); + conn.reducers.createNote(args); +} + +function deleteNote(noteId: string) { + if (!conn) throw new Error('not_connected'); + conn.reducers.deleteNote({ noteId }); +} + +function updateNote(args: { noteId: string; title: string; body: string }) { + if (!conn) throw new Error('not_connected'); + conn.reducers.updateNote(args); +} + +async function whoami() { + if (!conn) throw new Error('not_connected'); + const r = await conn.procedures.whoami({}); + currentSenderHex = r.senderIdentityHex; + emitAuthState(); + return r; +} + +async function listMySessions() { + if (!conn) throw new Error('not_connected'); + return await conn.procedures.listMySessions({}); +} + +function revokeMySession(sessionId: string) { + if (!conn) throw new Error('not_connected'); + conn.reducers.revokeMySession({ sessionId }); +} + +async function forgotPassword(email: string) { + await callJson('/auth/password/forgot', { email }); +} + +async function resetPassword(token: string, newPassword: string) { + await callJson('/auth/password/reset', { token, newPassword }); +} + +async function requestEmailVerify() { + await callJson('/auth/email/verify-request', {}); +} + +function oauthStart(provider: 'google' | 'github') { + window.location.href = `/auth/${provider}/start?redirectTo=/`; +} + +function setProfile(args: { name?: string; image?: string }) { + if (!conn) throw new Error('not_connected'); + conn.reducers.updateProfile({ name: args.name, image: args.image }); +} + +const authResult = authUrlState(window.location); +const authPanelRoot = document.getElementById('auth-panel'); +if (!authPanelRoot) throw new Error('missing_auth_panel'); +const authPanel = mountAuthPanel(authPanelRoot, { + productName: 'Notes', + actions: { + login, + signup, + forgotPassword, + resetPassword, + oauthStart, + }, + initialMode: authResult.mode, + resetToken: authResult.resetToken, +}); +if (authResult.oauthError) { + authPanel.showMessage('error', `OAuth: ${authResult.oauthError}`); +} +if (authResult.verified) { + authPanel.showMessage('success', 'Email verified.'); +} +clearAuthResultParams(window.location, window.history); + +window.auth = { + signup, + login, + logout, + createNote, + updateNote, + deleteNote, + whoami, + oauthStart, + listMySessions, + revokeMySession, + forgotPassword, + resetPassword, + requestEmailVerify, + setProfile, +}; + +async function main(): Promise { + emitConnectionState('idle'); + try { + serverCfg = await loadServerConfig(); + } catch (err) { + emitConnectionState('error', (err as Error).message); + return; + } + await restoreSession(); + emitAppEvent('auth:ready', {}); +} + +void main(); diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/get_auth_public_key_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/link_connection_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionToken: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/list_my_sessions_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts new file mode 100644 index 00000000000..6573c3fe132 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + userId: __t.string().primaryKey().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts new file mode 100644 index 00000000000..e39846ca8d9 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/add_rate_limit_admin_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + identity: __t.identity(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts new file mode 100644 index 00000000000..189f539a043 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/admin_rate_limit_buckets_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + key: __t.string().primaryKey(), + scope: __t.string(), + windowStart: __t.timestamp().name("window_start"), + expiresAt: __t.timestamp().name("expires_at"), + count: __t.u32(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts new file mode 100644 index 00000000000..a98b8588aad --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/consume_procedure.ts @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + RateLimitConsumeResult, +} from "./types"; + +export const params = { + scope: __t.string(), + actorKey: __t.string(), + limit: __t.u32(), + windowSeconds: __t.u32(), + cost: __t.option(__t.u32()), +}; +export const returnType = RateLimitConsumeResult \ No newline at end of file diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts new file mode 100644 index 00000000000..66ffe86e399 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/rate_limit_config_table.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + singleton: __t.bool().primaryKey(), + sweepBatch: __t.u32().name("sweep_batch"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts new file mode 100644 index 00000000000..a7c5cc5274f --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/reset_buckets_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + maxRows: __t.option(__t.u32()), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts new file mode 100644 index 00000000000..9815c99eb38 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/run_sweep_procedure.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const params = { + maxRows: __t.option(__t.u32()), +}; +export const returnType = __t.u32() \ No newline at end of file diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/types.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/types.ts new file mode 100644 index 00000000000..151a90e827f --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/types.ts @@ -0,0 +1,56 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AdminRateLimitBuckets = __t.object("AdminRateLimitBuckets", {}); +export type AdminRateLimitBuckets = __Infer; + +export const RateLimitAdminIdentity = __t.object("RateLimitAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type RateLimitAdminIdentity = __Infer; + +export const RateLimitBucket = __t.object("RateLimitBucket", { + key: __t.string(), + scope: __t.string(), + windowStart: __t.timestamp(), + expiresAt: __t.timestamp(), + count: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitBucket = __Infer; + +export const RateLimitConfig = __t.object("RateLimitConfig", { + singleton: __t.bool(), + sweepBatch: __t.u32(), + updatedAt: __t.timestamp(), +}); +export type RateLimitConfig = __Infer; + +export const RateLimitConsumeResult = __t.object("RateLimitConsumeResult", { + allowed: __t.bool(), + scope: __t.string(), + key: __t.string(), + limit: __t.u32(), + used: __t.u32(), + remaining: __t.u32(), + retryAfterSeconds: __t.u32(), + resetAt: __t.timestamp(), +}); +export type RateLimitConsumeResult = __Infer; + +export const RateLimitSweepTick = __t.object("RateLimitSweepTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type RateLimitSweepTick = __Infer; + diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts new file mode 100644 index 00000000000..54fcf361af1 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/rateLimit/update_config_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sweepBatch: __t.u32(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_my_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/revoke_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/set_auth_config_reducer.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/types.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/types.ts new file mode 100644 index 00000000000..8df99e3d1b4 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/types.ts @@ -0,0 +1,137 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AuthAccount = __t.object("AuthAccount", { + accountId: __t.string(), + userId: __t.string(), + providerId: __t.string(), + providerAccountId: __t.string(), + passwordHash: __t.option(__t.string()), + accessToken: __t.option(__t.string()), + refreshToken: __t.option(__t.string()), + accessTokenExpiresAt: __t.option(__t.timestamp()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthAccount = __Infer; + +export const AuthAdminIdentity = __t.object("AuthAdminIdentity", { + identity: __t.identity(), + addedAtMicros: __t.i64(), +}); +export type AuthAdminIdentity = __Infer; + +export const AuthConfig = __t.object("AuthConfig", { + singleton: __t.bool(), + issuerUrl: __t.string(), + baseUrl: __t.string(), + cookieName: __t.string(), + sessionTtlSeconds: __t.u64(), + es256PrivateKeyPem: __t.string(), + es256PublicKeyPem: __t.string(), + keyId: __t.string(), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), + updatedAt: __t.timestamp(), +}); +export type AuthConfig = __Infer; + +export const AuthConnectionBinding = __t.object("AuthConnectionBinding", { + stdbIdentity: __t.identity(), + userId: __t.string(), + linkedAt: __t.timestamp(), +}); +export type AuthConnectionBinding = __Infer; + +export const AuthOauthState = __t.object("AuthOauthState", { + state: __t.string(), + provider: __t.string(), + codeVerifier: __t.string(), + redirectTo: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthOauthState = __Infer; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const AuthSession = __t.object("AuthSession", { + sessionId: __t.string(), + userId: __t.string(), + token: __t.string(), + expiresAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + createdAt: __t.timestamp(), +}); +export type AuthSession = __Infer; + +export const AuthSweeperTick = __t.object("AuthSweeperTick", { + scheduledId: __t.u64(), + scheduledAt: __t.scheduleAt(), +}); +export type AuthSweeperTick = __Infer; + +export const AuthUser = __t.object("AuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type AuthUser = __Infer; + +export const AuthVerification = __t.object("AuthVerification", { + verificationId: __t.string(), + identifier: __t.string(), + value: __t.string(), + purpose: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), +}); +export type AuthVerification = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/auth/whoami_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/auth/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/auth/whoami_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-auth-ts/example/src/module_bindings/app/create_note_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/create_note_reducer.ts new file mode 100644 index 00000000000..cf5ec16d854 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/create_note_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + title: __t.string(), + body: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/delete_note_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/delete_note_reducer.ts new file mode 100644 index 00000000000..60d89c56797 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/delete_note_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + noteId: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts new file mode 100644 index 00000000000..c32c6231beb --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/get_auth_public_key_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + AuthPubKey, +} from "./types"; + +export const params = { +}; +export const returnType = AuthPubKey \ No newline at end of file diff --git a/spacetime-auth-ts/example/src/module_bindings/app/index.ts b/spacetime-auth-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..4b21b12ba70 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,259 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from "spacetimedb"; + +// Import all reducer arg schemas +import CreateNoteReducer from "./create_note_reducer"; +import DeleteNoteReducer from "./delete_note_reducer"; +import LinkConnectionReducer from "./link_connection_reducer"; +import RevokeMySessionReducer from "./revoke_my_session_reducer"; +import RevokeSessionReducer from "./revoke_session_reducer"; +import SetAuthConfigReducer from "./set_auth_config_reducer"; +import UnlinkConnectionReducer from "./unlink_connection_reducer"; +import UpdateNoteReducer from "./update_note_reducer"; +import UpdateProfileReducer from "./update_profile_reducer"; + +// Import all procedure arg schemas +import * as GetAuthPublicKeyProcedure from "./get_auth_public_key_procedure"; +import * as ListMySessionsProcedure from "./list_my_sessions_procedure"; +import * as WhoamiProcedure from "./whoami_procedure"; + +// Import all table schema definitions +import MyAuthUserRow from "./my_auth_user_table"; +import MyNotesRow from "./my_notes_table"; + +// Import namespace table schema definitions +import AuthRateLimit_RateLimitConfigRow from "./auth/rateLimit/rate_limit_config_table"; +import Auth_MyAuthUserRow from "./auth/my_auth_user_table"; +import AuthRateLimit_AdminRateLimitBucketsRow from "./auth/rateLimit/admin_rate_limit_buckets_table"; + +// Import namespace reducer arg schemas +import Auth_LinkConnectionReducer from "./auth/link_connection_reducer"; +import Auth_RevokeMySessionReducer from "./auth/revoke_my_session_reducer"; +import Auth_RevokeSessionReducer from "./auth/revoke_session_reducer"; +import Auth_SetAuthConfigReducer from "./auth/set_auth_config_reducer"; +import Auth_UnlinkConnectionReducer from "./auth/unlink_connection_reducer"; +import Auth_UpdateProfileReducer from "./auth/update_profile_reducer"; +import AuthRateLimit_AddRateLimitAdminReducer from "./auth/rateLimit/add_rate_limit_admin_reducer"; +import AuthRateLimit_ResetBucketsReducer from "./auth/rateLimit/reset_buckets_reducer"; +import AuthRateLimit_UpdateConfigReducer from "./auth/rateLimit/update_config_reducer"; + +// Import namespace procedure arg schemas +import * as Auth_GetAuthPublicKeyProcedure from "./auth/get_auth_public_key_procedure"; +import * as Auth_ListMySessionsProcedure from "./auth/list_my_sessions_procedure"; +import * as Auth_WhoamiProcedure from "./auth/whoami_procedure"; +import * as AuthRateLimit_ConsumeProcedure from "./auth/rateLimit/consume_procedure"; +import * as AuthRateLimit_RunSweepProcedure from "./auth/rateLimit/run_sweep_procedure"; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + myAuthUser: __table({ + name: 'my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, MyAuthUserRow), + myNotes: __table({ + name: 'my_notes', + indexes: [ + ], + constraints: [ + ], + }, MyNotesRow), + "auth.rateLimit.rate_limit_config": __table({ + name: 'auth.rateLimit.rate_limit_config', + indexes: [ + { accessor: 'singleton', name: 'rate_limit_config_singleton_idx_btree', algorithm: 'btree', columns: [ + 'singleton', + ] }, + ], + constraints: [ + { name: 'rate_limit_config_singleton_key', constraint: 'unique', columns: ['singleton'] }, + ], + }, AuthRateLimit_RateLimitConfigRow), + "auth.my_auth_user": __table({ + name: 'auth.my_auth_user', + indexes: [ + ], + constraints: [ + ], + }, Auth_MyAuthUserRow), + "auth.rateLimit.admin_rate_limit_buckets": __table({ + name: 'auth.rateLimit.admin_rate_limit_buckets', + indexes: [ + ], + constraints: [ + ], + }, AuthRateLimit_AdminRateLimitBucketsRow), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema("create_note", CreateNoteReducer), + __reducerSchema("delete_note", DeleteNoteReducer), + __reducerSchema("link_connection", LinkConnectionReducer), + __reducerSchema("revoke_my_session", RevokeMySessionReducer), + __reducerSchema("revoke_session", RevokeSessionReducer), + __reducerSchema("set_auth_config", SetAuthConfigReducer), + __reducerSchema("unlink_connection", UnlinkConnectionReducer), + __reducerSchema("update_note", UpdateNoteReducer), + __reducerSchema("update_profile", UpdateProfileReducer), + __reducerSchema("auth.link_connection", Auth_LinkConnectionReducer), + __reducerSchema("auth.revoke_my_session", Auth_RevokeMySessionReducer), + __reducerSchema("auth.revoke_session", Auth_RevokeSessionReducer), + __reducerSchema("auth.set_auth_config", Auth_SetAuthConfigReducer), + __reducerSchema("auth.unlink_connection", Auth_UnlinkConnectionReducer), + __reducerSchema("auth.update_profile", Auth_UpdateProfileReducer), + __reducerSchema("auth.rateLimit.add_rate_limit_admin", AuthRateLimit_AddRateLimitAdminReducer), + __reducerSchema("auth.rateLimit.reset_buckets", AuthRateLimit_ResetBucketsReducer), + __reducerSchema("auth.rateLimit.update_config", AuthRateLimit_UpdateConfigReducer), +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( + __procedureSchema("get_auth_public_key", GetAuthPublicKeyProcedure.params, GetAuthPublicKeyProcedure.returnType), + __procedureSchema("list_my_sessions", ListMySessionsProcedure.params, ListMySessionsProcedure.returnType), + __procedureSchema("whoami", WhoamiProcedure.params, WhoamiProcedure.returnType), + __procedureSchema("auth.get_auth_public_key", Auth_GetAuthPublicKeyProcedure.params, Auth_GetAuthPublicKeyProcedure.returnType), + __procedureSchema("auth.list_my_sessions", Auth_ListMySessionsProcedure.params, Auth_ListMySessionsProcedure.returnType), + __procedureSchema("auth.whoami", Auth_WhoamiProcedure.params, Auth_WhoamiProcedure.returnType), + __procedureSchema("auth.rateLimit.consume", AuthRateLimit_ConsumeProcedure.params, AuthRateLimit_ConsumeProcedure.returnType), + __procedureSchema("auth.rateLimit.run_sweep", AuthRateLimit_RunSweepProcedure.params, AuthRateLimit_RunSweepProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +const __qb = __makeQueryBuilder(tablesSchema.schemaType); +export const tables = { + myAuthUser: __qb.myAuthUser, + myNotes: __qb.myNotes, + auth: { + myAuthUser: __qb["auth.my_auth_user"], + rateLimit: { + rateLimitConfig: __qb["auth.rateLimit.rate_limit_config"], + adminRateLimitBuckets: __qb["auth.rateLimit.admin_rate_limit_buckets"], + }, + }, +} as const; + +/** The reducers available in this remote SpacetimeDB module. */ +const __reducerAccessors = __convertToAccessorMap(reducersSchema.reducersType.reducers); +export const reducers = { + createNote: __reducerAccessors.createNote, + deleteNote: __reducerAccessors.deleteNote, + linkConnection: __reducerAccessors.linkConnection, + revokeMySession: __reducerAccessors.revokeMySession, + revokeSession: __reducerAccessors.revokeSession, + setAuthConfig: __reducerAccessors.setAuthConfig, + unlinkConnection: __reducerAccessors.unlinkConnection, + updateNote: __reducerAccessors.updateNote, + updateProfile: __reducerAccessors.updateProfile, + auth: { + linkConnection: __reducerAccessors["auth.linkConnection"], + revokeMySession: __reducerAccessors["auth.revokeMySession"], + revokeSession: __reducerAccessors["auth.revokeSession"], + setAuthConfig: __reducerAccessors["auth.setAuthConfig"], + unlinkConnection: __reducerAccessors["auth.unlinkConnection"], + updateProfile: __reducerAccessors["auth.updateProfile"], + rateLimit: { + addRateLimitAdmin: __reducerAccessors["auth.rateLimit.addRateLimitAdmin"], + resetBuckets: __reducerAccessors["auth.rateLimit.resetBuckets"], + updateConfig: __reducerAccessors["auth.rateLimit.updateConfig"], + }, + }, +} as const; + +/** The procedures available in this remote SpacetimeDB module. */ +const __procedureAccessors = __convertToAccessorMap(proceduresSchema.procedures); +export const procedures = { + getAuthPublicKey: __procedureAccessors.getAuthPublicKey, + listMySessions: __procedureAccessors.listMySessions, + whoami: __procedureAccessors.whoami, + auth: { + getAuthPublicKey: __procedureAccessors["auth.getAuthPublicKey"], + listMySessions: __procedureAccessors["auth.listMySessions"], + whoami: __procedureAccessors["auth.whoami"], + rateLimit: { + consume: __procedureAccessors["auth.rateLimit.consume"], + runSweep: __procedureAccessors["auth.rateLimit.runSweep"], + }, + }, +} as const; + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-auth-ts/example/src/module_bindings/app/link_connection_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/link_connection_reducer.ts new file mode 100644 index 00000000000..da04a554a3b --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/link_connection_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionToken: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts new file mode 100644 index 00000000000..0fff293b69e --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/list_my_sessions_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + MySessions, +} from "./types"; + +export const params = { +}; +export const returnType = MySessions \ No newline at end of file diff --git a/spacetime-auth-ts/example/src/module_bindings/app/my_auth_user_table.ts b/spacetime-auth-ts/example/src/module_bindings/app/my_auth_user_table.ts new file mode 100644 index 00000000000..5966b094061 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/my_auth_user_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + userId: __t.string().name("user_id"), + email: __t.string(), + emailVerified: __t.bool().name("email_verified"), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-auth-ts/example/src/module_bindings/app/my_notes_table.ts b/spacetime-auth-ts/example/src/module_bindings/app/my_notes_table.ts new file mode 100644 index 00000000000..52857c3cf19 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/my_notes_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + noteId: __t.string().primaryKey().name("note_id"), + authorId: __t.string().name("author_id"), + title: __t.string(), + body: __t.string(), + createdAt: __t.timestamp().name("created_at"), +}); diff --git a/spacetime-auth-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/revoke_my_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/revoke_session_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/revoke_session_reducer.ts new file mode 100644 index 00000000000..66f95f66b3f --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/revoke_session_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + sessionId: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/set_auth_config_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/set_auth_config_reducer.ts new file mode 100644 index 00000000000..790dfa70b07 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/set_auth_config_reducer.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + issuerUrl: __t.string(), + baseUrl: __t.option(__t.string()), + cookieName: __t.option(__t.string()), + sessionTtlSeconds: __t.option(__t.u64()), + es256PrivateKeyPem: __t.option(__t.string()), + googleClientId: __t.option(__t.string()), + googleClientSecret: __t.option(__t.string()), + githubClientId: __t.option(__t.string()), + githubClientSecret: __t.option(__t.string()), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/types.ts b/spacetime-auth-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..d57ecf300d0 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,68 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const AuthPubKey = __t.object("AuthPubKey", { + publicKeyPem: __t.string(), + keyId: __t.string(), + issuerUrl: __t.string(), +}); +export type AuthPubKey = __Infer; + +export const ExampleAuthUser = __t.object("ExampleAuthUser", { + userId: __t.string(), + email: __t.string(), + emailVerified: __t.bool(), + name: __t.option(__t.string()), + image: __t.option(__t.string()), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type ExampleAuthUser = __Infer; + +export const MyAuthUser = __t.object("MyAuthUser", {}); +export type MyAuthUser = __Infer; + +export const MyNotes = __t.object("MyNotes", {}); +export type MyNotes = __Infer; + +export const MySession = __t.object("MySession", { + sessionId: __t.string(), + expiresAt: __t.timestamp(), + createdAt: __t.timestamp(), + ipAddress: __t.option(__t.string()), + userAgent: __t.option(__t.string()), + isCurrent: __t.bool(), +}); +export type MySession = __Infer; + +export const MySessions = __t.object("MySessions", { + get sessions() { + return __t.array(MySession); + }, +}); +export type MySessions = __Infer; + +export const Note = __t.object("Note", { + noteId: __t.string(), + authorId: __t.string(), + title: __t.string(), + body: __t.string(), + createdAt: __t.timestamp(), +}); +export type Note = __Infer; + +export const WhoAmI = __t.object("WhoAmI", { + userId: __t.option(__t.string()), + senderIdentityHex: __t.string(), +}); +export type WhoAmI = __Infer; + diff --git a/spacetime-auth-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-auth-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..243379b58a2 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/types/procedures.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as GetAuthPublicKeyProcedure from "../get_auth_public_key_procedure"; +import * as ListMySessionsProcedure from "../list_my_sessions_procedure"; +import * as WhoamiProcedure from "../whoami_procedure"; + +export type GetAuthPublicKeyArgs = __Infer; +export type GetAuthPublicKeyResult = __Infer; +export type ListMySessionsArgs = __Infer; +export type ListMySessionsResult = __Infer; +export type WhoamiArgs = __Infer; +export type WhoamiResult = __Infer; + diff --git a/spacetime-auth-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-auth-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..14ac95e9a10 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/types/reducers.ts @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import CreateNoteReducer from "../create_note_reducer"; +import DeleteNoteReducer from "../delete_note_reducer"; +import LinkConnectionReducer from "../link_connection_reducer"; +import RevokeMySessionReducer from "../revoke_my_session_reducer"; +import RevokeSessionReducer from "../revoke_session_reducer"; +import SetAuthConfigReducer from "../set_auth_config_reducer"; +import UnlinkConnectionReducer from "../unlink_connection_reducer"; +import UpdateNoteReducer from "../update_note_reducer"; +import UpdateProfileReducer from "../update_profile_reducer"; + +export type CreateNoteParams = __Infer; +export type DeleteNoteParams = __Infer; +export type LinkConnectionParams = __Infer; +export type RevokeMySessionParams = __Infer; +export type RevokeSessionParams = __Infer; +export type SetAuthConfigParams = __Infer; +export type UnlinkConnectionParams = __Infer; +export type UpdateNoteParams = __Infer; +export type UpdateProfileParams = __Infer; + diff --git a/spacetime-auth-ts/example/src/module_bindings/app/unlink_connection_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/unlink_connection_reducer.ts new file mode 100644 index 00000000000..e18fbc0a086 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/unlink_connection_reducer.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default {}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/update_note_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/update_note_reducer.ts new file mode 100644 index 00000000000..52bd16908e4 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/update_note_reducer.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + noteId: __t.string(), + title: __t.string(), + body: __t.string(), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/update_profile_reducer.ts b/spacetime-auth-ts/example/src/module_bindings/app/update_profile_reducer.ts new file mode 100644 index 00000000000..f940573d72c --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/update_profile_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + name: __t.option(__t.string()), + image: __t.option(__t.string()), +}; diff --git a/spacetime-auth-ts/example/src/module_bindings/app/whoami_procedure.ts b/spacetime-auth-ts/example/src/module_bindings/app/whoami_procedure.ts new file mode 100644 index 00000000000..fb2b14ac8d7 --- /dev/null +++ b/spacetime-auth-ts/example/src/module_bindings/app/whoami_procedure.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + WhoAmI, +} from "./types"; + +export const params = { +}; +export const returnType = WhoAmI \ No newline at end of file diff --git a/spacetime-auth-ts/example/tsconfig.json b/spacetime-auth-ts/example/tsconfig.json new file mode 100644 index 00000000000..9b159ac1913 --- /dev/null +++ b/spacetime-auth-ts/example/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"], + "exclude": ["node_modules", "spacetimedb"] +} diff --git a/spacetime-auth-ts/package.json b/spacetime-auth-ts/package.json new file mode 100644 index 00000000000..8a8bb1ae7ab --- /dev/null +++ b/spacetime-auth-ts/package.json @@ -0,0 +1,85 @@ +{ + "name": "@spacetimedb/auth", + "description": "Password, OAuth, session, JWT, and profile primitives for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./tables": { + "types": "./src/tables.ts", + "default": "./src/tables.ts" + }, + "./handlers": { + "types": "./src/handlers/index.ts", + "default": "./src/handlers/index.ts" + }, + "./crypto": { + "types": "./src/crypto.ts", + "default": "./src/crypto.ts" + }, + "./jwt": { + "types": "./src/jwt.ts", + "default": "./src/jwt.ts" + }, + "./keys": { + "types": "./src/keys.ts", + "default": "./src/keys.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-auth-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-auth-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "authentication", + "oauth", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts" + }, + "dependencies": { + "@spacetimedb/rate-limit": "workspace:^", + "@noble/curves": "^2.2.0", + "@noble/hashes": "^1.4.0" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^22.10.2", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-auth-ts/scripts/test.ts b/spacetime-auth-ts/scripts/test.ts new file mode 100644 index 00000000000..1c5705d840d --- /dev/null +++ b/spacetime-auth-ts/scripts/test.ts @@ -0,0 +1,345 @@ +// Pure-Node sanity tests. No STDB needed. Run: pnpm run test +// Covers: keys, jwt, crypto. + +import { p256 } from '@noble/curves/nist.js'; +import type { Request } from 'spacetimedb/server'; + +import { + generateEs256Keypair, + fromPrivateKeyBytes, + privateKeyFromPem, + publicKeyFromPem, +} from '../src/keys.ts'; +import { signJwt, verifyJwt, decodeJwtPayloadUnsafe } from '../src/jwt.ts'; +import { + hashPassword, + verifyPassword, + randomToken, + randomBytes, + uuidV7, + pkceChallenge, + newPkceVerifier, +} from '../src/crypto.ts'; +import { + clientKey, + safeRedirectPath, + shouldUseSecureCookies, + userAgent, +} from '../src/request-trust.ts'; + +let pass = 0; +let fail = 0; + +function ok(name: string): void { + pass++; + process.stdout.write(` ok ${name}\n`); +} + +function err(name: string, detail: string): void { + fail++; + process.stdout.write(` FAIL ${name}\n ${detail}\n`); +} + +function assert(cond: boolean, name: string, detail = ''): void { + if (cond) ok(name); + else err(name, detail); +} + +function assertEq(actual: unknown, expected: unknown, name: string): void { + if (actual === expected) ok(name); + else err(name, `expected ${String(expected)}, got ${String(actual)}`); +} + +function bytesEq(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +// Test-only RandomSource compatible with STDB Random. + +const TEST_RNG: { fill(a: T): T } = { + fill(a: T): T { + for (let i = 0; i < a.length; i++) a[i] = Math.floor(Math.random() * 256); + return a; + }, +}; + +process.stdout.write('\nhttp trust\n'); + +{ + const headers = new Map([ + ['x-forwarded-for', '203.0.113.10, 10.0.0.2'], + ['x-real-ip', '198.51.100.4'], + ]); + const req = { + headers: { + get(name: string) { + return headers.get(name.toLowerCase()) ?? null; + }, + }, + } as unknown as Request; + assertEq( + clientKey(req), + undefined, + 'proxy headers ignored unless configured' + ); + assertEq( + clientKey(req, 'x-forwarded-for'), + '203.0.113.10', + 'trusted forwarded header uses first address' + ); + assertEq( + clientKey(req, 'x-real-ip'), + '198.51.100.4', + 'trusted real IP is accepted' + ); + assertEq(userAgent(req), undefined, 'missing user agent is omitted'); + const longUserAgentReq = { + headers: { + get: (name: string) => (name === 'user-agent' ? 'x'.repeat(513) : null), + }, + } as unknown as Request; + assertEq( + userAgent(longUserAgentReq), + undefined, + 'oversized user agent is omitted' + ); + assertEq(shouldUseSecureCookies(), true, 'cookies are secure by default'); + assertEq( + shouldUseSecureCookies(false), + false, + 'local HTTP can opt out explicitly' + ); + assertEq( + safeRedirectPath('/dashboard?tab=billing'), + '/dashboard?tab=billing', + 'relative redirect accepted' + ); + for (const redirect of [ + 'https://attacker.example', + '//attacker.example', + '/%2f%2fattacker.example', + '/\\attacker.example', + '/%5cattacker.example', + '/ok%0d%0alocation:%20https://attacker.example', + '/ok\u007fblocked', + '/path#fragment', + ]) { + assertEq( + safeRedirectPath(redirect), + undefined, + `unsafe redirect rejected: ${redirect}` + ); + } +} + +// keys + +process.stdout.write('\nkeys\n'); + +{ + const kp = generateEs256Keypair(); + assertEq(kp.privateKey.length, 32, 'private key is 32 bytes'); + assertEq(kp.publicKey.length, 65, 'public key is 65 bytes uncompressed'); + assertEq(kp.publicKey[0], 0x04, 'public key starts with 0x04'); + assert( + kp.publicKeyPem.includes('BEGIN PUBLIC KEY'), + 'public PEM has BEGIN PUBLIC KEY' + ); + assert( + kp.privateKeyPem.includes('BEGIN PRIVATE KEY'), + 'private PEM has BEGIN PRIVATE KEY' + ); + assert(kp.kid.length > 0, 'kid (JWK thumbprint) is non-empty'); + assertEq(kp.publicKeyJwk.kty, 'EC', 'JWK kty is EC'); + assertEq(kp.publicKeyJwk.crv, 'P-256', 'JWK crv is P-256'); + assertEq(kp.publicKeyJwk.alg, 'ES256', 'JWK alg is ES256'); + + // Re-derive public from stored private. + const kp2 = fromPrivateKeyBytes(kp.privateKey); + assert( + bytesEq(kp.publicKey, kp2.publicKey), + 'public key re-derives from private' + ); + assertEq(kp.kid, kp2.kid, 'kid is stable across re-derivation'); + + // PEM round-trip private. + const decodedPriv = privateKeyFromPem(kp.privateKeyPem); + assert( + bytesEq(kp.privateKey, decodedPriv), + 'private key round-trips through PEM' + ); + + // PEM round-trip public. + const decodedPub = publicKeyFromPem(kp.publicKeyPem); + assert( + bytesEq(kp.publicKey, decodedPub), + 'public key round-trips through PEM' + ); +} + +// jwt + +process.stdout.write('\njwt\n'); + +{ + const kp = generateEs256Keypair(); + const now = Math.floor(Date.now() / 1000); + + const token = signJwt( + kp.privateKey, + { + iss: 'https://auth.example.com', + sub: 'user-1234', + aud: 'https://auth.example.com', + iat: now, + exp: now + 3600, + jti: 'session-1', + }, + kp.kid + ); + assertEq(token.split('.').length, 3, 'JWT has three parts'); + + // Header decodes correctly. + const header = JSON.parse( + new TextDecoder().decode(base64urlDecode(token.split('.')[0])) + ); + assertEq(header.alg, 'ES256', 'header alg is ES256'); + assertEq(header.typ, 'JWT', 'header typ is JWT'); + assertEq(header.kid, kp.kid, 'header kid matches'); + + // Roundtrip verify. + const v = verifyJwt(kp.publicKey, token, { + issuer: 'https://auth.example.com', + audience: 'https://auth.example.com', + }); + assert(v.ok, 'sign+verify roundtrip with same key'); + if (v.ok) { + assertEq(v.claims.sub, 'user-1234', 'verified claims.sub'); + assertEq(v.claims.iss, 'https://auth.example.com', 'verified claims.iss'); + } + + // Wrong key fails. + const otherKp = generateEs256Keypair(); + const v2 = verifyJwt(otherKp.publicKey, token); + assert( + !v2.ok && v2.reason === 'bad-signature', + 'wrong key fails with bad-signature' + ); + + // Expired token fails. + const expired = signJwt(kp.privateKey, { + iss: 'x', + sub: 'y', + iat: now - 7200, + exp: now - 3600, + }); + const v3 = verifyJwt(kp.publicKey, expired); + assert(!v3.ok && v3.reason === 'expired', 'expired token fails with expired'); + + // Wrong issuer fails. + const v4 = verifyJwt(kp.publicKey, token, { + issuer: 'https://wrong.example.com', + }); + assert( + !v4.ok && v4.reason === 'bad-issuer', + 'wrong issuer fails with bad-issuer' + ); + + // Wrong audience fails. + const v5 = verifyJwt(kp.publicKey, token, { + audience: 'https://wrong.example.com', + }); + assert( + !v5.ok && v5.reason === 'bad-audience', + 'wrong audience fails with bad-audience' + ); + + // Malformed token fails. + const v6 = verifyJwt(kp.publicKey, 'not.a.jwt'); + assert( + !v6.ok && v6.reason === 'malformed', + 'malformed token fails with malformed' + ); + + // Decode-unsafe extracts payload. + const payload = decodeJwtPayloadUnsafe(token); + assertEq(payload?.sub, 'user-1234', 'decodeJwtPayloadUnsafe returns sub'); + + // Signature is 64 bytes (compact r||s). + const sigBytes = base64urlDecode(token.split('.')[2]); + assertEq(sigBytes.length, 64, 'ES256 signature is 64 bytes (r||s)'); + + // Verify with noble directly to cross-check. + const signingInput = `${token.split('.')[0]}.${token.split('.')[1]}`; + const directOk = p256.verify( + sigBytes, + new TextEncoder().encode(signingInput), + kp.publicKey + ); + assert(directOk, 'noble verifies the produced signature directly'); +} + +// crypto + +process.stdout.write('\ncrypto\n'); + +{ + // Password hash + verify roundtrip. scrypt is SLOW, this takes ~1s. + const password = 'correct horse battery staple'; + // Low N so tests don't take forever. + const hash = hashPassword(TEST_RNG, password, { N: 1 << 10 }); + assert(hash.startsWith('scrypt$1024$'), 'hashPassword encodes scrypt params'); + assert( + verifyPassword(password, hash), + 'verifyPassword accepts correct password' + ); + assert( + !verifyPassword('wrong', hash), + 'verifyPassword rejects wrong password' + ); + + // Random token shape. + const token = randomToken(TEST_RNG, 32); + assert(/^[A-Za-z0-9_-]+$/.test(token), 'randomToken is base64url-safe'); + assert(token.length >= 40, 'randomToken has enough entropy bits'); + + // Random bytes length. + const bytes = randomBytes(TEST_RNG, 16); + assertEq(bytes.length, 16, 'randomBytes returns requested length'); + + // UUIDv7 shape. + const id = uuidV7(TEST_RNG, Date.now()); + assert( + /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + id + ), + 'uuidV7 matches v7 regex' + ); + + // PKCE challenge. + const verifier = newPkceVerifier(TEST_RNG); + const challenge = pkceChallenge(verifier); + assert(/^[A-Za-z0-9_-]+$/.test(challenge), 'pkceChallenge is base64url-safe'); + assertEq( + challenge.length, + 43, + 'pkceChallenge is 43 chars (SHA-256 base64url, no pad)' + ); +} + +// summary + +process.stdout.write(`\n${pass} passed, ${fail} failed\n`); +process.exit(fail === 0 ? 0 : 1); + +// helpers + +function base64urlDecode(s: string): Uint8Array { + const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4)); + const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/') + pad); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} diff --git a/spacetime-auth-ts/spacetimedb/.npmrc b/spacetime-auth-ts/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-auth-ts/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-auth-ts/spacetimedb/package.json b/spacetime-auth-ts/spacetimedb/package.json new file mode 100644 index 00000000000..42e0d4ff5a0 --- /dev/null +++ b/spacetime-auth-ts/spacetimedb/package.json @@ -0,0 +1,20 @@ +{ + "name": "spacetime-auth-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-auth", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-auth" + }, + "dependencies": { + "@spacetimedb/auth": "workspace:*", + "@spacetimedb/rate-limit": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-auth-ts/spacetimedb/src/index.ts b/spacetime-auth-ts/spacetimedb/src/index.ts new file mode 100644 index 00000000000..160a0b54719 --- /dev/null +++ b/spacetime-auth-ts/spacetimedb/src/index.ts @@ -0,0 +1,2 @@ +export { default } from '../../src/submodule/index'; +export * from '../../src/submodule/index'; diff --git a/spacetime-auth-ts/spacetimedb/tsconfig.json b/spacetime-auth-ts/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..c18065b7cb8 --- /dev/null +++ b/spacetime-auth-ts/spacetimedb/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-auth-ts/src/admin.ts b/spacetime-auth-ts/src/admin.ts new file mode 100644 index 00000000000..cbed5bad178 --- /dev/null +++ b/spacetime-auth-ts/src/admin.ts @@ -0,0 +1,34 @@ +import type { Identity, Timestamp } from 'spacetimedb'; +import { SenderError } from 'spacetimedb/server'; +import type { AuthTransactionCtx } from './context.ts'; + +export type AdminVerdict = 'admin' | 'denied'; + +// Non-throwing read so callers can compute the verdict inside a tx and throw +// outside it. A SenderError thrown inside ctx.withTx surfaces as a fatal +// instance error, not a recoverable rejection. +export function authAdminVerdict( + tx: AuthTransactionCtx, + sender: Identity +): AdminVerdict { + return tx.db.authAdminIdentity.identity.find(sender) != null + ? 'admin' + : 'denied'; +} + +export function denyIfNotAdmin(verdict: AdminVerdict): void { + if (verdict === 'denied') throw new SenderError('auth.not_authorized'); +} + +// For owner-gated setup code only. Do not call from a public bootstrap path. +export function seedAuthAdmin( + tx: AuthTransactionCtx, + sender: Identity, + timestamp: Timestamp +): void { + if (tx.db.authAdminIdentity.identity.find(sender) != null) return; + tx.db.authAdminIdentity.insert({ + identity: sender, + addedAtMicros: timestamp.microsSinceUnixEpoch, + }); +} diff --git a/spacetime-auth-ts/src/caller.ts b/spacetime-auth-ts/src/caller.ts new file mode 100644 index 00000000000..c5be333db22 --- /dev/null +++ b/spacetime-auth-ts/src/caller.ts @@ -0,0 +1,48 @@ +// Browser clients must call link_connection after connecting to SpacetimeDB. + +import { SenderError } from 'spacetimedb/server'; +import type { + AuthProcedureCtx, + AuthReducerCtx, + AuthViewCtx, +} from './context.ts'; +import type { AuthUser } from './types.ts'; + +type CallerContext = AuthReducerCtx | AuthProcedureCtx | AuthViewCtx; + +function hasDirectDb(ctx: CallerContext): ctx is AuthReducerCtx | AuthViewCtx { + return 'db' in ctx; +} + +/** Returns the userId bound to ctx.sender, or null if not linked. */ +export function getCallerUserId(ctx: CallerContext): string | null { + if (hasDirectDb(ctx)) { + const binding = ctx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + return binding?.userId ?? null; + } + return ctx.withTx(tx => { + const binding = tx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + return binding?.userId ?? null; + }); +} + +/** Look up the caller's auth_user row, or null. */ +export function findCallerUser(ctx: CallerContext): AuthUser | null { + if (hasDirectDb(ctx)) { + const binding = ctx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + if (!binding) return null; + return ctx.db.authUser.userId.find(binding.userId) ?? null; + } + return ctx.withTx(tx => { + const binding = tx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + if (!binding) return null; + return tx.db.authUser.userId.find(binding.userId) ?? null; + }); +} + +/** Returns userId. Throws SenderError('auth.not_authenticated') if no binding. */ +export function requireCallerUserId(ctx: CallerContext): string { + const userId = getCallerUserId(ctx); + if (!userId) throw new SenderError('auth.not_authenticated'); + return userId; +} diff --git a/spacetime-auth-ts/src/context.ts b/spacetime-auth-ts/src/context.ts new file mode 100644 index 00000000000..c1df949fbc1 --- /dev/null +++ b/spacetime-auth-ts/src/context.ts @@ -0,0 +1,37 @@ +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; +import { + schema, + table, + t, + type HandlerContext, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { authTables } from './tables.ts'; + +const authSweeperTick = table( + { name: 'auth_sweeper_tick' }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +// This schema exists only to derive the context types shared by the package's +// reducer, procedure, view, and HTTP implementations. Runtime modules register the +// same auth tables and the rate-limit submodule under their own schema. +const _authContextSchema = schema({ + ...authTables, + authSweeperTick, + rateLimit, +}); + +export type AuthSchema = InferSchema; +export type AuthReducerCtx = ReducerCtx; +export type AuthProcedureCtx = ProcedureCtx; +export type AuthTransactionCtx = TransactionCtx; +export type AuthViewCtx = ViewCtx; +export type AuthHandlerCtx = HandlerContext; diff --git a/spacetime-auth-ts/src/crypto.ts b/spacetime-auth-ts/src/crypto.ts new file mode 100644 index 00000000000..c8391dff18c --- /dev/null +++ b/spacetime-auth-ts/src/crypto.ts @@ -0,0 +1,173 @@ +import { scrypt } from '@noble/hashes/scrypt'; +import { sha256 } from '@noble/hashes/sha2'; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder('utf-8'); + +// N=2^14 keeps single-hash under ~300ms in STDB's V8 isolate. +export interface ScryptParams { + N: number; + r: number; + p: number; + dkLen: number; +} +const DEFAULT_SCRYPT: ScryptParams = { N: 1 << 14, r: 8, p: 1, dkLen: 32 }; + +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +const B64_REV = (() => { + const m = new Int8Array(256).fill(-1); + for (let i = 0; i < B64.length; i++) m[B64.charCodeAt(i)] = i; + return m; +})(); + +function b64encode(bytes: Uint8Array): string { + let out = ''; + let i = 0; + for (; i + 2 < bytes.length; i += 3) { + out += B64[bytes[i] >> 2]; + out += B64[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += B64[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; + out += B64[bytes[i + 2] & 63]; + } + if (i < bytes.length) { + out += B64[bytes[i] >> 2]; + if (i + 1 === bytes.length) { + out += B64[(bytes[i] & 3) << 4]; + out += '=='; + } else { + out += B64[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += B64[(bytes[i + 1] & 15) << 2]; + out += '='; + } + } + return out; +} + +function b64decode(str: string): Uint8Array { + let s = ''; + for (let i = 0; i < str.length; i++) { + if (B64_REV[str.charCodeAt(i)] >= 0) s += str[i]; + } + const out = new Uint8Array((s.length * 3) >> 2); + let oi = 0; + for (let i = 0; i + 3 < s.length; i += 4) { + const a = B64_REV[s.charCodeAt(i)]; + const b = B64_REV[s.charCodeAt(i + 1)]; + const c = B64_REV[s.charCodeAt(i + 2)]; + const d = B64_REV[s.charCodeAt(i + 3)]; + out[oi++] = (a << 2) | (b >> 4); + out[oi++] = ((b & 15) << 4) | (c >> 2); + out[oi++] = ((c & 3) << 6) | d; + } + const tail = s.length & 3; + if (tail >= 2) { + const i = s.length - tail; + const a = B64_REV[s.charCodeAt(i)]; + const b = B64_REV[s.charCodeAt(i + 1)]; + out[oi++] = (a << 2) | (b >> 4); + if (tail === 3) { + const c = B64_REV[s.charCodeAt(i + 2)]; + out[oi++] = ((b & 15) << 4) | (c >> 2); + } + } + return out.subarray(0, oi); +} + +/** Subset of STDB Random. */ +export interface RandomSource { + fill(array: T): T; +} + +export function randomBytes(source: RandomSource, n: number): Uint8Array { + return source.fill(new Uint8Array(n)); +} + +export function randomToken(source: RandomSource, byteLen = 32): string { + return b64encode(randomBytes(source, byteLen)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** Prefer ctx.newUuidV7() if available. */ +export function uuidV7(source: RandomSource, nowMs: number): string { + const rand = randomBytes(source, 10); + const ts = BigInt(nowMs); + const bytes = new Uint8Array(16); + bytes[0] = Number((ts >> 40n) & 0xffn); + bytes[1] = Number((ts >> 32n) & 0xffn); + bytes[2] = Number((ts >> 24n) & 0xffn); + bytes[3] = Number((ts >> 16n) & 0xffn); + bytes[4] = Number((ts >> 8n) & 0xffn); + bytes[5] = Number(ts & 0xffn); + for (let i = 0; i < 10; i++) bytes[6 + i] = rand[i]; + bytes[6] = (bytes[6] & 0x0f) | 0x70; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** Encoded as `scrypt$N$r$p$saltB64$hashB64`. */ +export function hashPassword( + source: RandomSource, + password: string, + params: Partial = {} +): string { + const p = { ...DEFAULT_SCRYPT, ...params }; + const salt = randomBytes(source, 16); + const hash = scrypt(textEncoder.encode(password), salt, { + N: p.N, + r: p.r, + p: p.p, + dkLen: p.dkLen, + }); + return `scrypt$${p.N}$${p.r}$${p.p}$${b64encode(salt)}$${b64encode(hash)}`; +} + +export function verifyPassword(password: string, encoded: string): boolean { + const parts = encoded.split('$'); + if (parts.length !== 6 || parts[0] !== 'scrypt') return false; + const scryptCost = parseInt(parts[1], 10); + const r = parseInt(parts[2], 10); + const p = parseInt(parts[3], 10); + if ( + !Number.isFinite(scryptCost) || + !Number.isFinite(r) || + !Number.isFinite(p) + ) + return false; + const salt = b64decode(parts[4]); + const expected = b64decode(parts[5]); + const actual = scrypt(textEncoder.encode(password), salt, { + N: scryptCost, + r, + p, + dkLen: expected.length, + }); + return constantTimeEqual(expected, actual); +} + +export function newSessionToken(source: RandomSource): string { + return randomToken(source, 32); +} + +export function newPkceVerifier(source: RandomSource): string { + return randomToken(source, 32); +} + +export function pkceChallenge(verifier: string): string { + const hash = sha256(textEncoder.encode(verifier)); + return b64encode(hash) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} + +export { textEncoder as utf8Encoder, textDecoder as utf8Decoder }; diff --git a/spacetime-auth-ts/src/handlers/email_verify.ts b/spacetime-auth-ts/src/handlers/email_verify.ts new file mode 100644 index 00000000000..944d8878dc7 --- /dev/null +++ b/spacetime-auth-ts/src/handlers/email_verify.ts @@ -0,0 +1,178 @@ +import type { SyncResponse, Request } from 'spacetimedb/server'; +import { uuidV7, randomToken } from '../crypto.ts'; +import { + buildVerifyEmail, + MailerNotConfiguredError, + type SendMailFn, +} from '../mailer.ts'; +import { + type AuthHandlerCtx, + ConfigMissingError, + errorResponse, + jsonResponse, + parseCookies, + parseQueryString, + redirectResponse, + requireConfig, +} from './http.ts'; +import { verifyJwt } from '../jwt.ts'; +import { publicKeyFromPem } from '../keys.ts'; +import { Timestamp } from 'spacetimedb'; +import { + AUTH_RATE_LIMITS, + type AuthHttpOptions, + enforceIpRateLimit, +} from '../rate_limit.ts'; + +const PURPOSE = 'email_verify'; +const TOKEN_TTL_SECONDS = 60n * 60n * 24n; + +export interface VerifyRequestOpts extends AuthHttpOptions { + sendMail: SendMailFn; + appName?: string; + /** Default '/'. */ + successRedirect?: string; +} + +export function makeEmailVerifyRequestHandler(opts: VerifyRequestOpts) { + return function emailVerifyRequest( + ctx: AuthHandlerCtx, + req: Request + ): SyncResponse { + if (!opts.sendMail) throw new MailerNotConfiguredError(); + const limited = enforceIpRateLimit( + ctx, + req, + AUTH_RATE_LIMITS.emailVerifyRequest, + opts.trustedProxyHeader + ); + if (limited) return limited; + + const verificationId = uuidV7( + ctx.random, + Number(ctx.timestamp.microsSinceUnixEpoch / 1000n) + ); + const token = randomToken(ctx.random, 32); + + let userEmail: string; + let baseUrl: string; + try { + ({ userEmail, baseUrl } = ctx.withTx(tx => { + const cfg = requireConfig(tx); + const cookies = parseCookies(req.headers.get('cookie')); + const bearer = req.headers.get('authorization'); + const sessionToken = + bearer && bearer.toLowerCase().startsWith('bearer ') + ? bearer.slice(7).trim() + : cookies[cfg.cookieName]; + if (!sessionToken) throw new UnauthenticatedError(); + + const pub = publicKeyFromPem(cfg.es256PublicKeyPem); + const nowMicros = ctx.timestamp.microsSinceUnixEpoch as bigint; + const v = verifyJwt(pub, sessionToken, { + issuer: cfg.issuerUrl, + nowSeconds: Number(nowMicros / 1_000_000n), + }); + if (!v.ok) throw new UnauthenticatedError(); + if (!v.claims.jti) throw new UnauthenticatedError(); + + const session = tx.db.authSession.sessionId.find(v.claims.jti); + if (!session || session.userId !== v.claims.sub) + throw new UnauthenticatedError(); + if ((session.expiresAt.microsSinceUnixEpoch as bigint) < nowMicros) { + throw new UnauthenticatedError(); + } + + const user = tx.db.authUser.userId.find(v.claims.sub); + if (!user) throw new UnauthenticatedError(); + if (user.emailVerified) throw new AlreadyVerifiedError(); + + for (const row of tx.db.authVerification.identifier.filter( + user.email + )) { + if (row.purpose === PURPOSE) tx.db.authVerification.delete(row); + } + + tx.db.authVerification.insert({ + verificationId, + identifier: user.email, + value: token, + purpose: PURPOSE, + expiresAt: new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + TOKEN_TTL_SECONDS * 1_000_000n + ), + createdAt: ctx.timestamp, + }); + return { userEmail: user.email, baseUrl: cfg.baseUrl }; + })); + } catch (e) { + if (e instanceof UnauthenticatedError) + return errorResponse('unauthenticated', 401); + if (e instanceof AlreadyVerifiedError) + return jsonResponse({ ok: true, alreadyVerified: true }); + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + throw e; + } + + const mail = buildVerifyEmail({ baseUrl, token, appName: opts.appName }); + mail.to = userEmail; + opts.sendMail(ctx, mail); + return jsonResponse({ ok: true }); + }; +} + +export function makeEmailVerifyHandler( + opts: { successRedirect?: string } = {} +) { + return function emailVerify(ctx: AuthHandlerCtx, req: Request): SyncResponse { + const q = parseQueryString(req.uri); + const token = q['token']; + if (!token) return errorResponse('missing_token', 400); + + try { + ctx.withTx(tx => { + const row = tx.db.authVerification.value.find(token); + if (!row || row.purpose !== PURPOSE) throw new BadTokenError(); + if ( + row.expiresAt.microsSinceUnixEpoch < + ctx.timestamp.microsSinceUnixEpoch + ) { + tx.db.authVerification.delete(row); + throw new BadTokenError(); + } + + const user = tx.db.authUser.email.find(row.identifier); + tx.db.authVerification.delete(row); + if (!user) throw new BadTokenError(); + + tx.db.authUser.userId.update({ + ...user, + emailVerified: true, + updatedAt: ctx.timestamp, + }); + }); + } catch (e) { + if (e instanceof BadTokenError) return errorResponse('bad_token', 400); + throw e; + } + + return redirectResponse(opts.successRedirect ?? '/'); + }; +} + +class UnauthenticatedError extends Error { + constructor() { + super('unauthenticated'); + } +} +class AlreadyVerifiedError extends Error { + constructor() { + super('already_verified'); + } +} +class BadTokenError extends Error { + constructor() { + super('bad_token'); + } +} diff --git a/spacetime-auth-ts/src/handlers/github.ts b/spacetime-auth-ts/src/handlers/github.ts new file mode 100644 index 00000000000..6189c53cac6 --- /dev/null +++ b/spacetime-auth-ts/src/handlers/github.ts @@ -0,0 +1,97 @@ +import { + makeOAuthCallbackHandler, + makeOAuthStartHandler, + type OAuthProfile, + type OAuthProviderSpec, +} from './oauth.ts'; +import type { AuthHandlerCtx } from './http.ts'; + +const githubHeaders = { + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + 'user-agent': 'spacetimedb-auth-submodule', +}; + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null + ? (value as Record) + : {}; +} + +function pickGithubEmail(rows: unknown): string { + if (!Array.isArray(rows)) return ''; + const primaryVerified = rows.find(value => { + const row = record(value); + return ( + row.primary === true && + row.verified === true && + typeof row.email === 'string' + ); + }); + if (primaryVerified) return String(record(primaryVerified).email); + const verified = rows.find(value => { + const row = record(value); + return row.verified === true && typeof row.email === 'string'; + }); + return verified ? String(record(verified).email) : ''; +} + +function resolveGithubProfile( + ctx: AuthHandlerCtx, + accessToken: string +): OAuthProfile | { error: string } { + const headers = { + ...githubHeaders, + authorization: `Bearer ${accessToken}`, + }; + + const userRes = ctx.http.fetch('https://api.github.com/user', { + method: 'GET', + headers, + }); + if (!userRes.ok) return { error: `userinfo_failed:${userRes.status}` }; + + const user = record(userRes.json()); + const emailRes = ctx.http.fetch('https://api.github.com/user/emails', { + method: 'GET', + headers, + }); + if (!emailRes.ok) return { error: `github_email_failed:${emailRes.status}` }; + const email = pickGithubEmail(emailRes.json()); + + return { + sub: String(user.id ?? ''), + email, + emailVerified: email.length > 0, + name: typeof user.name === 'string' ? user.name : String(user.login ?? ''), + image: typeof user.avatar_url === 'string' ? user.avatar_url : undefined, + }; +} + +const github: OAuthProviderSpec = { + id: 'github', + authorizeUrl: 'https://github.com/login/oauth/authorize', + tokenUrl: 'https://github.com/login/oauth/access_token', + scope: 'read:user user:email', + oidc: false, + userInfoUrl: 'https://api.github.com/user', + userInfoHeaders: githubHeaders, + getClientId: cfg => cfg.githubClientId ?? '', + getClientSecret: cfg => cfg.githubClientSecret ?? '', + resolveProfile: resolveGithubProfile, + parseProfile: data => { + const user = record(data); + return { + sub: String(user.id ?? ''), + email: String(user.email ?? ''), + emailVerified: true, + name: + typeof user.name === 'string' ? user.name : String(user.login ?? ''), + image: typeof user.avatar_url === 'string' ? user.avatar_url : undefined, + }; + }, + usePkce: false, +}; + +export const githubStartHandler = makeOAuthStartHandler(github); +export const githubCallbackHandler = makeOAuthCallbackHandler(github); diff --git a/spacetime-auth-ts/src/handlers/google.ts b/spacetime-auth-ts/src/handlers/google.ts new file mode 100644 index 00000000000..f6ac74c7c55 --- /dev/null +++ b/spacetime-auth-ts/src/handlers/google.ts @@ -0,0 +1,36 @@ +import { + makeOAuthCallbackHandler, + makeOAuthStartHandler, + type OAuthProviderSpec, +} from './oauth.ts'; + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null + ? (value as Record) + : {}; +} + +const google: OAuthProviderSpec = { + id: 'google', + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + scope: 'openid email profile', + oidc: false, + userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo', + getClientId: cfg => cfg.googleClientId ?? '', + getClientSecret: cfg => cfg.googleClientSecret ?? '', + parseProfile: data => { + const claims = record(data); + return { + sub: String(claims.sub ?? ''), + email: String(claims.email ?? ''), + emailVerified: claims.email_verified === true, + name: typeof claims.name === 'string' ? claims.name : undefined, + image: typeof claims.picture === 'string' ? claims.picture : undefined, + }; + }, + authorizeExtras: { access_type: 'offline', prompt: 'consent' }, +}; + +export const googleStartHandler = makeOAuthStartHandler(google); +export const googleCallbackHandler = makeOAuthCallbackHandler(google); diff --git a/spacetime-auth-ts/src/handlers/http.ts b/spacetime-auth-ts/src/handlers/http.ts new file mode 100644 index 00000000000..65d4323d0ed --- /dev/null +++ b/spacetime-auth-ts/src/handlers/http.ts @@ -0,0 +1,166 @@ +import { SyncResponse, type Request } from 'spacetimedb/server'; +import { verifyJwt, type JwtClaims } from '../jwt.ts'; +import { privateKeyFromPem, publicKeyFromPem } from '../keys.ts'; +import type { AuthConfig } from '../types.ts'; +import type { AuthHandlerCtx, AuthTransactionCtx } from '../context.ts'; + +export type { AuthHandlerCtx, AuthTransactionCtx }; + +export interface CookieOptions { + maxAgeSeconds?: number; + path?: string; + domain?: string; + httpOnly?: boolean; + secure?: boolean; + sameSite?: 'Strict' | 'Lax' | 'None'; +} + +export function makeCookie( + name: string, + value: string, + options: CookieOptions = {} +): string { + const parts = [`${name}=${value}`]; + parts.push(`Path=${options.path ?? '/'}`); + if (options.maxAgeSeconds != null) + parts.push(`Max-Age=${options.maxAgeSeconds}`); + if (options.domain) parts.push(`Domain=${options.domain}`); + if (options.httpOnly !== false) parts.push('HttpOnly'); + if (options.secure !== false) parts.push('Secure'); + parts.push(`SameSite=${options.sameSite ?? 'Lax'}`); + return parts.join('; '); +} + +export function clearCookie(name: string, options: CookieOptions = {}): string { + return makeCookie(name, '', { ...options, maxAgeSeconds: 0 }); +} + +export { shouldUseSecureCookies, userAgent } from '../request-trust.ts'; + +export function parseCookies( + header: string | null | undefined +): Record { + const out: Record = {}; + if (!header) return out; + for (const part of header.split(';')) { + const eq = part.indexOf('='); + if (eq < 0) continue; + const k = part.slice(0, eq).trim(); + const v = part.slice(eq + 1).trim(); + if (k) out[k] = v; + } + return out; +} + +export function jsonResponse( + body: unknown, + status = 200, + extraHeaders: Record = {} +): SyncResponse { + return new SyncResponse(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json', ...extraHeaders }, + }); +} + +export function errorResponse( + code: string, + status: number, + extraHeaders: Record = {} +): SyncResponse { + return jsonResponse({ error: code }, status, extraHeaders); +} + +export function redirectResponse( + location: string, + extraHeaders: Record = {} +): SyncResponse { + return new SyncResponse('', { + status: 302, + headers: { location, ...extraHeaders }, + }); +} + +export function requireConfig(tx: AuthTransactionCtx): AuthConfig { + const cfg = tx.db.authConfig.singleton.find(true); + if (!cfg) throw new ConfigMissingError(); + return cfg; +} + +export class ConfigMissingError extends Error { + constructor() { + super('auth_config singleton missing; call setAuthConfig first'); + } +} + +export function microsToSeconds(t: { microsSinceUnixEpoch: bigint }): number { + return Number(t.microsSinceUnixEpoch / 1_000_000n); +} + +export function secondsToTimestamp(seconds: number | bigint): { + microsSinceUnixEpoch: bigint; +} { + return { microsSinceUnixEpoch: BigInt(seconds) * 1_000_000n }; +} + +export function readBearer(req: Request, cookieName: string): string | null { + const auth = req.headers.get('authorization'); + if (auth && auth.toLowerCase().startsWith('bearer ')) { + return auth.slice(7).trim(); + } + const cookies = parseCookies(req.headers.get('cookie')); + return cookies[cookieName] ?? null; +} + +export function readSession( + req: Request, + cookieName: string, + publicKey: Uint8Array, + issuer?: string +): JwtClaims | null { + const token = readBearer(req, cookieName); + if (!token) return null; + const r = verifyJwt(publicKey, token, { issuer }); + return r.ok ? r.claims : null; +} + +export function configKeys(cfg: AuthConfig): { + privateKey: Uint8Array; + publicKey: Uint8Array; +} { + return { + privateKey: privateKeyFromPem(cfg.es256PrivateKeyPem), + publicKey: publicKeyFromPem(cfg.es256PublicKeyPem), + }; +} + +export function safeJson(req: Request): T | null { + try { + return req.json() as T; + } catch { + return null; + } +} + +/** STDB V8 isolate has no globalThis.URL. */ +export function parseQueryString(uri: string): Record { + const q = uri.indexOf('?'); + if (q < 0) return {}; + const out: Record = {}; + for (const pair of uri.slice(q + 1).split('&')) { + const eq = pair.indexOf('='); + try { + if (eq < 0) { + out[decodeURIComponent(pair)] = ''; + } else { + out[decodeURIComponent(pair.slice(0, eq))] = decodeURIComponent( + pair.slice(eq + 1) + ); + } + } catch { + // Ignore malformed percent-encoding. Callers will treat the missing + // parameter as a controlled bad request and keep the handler available. + } + } + return out; +} diff --git a/spacetime-auth-ts/src/handlers/index.ts b/spacetime-auth-ts/src/handlers/index.ts new file mode 100644 index 00000000000..213db3d7c39 --- /dev/null +++ b/spacetime-auth-ts/src/handlers/index.ts @@ -0,0 +1,33 @@ +export { passwordLoginHandler, passwordSignupHandler } from './password.ts'; +export { googleStartHandler, googleCallbackHandler } from './google.ts'; +export { githubStartHandler, githubCallbackHandler } from './github.ts'; +export { meHandler, logoutHandler, refreshHandler } from './session.ts'; +export { + makeOAuthCallbackHandler, + makeOAuthStartHandler, + type OAuthProviderSpec, + type OAuthProfile, +} from './oauth.ts'; +export { + makeEmailVerifyHandler, + makeEmailVerifyRequestHandler, + type VerifyRequestOpts, +} from './email_verify.ts'; +export { + makeForgotPasswordHandler, + resetPasswordHandler, + type ForgotPasswordOpts, +} from './password_reset.ts'; +export { + clearCookie, + makeCookie, + parseCookies, + jsonResponse, + errorResponse, + redirectResponse, + readBearer, + readSession, + shouldUseSecureCookies, + type CookieOptions, +} from './http.ts'; +export type { AuthHttpOptions, TrustedProxyHeader } from '../rate_limit.ts'; diff --git a/spacetime-auth-ts/src/handlers/oauth.ts b/spacetime-auth-ts/src/handlers/oauth.ts new file mode 100644 index 00000000000..70f0b04a9ee --- /dev/null +++ b/spacetime-auth-ts/src/handlers/oauth.ts @@ -0,0 +1,451 @@ +import { SyncResponse, type Request } from 'spacetimedb/server'; +import { Timestamp } from 'spacetimedb'; +import { + newSessionToken, + newPkceVerifier, + pkceChallenge, + randomToken, + uuidV7, +} from '../crypto.ts'; +import { signJwt } from '../jwt.ts'; +import { privateKeyFromPem } from '../keys.ts'; +import { + type AuthHandlerCtx, + shouldUseSecureCookies, + userAgent, + ConfigMissingError, + errorResponse, + makeCookie, + parseQueryString, + redirectResponse, + requireConfig, +} from './http.ts'; +import { + AUTH_RATE_LIMITS, + type AuthHttpOptions, + clientKey, + enforceRateLimits, +} from '../rate_limit.ts'; +import { safeRedirectPath } from '../request-trust.ts'; +import type { AuthAccount, AuthConfig } from '../types.ts'; + +const OAUTH_STATE_TTL_SECONDS = 600n; +const MAX_OAUTH_CODE_LENGTH = 4096; +const MAX_OAUTH_STATE_LENGTH = 256; +const MAX_PROFILE_SUB_LENGTH = 512; +const MAX_PROFILE_EMAIL_LENGTH = 320; +const MAX_PROFILE_NAME_LENGTH = 256; +const MAX_PROFILE_IMAGE_LENGTH = 2048; + +export interface OAuthProviderSpec { + id: string; + authorizeUrl: string; + tokenUrl: string; + scope: string; + getClientId: (cfg: AuthConfig) => string; + getClientSecret: (cfg: AuthConfig) => string; + /** Reserved for verified OIDC id_token support. Prefer userInfoUrl. */ + oidc: boolean; + userInfoUrl?: string; + userInfoHeaders?: Record; + parseProfile: (data: unknown) => OAuthProfile; + resolveProfile?: ( + ctx: AuthHandlerCtx, + accessToken: string + ) => OAuthProfile | OAuthProfileError; + authorizeExtras?: Record; + /** Default true. */ + usePkce?: boolean; +} + +export interface OAuthProfile { + sub: string; + email: string; + emailVerified?: boolean; + name?: string; + image?: string; +} + +export interface OAuthProfileError { + error: string; +} + +function isProfileError( + value: OAuthProfile | OAuthProfileError +): value is OAuthProfileError { + return typeof (value as OAuthProfileError).error === 'string'; +} + +export function makeOAuthStartHandler( + provider: OAuthProviderSpec, + defaultOptions: AuthHttpOptions = {} +) { + return function start( + ctx: AuthHandlerCtx, + req: Request, + options: AuthHttpOptions = defaultOptions + ): SyncResponse { + const q = parseQueryString(req.uri); + const requestedRedirect = q['redirectTo']; + const redirectTo = + requestedRedirect === undefined + ? '/' + : safeRedirectPath(requestedRedirect); + if (redirectTo === undefined) return errorResponse('invalid_redirect', 400); + const ipKey = clientKey(req, options.trustedProxyHeader); + const limited = ipKey + ? enforceRateLimits(ctx, req, [ + { + policy: AUTH_RATE_LIMITS.oauthStart, + actor: `ip:${ipKey}:${provider.id}`, + }, + ]) + : null; + if (limited) return limited; + + const state = randomToken(ctx.random, 32); + const verifier = + (provider.usePkce ?? true) ? newPkceVerifier(ctx.random) : ''; + + let baseUrl: string; + let clientId: string; + try { + ({ baseUrl, clientId } = ctx.withTx(tx => { + const cfg = requireConfig(tx); + const cid = provider.getClientId(cfg); + if (!cid) throw new ProviderNotConfiguredError(provider.id); + tx.db.authOauthState.insert({ + state, + provider: provider.id, + codeVerifier: verifier, + redirectTo, + expiresAt: new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + + OAUTH_STATE_TTL_SECONDS * 1_000_000n + ), + createdAt: ctx.timestamp, + }); + return { baseUrl: cfg.baseUrl, clientId: cid }; + })); + } catch (e) { + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + if (e instanceof ProviderNotConfiguredError) + return errorResponse(`provider_not_configured:${e.provider}`, 500); + throw e; + } + + const params: Record = { + client_id: clientId, + redirect_uri: `${baseUrl}/auth/${provider.id}/callback`, + response_type: 'code', + scope: provider.scope, + state, + }; + if (provider.usePkce ?? true) { + params['code_challenge'] = pkceChallenge(verifier); + params['code_challenge_method'] = 'S256'; + } + for (const [k, v] of Object.entries(provider.authorizeExtras ?? {})) { + params[k] = v; + } + const qs = Object.entries(params) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + const sep = provider.authorizeUrl.includes('?') ? '&' : '?'; + return redirectResponse(`${provider.authorizeUrl}${sep}${qs}`); + }; +} + +export function makeOAuthCallbackHandler( + provider: OAuthProviderSpec, + defaultOptions: AuthHttpOptions = {} +) { + return function callback( + ctx: AuthHandlerCtx, + req: Request, + options: AuthHttpOptions = defaultOptions + ): SyncResponse { + const q = parseQueryString(req.uri); + const code = q['code']; + const state = q['state']; + if (!code || !state) return errorResponse('missing_code_or_state', 400); + if ( + code.length > MAX_OAUTH_CODE_LENGTH || + state.length > MAX_OAUTH_STATE_LENGTH + ) { + return errorResponse('invalid_code_or_state', 400); + } + + let baseUrl: string; + let clientId: string; + let clientSecret: string; + let codeVerifier: string; + let redirectTo: string; + try { + ({ baseUrl, clientId, clientSecret, codeVerifier, redirectTo } = + ctx.withTx(tx => { + const cfg = requireConfig(tx); + const row = tx.db.authOauthState.state.find(state); + if (!row) throw new BadStateError(); + if (row.provider !== provider.id) throw new BadStateError(); + if ( + row.expiresAt.microsSinceUnixEpoch < + ctx.timestamp.microsSinceUnixEpoch + ) { + tx.db.authOauthState.delete(row); + throw new BadStateError(); + } + const out = { + baseUrl: cfg.baseUrl, + clientId: provider.getClientId(cfg), + clientSecret: provider.getClientSecret(cfg), + codeVerifier: row.codeVerifier, + redirectTo: row.redirectTo, + }; + tx.db.authOauthState.delete(row); + return out; + })); + } catch (e) { + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + if (e instanceof BadStateError) return errorResponse('bad_state', 400); + throw e; + } + + const formParams: Record = { + grant_type: 'authorization_code', + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: `${baseUrl}/auth/${provider.id}/callback`, + }; + if (codeVerifier) formParams['code_verifier'] = codeVerifier; + const tokenForm = Object.entries(formParams) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); + + const tokRes = ctx.http.fetch(provider.tokenUrl, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + accept: 'application/json', + }, + body: tokenForm, + }); + if (!tokRes.ok) return errorResponse('token_exchange_failed', 502); + const tokens = tokRes.json() as unknown; + const tokenRecord = + typeof tokens === 'object' && tokens !== null + ? (tokens as Record) + : {}; + const accessToken = + typeof tokenRecord.access_token === 'string' + ? tokenRecord.access_token + : undefined; + const refreshToken = + typeof tokenRecord.refresh_token === 'string' + ? tokenRecord.refresh_token + : undefined; + const idToken = + typeof tokenRecord.id_token === 'string' + ? tokenRecord.id_token + : undefined; + const expiresIn = + typeof tokenRecord.expires_in === 'number' && + Number.isSafeInteger(tokenRecord.expires_in) && + tokenRecord.expires_in > 0 + ? tokenRecord.expires_in + : undefined; + if (!accessToken && !idToken) + return errorResponse('no_token_in_response', 502); + + let profile: OAuthProfile; + if (provider.resolveProfile && accessToken) { + const resolved = provider.resolveProfile(ctx, accessToken); + if (isProfileError(resolved)) return errorResponse(resolved.error, 502); + profile = resolved; + } else if (provider.userInfoUrl && accessToken) { + const uRes = ctx.http.fetch(provider.userInfoUrl, { + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}`, + accept: 'application/json', + 'user-agent': 'spacetimedb-auth-submodule', + ...(provider.userInfoHeaders ?? {}), + }, + }); + if (!uRes.ok) return errorResponse(`userinfo_failed:${uRes.status}`, 502); + profile = provider.parseProfile(uRes.json()); + } else if (provider.oidc && idToken) { + return errorResponse('id_token_verification_unsupported', 502); + } else { + return errorResponse('cannot_resolve_profile', 502); + } + + if ( + !profile.email || + !profile.sub || + profile.email.length > MAX_PROFILE_EMAIL_LENGTH || + profile.sub.length > MAX_PROFILE_SUB_LENGTH || + (profile.name?.length ?? 0) > MAX_PROFILE_NAME_LENGTH || + (profile.image?.length ?? 0) > MAX_PROFILE_IMAGE_LENGTH + ) + return errorResponse('incomplete_profile', 502); + + const nowMs = Number(ctx.timestamp.microsSinceUnixEpoch / 1000n); + const newUserId = uuidV7(ctx.random, nowMs); + const newAccountId = uuidV7(ctx.random, nowMs); + const sessionId = uuidV7(ctx.random, nowMs); + const sessionToken = newSessionToken(ctx.random); + + let authResult: { + issuerUrl: string; + cookieName: string; + sessionTtlSeconds: bigint; + privateKeyPem: string; + keyId: string; + userId: string; + }; + try { + authResult = ctx.withTx(tx => { + const cfg = requireConfig(tx); + + let existing: AuthAccount | undefined; + for (const a of tx.db.authAccount.providerAccountId.filter( + profile.sub + )) { + if (a.providerId === provider.id) { + existing = a; + break; + } + } + + let resolvedUserId: string; + if (existing) { + resolvedUserId = existing.userId; + tx.db.authAccount.accountId.update({ + ...existing, + passwordHash: existing.passwordHash, + accessToken: accessToken ?? existing.accessToken, + refreshToken: refreshToken ?? existing.refreshToken, + accessTokenExpiresAt: expiresIn + ? new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + + BigInt(expiresIn) * 1_000_000n + ) + : existing.accessTokenExpiresAt, + updatedAt: ctx.timestamp, + }); + } else { + const byEmail = tx.db.authUser.email.find( + profile.email.toLowerCase() + ); + if (byEmail) throw new AccountLinkRequiredError(); + resolvedUserId = newUserId; + tx.db.authUser.insert({ + userId: newUserId, + email: profile.email.toLowerCase(), + emailVerified: profile.emailVerified ?? false, + name: profile.name, + image: profile.image, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + tx.db.authAccount.insert({ + accountId: newAccountId, + userId: resolvedUserId, + providerId: provider.id, + providerAccountId: profile.sub, + passwordHash: undefined, + accessToken, + refreshToken, + accessTokenExpiresAt: expiresIn + ? new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + + BigInt(expiresIn) * 1_000_000n + ) + : undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + } + + tx.db.authSession.insert({ + sessionId, + userId: resolvedUserId, + token: sessionToken, + expiresAt: new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + + BigInt(cfg.sessionTtlSeconds) * 1_000_000n + ), + ipAddress: clientKey(req, options.trustedProxyHeader), + userAgent: userAgent(req), + createdAt: ctx.timestamp, + }); + + return { + issuerUrl: cfg.issuerUrl, + cookieName: cfg.cookieName, + sessionTtlSeconds: cfg.sessionTtlSeconds, + privateKeyPem: cfg.es256PrivateKeyPem, + keyId: cfg.keyId, + userId: resolvedUserId, + }; + }); + } catch (error) { + if (error instanceof AccountLinkRequiredError) { + return errorResponse('account_link_required', 409); + } + throw error; + } + + const { + issuerUrl, + cookieName, + sessionTtlSeconds, + privateKeyPem, + keyId, + userId, + } = authResult; + + const nowSec = Math.floor(nowMs / 1000); + const ttlSec = Number(sessionTtlSeconds); + const privateKey = privateKeyFromPem(privateKeyPem); + const jwt = signJwt( + privateKey, + { + iss: issuerUrl, + sub: userId, + aud: issuerUrl, + iat: nowSec, + exp: nowSec + ttlSec, + jti: sessionId, + }, + keyId + ); + + return redirectResponse(redirectTo, { + 'set-cookie': makeCookie(cookieName, jwt, { + maxAgeSeconds: ttlSec, + secure: shouldUseSecureCookies(options.secureCookies), + }), + }); + }; +} + +class BadStateError extends Error { + constructor() { + super('bad_state'); + } +} +class AccountLinkRequiredError extends Error { + constructor() { + super('account_link_required'); + } +} +class ProviderNotConfiguredError extends Error { + constructor(public provider: string) { + super(`provider_not_configured:${provider}`); + } +} diff --git a/spacetime-auth-ts/src/handlers/password.ts b/spacetime-auth-ts/src/handlers/password.ts new file mode 100644 index 00000000000..36cb58b2f86 --- /dev/null +++ b/spacetime-auth-ts/src/handlers/password.ts @@ -0,0 +1,285 @@ +import type { SyncResponse, Request } from 'spacetimedb/server'; +import { Timestamp } from 'spacetimedb'; +import { + hashPassword, + verifyPassword, + newSessionToken, + uuidV7, +} from '../crypto.ts'; +import { signJwt } from '../jwt.ts'; +import { privateKeyFromPem } from '../keys.ts'; +import { + type AuthHandlerCtx, + shouldUseSecureCookies, + userAgent, + ConfigMissingError, + errorResponse, + jsonResponse, + makeCookie, + requireConfig, + safeJson, +} from './http.ts'; +import { + AUTH_RATE_LIMITS, + type AuthHttpOptions, + clientKey, + enforceRateLimits, +} from '../rate_limit.ts'; +import type { AuthAccount } from '../types.ts'; + +interface SignupBody { + email: string; + password: string; + name?: string; +} + +interface LoginBody { + email: string; + password: string; +} + +const MIN_PASSWORD_LEN = 8; +const MAX_PASSWORD_LEN = 1024; +const MAX_EMAIL_LEN = 320; +const MAX_NAME_LEN = 128; + +export function passwordSignupHandler( + ctx: AuthHandlerCtx, + req: Request, + options: AuthHttpOptions = {} +): SyncResponse { + const body = safeJson(req); + if (!body?.email || !body?.password) + return errorResponse('invalid_request', 400); + if (body.password.length < MIN_PASSWORD_LEN) + return errorResponse('password_too_short', 400); + if (body.password.length > MAX_PASSWORD_LEN) + return errorResponse('password_too_long', 400); + const email = body.email.toLowerCase().trim(); + if (email.length === 0 || email.length > MAX_EMAIL_LEN) + return errorResponse('invalid_email', 400); + if (body.name !== undefined && body.name.length > MAX_NAME_LEN) + return errorResponse('name_too_long', 400); + const ipKey = clientKey(req, options.trustedProxyHeader); + const limited = enforceRateLimits(ctx, req, [ + { policy: AUTH_RATE_LIMITS.passwordSignup, actor: `email:${email}` }, + ...(ipKey + ? [{ policy: AUTH_RATE_LIMITS.passwordSignup, actor: `ip:${ipKey}` }] + : []), + ]); + if (limited) return limited; + + const hash = hashPassword(ctx.random, body.password); + const nowMs = Number(ctx.timestamp.microsSinceUnixEpoch / 1000n); + const userId = uuidV7(ctx.random, nowMs); + const accountId = uuidV7(ctx.random, nowMs); + const sessionId = uuidV7(ctx.random, nowMs); + const sessionToken = newSessionToken(ctx.random); + + let issuerUrl: string; + let cookieName: string; + let sessionTtlSeconds: bigint; + let privateKeyPem: string; + let keyId: string; + + try { + ({ issuerUrl, cookieName, sessionTtlSeconds, privateKeyPem, keyId } = + ctx.withTx(tx => { + const cfg = requireConfig(tx); + if (tx.db.authUser.email.find(email) != null) + throw new EmailTakenError(); + + tx.db.authUser.insert({ + userId, + email, + emailVerified: false, + name: body.name, + image: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + tx.db.authAccount.insert({ + accountId, + userId, + providerId: 'password', + providerAccountId: email, + passwordHash: hash, + accessToken: undefined, + refreshToken: undefined, + accessTokenExpiresAt: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + const ttlMicros = BigInt(cfg.sessionTtlSeconds) * 1_000_000n; + tx.db.authSession.insert({ + sessionId, + userId, + token: sessionToken, + expiresAt: new Timestamp( + (ctx.timestamp.microsSinceUnixEpoch as bigint) + ttlMicros + ), + ipAddress: clientKey(req, options.trustedProxyHeader), + userAgent: userAgent(req), + createdAt: ctx.timestamp, + }); + + return { + issuerUrl: cfg.issuerUrl, + cookieName: cfg.cookieName, + sessionTtlSeconds: BigInt(cfg.sessionTtlSeconds), + privateKeyPem: cfg.es256PrivateKeyPem, + keyId: cfg.keyId, + }; + })); + } catch (e) { + if (e instanceof EmailTakenError) return errorResponse('email_taken', 409); + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + throw e; + } + + const nowSec = Math.floor(nowMs / 1000); + const ttlSec = Number(sessionTtlSeconds); + const privateKey = privateKeyFromPem(privateKeyPem); + const jwt = signJwt( + privateKey, + { + iss: issuerUrl, + sub: userId, + aud: issuerUrl, + iat: nowSec, + exp: nowSec + ttlSec, + jti: sessionId, + }, + keyId + ); + + return jsonResponse({ user: { userId, email }, token: jwt }, 200, { + 'set-cookie': makeCookie(cookieName, jwt, { + maxAgeSeconds: ttlSec, + secure: shouldUseSecureCookies(options.secureCookies), + }), + }); +} + +export function passwordLoginHandler( + ctx: AuthHandlerCtx, + req: Request, + options: AuthHttpOptions = {} +): SyncResponse { + const body = safeJson(req); + if (!body?.email || !body?.password) + return errorResponse('invalid_request', 400); + if (body.password.length > MAX_PASSWORD_LEN) + return errorResponse('invalid_credentials', 401); + const email = body.email.toLowerCase().trim(); + if (email.length === 0 || email.length > MAX_EMAIL_LEN) + return errorResponse('invalid_credentials', 401); + const ipKey = clientKey(req, options.trustedProxyHeader); + const limited = enforceRateLimits(ctx, req, [ + ...(ipKey + ? [{ policy: AUTH_RATE_LIMITS.passwordLoginIp, actor: `ip:${ipKey}` }] + : []), + { policy: AUTH_RATE_LIMITS.passwordLoginEmail, actor: `email:${email}` }, + ]); + if (limited) return limited; + + const nowMs = Number(ctx.timestamp.microsSinceUnixEpoch / 1000n); + const sessionId = uuidV7(ctx.random, nowMs); + const sessionToken = newSessionToken(ctx.random); + + let issuerUrl: string; + let cookieName: string; + let sessionTtlSeconds: bigint; + let privateKeyPem: string; + let keyId: string; + let loggedInUserId: string; + + try { + ({ + issuerUrl, + cookieName, + sessionTtlSeconds, + privateKeyPem, + keyId, + loggedInUserId, + } = ctx.withTx(tx => { + const cfg = requireConfig(tx); + const user = tx.db.authUser.email.find(email); + if (!user) throw new InvalidCredentialsError(); + + let acct: AuthAccount | undefined; + for (const a of tx.db.authAccount.providerAccountId.filter(email)) { + if (a.providerId === 'password' && a.userId === user.userId) { + acct = a; + break; + } + } + if (!acct?.passwordHash) throw new InvalidCredentialsError(); + if (!verifyPassword(body.password, acct.passwordHash)) + throw new InvalidCredentialsError(); + + tx.db.authSession.insert({ + sessionId, + userId: user.userId, + token: sessionToken, + expiresAt: new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + + BigInt(cfg.sessionTtlSeconds) * 1_000_000n + ), + ipAddress: clientKey(req, options.trustedProxyHeader), + userAgent: userAgent(req), + createdAt: ctx.timestamp, + }); + + return { + issuerUrl: cfg.issuerUrl, + cookieName: cfg.cookieName, + sessionTtlSeconds: BigInt(cfg.sessionTtlSeconds), + privateKeyPem: cfg.es256PrivateKeyPem, + keyId: cfg.keyId, + loggedInUserId: user.userId, + }; + })); + } catch (e) { + if (e instanceof InvalidCredentialsError) + return errorResponse('invalid_credentials', 401); + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + throw e; + } + + const nowSec = Math.floor(nowMs / 1000); + const ttlSec = Number(sessionTtlSeconds); + const privateKey = privateKeyFromPem(privateKeyPem); + const jwt = signJwt( + privateKey, + { + iss: issuerUrl, + sub: loggedInUserId, + aud: issuerUrl, + iat: nowSec, + exp: nowSec + ttlSec, + jti: sessionId, + }, + keyId + ); + + return jsonResponse({ userId: loggedInUserId, token: jwt }, 200, { + 'set-cookie': makeCookie(cookieName, jwt, { + maxAgeSeconds: ttlSec, + secure: shouldUseSecureCookies(options.secureCookies), + }), + }); +} + +class EmailTakenError extends Error { + constructor() { + super('email_taken'); + } +} +class InvalidCredentialsError extends Error { + constructor() { + super('invalid_credentials'); + } +} diff --git a/spacetime-auth-ts/src/handlers/password_reset.ts b/spacetime-auth-ts/src/handlers/password_reset.ts new file mode 100644 index 00000000000..8000c7be4a6 --- /dev/null +++ b/spacetime-auth-ts/src/handlers/password_reset.ts @@ -0,0 +1,196 @@ +import type { SyncResponse, Request } from 'spacetimedb/server'; +import { hashPassword, randomToken, uuidV7 } from '../crypto.ts'; +import { + buildPasswordResetEmail, + MailerNotConfiguredError, + type SendMailFn, +} from '../mailer.ts'; +import { + type AuthHandlerCtx, + ConfigMissingError, + errorResponse, + jsonResponse, + requireConfig, + safeJson, +} from './http.ts'; +import { Timestamp } from 'spacetimedb'; +import { + AUTH_RATE_LIMITS, + type AuthHttpOptions, + clientKey, + enforceIpRateLimit, + enforceRateLimits, +} from '../rate_limit.ts'; + +const PURPOSE = 'password_reset'; +const TOKEN_TTL_SECONDS = 60n * 60n; +const MIN_PASSWORD_LEN = 8; +const MAX_PASSWORD_LEN = 1024; +const MAX_EMAIL_LEN = 320; +const MAX_TOKEN_LEN = 256; + +interface ForgotBody { + email: string; +} +interface ResetBody { + token: string; + newPassword: string; +} + +export interface ForgotPasswordOpts extends AuthHttpOptions { + sendMail: SendMailFn; + appName?: string; +} + +// Always return 200 to keep account existence private. +export function makeForgotPasswordHandler(opts: ForgotPasswordOpts) { + return function forgot(ctx: AuthHandlerCtx, req: Request): SyncResponse { + if (!opts.sendMail) throw new MailerNotConfiguredError(); + + const body = safeJson(req); + if (!body?.email) return errorResponse('invalid_request', 400); + const email = body.email.toLowerCase().trim(); + if (email.length === 0 || email.length > MAX_EMAIL_LEN) + return errorResponse('invalid_request', 400); + const ipKey = clientKey(req, opts.trustedProxyHeader); + const limited = enforceRateLimits(ctx, req, [ + ...(ipKey + ? [{ policy: AUTH_RATE_LIMITS.passwordForgotIp, actor: `ip:${ipKey}` }] + : []), + { policy: AUTH_RATE_LIMITS.passwordForgotEmail, actor: `email:${email}` }, + ]); + if (limited) return limited; + + const verificationId = uuidV7( + ctx.random, + Number(ctx.timestamp.microsSinceUnixEpoch / 1000n) + ); + const token = randomToken(ctx.random, 32); + + let recipient: string | null = null; + let baseUrl: string; + try { + ({ recipient, baseUrl } = ctx.withTx(tx => { + const cfg = requireConfig(tx); + const user = tx.db.authUser.email.find(email); + if (!user) return { recipient: null, baseUrl: cfg.baseUrl }; + + for (const row of tx.db.authVerification.identifier.filter(email)) { + if (row.purpose === PURPOSE) tx.db.authVerification.delete(row); + } + tx.db.authVerification.insert({ + verificationId, + identifier: email, + value: token, + purpose: PURPOSE, + expiresAt: new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + TOKEN_TTL_SECONDS * 1_000_000n + ), + createdAt: ctx.timestamp, + }); + return { recipient: email, baseUrl: cfg.baseUrl }; + })); + } catch (e) { + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + throw e; + } + + if (recipient) { + const mail = buildPasswordResetEmail({ + baseUrl, + token, + appName: opts.appName, + }); + mail.to = recipient; + opts.sendMail(ctx, mail); + } + return jsonResponse({ ok: true }); + }; +} + +export function resetPasswordHandler( + ctx: AuthHandlerCtx, + req: Request, + options: AuthHttpOptions = {} +): SyncResponse { + const body = safeJson(req); + if (!body?.token || !body?.newPassword) + return errorResponse('invalid_request', 400); + if (body.newPassword.length < MIN_PASSWORD_LEN) + return errorResponse('password_too_short', 400); + if (body.newPassword.length > MAX_PASSWORD_LEN) + return errorResponse('password_too_long', 400); + if (body.token.length > MAX_TOKEN_LEN) return errorResponse('bad_token', 400); + const limited = enforceIpRateLimit( + ctx, + req, + AUTH_RATE_LIMITS.passwordReset, + options.trustedProxyHeader + ); + if (limited) return limited; + + const newHash = hashPassword(ctx.random, body.newPassword); + + try { + ctx.withTx(tx => { + const row = tx.db.authVerification.value.find(body.token); + if (!row || row.purpose !== PURPOSE) throw new BadTokenError(); + if ( + row.expiresAt.microsSinceUnixEpoch < ctx.timestamp.microsSinceUnixEpoch + ) { + tx.db.authVerification.delete(row); + throw new BadTokenError(); + } + + const user = tx.db.authUser.email.find(row.identifier); + tx.db.authVerification.delete(row); + if (!user) throw new BadTokenError(); + + let updated = false; + for (const acct of tx.db.authAccount.userId.filter(user.userId)) { + if (acct.providerId === 'password') { + tx.db.authAccount.accountId.update({ + ...acct, + passwordHash: newHash, + updatedAt: ctx.timestamp, + }); + updated = true; + } + } + if (!updated) { + const accountId = uuidV7( + ctx.random, + Number(ctx.timestamp.microsSinceUnixEpoch / 1000n) + ); + tx.db.authAccount.insert({ + accountId, + userId: user.userId, + providerId: 'password', + providerAccountId: user.email, + passwordHash: newHash, + accessToken: undefined, + refreshToken: undefined, + accessTokenExpiresAt: undefined, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + } + + for (const s of tx.db.authSession.userId.filter(user.userId)) { + tx.db.authSession.delete(s); + } + }); + } catch (e) { + if (e instanceof BadTokenError) return errorResponse('bad_token', 400); + throw e; + } + + return jsonResponse({ ok: true }); +} + +class BadTokenError extends Error { + constructor() { + super('bad_token'); + } +} diff --git a/spacetime-auth-ts/src/handlers/session.ts b/spacetime-auth-ts/src/handlers/session.ts new file mode 100644 index 00000000000..93972d29e9e --- /dev/null +++ b/spacetime-auth-ts/src/handlers/session.ts @@ -0,0 +1,229 @@ +import type { SyncResponse, Request } from 'spacetimedb/server'; +import { Timestamp } from 'spacetimedb'; +import { + type AuthHandlerCtx, + type AuthTransactionCtx, + shouldUseSecureCookies, + clearCookie, + errorResponse, + jsonResponse, + makeCookie, + parseCookies, + requireConfig, + ConfigMissingError, + userAgent, +} from './http.ts'; +import { signJwt, verifyJwt } from '../jwt.ts'; +import { privateKeyFromPem, publicKeyFromPem } from '../keys.ts'; +import { newSessionToken, uuidV7 } from '../crypto.ts'; +import { type AuthHttpOptions, clientKey } from '../rate_limit.ts'; +type StoredAuthSession = NonNullable< + ReturnType +>; + +function readToken(req: Request, cookieName: string): string | null { + const auth = req.headers.get('authorization'); + if (auth && auth.toLowerCase().startsWith('bearer ')) + return auth.slice(7).trim(); + const cookies = parseCookies(req.headers.get('cookie')); + return cookies[cookieName] ?? null; +} + +function nowSeconds(ctx: AuthHandlerCtx): number { + return Number((ctx.timestamp.microsSinceUnixEpoch as bigint) / 1_000_000n); +} + +function findLiveSession( + tx: AuthTransactionCtx, + claims: { sub?: string; jti?: string }, + nowMicros: bigint +): StoredAuthSession | null { + if (!claims.sub || !claims.jti) return null; + const session = tx.db.authSession.sessionId.find(claims.jti); + if (!session) return null; + if (session.userId !== claims.sub) return null; + if ((session.expiresAt.microsSinceUnixEpoch as bigint) < nowMicros) + return null; + return session; +} + +export function meHandler(ctx: AuthHandlerCtx, req: Request): SyncResponse { + try { + const result = ctx.withTx(tx => { + const cfg = requireConfig(tx); + const token = readToken(req, cfg.cookieName); + if (!token) return { status: 401 as const }; + + const pub = publicKeyFromPem(cfg.es256PublicKeyPem); + const v = verifyJwt(pub, token, { + issuer: cfg.issuerUrl, + nowSeconds: nowSeconds(ctx), + }); + if (!v.ok) return { status: 401 as const }; + if ( + !findLiveSession( + tx, + v.claims, + ctx.timestamp.microsSinceUnixEpoch as bigint + ) + ) { + return { status: 401 as const }; + } + + const user = tx.db.authUser.userId.find(v.claims.sub); + if (!user) return { status: 401 as const }; + + return { + status: 200 as const, + body: { + user: { + userId: user.userId, + email: user.email, + emailVerified: user.emailVerified, + name: user.name, + image: user.image, + }, + sessionExpiresAt: v.claims.exp, + }, + }; + }); + + if (result.status === 401) return errorResponse('unauthenticated', 401); + return jsonResponse(result.body); + } catch (e) { + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + throw e; + } +} + +export function refreshHandler( + ctx: AuthHandlerCtx, + req: Request, + options: AuthHttpOptions = {} +): SyncResponse { + const nowMs = Number(ctx.timestamp.microsSinceUnixEpoch / 1000n); + const sessionId = uuidV7(ctx.random, nowMs); + const sessionToken = newSessionToken(ctx.random); + + try { + const out = ctx.withTx(tx => { + const cfg = requireConfig(tx); + const token = readToken(req, cfg.cookieName); + if (!token) return { status: 401 as const }; + + const pub = publicKeyFromPem(cfg.es256PublicKeyPem); + const v = verifyJwt(pub, token, { + issuer: cfg.issuerUrl, + nowSeconds: nowSeconds(ctx), + }); + if (!v.ok) return { status: 401 as const }; + const existingSession = findLiveSession( + tx, + v.claims, + ctx.timestamp.microsSinceUnixEpoch as bigint + ); + if (!existingSession) return { status: 401 as const }; + + const user = tx.db.authUser.userId.find(v.claims.sub); + if (!user) return { status: 401 as const }; + tx.db.authSession.delete(existingSession); + + const ttlMicros = BigInt(cfg.sessionTtlSeconds) * 1_000_000n; + tx.db.authSession.insert({ + sessionId, + userId: user.userId, + token: sessionToken, + expiresAt: new Timestamp( + ctx.timestamp.microsSinceUnixEpoch + ttlMicros + ), + ipAddress: clientKey(req, options.trustedProxyHeader), + userAgent: userAgent(req), + createdAt: ctx.timestamp, + }); + + return { + status: 200 as const, + privateKeyPem: cfg.es256PrivateKeyPem, + keyId: cfg.keyId, + issuerUrl: cfg.issuerUrl, + cookieName: cfg.cookieName, + sessionTtlSeconds: BigInt(cfg.sessionTtlSeconds), + user: { + userId: user.userId, + email: user.email, + emailVerified: user.emailVerified, + name: user.name, + image: user.image, + }, + }; + }); + + if (out.status === 401) return errorResponse('unauthenticated', 401); + + const nowSec = Math.floor(nowMs / 1000); + const ttlSec = Number(out.sessionTtlSeconds); + const priv = privateKeyFromPem(out.privateKeyPem); + const jwt = signJwt( + priv, + { + iss: out.issuerUrl, + sub: out.user.userId, + aud: out.issuerUrl, + iat: nowSec, + exp: nowSec + ttlSec, + jti: sessionId, + }, + out.keyId + ); + + return jsonResponse( + { user: out.user, token: jwt, sessionExpiresAt: nowSec + ttlSec }, + 200, + { + 'set-cookie': makeCookie(out.cookieName, jwt, { + maxAgeSeconds: ttlSec, + secure: shouldUseSecureCookies(options.secureCookies), + }), + } + ); + } catch (e) { + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + throw e; + } +} + +export function logoutHandler( + ctx: AuthHandlerCtx, + req: Request, + options: AuthHttpOptions = {} +): SyncResponse { + try { + const cookieName = ctx.withTx(tx => { + const cfg = requireConfig(tx); + const token = readToken(req, cfg.cookieName); + if (token) { + const pub = publicKeyFromPem(cfg.es256PublicKeyPem); + const v = verifyJwt(pub, token, { + issuer: cfg.issuerUrl, + nowSeconds: nowSeconds(ctx), + }); + if (v.ok && v.claims.jti) { + const session = tx.db.authSession.sessionId.find(v.claims.jti); + if (session) tx.db.authSession.delete(session); + } + } + return cfg.cookieName; + }); + return jsonResponse({ ok: true }, 200, { + 'set-cookie': clearCookie(cookieName, { + secure: shouldUseSecureCookies(options.secureCookies), + }), + }); + } catch (e) { + if (e instanceof ConfigMissingError) + return errorResponse('config_missing', 500); + throw e; + } +} diff --git a/spacetime-auth-ts/src/index.ts b/spacetime-auth-ts/src/index.ts new file mode 100644 index 00000000000..0743606e0c1 --- /dev/null +++ b/spacetime-auth-ts/src/index.ts @@ -0,0 +1,156 @@ +export { + authTables, + authUserTable, + authSessionTable, + authAccountTable, + authVerificationTable, + authOauthStateTable, + authConfigTable, + authConnectionBindingTable, + authAdminIdentityTable, + authUserRow, + authSessionRow, + authAccountRow, + authVerificationRow, + authOauthStateRow, + authConfigRow, + authConnectionBindingRow, + authAdminIdentityRow, +} from './tables.ts'; + +export { + authAdminVerdict, + denyIfNotAdmin, + seedAuthAdmin, + type AdminVerdict, +} from './admin.ts'; + +export { + passwordLoginHandler, + passwordSignupHandler, + googleStartHandler, + googleCallbackHandler, + githubStartHandler, + githubCallbackHandler, + meHandler, + logoutHandler, + refreshHandler, + makeOAuthStartHandler, + makeOAuthCallbackHandler, + makeEmailVerifyHandler, + makeEmailVerifyRequestHandler, + makeForgotPasswordHandler, + resetPasswordHandler, + type OAuthProviderSpec, + type OAuthProfile, + type VerifyRequestOpts, + type ForgotPasswordOpts, +} from './handlers/index.ts'; + +export { + clearCookie, + makeCookie, + parseCookies, + jsonResponse, + errorResponse, + redirectResponse, + readBearer, + readSession, + configKeys, + type CookieOptions, +} from './handlers/http.ts'; + +export { + setAuthConfigParams, + setAuthConfig, + authSweep, + revokeSessionParams, + revokeSession, + listMySessionsParams, + listMySessions, + revokeMySessionParams, + revokeMySession, + getPublicKeyPemParams, + getPublicKeyPem, + linkConnectionParams, + linkConnection, + unlinkConnectionParams, + unlinkConnection, + updateProfileParams, + updateProfile, +} from './procedures.ts'; + +export { + signJwt, + verifyJwt, + decodeJwtPayloadUnsafe, + type JwtClaims, + type JwtHeader, + type VerifyResult, + type VerifyJwtOptions, +} from './jwt.ts'; + +export { + hashPassword, + verifyPassword, + newSessionToken, + newPkceVerifier, + pkceChallenge, + randomToken, + randomBytes, + uuidV7, + type RandomSource, + type ScryptParams, +} from './crypto.ts'; + +export { + generateEs256Keypair, + fromPrivateKeyBytes, + privateKeyFromPem, + publicKeyFromPem, + type Es256Keypair, + type PublicKeyJwk, +} from './keys.ts'; + +export { + getCallerUserId, + findCallerUser, + requireCallerUserId, +} from './caller.ts'; + +export { + consumeRateLimit, + sweepRateLimits, + type ConsumeRateLimitOpts, + type RateLimitResult, +} from '@spacetimedb/rate-limit/submodule'; + +export { + AUTH_RATE_LIMITS, + clientKey, + enforceIpRateLimit, + enforceRateLimits, + rateLimitKey, + rateLimitResponse, + type AuthHttpOptions, + type AuthRateLimitPolicy, + type TrustedProxyHeader, +} from './rate_limit.ts'; + +export { + MailerNotConfiguredError, + buildVerifyEmail, + buildPasswordResetEmail, + type SendMailFn, + type MailParams, +} from './mailer.ts'; + +export type { + AuthUser, + AuthSession, + AuthAccount, + AuthVerification, + AuthOauthState, + AuthConfig, + AuthConnectionBinding, +} from './types.ts'; diff --git a/spacetime-auth-ts/src/jwt.ts b/spacetime-auth-ts/src/jwt.ts new file mode 100644 index 00000000000..1c5e6147c3d --- /dev/null +++ b/spacetime-auth-ts/src/jwt.ts @@ -0,0 +1,193 @@ +import { p256 } from '@noble/curves/nist.js'; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder('utf-8'); + +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +const B64_REV = (() => { + const m = new Int8Array(256).fill(-1); + for (let i = 0; i < B64.length; i++) m[B64.charCodeAt(i)] = i; + return m; +})(); + +function b64uEncode(bytes: Uint8Array): string { + let out = ''; + let i = 0; + for (; i + 2 < bytes.length; i += 3) { + out += B64[bytes[i] >> 2]; + out += B64[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += B64[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; + out += B64[bytes[i + 2] & 63]; + } + if (i < bytes.length) { + out += B64[bytes[i] >> 2]; + if (i + 1 === bytes.length) { + out += B64[(bytes[i] & 3) << 4]; + } else { + out += B64[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += B64[(bytes[i + 1] & 15) << 2]; + } + } + return out.replace(/\+/g, '-').replace(/\//g, '_'); +} + +function b64uDecode(str: string): Uint8Array { + let s = ''; + for (let i = 0; i < str.length; i++) { + const c = str[i] === '-' ? '+' : str[i] === '_' ? '/' : str[i]; + if (B64_REV[c.charCodeAt(0)] >= 0) s += c; + } + const out = new Uint8Array((s.length * 3) >> 2); + let oi = 0; + for (let i = 0; i + 3 < s.length; i += 4) { + const a = B64_REV[s.charCodeAt(i)]; + const b = B64_REV[s.charCodeAt(i + 1)]; + const c = B64_REV[s.charCodeAt(i + 2)]; + const d = B64_REV[s.charCodeAt(i + 3)]; + out[oi++] = (a << 2) | (b >> 4); + out[oi++] = ((b & 15) << 4) | (c >> 2); + out[oi++] = ((c & 3) << 6) | d; + } + const tail = s.length & 3; + if (tail >= 2) { + const i = s.length - tail; + const a = B64_REV[s.charCodeAt(i)]; + const b = B64_REV[s.charCodeAt(i + 1)]; + out[oi++] = (a << 2) | (b >> 4); + if (tail === 3) { + const c = B64_REV[s.charCodeAt(i + 2)]; + out[oi++] = ((b & 15) << 4) | (c >> 2); + } + } + return out.subarray(0, oi); +} + +function b64uJson(obj: unknown): string { + return b64uEncode(textEncoder.encode(JSON.stringify(obj))); +} + +export interface JwtHeader { + alg: 'ES256'; + typ: 'JWT'; + kid?: string; +} + +export interface JwtClaims { + iss: string; + sub: string; + aud?: string | string[]; + iat: number; + exp: number; + nbf?: number; + jti?: string; + [k: string]: unknown; +} + +/** Sign a JWT with ES256. privateKey is 32 raw bytes. */ +export function signJwt( + privateKey: Uint8Array, + claims: JwtClaims, + kid?: string +): string { + const header: JwtHeader = { alg: 'ES256', typ: 'JWT' }; + if (kid) header.kid = kid; + const headPart = b64uJson(header); + const payloadPart = b64uJson(claims); + const signingInput = `${headPart}.${payloadPart}`; + const sig = p256.sign(textEncoder.encode(signingInput), privateKey); + return `${signingInput}.${b64uEncode(sig)}`; +} + +export interface VerifyJwtOptions { + issuer?: string; + audience?: string; + /** Default 60. */ + clockToleranceSeconds?: number; + /** Default Date.now()/1000. */ + nowSeconds?: number; +} + +export type VerifyResult = + | { ok: true; claims: JwtClaims; header: JwtHeader } + | { + ok: false; + reason: + | 'malformed' + | 'bad-signature' + | 'expired' + | 'not-yet-valid' + | 'bad-issuer' + | 'bad-audience'; + }; + +/** publicKey: 65-byte uncompressed P-256 key. */ +export function verifyJwt( + publicKey: Uint8Array, + token: string, + opts: VerifyJwtOptions = {} +): VerifyResult { + const parts = token.split('.'); + if (parts.length !== 3) return { ok: false, reason: 'malformed' }; + const [headPart, payloadPart, sigPart] = parts; + + let header: JwtHeader; + let claims: JwtClaims; + try { + header = JSON.parse(textDecoder.decode(b64uDecode(headPart))); + claims = JSON.parse(textDecoder.decode(b64uDecode(payloadPart))); + } catch { + return { ok: false, reason: 'malformed' }; + } + if (header.alg !== 'ES256') return { ok: false, reason: 'bad-signature' }; + + let sig: Uint8Array; + try { + sig = b64uDecode(sigPart); + } catch { + return { ok: false, reason: 'malformed' }; + } + if (sig.length !== 64) return { ok: false, reason: 'bad-signature' }; + + let ok = false; + try { + ok = p256.verify( + sig, + textEncoder.encode(`${headPart}.${payloadPart}`), + publicKey + ); + } catch { + ok = false; + } + if (!ok) return { ok: false, reason: 'bad-signature' }; + + const now = opts.nowSeconds ?? Math.floor(Date.now() / 1000); + const skew = opts.clockToleranceSeconds ?? 60; + if (typeof claims.exp === 'number' && claims.exp + skew < now) { + return { ok: false, reason: 'expired' }; + } + if (typeof claims.nbf === 'number' && claims.nbf - skew > now) { + return { ok: false, reason: 'not-yet-valid' }; + } + if (opts.issuer != null && claims.iss !== opts.issuer) { + return { ok: false, reason: 'bad-issuer' }; + } + if (opts.audience != null) { + const aud = claims.aud; + const matches = Array.isArray(aud) + ? aud.includes(opts.audience) + : aud === opts.audience; + if (!matches) return { ok: false, reason: 'bad-audience' }; + } + return { ok: true, claims, header }; +} + +/** Unsafe: no verification. Use only on trusted input. */ +export function decodeJwtPayloadUnsafe(token: string): JwtClaims | null { + const parts = token.split('.'); + if (parts.length !== 3) return null; + try { + return JSON.parse(textDecoder.decode(b64uDecode(parts[1]))); + } catch { + return null; + } +} diff --git a/spacetime-auth-ts/src/keys.ts b/spacetime-auth-ts/src/keys.ts new file mode 100644 index 00000000000..e3285975f7d --- /dev/null +++ b/spacetime-auth-ts/src/keys.ts @@ -0,0 +1,279 @@ +import { p256 } from '@noble/curves/nist.js'; +import { sha256 } from '@noble/hashes/sha2'; + +const PRIV_LEN = 32; +const COORD_LEN = 32; + +export interface Es256Keypair { + privateKey: Uint8Array; + publicKey: Uint8Array; + privateKeyPem: string; + publicKeyPem: string; + publicKeyJwk: PublicKeyJwk; + kid: string; +} + +export interface PublicKeyJwk { + kty: 'EC'; + crv: 'P-256'; + x: string; + y: string; + alg: 'ES256'; + use: 'sig'; + kid?: string; +} + +export interface RandomSource { + fill(array: T): T; +} + +/** + * SECURITY: When called inside STDB modules with ctx.random, the resulting key + * is DETERMINISTIC w.r.t. ctx.timestamp. Generate outside the module for prod. + */ +export function generateEs256Keypair(rng?: RandomSource): Es256Keypair { + const seed = rng ? rng.fill(new Uint8Array(48)) : undefined; + const { secretKey } = p256.keygen(seed); + const publicKey = p256.getPublicKey(secretKey, false); + return assemble(secretKey, publicKey); +} + +export function fromPrivateKeyBytes(privateKey: Uint8Array): Es256Keypair { + if (privateKey.length !== PRIV_LEN) { + throw new TypeError(`ES256 private key must be ${PRIV_LEN} bytes`); + } + const publicKey = p256.getPublicKey(privateKey, false); + return assemble(privateKey, publicKey); +} + +function assemble(privateKey: Uint8Array, publicKey: Uint8Array): Es256Keypair { + const { x, y } = splitUncompressedPublicKey(publicKey); + const publicKeyJwk: PublicKeyJwk = { + kty: 'EC', + crv: 'P-256', + alg: 'ES256', + use: 'sig', + x: b64uEncode(x), + y: b64uEncode(y), + }; + const kid = jwkThumbprint(publicKeyJwk); + publicKeyJwk.kid = kid; + return { + privateKey, + publicKey, + privateKeyPem: encodePrivateKeyPem(privateKey, publicKey), + publicKeyPem: encodePublicKeyPem(publicKey), + publicKeyJwk, + kid, + }; +} + +function splitUncompressedPublicKey(pub: Uint8Array): { + x: Uint8Array; + y: Uint8Array; +} { + if (pub.length !== 1 + COORD_LEN * 2 || pub[0] !== 0x04) { + throw new TypeError('expected uncompressed P-256 public key'); + } + return { x: pub.slice(1, 1 + COORD_LEN), y: pub.slice(1 + COORD_LEN) }; +} + +/** RFC 7638. */ +function jwkThumbprint(jwk: PublicKeyJwk): string { + const canonical = JSON.stringify({ + crv: jwk.crv, + kty: jwk.kty, + x: jwk.x, + y: jwk.y, + }); + const hash = sha256(new TextEncoder().encode(canonical)); + return b64uEncode(hash); +} + +// SPKI ECDSA P-256 algorithm OID prefix. +const SPKI_ALG_DER = new Uint8Array([ + 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, + 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, +]); + +function encodePublicKeyPem(publicKey65: Uint8Array): string { + const bitString = concat([ + new Uint8Array([0x03, publicKey65.length + 1, 0x00]), + publicKey65, + ]); + const body = concat([SPKI_ALG_DER, bitString]); + const der = wrapSequence(body); + return pemWrap('PUBLIC KEY', der); +} + +function encodePrivateKeyPem( + privateKey: Uint8Array, + publicKey65: Uint8Array +): string { + // RFC 5915 ECPrivateKey wrapped in PKCS#8 PrivateKeyInfo. + const version = new Uint8Array([0x02, 0x01, 0x01]); + const privOctet = concat([ + new Uint8Array([0x04, privateKey.length]), + privateKey, + ]); + const namedCurveBody = new Uint8Array([ + 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, + ]); + const namedCurveTagged = concat([ + new Uint8Array([0xa0, namedCurveBody.length]), + namedCurveBody, + ]); + const pubBitString = concat([ + new Uint8Array([0x03, publicKey65.length + 1, 0x00]), + publicKey65, + ]); + const pubTagged = concat([ + new Uint8Array([0xa1, pubBitString.length]), + pubBitString, + ]); + const ecPrivBody = concat([version, privOctet, namedCurveTagged, pubTagged]); + const ecPrivDer = wrapSequence(ecPrivBody); + + const p8version = new Uint8Array([0x02, 0x01, 0x00]); + const p8alg = SPKI_ALG_DER; + const p8privOctet = concat([ + encodeLengthPrefix(0x04, ecPrivDer.length), + ecPrivDer, + ]); + const p8body = concat([p8version, p8alg, p8privOctet]); + const p8der = wrapSequence(p8body); + return pemWrap('PRIVATE KEY', p8der); +} + +function wrapSequence(body: Uint8Array): Uint8Array { + return concat([encodeLengthPrefix(0x30, body.length), body]); +} + +function encodeLengthPrefix(tag: number, len: number): Uint8Array { + if (len < 0x80) return new Uint8Array([tag, len]); + if (len < 0x100) return new Uint8Array([tag, 0x81, len]); + if (len < 0x10000) + return new Uint8Array([tag, 0x82, (len >> 8) & 0xff, len & 0xff]); + throw new RangeError('DER length too large for this encoder'); +} + +function concat(arrays: Uint8Array[]): Uint8Array { + let n = 0; + for (const a of arrays) n += a.length; + const out = new Uint8Array(n); + let off = 0; + for (const a of arrays) { + out.set(a, off); + off += a.length; + } + return out; +} + +function pemWrap(label: string, der: Uint8Array): string { + const b64 = btoaBytes(der); + const lines: string[] = []; + for (let i = 0; i < b64.length; i += 64) lines.push(b64.slice(i, i + 64)); + return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`; +} + +const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +function btoaBytes(bytes: Uint8Array): string { + let out = ''; + let i = 0; + for (; i + 2 < bytes.length; i += 3) { + out += B64[bytes[i] >> 2]; + out += B64[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += B64[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; + out += B64[bytes[i + 2] & 63]; + } + if (i < bytes.length) { + out += B64[bytes[i] >> 2]; + if (i + 1 === bytes.length) { + out += B64[(bytes[i] & 3) << 4]; + out += '=='; + } else { + out += B64[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; + out += B64[(bytes[i + 1] & 15) << 2]; + out += '='; + } + } + return out; +} + +const B64_REV = (() => { + const m = new Int8Array(256).fill(-1); + for (let i = 0; i < B64.length; i++) m[B64.charCodeAt(i)] = i; + return m; +})(); +function atobBytes(b64: string): Uint8Array { + let s = ''; + for (let i = 0; i < b64.length; i++) { + const c = b64.charCodeAt(i); + if (B64_REV[c] >= 0) s += b64[i]; + } + const out = new Uint8Array((s.length * 3) >> 2); + let oi = 0; + for (let i = 0; i + 3 < s.length; i += 4) { + const a = B64_REV[s.charCodeAt(i)]; + const b = B64_REV[s.charCodeAt(i + 1)]; + const c = B64_REV[s.charCodeAt(i + 2)]; + const d = B64_REV[s.charCodeAt(i + 3)]; + out[oi++] = (a << 2) | (b >> 4); + out[oi++] = ((b & 15) << 4) | (c >> 2); + out[oi++] = ((c & 3) << 6) | d; + } + const tail = s.length & 3; + if (tail >= 2) { + const i = s.length - tail; + const a = B64_REV[s.charCodeAt(i)]; + const b = B64_REV[s.charCodeAt(i + 1)]; + out[oi++] = (a << 2) | (b >> 4); + if (tail === 3) { + const c = B64_REV[s.charCodeAt(i + 2)]; + out[oi++] = ((b & 15) << 4) | (c >> 2); + } + } + return out.subarray(0, oi); +} + +function b64uEncode(bytes: Uint8Array): string { + return btoaBytes(bytes) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function pemUnwrap(label: string, pem: string): Uint8Array { + const prefix = `-----BEGIN ${label}-----`; + const suffix = `-----END ${label}-----`; + const start = pem.indexOf(prefix); + const end = pem.indexOf(suffix); + if (start < 0 || end < 0) throw new TypeError(`PEM ${label} block not found`); + const inner = pem.slice(start + prefix.length, end).replace(/\s+/g, ''); + return atobBytes(inner); +} + +export function privateKeyFromPem(pem: string): Uint8Array { + const der = pemUnwrap('PRIVATE KEY', pem); + for (let i = 0; i < der.length - 33; i++) { + if (der[i] === 0x04 && der[i + 1] === 0x20) { + return der.slice(i + 2, i + 2 + 32); + } + } + throw new TypeError('could not extract raw private key from PKCS#8 PEM'); +} + +export function publicKeyFromPem(pem: string): Uint8Array { + const der = pemUnwrap('PUBLIC KEY', pem); + for (let i = 0; i < der.length - 67; i++) { + if ( + der[i] === 0x03 && + der[i + 1] === 0x42 && + der[i + 2] === 0x00 && + der[i + 3] === 0x04 + ) { + return der.slice(i + 3, i + 68); + } + } + throw new TypeError('could not extract raw public key from SPKI PEM'); +} diff --git a/spacetime-auth-ts/src/mailer.ts b/spacetime-auth-ts/src/mailer.ts new file mode 100644 index 00000000000..38a57da4f58 --- /dev/null +++ b/spacetime-auth-ts/src/mailer.ts @@ -0,0 +1,46 @@ +import type { AuthHandlerCtx } from './context.ts'; + +export interface MailParams { + to: string; + subject: string; + text: string; + html?: string; +} + +export type SendMailFn = (ctx: AuthHandlerCtx, params: MailParams) => void; + +export class MailerNotConfiguredError extends Error { + constructor() { + super('auth.mailer_not_configured'); + } +} + +export function buildVerifyEmail(opts: { + baseUrl: string; + token: string; + appName?: string; +}): MailParams { + const url = `${opts.baseUrl}/auth/email/verify?token=${encodeURIComponent(opts.token)}`; + const app = opts.appName ?? 'this app'; + return { + to: '', + subject: `Verify your email for ${app}`, + text: `Click to verify your email:\n\n${url}\n\nThis link expires in 24 hours.`, + html: `

Click to verify your email:

${url}

This link expires in 24 hours.

`, + }; +} + +export function buildPasswordResetEmail(opts: { + baseUrl: string; + token: string; + appName?: string; +}): MailParams { + const url = `${opts.baseUrl}/auth/password/reset?token=${encodeURIComponent(opts.token)}`; + const app = opts.appName ?? 'this app'; + return { + to: '', + subject: `Reset your password for ${app}`, + text: `Click to reset your password:\n\n${url}\n\nThis link expires in 1 hour. Ignore this email if the request was unexpected.`, + html: `

Click to reset your password:

${url}

This link expires in 1 hour. Ignore this email if the request was unexpected.

`, + }; +} diff --git a/spacetime-auth-ts/src/procedures.ts b/spacetime-auth-ts/src/procedures.ts new file mode 100644 index 00000000000..c1808253dc3 --- /dev/null +++ b/spacetime-auth-ts/src/procedures.ts @@ -0,0 +1,374 @@ +import type { Timestamp } from 'spacetimedb'; +import { + Range, + t, + SenderError, + type InferTypeOfParams, +} from 'spacetimedb/server'; +import { + generateEs256Keypair, + fromPrivateKeyBytes, + privateKeyFromPem, + publicKeyFromPem, +} from './keys.ts'; +import { verifyJwt } from './jwt.ts'; +import { authAdminVerdict, denyIfNotAdmin } from './admin.ts'; +import type { + AuthProcedureCtx, + AuthReducerCtx, + AuthTransactionCtx, +} from './context.ts'; + +type AuthWriteCtx = AuthReducerCtx | AuthProcedureCtx; + +function withCtx(ctx: AuthWriteCtx, fn: (tx: AuthTransactionCtx) => T): T { + return 'withTx' in ctx ? ctx.withTx(fn) : fn(ctx); +} + +export const setAuthConfigParams = { + issuerUrl: t.string(), + baseUrl: t.option(t.string()), + cookieName: t.option(t.string()), + sessionTtlSeconds: t.option(t.u64()), + /** If omitted on first call, a fresh keypair is generated. */ + es256PrivateKeyPem: t.option(t.string()), + googleClientId: t.option(t.string()), + googleClientSecret: t.option(t.string()), + githubClientId: t.option(t.string()), + githubClientSecret: t.option(t.string()), +}; + +const DEFAULT_COOKIE_NAME = 'stdb_auth'; +const DEFAULT_SESSION_TTL_SECONDS = 60n * 60n * 24n * 7n; + +export function setAuthConfig( + ctx: AuthWriteCtx, + args: InferTypeOfParams +): void { + // Requires an admin row seeded by the database owner. Without this, any + // client could rotate the signing keys / overwrite OAuth secrets and forge + // sessions. The verdict is read inside a tx but the denial is thrown outside + // it (a SenderError thrown inside ctx.withTx surfaces as a fatal instance + // error, not a clean rejection). + const verdict = withCtx(ctx, tx => authAdminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + + withCtx(ctx, tx => { + const existing = tx.db.authConfig.singleton.find(true); + + let privateKeyPem: string; + let publicKeyPem: string; + let keyId: string; + + if (args.es256PrivateKeyPem) { + let raw: Uint8Array; + try { + raw = privateKeyFromPem(args.es256PrivateKeyPem); + } catch (e) { + throw new SenderError( + `auth.invalid_private_key_pem:${(e as Error).message}` + ); + } + const kp = fromPrivateKeyBytes(raw); + privateKeyPem = kp.privateKeyPem; + publicKeyPem = kp.publicKeyPem; + keyId = kp.kid; + } else if (existing) { + privateKeyPem = existing.es256PrivateKeyPem; + publicKeyPem = existing.es256PublicKeyPem; + keyId = existing.keyId; + } else { + const kp = generateEs256Keypair(ctx.random); + privateKeyPem = kp.privateKeyPem; + publicKeyPem = kp.publicKeyPem; + keyId = kp.kid; + } + + if (existing) { + tx.db.authConfig.singleton.update({ + ...existing, + issuerUrl: args.issuerUrl, + baseUrl: args.baseUrl ?? existing.baseUrl, + cookieName: args.cookieName ?? existing.cookieName, + sessionTtlSeconds: args.sessionTtlSeconds ?? existing.sessionTtlSeconds, + es256PrivateKeyPem: privateKeyPem, + es256PublicKeyPem: publicKeyPem, + keyId, + googleClientId: args.googleClientId ?? existing.googleClientId, + googleClientSecret: + args.googleClientSecret ?? existing.googleClientSecret, + githubClientId: args.githubClientId ?? existing.githubClientId, + githubClientSecret: + args.githubClientSecret ?? existing.githubClientSecret, + updatedAt: ctx.timestamp, + }); + return; + } + + tx.db.authConfig.insert({ + singleton: true, + issuerUrl: args.issuerUrl, + baseUrl: args.baseUrl ?? args.issuerUrl, + cookieName: args.cookieName ?? DEFAULT_COOKIE_NAME, + sessionTtlSeconds: args.sessionTtlSeconds ?? DEFAULT_SESSION_TTL_SECONDS, + es256PrivateKeyPem: privateKeyPem, + es256PublicKeyPem: publicKeyPem, + keyId, + googleClientId: args.googleClientId, + googleClientSecret: args.googleClientSecret, + githubClientId: args.githubClientId, + githubClientSecret: args.githubClientSecret, + updatedAt: ctx.timestamp, + }); + }); +} + +const SWEEP_BATCH = 500; + +export function authSweep(ctx: AuthWriteCtx): void { + const nowMicros = ctx.timestamp.microsSinceUnixEpoch as bigint; + withCtx(ctx, tx => { + let n = 0; + for (const row of tx.db.authSession.expiresAt.filter( + new Range(undefined, { tag: 'excluded', value: ctx.timestamp }) + )) { + if (n >= SWEEP_BATCH) break; + if ((row.expiresAt.microsSinceUnixEpoch as bigint) < nowMicros) { + tx.db.authSession.delete(row); + n++; + } + } + for (const row of tx.db.authVerification.expiresAt.filter( + new Range(undefined, { tag: 'excluded', value: ctx.timestamp }) + )) { + if (n >= SWEEP_BATCH) break; + if ((row.expiresAt.microsSinceUnixEpoch as bigint) < nowMicros) { + tx.db.authVerification.delete(row); + n++; + } + } + for (const row of tx.db.authOauthState.expiresAt.filter( + new Range(undefined, { tag: 'excluded', value: ctx.timestamp }) + )) { + if (n >= SWEEP_BATCH) break; + if ((row.expiresAt.microsSinceUnixEpoch as bigint) < nowMicros) { + tx.db.authOauthState.delete(row); + n++; + } + } + }); +} + +export const revokeSessionParams = { sessionId: t.string() }; + +export function revokeSession( + ctx: AuthWriteCtx, + args: InferTypeOfParams +): void { + // Admin action for revoking any user's session. Self-service revocation is + // revokeMySession is caller-scoped. Compute the verdict inside the transaction. + const verdict = withCtx(ctx, tx => authAdminVerdict(tx, ctx.sender)); + denyIfNotAdmin(verdict); + + withCtx(ctx, tx => { + const s = tx.db.authSession.sessionId.find(args.sessionId); + if (s) tx.db.authSession.delete(s); + }); +} + +export const listMySessionsParams = {}; + +export interface MySessionSummary { + sessionId: string; + expiresAt: Timestamp; + createdAt: Timestamp; + ipAddress: string | undefined; + userAgent: string | undefined; + isCurrent: boolean; +} + +export function listMySessions( + ctx: AuthWriteCtx, + _args: Record +): { sessions: MySessionSummary[] } { + return withCtx(ctx, tx => { + const binding = tx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + if (!binding) return { sessions: [] }; + const userId = binding.userId; + const nowMicros = ctx.timestamp.microsSinceUnixEpoch as bigint; + const sessions: MySessionSummary[] = []; + for (const s of tx.db.authSession.userId.filter(userId)) { + if ((s.expiresAt.microsSinceUnixEpoch as bigint) < nowMicros) continue; + sessions.push({ + sessionId: s.sessionId, + expiresAt: s.expiresAt, + createdAt: s.createdAt, + ipAddress: s.ipAddress, + userAgent: s.userAgent, + isCurrent: false, + }); + } + sessions.sort((a, b) => + Number( + (b.createdAt.microsSinceUnixEpoch as bigint) - + (a.createdAt.microsSinceUnixEpoch as bigint) + ) + ); + return { sessions }; + }); +} + +export const revokeMySessionParams = { sessionId: t.string() }; + +export function revokeMySession( + ctx: AuthWriteCtx, + args: InferTypeOfParams +): void { + withCtx(ctx, tx => { + const binding = tx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + if (!binding) throw new SenderError('auth.not_authenticated'); + const s = tx.db.authSession.sessionId.find(args.sessionId); + if (!s) return; + if (s.userId !== binding.userId) + throw new SenderError('auth.session_not_owned'); + tx.db.authSession.delete(s); + }); +} + +export const getPublicKeyPemParams = {}; + +export function getPublicKeyPem( + ctx: AuthWriteCtx, + _args: Record +): { publicKeyPem: string; keyId: string; issuerUrl: string } { + return withCtx(ctx, tx => { + const cfg = tx.db.authConfig.singleton.find(true); + if (!cfg) throw new SenderError('auth.config_missing'); + return { + publicKeyPem: cfg.es256PublicKeyPem, + keyId: cfg.keyId, + issuerUrl: cfg.issuerUrl, + }; + }); +} + +/** Call once after each STDB connect. Idempotent. */ +export const linkConnectionParams = { sessionToken: t.string() }; + +const RETRY_FAILED_MSG = 'transaction retry failed again'; + +export function linkConnection( + ctx: AuthWriteCtx, + args: InferTypeOfParams +): { userId: string } { + try { + return withCtx(ctx, tx => { + const cfg = tx.db.authConfig.singleton.find(true); + if (!cfg) throw new SenderError('auth.config_missing'); + + const pub = publicKeyFromPem(cfg.es256PublicKeyPem); + const nowMicros = ctx.timestamp.microsSinceUnixEpoch as bigint; + const nowSec = Number(nowMicros / 1_000_000n); + const v = verifyJwt(pub, args.sessionToken, { + issuer: cfg.issuerUrl, + nowSeconds: nowSec, + }); + if (!v.ok) throw new SenderError(`auth.invalid_token:${v.reason}`); + + const userId = v.claims.sub; + if (!userId) throw new SenderError('auth.token_missing_sub'); + const sessionId = v.claims.jti; + if (!sessionId) throw new SenderError('auth.token_missing_session'); + + const session = tx.db.authSession.sessionId.find(sessionId); + if (!session || session.userId !== userId) + throw new SenderError('auth.session_not_found'); + if ((session.expiresAt.microsSinceUnixEpoch as bigint) < nowMicros) { + throw new SenderError('auth.session_expired'); + } + + const existing = tx.db.authConnectionBinding.stdbIdentity.find( + ctx.sender + ); + if (existing) { + tx.db.authConnectionBinding.stdbIdentity.update({ + ...existing, + userId, + linkedAt: ctx.timestamp, + }); + } else { + tx.db.authConnectionBinding.insert({ + stdbIdentity: ctx.sender, + userId, + linkedAt: ctx.timestamp, + }); + } + return { userId }; + }); + } catch (e: unknown) { + if (e instanceof Error && e.message.includes(RETRY_FAILED_MSG)) { + throw new SenderError('auth.link_busy_retry'); + } + throw e; + } +} + +export const unlinkConnectionParams = {}; + +export function unlinkConnection( + ctx: AuthWriteCtx, + _args: Record +): void { + withCtx(ctx, tx => { + const existing = tx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + if (existing) tx.db.authConnectionBinding.delete(existing); + }); +} + +const MAX_NAME_LEN = 64; +const MAX_IMAGE_LEN = 2048; + +/** Caller updates their own display name / image. Either field, when present, + * sets the row's value; pass an empty string to clear it (becomes none). */ +export const updateProfileParams = { + name: t.option(t.string()), + image: t.option(t.string()), +}; + +export function updateProfile( + ctx: AuthWriteCtx, + args: InferTypeOfParams +): void { + withCtx(ctx, tx => { + const binding = tx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + if (!binding) throw new SenderError('auth.not_authenticated'); + const user = tx.db.authUser.userId.find(binding.userId); + if (!user) throw new SenderError('auth.user_not_found'); + + const nextName = + args.name === undefined + ? user.name + : args.name.length === 0 + ? undefined + : args.name.trim(); + const nextImage = + args.image === undefined + ? user.image + : args.image.length === 0 + ? undefined + : args.image.trim(); + if (nextName !== undefined && nextName.length > MAX_NAME_LEN) { + throw new SenderError(`auth.name_too_long:max=${MAX_NAME_LEN}`); + } + if (nextImage !== undefined && nextImage.length > MAX_IMAGE_LEN) { + throw new SenderError(`auth.image_too_long:max=${MAX_IMAGE_LEN}`); + } + + tx.db.authUser.userId.update({ + ...user, + name: nextName, + image: nextImage, + updatedAt: ctx.timestamp, + }); + }); +} diff --git a/spacetime-auth-ts/src/rate_limit.ts b/spacetime-auth-ts/src/rate_limit.ts new file mode 100644 index 00000000000..7435adb37c0 --- /dev/null +++ b/spacetime-auth-ts/src/rate_limit.ts @@ -0,0 +1,110 @@ +import type { Request, SyncResponse } from 'spacetimedb/server'; +import { + consumeRateLimit, + type RateLimitResult, +} from '@spacetimedb/rate-limit/submodule'; +import { errorResponse } from './handlers/http.ts'; +import { clientKey, type TrustedProxyHeader } from './request-trust.ts'; +import type { AuthHandlerCtx } from './context.ts'; +export { + clientKey, + type AuthHttpOptions, + type TrustedProxyHeader, +} from './request-trust.ts'; + +export interface AuthRateLimitPolicy { + scope: string; + limit: number; + windowSeconds: number; +} + +export const AUTH_RATE_LIMITS = { + passwordSignup: { + scope: 'auth.password.signup', + limit: 5, + windowSeconds: 3600, + }, + passwordLoginIp: { + scope: 'auth.password.login.ip', + limit: 30, + windowSeconds: 300, + }, + passwordLoginEmail: { + scope: 'auth.password.login.email', + limit: 10, + windowSeconds: 300, + }, + passwordForgotIp: { + scope: 'auth.password.forgot.ip', + limit: 5, + windowSeconds: 3600, + }, + passwordForgotEmail: { + scope: 'auth.password.forgot.email', + limit: 3, + windowSeconds: 3600, + }, + passwordReset: { scope: 'auth.password.reset', limit: 5, windowSeconds: 900 }, + oauthStart: { scope: 'auth.oauth.start', limit: 30, windowSeconds: 300 }, + emailVerifyRequest: { + scope: 'auth.email.verify_request', + limit: 5, + windowSeconds: 3600, + }, +} satisfies Record; + +function normalizePart(value: string): string { + return value.toLowerCase().trim().slice(0, 256); +} + +export function rateLimitKey( + policy: AuthRateLimitPolicy, + actor: string +): string { + return `${policy.scope}:${normalizePart(actor)}`; +} + +export function rateLimitResponse(result: RateLimitResult): SyncResponse { + return errorResponse('rate_limited', 429, { + 'retry-after': String(result.retryAfterSeconds), + 'x-ratelimit-limit': String(result.limit), + 'x-ratelimit-remaining': String(result.remaining), + 'x-ratelimit-reset': String( + Number((result.resetAt.microsSinceUnixEpoch as bigint) / 1_000_000n) + ), + }); +} + +export function enforceRateLimits( + ctx: AuthHandlerCtx, + _req: Request, + checks: Array<{ policy: AuthRateLimitPolicy; actor: string }> +): SyncResponse | null { + let blocked: RateLimitResult | null = null; + for (const check of checks) { + const result = ctx.as.rateLimit.withTx(tx => + consumeRateLimit(tx, { + key: rateLimitKey(check.policy, check.actor), + scope: check.policy.scope, + limit: check.policy.limit, + windowSeconds: check.policy.windowSeconds, + }) + ); + if (!result.allowed) { + blocked = result; + break; + } + } + return blocked ? rateLimitResponse(blocked) : null; +} + +export function enforceIpRateLimit( + ctx: AuthHandlerCtx, + req: Request, + policy: AuthRateLimitPolicy, + trustedProxyHeader?: TrustedProxyHeader +): SyncResponse | null { + const key = clientKey(req, trustedProxyHeader); + if (!key) return null; + return enforceRateLimits(ctx, req, [{ policy, actor: `ip:${key}` }]); +} diff --git a/spacetime-auth-ts/src/request-trust.ts b/spacetime-auth-ts/src/request-trust.ts new file mode 100644 index 00000000000..dc507700adb --- /dev/null +++ b/spacetime-auth-ts/src/request-trust.ts @@ -0,0 +1,74 @@ +import type { Request } from 'spacetimedb/server'; + +export type TrustedProxyHeader = + | 'cf-connecting-ip' + | 'x-real-ip' + | 'x-forwarded-for'; + +export interface AuthHttpOptions { + /** Header set by a trusted proxy after it removes any client-supplied value. */ + trustedProxyHeader?: TrustedProxyHeader; + /** Defaults to true. Set false only for local HTTP development. */ + secureCookies?: boolean; +} + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +function firstHeaderValue(value: string | null): string | undefined { + if (!value) return undefined; + const first = value.split(',')[0]?.trim(); + return first && first.length <= 128 ? first : undefined; +} + +export function clientKey( + req: Request, + trustedProxyHeader?: TrustedProxyHeader +): string | undefined { + if (!trustedProxyHeader) return undefined; + return firstHeaderValue(req.headers.get(trustedProxyHeader)); +} + +export function userAgent(req: Request): string | undefined { + const value = req.headers.get('user-agent')?.trim(); + return value && value.length <= 512 ? value : undefined; +} + +export function shouldUseSecureCookies(secureCookies?: boolean): boolean { + return secureCookies !== false; +} + +export function safeRedirectPath( + value: string | undefined +): string | undefined { + if (value === undefined || value.length === 0 || value.length > 2048) + return undefined; + if (!value.startsWith('/') || value.startsWith('//')) return undefined; + if ( + value.includes('\\') || + value.includes('#') || + hasControlCharacter(value) + ) { + return undefined; + } + try { + const decoded = decodeURIComponent(value); + if ( + !decoded.startsWith('/') || + decoded.startsWith('//') || + decoded.includes('\\') || + decoded.includes('#') || + hasControlCharacter(decoded) + ) { + return undefined; + } + } catch { + return undefined; + } + return value; +} diff --git a/spacetime-auth-ts/src/submodule.ts b/spacetime-auth-ts/src/submodule.ts new file mode 100644 index 00000000000..44a293f37d8 --- /dev/null +++ b/spacetime-auth-ts/src/submodule.ts @@ -0,0 +1,48 @@ +export { default } from './submodule/index'; +export { installAuth } from './submodule/install'; +export { + auth_sweep, + get_auth_public_key, + link_connection, + list_my_sessions, + myAuthUser, + revoke_my_session, + revoke_session, + set_auth_config, + unlink_connection, + update_profile, + whoami, +} from './submodule/index'; + +export { + setAuthConfigParams, + getPublicKeyPemParams, + linkConnectionParams, + linkConnection, + unlinkConnectionParams, + updateProfileParams, + revokeSessionParams, + listMySessionsParams, + revokeMySessionParams, + passwordSignupHandler, + parseCookies, + passwordLoginHandler, + meHandler, + logoutHandler, + refreshHandler, + googleStartHandler, + googleCallbackHandler, + githubStartHandler, + githubCallbackHandler, + makeForgotPasswordHandler, + resetPasswordHandler, + makeEmailVerifyRequestHandler, + makeEmailVerifyHandler, + getCallerUserId, + publicKeyFromPem, + verifyJwt, + type AuthHttpOptions, + type TrustedProxyHeader, + type SendMailFn, + type MailParams, +} from './index'; diff --git a/spacetime-auth-ts/src/submodule/index.ts b/spacetime-auth-ts/src/submodule/index.ts new file mode 100644 index 00000000000..0dd0e965da8 --- /dev/null +++ b/spacetime-auth-ts/src/submodule/index.ts @@ -0,0 +1,161 @@ +import { schema, t, table } from 'spacetimedb/server'; +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; +import { installAuth } from './install'; +import { + authAccountTable as authAccount, + authAdminIdentityTable as authAdminIdentity, + authConfigTable as authConfig, + authConnectionBindingTable as authConnectionBinding, + authOauthStateTable as authOauthState, + authSessionTable as authSession, + authUserTable as authUser, + authVerificationTable as authVerification, +} from '../tables'; +import { + setAuthConfigParams, + setAuthConfig, + authSweep, + getPublicKeyPemParams, + getPublicKeyPem, + linkConnectionParams, + linkConnection, + unlinkConnectionParams, + unlinkConnection, + updateProfileParams, + updateProfile, + revokeSessionParams, + revokeSession, + listMySessionsParams, + listMySessions, + revokeMySessionParams, + revokeMySession, + getCallerUserId, +} from '../index'; + +const authSweeperTick = table( + { name: 'auth_sweeper_tick' }, + { + scheduledId: t.u64().primaryKey().autoInc(), + scheduledAt: t.scheduleAt(), + } +); + +const spacetimedb = schema({ + rateLimit, + authUser, + authSession, + authAccount, + authVerification, + authOauthState, + authConfig, + authConnectionBinding, + authAdminIdentity, + authSweeperTick, +}); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + installAuth(ctx); +}); + +// On the first set_auth_config call, setAuthConfig generates an ES256 keypair when no PEM is supplied. +export const set_auth_config = spacetimedb.reducer( + setAuthConfigParams, + (ctx, args) => { + setAuthConfig(ctx, args); + } +); + +export const get_auth_public_key = spacetimedb.procedure( + getPublicKeyPemParams, + t.object('AuthPubKey', { + publicKeyPem: t.string(), + keyId: t.string(), + issuerUrl: t.string(), + }), + getPublicKeyPem +); + +export const link_connection = spacetimedb.reducer( + linkConnectionParams, + (ctx, args) => { + linkConnection(ctx, args); + } +); + +export const unlink_connection = spacetimedb.reducer( + unlinkConnectionParams, + (ctx, args) => { + unlinkConnection(ctx, args); + } +); + +export const update_profile = spacetimedb.reducer( + updateProfileParams, + updateProfile +); + +export const revoke_session = spacetimedb.reducer( + revokeSessionParams, + (ctx, args) => { + revokeSession(ctx, args); + } +); + +export const list_my_sessions = spacetimedb.procedure( + listMySessionsParams, + t.object('MySessions', { + sessions: t.array( + t.object('MySession', { + sessionId: t.string(), + expiresAt: t.timestamp(), + createdAt: t.timestamp(), + ipAddress: t.option(t.string()), + userAgent: t.option(t.string()), + isCurrent: t.bool(), + }) + ), + }), + listMySessions +); + +export const revoke_my_session = spacetimedb.reducer( + revokeMySessionParams, + (ctx, args) => { + revokeMySession(ctx, args); + } +); + +export const auth_sweep = spacetimedb.reducer( + { onSchedule: authSweeperTick }, + { arg: authSweeperTick.rowType }, + (ctx, _arg) => { + authSweep(ctx); + } +); + +export const myAuthUser = spacetimedb.view( + { name: 'my_auth_user', public: true }, + t.array(authUser.rowType), + ctx => { + const binding = ctx.db.authConnectionBinding.stdbIdentity.find(ctx.sender); + if (!binding) return []; + const row = ctx.db.authUser.userId.find(binding.userId); + return row ? [row] : []; + } +); + +export const whoami = spacetimedb.procedure( + {}, + t.object('WhoAmI', { + userId: t.option(t.string()), + senderIdentityHex: t.string(), + }), + (ctx, _args) => { + const userId = getCallerUserId(ctx); + return { + userId: userId ?? undefined, + senderIdentityHex: ctx.sender.toHexString(), + }; + } +); diff --git a/spacetime-auth-ts/src/submodule/install.ts b/spacetime-auth-ts/src/submodule/install.ts new file mode 100644 index 00000000000..52011b606ae --- /dev/null +++ b/spacetime-auth-ts/src/submodule/install.ts @@ -0,0 +1,26 @@ +import { ScheduleAt } from 'spacetimedb'; +import type { InferSchema, ReducerCtx } from 'spacetimedb/server'; +import * as rateLimit from '@spacetimedb/rate-limit/submodule'; +import type spacetimedb from './index'; + +const ONE_SECOND_MICROS = 1_000_000n; + +type Schema = InferSchema; +type InstallCtx = ReducerCtx; + +export function installAuth(ctx: InstallCtx) { + rateLimit.installRateLimit(ctx.as.rateLimit); + + if (ctx.db.authAdminIdentity.identity.find(ctx.sender) == null) { + ctx.db.authAdminIdentity.insert({ + identity: ctx.sender, + addedAtMicros: ctx.timestamp.microsSinceUnixEpoch, + }); + } + if (ctx.db.authSweeperTick.count() === 0n) { + ctx.db.authSweeperTick.insert({ + scheduledId: 0n, + scheduledAt: ScheduleAt.interval(60n * ONE_SECOND_MICROS), + }); + } +} diff --git a/spacetime-auth-ts/src/tables.ts b/spacetime-auth-ts/src/tables.ts new file mode 100644 index 00000000000..b7cad8aeda3 --- /dev/null +++ b/spacetime-auth-ts/src/tables.ts @@ -0,0 +1,137 @@ +import { table, t } from 'spacetimedb/server'; + +export const authUserRow = { + userId: t.string().primaryKey(), + email: t.string().unique(), + emailVerified: t.bool(), + name: t.option(t.string()), + image: t.option(t.string()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const authSessionRow = { + sessionId: t.string().primaryKey(), + userId: t.string().index(), + token: t.string().unique(), + expiresAt: t.timestamp().index(), + ipAddress: t.option(t.string()), + userAgent: t.option(t.string()), + createdAt: t.timestamp(), +}; + +// providerId: 'password' | 'google' | 'github'. providerAccountId: email or provider sub. +export const authAccountRow = { + accountId: t.string().primaryKey(), + userId: t.string().index(), + providerId: t.string().index(), + providerAccountId: t.string().index(), + passwordHash: t.option(t.string()), + accessToken: t.option(t.string()), + refreshToken: t.option(t.string()), + accessTokenExpiresAt: t.option(t.timestamp()), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const authVerificationRow = { + verificationId: t.string().primaryKey(), + identifier: t.string().index(), + value: t.string().unique(), + purpose: t.string(), + expiresAt: t.timestamp().index(), + createdAt: t.timestamp(), +}; + +export const authOauthStateRow = { + state: t.string().primaryKey(), + provider: t.string(), + codeVerifier: t.string(), + redirectTo: t.string(), + expiresAt: t.timestamp().index(), + createdAt: t.timestamp(), +}; + +// Private singleton; populated by setAuthConfig. +export const authConfigRow = { + singleton: t.bool().primaryKey(), + issuerUrl: t.string(), + baseUrl: t.string(), + cookieName: t.string(), + sessionTtlSeconds: t.u64(), + es256PrivateKeyPem: t.string(), + es256PublicKeyPem: t.string(), + keyId: t.string(), + googleClientId: t.option(t.string()), + googleClientSecret: t.option(t.string()), + githubClientId: t.option(t.string()), + githubClientSecret: t.option(t.string()), + updatedAt: t.timestamp(), +}; + +// Maps STDB Identity to auth_user; populated by link_connection. +export const authConnectionBindingRow = { + stdbIdentity: t.identity().primaryKey(), + userId: t.string().index(), + linkedAt: t.timestamp(), +}; + +// Operator allowlist. Seeded by the database owner; privileged calls +// (re-config, revoke_session) must come from a seeded admin. +export const authAdminIdentityRow = { + identity: t.identity().primaryKey(), + addedAtMicros: t.i64(), +}; + +// Scheduled-tick row: callers define their own scheduled table pointing to auth_sweep. + +export const authUserTable = table( + { name: 'auth_user', public: false }, + authUserRow +); + +export const authSessionTable = table( + { name: 'auth_session', public: false }, + authSessionRow +); + +export const authAccountTable = table( + { name: 'auth_account', public: false }, + authAccountRow +); + +export const authVerificationTable = table( + { name: 'auth_verification', public: false }, + authVerificationRow +); + +export const authOauthStateTable = table( + { name: 'auth_oauth_state', public: false }, + authOauthStateRow +); + +export const authConfigTable = table( + { name: 'auth_config', public: false }, + authConfigRow +); + +export const authConnectionBindingTable = table( + { name: 'auth_connection_binding', public: false }, + authConnectionBindingRow +); + +export const authAdminIdentityTable = table( + { name: 'auth_admin_identity', public: false }, + authAdminIdentityRow +); + +export const authTables = { + authUser: authUserTable, + authSession: authSessionTable, + authAccount: authAccountTable, + authVerification: authVerificationTable, + authOauthState: authOauthStateTable, + authConfig: authConfigTable, + authConnectionBinding: authConnectionBindingTable, + authAdminIdentity: authAdminIdentityTable, +}; diff --git a/spacetime-auth-ts/src/types.ts b/spacetime-auth-ts/src/types.ts new file mode 100644 index 00000000000..92a6c1e7abb --- /dev/null +++ b/spacetime-auth-ts/src/types.ts @@ -0,0 +1,18 @@ +import type { Infer } from 'spacetimedb/server'; +import type { + authUserRow, + authSessionRow, + authAccountRow, + authVerificationRow, + authOauthStateRow, + authConfigRow, + authConnectionBindingRow, +} from './tables.ts'; + +export type AuthUser = Infer; +export type AuthSession = Infer; +export type AuthAccount = Infer; +export type AuthVerification = Infer; +export type AuthOauthState = Infer; +export type AuthConfig = Infer; +export type AuthConnectionBinding = Infer; diff --git a/spacetime-auth-ts/tsconfig.json b/spacetime-auth-ts/tsconfig.json new file mode 100644 index 00000000000..b2b4ecdcdda --- /dev/null +++ b/spacetime-auth-ts/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-files-ts/.npmrc b/spacetime-files-ts/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-files-ts/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-files-ts/LICENSE.txt b/spacetime-files-ts/LICENSE.txt new file mode 100644 index 00000000000..ea0cb1c5e9e --- /dev/null +++ b/spacetime-files-ts/LICENSE.txt @@ -0,0 +1,759 @@ +SPACETIMEDB BUSINESS SOURCE LICENSE AGREEMENT + +Business Source License 1.1 + +Parameters + +Licensor: Clockwork Laboratories, Inc. +Licensed Work: SpacetimeDB 2.8.3 + The Licensed Work is + (c) 2023 Clockwork Laboratories, Inc. + +Additional Use Grant: You may make use of the Licensed Work provided your + application or service uses the Licensed Work with no + more than one SpacetimeDB instance in production and + provided that you do not use the Licensed Work for a + Database Service. + + A “Database Service” is a commercial offering that + allows third parties (other than your employees and + contractors) to access the functionality of the + Licensed Work by creating tables whose schemas are + controlled by such third parties. + +Change Date: 2031-08-18 + +Change License: GNU Affero General Public License v3.0 with a linking + exception + +For information about alternative licensing arrangements for the Software, +please visit: https://spacetimedb.com + +Notice + +The Business Source License (this document, or the “License”) is not an Open +Source license. However, the Licensed Work will eventually be made available +under an Open Source License, as stated in this License. + +License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved. +“Business Source License” is a trademark of MariaDB Corporation Ab. + +----------------------------------------------------------------------------- + +Base License and Subdirectory Specific Licenses + +1. Repository-Wide License +Except as provided in Section 2 below, the contents of this repository are licensed under the Business Source License (“BSL”), which includes a change date resulting in a licensing change to the GNU Affero General Public License v3.0 with Linking Exception on that date. See the full text of the BSL and AGPL with Linking Exception in this file below. + +2. Subdirectory-Specific Licenses +Certain subdirectories within this repository are licensed under different terms. + +If a subdirectory contains its own LICENSE or LICENSE.txt file, the terms in that file apply exclusively to all files and subfolders within that subdirectory. + +In the event of any conflict between this base license and a subdirectory’s license, the base license will govern for that subdirectory’s contents. + +3. Contributor Acknowledgement +By contributing to this repository, you agree that: + +Your contributions will be licensed under the license applicable to the directory or subdirectory in which your contribution is made. + +If you contribute to multiple subdirectories, the applicable license for each subdirectory will apply to your contributions in that subdirectory. + +4. Reading the Applicable License +Before using, modifying, or distributing code from this repository, you must read: + +This base LICENSE.txt file for the overall repository license. + +Any LICENSE or LICENSE.txt file in a subdirectory that you intend to use or contribute to. + +----------------------------------------------------------------------------- + +Business Source License 1.1 + +Terms + +The Licensor hereby grants you the right to copy, modify, create derivative +works, redistribute, and make non-production use of the Licensed Work. The +Licensor may make an Additional Use Grant, above, permitting limited +production use. + +Effective on the Change Date, or the fourth anniversary of the first publicly +available distribution of a specific version of the Licensed Work under this +License, whichever comes first, the Licensor hereby grants you rights under +the terms of the Change License, and the rights granted in the paragraph +above terminate. + +If your use of the Licensed Work does not comply with the requirements +currently in effect as described in this License, you must purchase a +commercial license from the Licensor, its affiliated entities, or authorized +resellers, or you must refrain from using the Licensed Work. + +All copies of the original and modified Licensed Work, and derivative works +of the Licensed Work, are subject to this License. This License applies +separately for each version of the Licensed Work and the Change Date may vary +for each version of the Licensed Work released by Licensor. + +You must conspicuously display this License on each original or modified copy +of the Licensed Work. If you receive the Licensed Work in original or +modified form from a third party, the terms and conditions set forth in this +License apply to your use of that work. + +Any use of the Licensed Work in violation of this License will automatically +terminate your rights under this License for the current and all other +versions of the Licensed Work. + +This License does not grant you any right in any trademark or logo of +Licensor or its affiliates (provided that you may use a trademark or logo of +Licensor as expressly required by this License). + +TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON +AN “AS IS” BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS, +EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND +TITLE. + +MariaDB hereby grants you permission to use this License’s text to license +your works, and to refer to it using the trademark “Business Source License”, +as long as you comply with the Covenants of Licensor below. + +Covenants of Licensor + +In consideration of the right to use this License’s text and the “Business +Source License” name and trademark, Licensor covenants to MariaDB, and to all +other recipients of the licensed work to be provided by Licensor: + +1. To specify as the Change License the GPL Version 2.0 or any later version, + or a license that is compatible with GPL Version 2.0 or a later version, + where “compatible” means that software provided under the Change License can + be included in a program with software provided under GPL Version 2.0 or a + later version. Licensor may specify additional Change Licenses without + limitation. + +2. To either: (a) specify an additional grant of rights to use that does not + impose any additional restriction on the right granted in this License, as + the Additional Use Grant; or (b) insert the text “None”. + +3. To specify a Change Date. + +4. Not to modify this License in any other way. + +----------------------------------------------------------------------------- + +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU Affero General Public License, version 3, as published +by the Free Software Foundation. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU Affero General Public License +along with this program; if not, see . + +Additional permission under GNU GPL version 3 section 7 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission to convey the resulting work. + +Additional permission under GNU AGPL version 3 section 13 + +If you modify this Program, or any covered work, by linking or combining it +with SpacetimeDB (or a modified version of that library), containing parts +covered by the terms of the AGPL v3.0, the licensors of this Program grant +you additional permission that, notwithstanding any other provision of this +License, you need not prominently offer all users interacting with your +modified version remotely through a computer network an opportunity to +receive the Corresponding Source of your version from a network server at no +charge, if your version supports such interaction. This permission does not +waive or modify any other obligations or terms of the AGPL v3.0, except for +the specific requirement set forth in section 13. + +A copy of the AGPL v3.0 license is reproduced below. + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright © 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies of this license +document, but changing it is not allowed. + +Preamble +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take +away your freedom to share and change the works. By contrast, our General +Public Licenses are intended to guarantee your freedom to share and change +all versions of a program--to make sure it remains free software for all its +users. + +When we speak of free software, we are referring to freedom, not price. Our +General Public Licenses are designed to make sure that you have the freedom +to distribute copies of free software (and charge for them if you wish), that +you receive source code or can get it if you want it, that you can change the +software or use pieces of it in new free programs, and that you know you can +do these things. + +Developers that use our General Public Licenses protect your rights with two +steps: (1) assert copyright on the software, and (2) offer you this License +which gives you legal permission to copy, distribute and/or modify the +software. + +A secondary benefit of defending all users' freedom is that improvements made +in alternate versions of the program, if they receive widespread use, become +available for other developers to incorporate. Many developers of free +software are heartened and encouraged by the resulting cooperation. However, +in the case of software used on network servers, this result may fail to come +about. The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its source +code to the public. + +The GNU Affero General Public License is designed specifically to ensure +that, in such cases, the modified source code becomes available to the +community. It requires the operator of a network server to provide the source +code of the modified version running there to the users of that server. +Therefore, public use of a modified version, on a publicly accessible server, +gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by +Affero, was designed to accomplish similar goals. This is a different +license, not a version of the Affero GPL, but Affero has released a new +version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification +follow. + +TERMS AND CONDITIONS +0. Definitions. +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. +Each licensee is addressed as "you". "Licensees" and "recipients" may be +individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a +fashion requiring copyright permission, other than the making of an exact +copy. The resulting work is called a "modified version" of the earlier work +or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the +Program. + +To "propagate" a work means to do anything with it that, without permission, +would make you directly or secondarily liable for infringement under +applicable copyright law, except executing it on a computer or modifying a +private copy. Propagation includes copying, distribution (with or without +modification), making available to the public, and in some countries other +activities as well. + +To "convey" a work means any kind of propagation that enables other parties +to make or receive copies. Mere interaction with a user through a computer +network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the +extent that it includes a convenient and prominently visible feature that (1) +displays an appropriate copyright notice, and (2) tells the user that there +is no warranty for the work (except to the extent that warranties are +provided), that licensees may convey the work under this License, and how to +view a copy of this License. If the interface presents a list of user +commands or options, such as a menu, a prominent item in the list meets this +criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making +modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard +defined by a recognized standards body, or, in the case of interfaces +specified for a particular programming language, one that is widely used +among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the +work as a whole, that (a) is included in the normal form of packaging a Major +Component, but which is not part of that Major Component, and (b) serves only +to enable use of the work with that Major Component, or to implement a +Standard Interface for which an implementation is available to the public in +source code form. A "Major Component", in this context, means a major +essential component (kernel, window system, and so on) of the specific +operating system (if any) on which the executable work runs, or a compiler +used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the +source code needed to generate, install, and (for an executable work) run the +object code and to modify the work, including scripts to control those +activities. However, it does not include the work's System Libraries, or +general-purpose tools or generally available free programs which are used +unmodified in performing those activities but which are not part of the work. +For example, Corresponding Source includes interface definition files +associated with source files for the work, and the source code for shared +libraries and dynamically linked subprograms that the work is specifically +designed to require, such as by intimate data communication or control flow +between those subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate +automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright +on the Program, and are irrevocable provided the stated conditions are met. +This License explicitly affirms your unlimited permission to run the +unmodified Program. The output from running a covered work is covered by this +License only if the output, given its content, constitutes a covered work. +This License acknowledges your rights of fair use or other equivalent, as +provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without +conditions so long as your license otherwise remains in force. You may convey +covered works to others for the sole purpose of having them make +modifications exclusively for you, or provide you with facilities for running +those works, provided that you comply with the terms of this License in +conveying all material for which you do not control copyright. Those thus +making or running the covered works for you must do so exclusively on your +behalf, under your direction and control, on terms that prohibit them from +making any copies of your copyrighted material outside their relationship +with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes it +unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure +under any applicable law fulfilling obligations under article 11 of the WIPO +copyright treaty adopted on 20 December 1996, or similar laws prohibiting or +restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention is +effected by exercising rights under this License with respect to the covered +work, and you disclaim any intention to limit operation or modification of +the work as a means of enforcing, against the work's users, your or third +parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive +it, in any medium, provided that you conspicuously and appropriately publish +on each copy an appropriate copyright notice; keep intact all notices stating +that this License and any non-permissive terms added in accord with section 7 +apply to the code; keep intact all notices of the absence of any warranty; +and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you +may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce +it from the Program, in the form of source code under the terms of section 4, +provided that you also meet all of these conditions: + +a) The work must carry prominent notices stating that you modified it, and +giving a relevant date. +b) The work must carry prominent notices stating that it is released under +this License and any conditions added under section 7. This requirement +modifies the requirement in section 4 to "keep intact all notices". +c) You must license the entire work, as a whole, under this License to anyone +who comes into possession of a copy. This License will therefore apply, along +with any applicable section 7 additional terms, to the whole of the work, and +all its parts, regardless of how they are packaged. This License gives no +permission to license the work in any other way, but it does not invalidate +such permission if you have separately received it. +d) If the work has interactive user interfaces, each must display Appropriate +Legal Notices; however, if the Program has interactive interfaces that do not +display Appropriate Legal Notices, your work need not make them do so. +A compilation of a covered work with other separate and independent works, +which are not by their nature extensions of the covered work, and which are +not combined with it such as to form a larger program, in or on a volume of a +storage or distribution medium, is called an "aggregate" if the compilation +and its resulting copyright are not used to limit the access or legal rights +of the compilation's users beyond what the individual works permit. Inclusion +of a covered work in an aggregate does not cause this License to apply to the +other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections +4 and 5, provided that you also convey the machine-readable Corresponding +Source under the terms of this License, in one of these ways: + +a) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by the Corresponding Source fixed +on a durable physical medium customarily used for software interchange. +b) Convey the object code in, or embodied in, a physical product (including a +physical distribution medium), accompanied by a written offer, valid for at +least three years and valid for as long as you offer spare parts or customer +support for that product model, to give anyone who possesses the object code +either (1) a copy of the Corresponding Source for all the software in the +product that is covered by this License, on a durable physical medium +customarily used for software interchange, for a price no more than your +reasonable cost of physically performing this conveying of source, or (2) +access to copy the Corresponding Source from a network server at no charge. +c) Convey individual copies of the object code with a copy of the written +offer to provide the Corresponding Source. This alternative is allowed only +occasionally and noncommercially, and only if you received the object code +with such an offer, in accord with subsection 6b. +d) Convey the object code by offering access from a designated place (gratis +or for a charge), and offer equivalent access to the Corresponding Source in +the same way through the same place at no further charge. You need not +require recipients to copy the Corresponding Source along with the object +code. If the place to copy the object code is a network server, the +Corresponding Source may be on a different server (operated by you or a third +party) that supports equivalent copying facilities, provided you maintain +clear directions next to the object code saying where to find the +Corresponding Source. Regardless of what server hosts the Corresponding +Source, you remain obligated to ensure that it is available for as long as +needed to satisfy these requirements. +e) Convey the object code using peer-to-peer transmission, provided you +inform other peers where the object code and Corresponding Source of the work +are being offered to the general public at no charge under subsection 6d. +A separable portion of the object code, whose source code is excluded from +the Corresponding Source as a System Library, need not be included in +conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible +personal property which is normally used for personal, family, or household +purposes, or (2) anything designed or sold for incorporation into a dwelling. +In determining whether a product is a consumer product, doubtful cases shall +be resolved in favor of coverage. For a particular product received by a +particular user, "normally used" refers to a typical or common use of that +class of product, regardless of the status of the particular user or of the +way in which the particular user actually uses, or expects or is expected to +use, the product. A product is a consumer product regardless of whether the +product has substantial commercial, industrial or non-consumer uses, unless +such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, +authorization keys, or other information required to install and execute +modified versions of a covered work in that User Product from a modified +version of its Corresponding Source. The information must suffice to ensure +that the continued functioning of the modified object code is in no case +prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as part of +a transaction in which the right of possession and use of the User Product is +transferred to the recipient in perpetuity or for a fixed term (regardless of +how the transaction is characterized), the Corresponding Source conveyed +under this section must be accompanied by the Installation Information. But +this requirement does not apply if neither you nor any third party retains +the ability to install modified object code on the User Product (for example, +the work has been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates for +a work that has been modified or installed by the recipient, or for the User +Product in which it has been modified or installed. Access to a network may +be denied when the modification itself materially and adversely affects the +operation of the network or violates the rules and protocols for +communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in +accord with this section must be in a format that is publicly documented (and +with an implementation available to the public in source code form), and must +require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License +by making exceptions from one or more of its conditions. Additional +permissions that are applicable to the entire Program shall be treated as +though they were included in this License, to the extent that they are valid +under applicable law. If additional permissions apply only to part of the +Program, that part may be used separately under those permissions, but the +entire Program remains governed by this License without regard to the +additional permissions. + +When you convey a copy of a covered work, you may at your option remove any +additional permissions from that copy, or from any part of it. (Additional +permissions may be written to require their own removal in certain cases when +you modify the work.) You may place additional permissions on material, added +by you to a covered work, for which you have or can give appropriate +copyright permission. + +Notwithstanding any other provision of this License, for material you add to +a covered work, you may (if authorized by the copyright holders of that +material) supplement the terms of this License with terms: + +a) Disclaiming warranty or limiting liability differently from the terms of +sections 15 and 16 of this License; or +b) Requiring preservation of specified reasonable legal notices or author +attributions in that material or in the Appropriate Legal Notices displayed +by works containing it; or +c) Prohibiting misrepresentation of the origin of that material, or requiring +that modified versions of such material be marked in reasonable ways as +different from the original version; or +d) Limiting the use for publicity purposes of names of licensors or authors +of the material; or +e) Declining to grant rights under trademark law for use of some trade names, +trademarks, or service marks; or +f) Requiring indemnification of licensors and authors of that material by +anyone who conveys the material (or modified versions of it) with contractual +assumptions of liability to the recipient, for any liability that these +contractual assumptions directly impose on those licensors and authors. +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is governed +by this License along with a term that is a further restriction, you may +remove that term. If a license document contains a further restriction but +permits relicensing or conveying under this License, you may add to a covered +work material governed by the terms of that license document, provided that +the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must +place, in the relevant source files, a statement of the additional terms that +apply to those files, or a notice indicating where to find the applicable +terms. + +Additional terms, permissive or non-permissive, may be stated in the form of +a separately written license, or stated as exceptions; the above requirements +apply either way. + +8. Termination. +You may not propagate or modify a covered work except as expressly provided +under this License. Any attempt otherwise to propagate or modify it is void, +and will automatically terminate your rights under this License (including +any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a +particular copyright holder is reinstated (a) provisionally, unless and until +the copyright holder explicitly and finally terminates your license, and (b) +permanently, if the copyright holder fails to notify you of the violation by +some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated +permanently if the copyright holder notifies you of the violation by some +reasonable means, this is the first time you have received notice of +violation of this License (for any work) from that copyright holder, and you +cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses +of parties who have received copies or rights from you under this License. If +your rights have been terminated and not permanently reinstated, you do not +qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. +You are not required to accept this License in order to receive or run a copy +of the Program. Ancillary propagation of a covered work occurring solely as a +consequence of using peer-to-peer transmission to receive a copy likewise +does not require acceptance. However, nothing other than this License grants +you permission to propagate or modify any covered work. These actions +infringe copyright if you do not accept this License. Therefore, by modifying +or propagating a covered work, you indicate your acceptance of this License +to do so. + +10. Automatic Licensing of Downstream Recipients. +Each time you convey a covered work, the recipient automatically receives a +license from the original licensors, to run, modify and propagate that work, +subject to this License. You are not responsible for enforcing compliance by +third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered work +results from an entity transaction, each party to that transaction who +receives a copy of the work also receives whatever licenses to the work the +party's predecessor in interest had or could give under the previous +paragraph, plus a right to possession of the Corresponding Source of the work +from the predecessor in interest, if the predecessor has it or can get it +with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights +granted or affirmed under this License. For example, you may not impose a +license fee, royalty, or other charge for exercise of rights granted under +this License, and you may not initiate litigation (including a cross-claim or +counterclaim in a lawsuit) alleging that any patent claim is infringed by +making, using, selling, offering for sale, or importing the Program or any +portion of it. + +11. Patents. +A "contributor" is a copyright holder who authorizes use under this License +of the Program or a work on which the Program is based. The work thus +licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or +controlled by the contributor, whether already acquired or hereafter +acquired, that would be infringed by some manner, permitted by this License, +of making, using, or selling its contributor version, but do not include +claims that would be infringed only as a consequence of further modification +of the contributor version. For purposes of this definition, "control" +includes the right to grant patent sublicenses in a manner consistent with +the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent +license under the contributor's essential patent claims, to make, use, sell, +offer for sale, import and otherwise run, modify and propagate the contents +of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent (such +as an express permission to practice a patent or covenant not to sue for +patent infringement). To "grant" such a patent license to a party means to +make such an agreement or commitment not to enforce a patent against the +party. + +If you convey a covered work, knowingly relying on a patent license, and the +Corresponding Source of the work is not available for anyone to copy, free of +charge and under the terms of this License, through a publicly available +network server or other readily accessible means, then you must either (1) +cause the Corresponding Source to be so available, or (2) arrange to deprive +yourself of the benefit of the patent license for this particular work, or +(3) arrange, in a manner consistent with the requirements of this License, to +extend the patent license to downstream recipients. "Knowingly relying" means +you have actual knowledge that, but for the patent license, your conveying +the covered work in a country, or your recipient's use of the covered work in +a country, would infringe one or more identifiable patents in that country +that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, +you convey, or propagate by procuring conveyance of, a covered work, and +grant a patent license to some of the parties receiving the covered work +authorizing them to use, propagate, modify or convey a specific copy of the +covered work, then the patent license you grant is automatically extended to +all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope +of its coverage, prohibits the exercise of, or is conditioned on the +non-exercise of one or more of the rights that are specifically granted under +this License. You may not convey a covered work if you are a party to an +arrangement with a third party that is in the business of distributing +software, under which you make payment to the third party based on the extent +of your activity of conveying the work, and under which the third party +grants, to any of the parties who would receive the covered work from you, a +discriminatory patent license (a) in connection with copies of the covered +work conveyed by you (or copies made from those copies), or (b) primarily for +and in connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any +implied license or other defenses to infringement that may otherwise be +available to you under applicable patent law. + +12. No Surrender of Others' Freedom. +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not excuse +you from the conditions of this License. If you cannot convey a covered work +so as to satisfy simultaneously your obligations under this License and any +other pertinent obligations, then as a consequence you may not convey it at +all. For example, if you agree to terms that obligate you to collect a +royalty for further conveying from those to whom you convey the Program, the +only way you could satisfy both those terms and this License would be to +refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users interacting +with it remotely through a computer network (if your version supports such +interaction) an opportunity to receive the Corresponding Source of your +version by providing access to the Corresponding Source from a network server +at no charge, through some standard or customary means of facilitating +copying of software. This Corresponding Source shall include the +Corresponding Source for any work covered by version 3 of the GNU General +Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to +link or combine any covered work with a work licensed under version 3 of the +GNU General Public License into a single combined work, and to convey the +resulting work. The terms of this License will continue to apply to the part +which is the covered work, but the work with which it is combined will remain +governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. +The Free Software Foundation may publish revised and/or new versions of the +GNU Affero General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General Public +License "or any later version" applies to it, you have the option of +following the terms and conditions either of that numbered version or of any +later version published by the Free Software Foundation. If the Program does +not specify a version number of the GNU Affero General Public License, you +may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the +GNU Affero General Public License can be used, that proxy's public statement +of acceptance of a version permanently authorizes you to choose that version +for the Program. + +Later license versions may give you additional or different permissions. +However, no additional obligations are imposed on any author or copyright +holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE +LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR +OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, +EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE +ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. +SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY +SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL +ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE +PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE +OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR +DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR +A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH +HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. +If the disclaimer of warranty and limitation of liability provided above +cannot be given local legal effect according to their terms, reviewing courts +shall apply local law that most closely approximates an absolute waiver of +all civil liability in connection with the Program, unless a warranty or +assumption of liability accompanies a copy of the Program in return for a +fee. + +END OF TERMS AND CONDITIONS + +How to Apply These Terms to Your New Programs +If you develop a new program, and you want it to be of the greatest possible +use to the public, the best way to achieve this is to make it free software +which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach +them to the start of each source file to most effectively state the exclusion +of warranty; and each file should have at least the "copyright" line and a +pointer to where the full notice is found. + +SpacetimeDB: A database which replaces your server. +Copyright (C) 2023 Clockwork Laboratories, Inc. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, +you should also make sure that it provides a way for users to get its source. +For example, if your program is a web application, its interface could +display a "Source" link that leads users to an archive of the code. There are +many ways you could offer source, and different solutions will be better for +different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if +any, to sign a "copyright disclaimer" for the program, if necessary. For more +information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/spacetime-files-ts/README.md b/spacetime-files-ts/README.md new file mode 100644 index 00000000000..7d29e04e279 --- /dev/null +++ b/spacetime-files-ts/README.md @@ -0,0 +1,257 @@ +# @spacetimedb/files + +File storage primitives for SpacetimeDB modules: upload, list, delete, and serve +byte blobs with per-file visibility, SHA-256 ETags, and an HTTP handler factory +that streams cached responses through the module's route. + +--- + +## Install + +```bash +npm install @spacetimedb/files spacetimedb@^2.8.3 +``` + +Requires SpacetimeDB 2.8.3 or later for submodule mounting. + +For the install-to-publish workflow, see +[Getting started](https://spacetimedb.com/docs/). + +Bytes live in the module's `file` table as transactional application state. + +## Usage + +### Integrate into an application + +For a new application, register the submodule first. The host must derive an owner +from its own identity or session model and expose narrow wrappers around the +file helpers. Keep the file table private. + +```ts +import { schema, t } from 'spacetimedb/server'; +import * as files from '@spacetimedb/files/submodule'; + +const spacetimedb = schema({ files }); +export default spacetimedb; + +export const init = spacetimedb.init(ctx => { + files.installFiles(ctx.as.files); +}); + +export const upload_file = spacetimedb.procedure( + files.uploadFileParams, + t.u64(), + (ctx, args) => files.uploadFile(ctx.as.files, args, ctx.sender.toHexString()) +); +``` + +The host module owns `init`, derives owners from its auth model, and wraps the +helper procedures and HTTP handler with `ctx.as.files`. +See the +[Vault host module](./example/spacetimedb/) +for upload wrappers, scoped metadata views, and an HTTP download route. + +### Standalone table builders + +Use the lower-level row and implementation exports only when the host needs to +own the file tables directly: + +```ts +import { fileRow } from '@spacetimedb/files/rows'; +``` + +| Field | Type | Notes | +| ------------------------- | ----------------- | -------------------------------------------------------------- | +| `id` | `u64` PK auto-inc | | +| `ownerPathKey` | `string` unique | Collision-safe internal owner/path key | +| `path` | `string` indexed | Canonical caller-supplied path, up to 1024 chars | +| `ownerUserId` | `string` indexed | Opaque identity, application user ID, or host-defined actor ID | +| `mimeType` | `string` | | +| `size` | `u64` | | +| `sha256Hex` | `string` | Lowercase hex of `SHA-256(bytes)`; used as strong ETag | +| `visibility` | `string` indexed | `FILE_VISIBILITY_OWNER` or `FILE_VISIBILITY_PUBLIC` | +| `createdAt` / `updatedAt` | `timestamp` | | + +The private `file_blob` table stores `{ fileId, bytes }` separately. Metadata +lookups, `HEAD`, and conditional `304` responses therefore avoid reading or +copying the blob. `GET` and authenticated byte procedures load it after access +checks pass. + +The submodule table is private. Host views should return `fileSummary` rows so +subscriptions carry safe metadata fields. + +The package also exports `fileSummary`, a safe metadata shape that omits +`ownerUserId`, `ownerPathKey`, and blob bytes, for use in procedure and view +return types. + +After generating bindings, upload through the host wrapper and subscribe to a +host view that returns file summaries: + +```ts +import { tables } from './module_bindings'; + +const fileId = await conn.procedures.uploadFile({ + path: '/avatars/me.png', + mimeType: 'image/png', + bytes: pngBytes, + visibility: 'owner', +}); + +conn.subscriptionBuilder().subscribe([tables.myFileSummaries]); +``` + +## API + +Each `*Impl` takes `(ctx, args, owner)` so the submodule stays identity-scheme-agnostic. Wrap them with thin reducers in your app module that derive `owner` however you want (caller `Identity`, a session lookup through an Auth submodule namespace, etc). + +Package entrypoints: + +- `@spacetimedb/files/submodule` supplies the submodule namespace and all + host integration helpers. +- `@spacetimedb/files` exports the lower-level rows, validation, + procedures, constants, and HTTP handler. +- `@spacetimedb/files/procedures` exports operation parameters, return + types, and implementations. +- `@spacetimedb/files/handlers` exports public-file HTTP serving. +- `@spacetimedb/files/rows` exports table row builders. +- `@spacetimedb/files/constants` is safe to import in browser code. + +Validation exports include `validateFileOwner`, `validateFilePath`, +`validateFilePrefix`, `validateMimeType`, `safeMimeType`, `ownerPathKey`, and +`FileValidationError`. + +### `uploadFile` + +```ts +import { uploadFileParams, uploadFile } from '@spacetimedb/files/procedures'; +``` + +- Args: `path`, `mimeType`, `bytes` (`u8[]`), `visibility`. +- Returns: `bigint` (the file `id`). +- Upserts by the owner/path pair. Different owners may use the same path. +- Requires an absolute canonical path such as `/images/avatar.png`. +- Enforces `bytes.length <= FILE_BYTES_MAX` (4 MB) and `path.length <= 1024`. +- Accepts a media type such as `image/png` or `image/svg+xml`. Parameters and + control characters are rejected. +- Computes the authoritative `sha256Hex` ETag server-side. + +### `deleteFile` + +- Args: `path`. +- Owner-gated through the owner/path key. +- Returns nothing when the caller has no file at that path. + +### `listFiles` + +```ts +import { + listFilesParams, + listFilesReturn, + listFiles, +} from '@spacetimedb/files/procedures'; +``` + +- Args: `prefix`, optional `cursor`, and optional `limit` from 1 to 200. +- Returns: `{ files, nextCursor }`, ordered by `path`. Pass `nextCursor` into + the next call until it is absent. `bytes` is omitted. +- Scopes to the caller's own files. + +### `setFileVisibility` + +- Args: `path`, `visibility`. +- Owner-gated. + +### `readFileBytes` + +```ts +import { + readFileBytesParams, + readFileBytesReturn, + readFileBytes, +} from '@spacetimedb/files/procedures'; +``` + +- Args: `path`. Returns: `{ bytes: u8[], mimeType: string }`. +- Owner-gated. Throws `files.not_found` / `files.not_owner`. +- **Private files use an authenticated procedure.** SpacetimeDB HTTP route + handlers see the _module's_ identity, so `createFileHttpHandler` serves public + files. Procedures receive the authenticated sender. Wrap `readFileBytes` + in a procedure for private previews and downloads, and use HTTP for cacheable + public files. + +```ts +export const read_file_bytes = spacetimedb.procedure( + readFileBytesParams, + readFileBytesReturn, + (ctx, args) => readFileBytes(ctx, args, ctx.sender.toHexString()) +); +``` + +## HTTP serve handler + +```ts +import { createFileHttpHandler } from '@spacetimedb/files/handlers'; +``` + +Wire a handler into your module's HTTP routes: + +```ts +const serveFile = createFileHttpHandler({ + getOwner: _ctx => undefined, +}); +``` + +Register it under a route such as `/files/*` from your module. The handler: + +- Accepts `GET` and `HEAD` only; everything else 405s. +- Reads the stable file ID from `?id=`. +- Returns 404 for an unknown file and 403 when an owner-only file has a different owner. +- Sends `etag: ""` and honors `If-None-Match` with 304. +- Sets `cache-control: public, max-age=300, must-revalidate` for public files; `private, max-age=60, must-revalidate` for owner files. +- `HEAD` returns headers only; `GET` returns the full body. + +`getOwner` is the host's authentication hook. Return the authenticated owner +value when private HTTP reads are supported. Returning `undefined` limits the +route to public files. + +## Constants + +| Constant | Value | +| ------------------------ | ----------- | +| `FILE_BYTES_MAX` | `4_000_000` | +| `FILE_PATH_MAX` | `1024` | +| `FILE_MIME_TYPE_MAX` | `127` | +| `FILE_LIST_PAGE_MAX` | `200` | +| `FILE_VISIBILITY_OWNER` | `'owner'` | +| `FILE_VISIBILITY_PUBLIC` | `'public'` | + +The limits also ship from the browser-safe `./constants` subpath. It has no +server imports, so clients can pre-validate uploads with the same values. + +## Errors + +All thrown as `SenderError` with stable codes: + +- `files.invalid_path` - non-canonical, unsafe, or longer than 1024 +- `files.invalid_prefix` / `files.invalid_cursor` - invalid listing position +- `files.invalid_page_size` - listing limit outside 1 to 200 +- `files.invalid_mime_type` - invalid or unsafe HTTP media type +- `files.invalid_visibility:` - not in `{owner, public}` +- `files.too_large:/` - body exceeds `FILE_BYTES_MAX` +- `files.not_found:` - `setFileVisibility` or `readFileBytes` on a missing row + +## Testing + +```bash +pnpm test +pnpm run typecheck +``` + +Build the +[example host module](./example/spacetimedb/) +to verify the +registered submodule and generated bindings together. + +## License + +[BUSL-1.1](./LICENSE.txt) - same as SpacetimeDB. diff --git a/spacetime-files-ts/example/.env.example b/spacetime-files-ts/example/.env.example new file mode 100644 index 00000000000..10d99a7dba8 --- /dev/null +++ b/spacetime-files-ts/example/.env.example @@ -0,0 +1,7 @@ +# Copy to .env. The example server loads this on startup. + +HOST=127.0.0.1 +PORT=8799 +STDB_URI=ws://127.0.0.1:3000 +STDB_HTTP=http://127.0.0.1:3000 +SPACETIMEDB_DB_NAME=spacetime-files-example diff --git a/spacetime-files-ts/example/.gitignore b/spacetime-files-ts/example/.gitignore new file mode 100644 index 00000000000..2c226008d16 --- /dev/null +++ b/spacetime-files-ts/example/.gitignore @@ -0,0 +1,3 @@ +.env +*.log +node_modules diff --git a/spacetime-files-ts/example/.npmrc b/spacetime-files-ts/example/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-files-ts/example/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-files-ts/example/README.md b/spacetime-files-ts/example/README.md new file mode 100644 index 00000000000..bb6b91a60f5 --- /dev/null +++ b/spacetime-files-ts/example/README.md @@ -0,0 +1,164 @@ +# Vault files example + +Vault is a small Drive-style file manager built with +[`@spacetimedb/files`](../). File bytes and file records live in the namespaced +Files submodule; the host module adds identity-owned folder metadata and scoped +views. + +## What this demonstrates + +- Uploading, moving, renaming, listing, downloading, and deleting files. +- Identity-owned folders and caller-scoped file-summary subscriptions. +- Keeping file bytes out of realtime subscriptions. +- Reading private bytes through a sender-aware procedure. +- Serving explicitly public files through the submodule HTTP handler. +- Drag-and-drop uploads, folder traversal, search, previews, bulk actions, and ZIP + downloads in a browser client. + +## Prerequisites + +- Node.js 20 or later and pnpm 10. +- The released SpacetimeDB 2.8 CLI. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity for publishing the example. + +Select the supported CLI release, then keep the local server running in a +separate terminal: + +```powershell +spacetime version install 2.8.3 +spacetime version use 2.8.3 +spacetime start +``` + +```powershell +spacetime server ping local +spacetime login show +``` + +## Quick start + +From `spacetime-files-ts/example`: + +```powershell +pnpm install +pnpm --dir spacetimedb install +node -e "require('node:fs').copyFileSync('.env.example', '.env')" +pnpm run build:module:fresh +pnpm run dev +``` + +Open and upload a small image or text file. + +`build:module:fresh` deletes and recreates only the local `spacetime-files-example` +database. Use `pnpm run build:module` when existing local files must be preserved. + +## Use in your project + +This workspace tests the submodule source in this repository. Consumer applications install published releases: + +```bash +npm install @spacetimedb/files spacetimedb@^2.8.3 +``` + +Follow the package's +[integration guide](../README.md#integrate-into-an-application). Copy the +owner-derivation, scoped-view, and download-handler patterns; the folder model +and file-manager UI are application code in the example. + +## Configuration + +| Variable | Default | Purpose | +| --------------------- | ------------------------- | ------------------------------------------------ | +| `HOST` | `127.0.0.1` | Development web-server bind address. | +| `PORT` | `8799` | Development web-server port. | +| `STDB_URI` | `ws://127.0.0.1:3000` | Browser WebSocket endpoint. | +| `STDB_HTTP` | `http://127.0.0.1:3000` | Upstream endpoint for public file HTTP requests. | +| `SPACETIMEDB_DB_NAME` | `spacetime-files-example` | Published database name. | + +The Node server hosts the bundle and proxies `/files?id=` to the module HTTP router. +It receives metadata for authorization decisions. Private bytes travel through +the authenticated SpacetimeDB connection. + +## Read and write paths + +The browser subscribes to `my_folders` and `my_file_summaries`. These views are +filtered by the connection identity, and summaries omit `bytes`. Folder and file +mutations use reducers. + +Private content is returned by the `read_file_bytes` procedure. Procedures retain +the real caller in `ctx.sender`, allowing the host module to enforce ownership +before returning bytes over the authenticated SpacetimeDB connection. HTTP +handlers execute with the module route context and serve public files. + +Files marked public can use `/files?id=` for direct HTTP reads. Making a file public +changes its confidentiality and creates a public download path. + +## Paths and limits + +- Paths are absolute and slash-prefixed, for example `/docs/readme.txt`. +- File and folder paths are owner-scoped. Two identities can each use `/docs` + and `/docs/readme.txt`. +- Public links use the stable numeric file ID. +- The submodule stores bytes in SpacetimeDB rows and caps each file at 4 MB. +- Vault demonstrates in-row storage for small assets. Use dedicated infrastructure + for streaming uploads, media transformation, backups, and CDN delivery. + +The browser stores its development SpacetimeDB identity token so files remain +associated with the same identity after reload. If a fresh database rejects the +token, the client obtains a new anonymous identity. Existing data remains with +its original identity. + +## Security and deployment boundaries + +- Reducers, views, and private-byte procedures enforce ownership. Browser controls + provide presentation only. +- Validate path normalization, MIME metadata, file size, and ownership before + accepting writes or moves. +- Treat uploaded bytes as untrusted. Production systems need content-disposition + policy, safe MIME handling, malware scanning where appropriate, and defenses + against active HTML/SVG content. +- Public file URLs are bearer-readable by design. Do not expose confidential files + by marking them public. +- The example buffers whole files and generated ZIPs in memory. Production limits + should account for per-file size, concurrent requests, and aggregate memory. +- The included proxy is a local development server. Production needs TLS, explicit + binding, request limits, origin policy, and process supervision. + +## Build and verification + +```powershell +pnpm --dir spacetimedb run build +pnpm run check +pnpm run build +``` + +For a release smoke test, use two independent browser identities and verify: + +1. Upload, preview, download, rename, move, and delete each supported small file + type. +2. Folder drag-and-drop and multi-selection perform the intended operation once. +3. Private file bytes and summaries are invisible to the other identity. +4. A public file is reachable through `/files?id=`; an owner-only file returns 403. +5. Oversized uploads and invalid or conflicting paths fail atomically. +6. Refresh preserves the owning development identity unless the database was + reset. + +## Troubleshooting + +- **A preview is empty:** inspect the procedure failure and verify the connected + identity owns the file. +- **A public link returns an error:** confirm `STDB_HTTP` and `SPACETIMEDB_DB_NAME` + target the database used by `STDB_URI`. +- **An upload exceeds the limit:** keep example files below 4 MB; use an external + object store for larger production assets. +- **Files disappear after a fresh publish:** `build:module:fresh` + replaces the local database and all of its rows. + +## Important files + +- `spacetimedb/src/index.ts` - Files registration, folders, scoped views, and private reads. +- `src/app.ts` - file-manager state, uploads, previews, downloads, and subscriptions. +- `server.ts` - static development server and public-file proxy. +- `public/index.html` - Vault interface. +- `public/styles.css` - Vault presentation. diff --git a/spacetime-files-ts/example/package.json b/spacetime-files-ts/example/package.json new file mode 100644 index 00000000000..cdb15d75c86 --- /dev/null +++ b/spacetime-files-ts/example/package.json @@ -0,0 +1,30 @@ +{ + "name": "spacetime-files-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings/app --module-path ./spacetimedb -y", + "build:module": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local && pnpm run spacetime:generate && pnpm run build:app", + "build:module:fresh": "pnpm --dir spacetimedb run build && pnpm --dir spacetimedb run publish:local:reset && pnpm run spacetime:generate && pnpm run build:app", + "build:app": "pnpm run check && esbuild src/app.ts --bundle --format=esm --outfile=public/app.js --target=es2022 --sourcemap", + "check": "tsc --noEmit", + "test": "tsx scripts/test-downloads.ts && tsx scripts/test-selection.ts", + "build": "pnpm run spacetime:generate && pnpm run build:app", + "dev": "pnpm run build && tsx server.ts" + }, + "dependencies": { + "@spacetimedb/submodule-shared": "workspace:*", + "@spacetimedb/files": "workspace:*", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.10.2", + "esbuild": "^0.28.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-files-ts/example/public/index.html b/spacetime-files-ts/example/public/index.html new file mode 100644 index 00000000000..05086e1cb76 --- /dev/null +++ b/spacetime-files-ts/example/public/index.html @@ -0,0 +1,594 @@ + + + + + + + SpacetimeDB Vault + + + + + + +
+
+
+ +
+

Vault

+ File manager +
+
+
+ +
+
+
+

Folders

+ +
+
+
    +
    +
    +
    + +
    +
    +
    + + +
    + + + + + + +
    +
    + +
    +
    + + + + + + +
    +
    +
      + +
      +
      + +
      +
      +

      Details

      + +
      +
      +
      +
      + + +
      + +
      + +
      + +
      + Drop to upload to/ +
      + +
      + + + +
      + + + + diff --git a/spacetime-files-ts/example/public/styles.css b/spacetime-files-ts/example/public/styles.css new file mode 100644 index 00000000000..7c1c0ca354e --- /dev/null +++ b/spacetime-files-ts/example/public/styles.css @@ -0,0 +1,1268 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap'); + +:root { + /* Tokens mirror spacetimedb.com/app/styles/variables.css */ + --font-inter: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-ibm: 'IBM Plex Mono', ui-monospace, monospace; + + --color-green: #4cf490; + --color-green-10: #4cf4901a; + --color-green-20: #4cf49033; + --color-white: #d7d8d9; + --color-yellow: #fbdc8e; + --color-blue: #02befa; + --color-purple: #a880ff; + --color-red: #ff4c4c; + --color-red-10: #ff4c4c1a; + --color-red-20: #ff4c4c33; + + --color-n1: #e6e9f0; + --color-n2: #ced3e0; + --color-n3: #b6c0cf; + --color-n4: #6f7987; + --color-n5: #363840; + --color-n8: #060606; + + --color-shade1: #162d38; + --color-shade4: #121e24; + --color-shade5: #0f191f; + --color-shade6: #0e161a; + --color-shade7: #0b1114; + --color-shade8: #0b0e12; + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; + + /* One source of truth for the file-list grid so headers align with rows. */ + --row-grid: 26px minmax(0, 1fr) 76px 92px 112px auto; +} + +* { + box-sizing: border-box; +} +html, +body { + margin: 0; + height: 100%; +} +body { + font-family: var(--font-inter); + color: var(--color-white); + background: var(--color-shade7); + overflow: hidden; + -webkit-font-smoothing: antialiased; +} +::selection { + background: var(--color-green); + color: var(--color-n8); +} +a { + color: var(--color-green); + text-decoration: none; +} + +* { + scrollbar-width: thin; + scrollbar-color: var(--color-shade4) transparent; +} +*::-webkit-scrollbar { + width: 7px; + height: 7px; +} +*::-webkit-scrollbar-track { + background: transparent; +} +*::-webkit-scrollbar-thumb { + background: var(--color-shade4); + border-radius: 4px; +} +*::-webkit-scrollbar-thumb:hover { + background: var(--color-shade1); +} + +button, +input, +select { + font: inherit; +} +button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 34px; + padding: 0 14px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: var(--color-shade7); + color: var(--color-n2); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: + background 0.16s, + border-color 0.16s, + color 0.16s; +} +button:hover:not(:disabled) { + background: var(--color-shade4); + color: var(--color-white); +} +button:focus-visible { + outline: 2px solid var(--color-green); + outline-offset: 2px; +} +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +button.primary { + background: var(--color-n3); + border-color: var(--color-n3); + color: var(--color-n8); +} +button.primary:hover:not(:disabled) { + background: var(--color-white); + border-color: var(--color-white); +} +button.primary:active:not(:disabled) { + background: var(--color-green); + border-color: var(--color-green); +} +button.danger { + color: var(--color-n3); +} +button.danger:hover:not(:disabled) { + background: var(--color-red-10); + border-color: var(--color-red-20); + color: var(--color-red); +} +button.icon { + min-height: 30px; + width: 30px; + padding: 0; + color: var(--color-n4); +} +button.icon:hover:not(:disabled) { + color: var(--color-white); +} +button.icon.danger:hover:not(:disabled) { + color: var(--color-red); +} +button.icon.active { + color: var(--color-green); + border-color: #1f4a34; + background: var(--color-green-10); +} +button svg, +.badge svg, +.brand-mark svg { + width: 16px; + height: 16px; + flex: 0 0 auto; +} +.ico { + width: 16px; + height: 16px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +input, +select { + width: 100%; + min-height: 38px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: var(--color-shade8); + color: var(--color-white); + padding: 0 12px; + outline: none; + transition: + border-color 0.15s, + box-shadow 0.15s; +} +input::placeholder { + color: var(--color-n4); +} +input:focus, +select:focus { + border-color: var(--color-green); + box-shadow: 0 0 0 3px var(--color-green-10); +} +input[type='checkbox'] { + width: 15px; + height: 15px; + min-height: 0; + margin: 0; + accent-color: var(--color-green); + cursor: pointer; +} + +.shell { + width: min(1320px, calc(100% - 28px)); + height: calc(100dvh - 28px); + margin: 14px auto; + display: grid; + grid-template-rows: auto 1fr auto; + gap: 12px; +} + +/* Topbar */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border: 1px solid #17303b; + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #0d1920, #0b1319); + box-shadow: inset 0 1px 0 #26435166; + padding: 11px 16px; +} +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.brand-mark { + flex: 0 0 auto; + width: 36px; + height: 36px; + border-radius: var(--radius); + display: grid; + place-items: center; + color: var(--color-green); + background: var(--color-green-10); + border: 1px solid var(--color-green-20); +} +.brand-mark svg { + width: 19px; + height: 19px; +} +.brand-text { + display: grid; + gap: 1px; + min-width: 0; +} +.brand-text h1 { + margin: 0; + font-size: 15px; + font-weight: 700; + line-height: 1.2; + color: var(--color-n1); +} +.brand-text span { + color: var(--color-n4); + font-size: 12px; +} + +/* Layout */ +.main { + min-height: 0; + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 12px; +} +.main.details-open { + grid-template-columns: 280px minmax(0, 1fr) 300px; +} +.main:not(.details-open) .details-panel { + display: none; +} +.panel { + min-height: 0; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + display: flex; + flex-direction: column; + overflow: hidden; +} +.panel-head { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 13px 14px; + border-bottom: 1px solid var(--color-shade4); +} +.panel-head h2 { + margin: 0; + font-family: var(--font-ibm); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--color-n4); +} +.panel-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 10px; +} +.storage { + flex: 0 0 auto; + padding: 10px 14px; + border-top: 1px solid var(--color-shade4); + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; +} + +.toolbar { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 11px 14px; + border-bottom: 1px solid var(--color-shade4); + min-height: 57px; +} +.toolbar-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex: 1; + min-width: 0; +} +.crumbs { + min-width: 0; + flex: 1; + display: flex; + align-items: center; + gap: 2px; + overflow: hidden; + white-space: nowrap; +} +.crumbs button { + min-height: 28px; + padding: 0 8px; + border-color: transparent; + background: transparent; + color: var(--color-n4); + font-weight: 600; +} +.crumbs button:hover { + background: var(--color-shade4); + color: var(--color-white); +} +.crumbs button:last-child { + color: var(--color-white); +} +.crumbs .sep { + color: var(--color-n5); + font-family: var(--font-ibm); +} +.crumbs .search-label { + color: var(--color-n3); + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 6px; +} +.actions { + display: inline-flex; + align-items: center; + gap: 8px; +} +.search { + width: 190px; + min-height: 32px; + flex: 0 1 auto; +} +.zoom { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 30px; + padding: 0 10px; + border: 1px solid var(--color-shade4); + border-radius: var(--radius-sm); + background: var(--color-shade8); + color: var(--color-n4); +} +.zoom[hidden] { + display: none; +} +.zoom .z-sm { + width: 9px; + height: 9px; + flex: 0 0 auto; +} +.zoom .z-lg { + width: 14px; + height: 14px; + flex: 0 0 auto; +} +.tile-slider { + appearance: auto; + width: 92px; + min-height: 0; + height: 16px; + padding: 0; + border: 0; + background: transparent; + accent-color: var(--color-green); + cursor: pointer; +} +.tile-slider:focus { + box-shadow: none; +} + +/* Bulk-selection bar (swaps in for the toolbar content) */ +.bulkbar { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} +.bulkbar[hidden] { + display: none; +} +.bulk-count { + color: var(--color-green); + font-family: var(--font-ibm); + font-size: 12px; + white-space: nowrap; + margin-right: 4px; +} +.bulkbar .spacer { + flex: 1; +} +.bulkbar button { + min-height: 30px; + padding: 0 11px; + font-size: 12px; +} + +/* Folder tree */ +.tree-list, +.file-list { + list-style: none; + margin: 0; + padding: 0; +} +.tree-btn { + width: 100%; + justify-content: flex-start; + min-height: 32px; + border-color: transparent; + background: transparent; + color: var(--color-n3); + font-weight: 600; +} +.tree-btn:hover { + background: var(--color-shade4); + color: var(--color-white); +} +.tree-btn.active { + color: var(--color-green); + background: var(--color-green-10); +} +.tree-btn .ico { + color: var(--color-yellow); +} +.tree-btn.active .ico { + color: var(--color-green); +} +.tree-btn span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tree-btn.drag-over, +.row.drag-over, +.tile.drag-over { + background: var(--color-green-10); + box-shadow: inset 0 0 0 1px var(--color-green); +} + +/* File list header (sortable) */ +.list-head { + flex: 0 0 auto; + display: grid; + grid-template-columns: var(--row-grid); + gap: 10px; + align-items: center; + padding: 6px 20px 6px 20px; + border-bottom: 1px solid var(--color-shade4); +} +.list-head.grid-mode { + display: none; +} +.list-head .sel { + display: inline-flex; + justify-content: center; +} +.list-head button { + justify-content: flex-start; + min-height: 26px; + padding: 0 4px; + border-color: transparent; + background: transparent; + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.list-head button:hover { + color: var(--color-n2); + background: transparent; +} +.list-head button.sorted { + color: var(--color-green); +} + +/* File rows (list view) */ +.row { + display: grid; + grid-template-columns: var(--row-grid); + gap: 10px; + align-items: center; + min-height: 52px; + border-radius: var(--radius-sm); + padding: 6px 10px; + transition: background 0.14s; + cursor: default; +} +.row:hover { + background: var(--color-shade4); +} +.row.focused { + background: var(--color-shade4); +} +.row.selected { + background: var(--color-green-10); +} +.row .sel { + display: inline-flex; + justify-content: center; + visibility: hidden; +} +.row:hover .sel, +.row.selected .sel, +.list.has-selection .row .sel { + visibility: visible; +} +@media (hover: none) { + .row .sel { + visibility: visible; + } +} +.row-name { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--color-white); + font-weight: 600; +} +.row-name .label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.kind { + width: 30px; + height: 30px; + flex: 0 0 auto; + border-radius: var(--radius-sm); + display: grid; + place-items: center; + background: var(--color-shade7); + border: 1px solid var(--color-shade4); +} +.kind.folder { + color: var(--color-yellow); +} +.kind.image { + color: var(--color-blue); +} +.kind.text { + color: var(--color-green); +} +.kind.media { + color: var(--color-purple); +} +.kind.generic { + color: var(--color-n3); +} +.meta { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 26px; + padding: 0 9px; + border-radius: 999px; + border: 1px solid var(--color-shade4); + background: var(--color-shade7); + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + cursor: pointer; + transition: + background 0.14s, + border-color 0.14s, + color 0.14s; +} +.badge svg { + width: 13px; + height: 13px; +} +.badge:hover { + border-color: var(--color-n5); + color: var(--color-n2); +} +.badge.public { + color: var(--color-green); + border-color: #1f4a34; + background: var(--color-green-10); +} +.badge.private { + color: var(--color-n3); +} +.row-actions { + display: inline-flex; + justify-content: flex-end; + gap: 3px; +} +/* Secondary actions reveal on hover on pointer devices; always shown on touch. */ +.row-actions .secondary { + opacity: 0; + transition: opacity 0.14s; +} +.row:hover .row-actions .secondary, +.row:focus-within .row-actions .secondary { + opacity: 1; +} +@media (hover: none) { + .row-actions .secondary { + opacity: 1; + } +} + +/* Grid view */ +.list.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--tile, 150px), 1fr)); + gap: 10px; + align-content: start; +} +.tile { + position: relative; + display: grid; + gap: 0; + border: 1px solid var(--color-shade4); + border-radius: var(--radius); + background: var(--color-shade7); + overflow: hidden; + transition: + border-color 0.14s, + background 0.14s; + cursor: default; +} +.tile:hover { + border-color: var(--color-shade1); +} +.tile.focused { + border-color: var(--color-n5); + background: var(--color-shade4); +} +.tile.selected { + border-color: #1f4a34; + background: var(--color-green-10); +} +.tile .sel { + position: absolute; + top: 7px; + left: 7px; + z-index: 2; + visibility: hidden; +} +.tile:hover .sel, +.tile.selected .sel, +.list.has-selection .tile .sel { + visibility: visible; +} +@media (hover: none) { + .tile .sel { + visibility: visible; + } +} +.tile .vis-dot { + position: absolute; + top: 7px; + right: 7px; + z-index: 2; + width: 22px; + height: 22px; + border-radius: 50%; + display: grid; + place-items: center; + background: rgba(6, 10, 12, 0.72); + color: var(--color-n3); +} +.tile .vis-dot svg { + width: 12px; + height: 12px; +} +.tile .vis-dot.public { + color: var(--color-green); +} +.thumb { + aspect-ratio: 4 / 3; + display: grid; + place-items: center; + background: var(--color-shade8); + border-bottom: 1px solid var(--color-shade4); + overflow: hidden; +} +.thumb svg { + width: 34px; + height: 34px; + opacity: 0.85; +} +.thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} +.tile-name { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + padding: 8px 10px; + font-size: 12px; + font-weight: 600; +} +.tile-name svg { + width: 14px; + height: 14px; + flex: 0 0 auto; +} +.tile-name .label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tile-name.folder svg { + color: var(--color-yellow); +} +.tile-name.image svg { + color: var(--color-blue); +} +.tile-name.text svg { + color: var(--color-green); +} +.tile-name.media svg { + color: var(--color-purple); +} +.tile-name.generic svg { + color: var(--color-n3); +} + +/* Context menu */ +.ctx { + position: fixed; + z-index: 65; + min-width: 190px; + display: none; + padding: 5px; + border: 1px solid var(--color-shade1); + border-radius: var(--radius); + background: var(--color-shade5); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55); +} +.ctx.open { + display: grid; +} +.ctx button { + justify-content: flex-start; + min-height: 32px; + padding: 0 10px; + border-color: transparent; + background: transparent; + font-size: 13px; + font-weight: 500; + gap: 10px; +} +.ctx button svg { + color: var(--color-n4); +} +.ctx button:hover { + background: var(--color-shade4); +} +.ctx button.danger:hover { + background: var(--color-red-10); + color: var(--color-red); +} +.ctx button.danger:hover svg { + color: var(--color-red); +} +.ctx .sep { + height: 1px; + background: var(--color-shade4); + margin: 4px 2px; +} + +.empty { + margin: 8px; + border: 1px dashed var(--color-shade4); + border-radius: var(--radius); + padding: 34px 20px; + display: grid; + justify-items: center; + gap: 12px; + color: var(--color-n4); + text-align: center; + font-size: 13px; + line-height: 1.5; +} +.empty svg { + width: 34px; + height: 34px; + color: var(--color-n5); + stroke-width: 1.5; +} +.empty strong { + color: var(--color-n2); + font-weight: 600; + font-size: 14px; +} + +/* Details panel (Drive-style info sidebar, toggled) */ +.d-thumb { + aspect-ratio: 4 / 3; + display: grid; + place-items: center; + border: 1px solid var(--color-shade4); + border-radius: var(--radius); + background: var(--color-shade8); + overflow: hidden; + margin-bottom: 12px; +} +.d-thumb svg { + width: 38px; + height: 38px; + opacity: 0.85; +} +.d-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} +.d-name { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + font-weight: 700; + color: var(--color-n1); + margin-bottom: 12px; + overflow-wrap: anywhere; +} +.d-name svg { + width: 16px; + height: 16px; + flex: 0 0 auto; + color: var(--color-n3); +} +.details { + display: grid; + gap: 8px; +} +.details div { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 10px; + font-size: 12px; +} +.details span { + flex: 0 0 auto; + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.details b { + color: var(--color-n2); + font-weight: 500; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.details b.mono { + font-family: var(--font-ibm); + font-size: 11px; +} +.details button.linkish { + min-height: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--color-green); + font-size: 12px; + font-weight: 500; +} +.details button.linkish:hover { + background: transparent; + text-decoration: underline; +} + +/* Viewer stage content (full-screen preview overlay) */ +.lb-stage pre { + margin: 0; + width: min(860px, calc(100vw - 48px)); + max-height: calc(100vh - 140px); + overflow: auto; + border: 1px solid var(--color-shade1); + border-radius: var(--radius); + padding: 18px; + background: var(--color-shade6); + color: var(--color-n2); + font: 13px/1.6 var(--font-ibm); + white-space: pre-wrap; +} +.lb-stage audio { + width: min(480px, calc(100vw - 48px)); +} +.lb-stage video { + max-width: calc(100vw - 48px); + max-height: calc(100vh - 140px); + border-radius: var(--radius); + background: var(--color-shade8); +} +.lb-stage iframe.pdf { + width: min(900px, calc(100vw - 48px)); + height: calc(100vh - 130px); + border: 1px solid var(--color-shade1); + border-radius: var(--radius); + background: #fff; +} +.lb-stage .notice { + display: grid; + gap: 14px; + justify-items: center; + padding: 34px 44px; + border: 1px dashed var(--color-shade1); + border-radius: var(--radius-lg); + background: var(--color-shade6); + color: var(--color-n3); + font-size: 14px; + text-align: center; +} +.lb-stage .notice svg { + width: 34px; + height: 34px; + color: var(--color-n4); +} + +/* Dialog */ +.dialog { + position: fixed; + inset: 0; + display: none; + place-items: center; + background: rgba(3, 8, 10, 0.62); + backdrop-filter: blur(2px); + z-index: 30; +} +.dialog.open { + display: grid; +} +.card { + width: min(480px, calc(100% - 32px)); + border: 1px solid var(--color-shade1); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, var(--color-shade5), var(--color-shade6)); + box-shadow: 0 30px 90px rgba(0, 0, 0, 0.55); + animation: card-in 0.18s ease-out; +} +@keyframes card-in { + from { + opacity: 0; + transform: translateY(-8px) scale(0.99); + } + to { + opacity: 1; + transform: none; + } +} +.card-head { + padding: 16px; + border-bottom: 1px solid var(--color-shade4); + font-weight: 700; + color: var(--color-n1); +} +.card-body { + padding: 16px; + display: grid; + gap: 12px; +} +.card-body p { + margin: 0; + color: var(--color-n3); + font-size: 13px; + line-height: 1.5; +} +.card-body.wide { + max-height: min(70vh, 520px); + overflow: auto; +} +.card-actions { + padding: 16px; + display: flex; + justify-content: flex-end; + gap: 8px; + border-top: 1px solid var(--color-shade4); +} +label { + display: grid; + gap: 7px; + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +/* OS-file drag: viewport ring + target pill (UI stays visible so folders + can be targeted directly). */ +body.dragging-files::after { + content: ''; + position: fixed; + inset: 6px; + z-index: 39; + border: 2px dashed var(--color-green); + border-radius: var(--radius-lg); + pointer-events: none; +} +.drop-hint { + position: fixed; + bottom: 22px; + left: 50%; + transform: translateX(-50%); + z-index: 40; + display: none; + align-items: center; + gap: 8px; + padding: 9px 15px; + border: 1px solid #1f4a34; + border-radius: 999px; + background: var(--color-shade5); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5); + color: var(--color-green); + font-size: 13px; + font-weight: 600; + pointer-events: none; + white-space: nowrap; +} +.drop-hint b { + font-family: var(--font-ibm); + font-weight: 500; + color: var(--color-n2); +} +body.dragging-files .drop-hint { + display: inline-flex; +} + +/* Lightbox (image zoom) */ +.lightbox { + position: fixed; + inset: 0; + display: none; + z-index: 60; + background: rgba(3, 8, 10, 0.88); + backdrop-filter: blur(3px); +} +.lightbox.open { + display: block; +} +.lb-controls { + position: absolute; + top: 14px; + right: 14px; + z-index: 2; + display: flex; + gap: 6px; + padding: 6px; + border: 1px solid var(--color-shade1); + border-radius: var(--radius); + background: var(--color-shade6); +} +.lb-controls button { + min-height: 30px; + padding: 0 11px; + font-size: 12px; +} +.lb-name { + position: absolute; + top: 16px; + left: 18px; + z-index: 2; + display: grid; + gap: 3px; + max-width: 44vw; +} +.lb-name strong { + color: var(--color-n1); + font-size: 14px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lb-name span { + color: var(--color-n4); + font-family: var(--font-ibm); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.lb-stage { + position: absolute; + inset: 0; + overflow: auto; + display: grid; + place-items: center; + padding: 24px; +} +.lb-stage img { + display: block; +} +.lb-stage img.fit { + max-width: calc(100vw - 48px); + max-height: calc(100vh - 48px); +} + +/* Toast */ +#toast { + position: fixed; + left: 50%; + bottom: 22px; + transform: translateX(-50%); + z-index: 70; + display: grid; + gap: 8px; +} +.toast { + border: 1px solid var(--color-shade1); + border-radius: var(--radius); + background: var(--color-shade5); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5); + padding: 11px 14px; + color: var(--color-n2); + font-size: 13px; + animation: card-in 0.18s ease-out; +} +.toast.ok { + border-color: #1f4a34; + color: var(--color-green); +} +.toast.err { + border-color: var(--color-red-20); + color: var(--color-red); +} + +/* Built-on footer */ +.built-on { + display: flex; + align-items: center; + justify-content: center; + gap: 11px; + min-height: 52px; + border-top: 1px solid var(--color-shade4); + color: var(--color-n4); +} +.built-on span { + font-family: var(--font-ibm); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; +} +.built-on img { + height: 26px; + opacity: 0.85; +} + +@media (prefers-reduced-motion: reduce) { + *, + ::before, + ::after { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + } +} + +@media (max-width: 1080px) { + .main, + .main.details-open { + grid-template-columns: 240px minmax(0, 1fr); + } + .details-panel { + display: none; + } +} +@media (max-width: 760px) { + body { + overflow: auto; + } + .shell { + height: auto; + min-height: calc(100dvh - 20px); + width: calc(100% - 20px); + margin: 10px auto; + } + .main { + grid-template-columns: 1fr; + } + .panel { + min-height: 260px; + } + :root { + --row-grid: 26px minmax(0, 1fr) auto; + } + .row .meta, + .row .badge-cell, + .list-head .meta-col { + display: none; + } + .toolbar-main { + flex-wrap: wrap; + } + .search { + width: 100%; + order: 3; + } +} diff --git a/spacetime-files-ts/example/scripts/test-downloads.ts b/spacetime-files-ts/example/scripts/test-downloads.ts new file mode 100644 index 00000000000..f6e3b9f0739 --- /dev/null +++ b/spacetime-files-ts/example/scripts/test-downloads.ts @@ -0,0 +1,120 @@ +import * as assert from 'node:assert/strict'; +import type { FileSummary } from '../src/module_bindings/app/types'; +import { + ARCHIVE_ENTRY_COUNT_MAX, + ARCHIVE_FILE_COUNT_MAX, + ARCHIVE_TOTAL_BYTES_MAX, + archiveSelectionError, +} from '../src/downloads'; +import { FileViewer } from '../src/viewer'; + +const fileWithSize = (size: bigint): FileSummary => ({ size }) as FileSummary; + +assert.equal(archiveSelectionError([fileWithSize(1024n)]), undefined); +assert.match( + archiveSelectionError( + Array.from({ length: ARCHIVE_FILE_COUNT_MAX + 1 }, () => fileWithSize(0n)) + ) ?? '', + /at most 250 files/ +); +assert.match( + archiveSelectionError([], ARCHIVE_ENTRY_COUNT_MAX + 1) ?? '', + /1000 entry limit/ +); +assert.match( + archiveSelectionError([fileWithSize(BigInt(ARCHIVE_TOTAL_BYTES_MAX) + 1n)]) ?? + '', + /64 MiB limit/ +); + +console.log('files download tests passed'); + +type TestElement = { + classList: { + add(name: string): void; + remove(name: string): void; + contains(name: string): boolean; + }; + innerHTML: string; + style: { display: string }; + textContent: string | null; + title: string; + disabled: boolean; + querySelector(): null; +}; + +function testElement(): TestElement { + const classes = new Set(); + return { + classList: { + add: name => classes.add(name), + remove: name => classes.delete(name), + contains: name => classes.has(name), + }, + innerHTML: '', + style: { display: '' }, + textContent: null, + title: '', + disabled: false, + querySelector: () => null, + }; +} + +async function testClosingViewerCancelsPendingLoad(): Promise { + const ids = [ + 'lightbox', + 'lb-stage', + 'lb-title', + 'lb-meta', + 'lb-prev', + 'lb-next', + 'lb-out', + 'lb-in', + 'lb-fit', + 'lb-full', + ]; + const elements = new Map(ids.map(id => [id, testElement()])); + const previousDocument = globalThis.document; + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + getElementById(id: string) { + return elements.get(id) ?? null; + }, + }, + }); + + let resolveBlob: ((blob: Blob) => void) | undefined; + const viewer = new FileViewer({ + loadBlob: () => + new Promise(resolve => { + resolveBlob = resolve; + }), + download: async () => undefined, + iconHtml: () => '', + }); + const row = { + path: '/notes.txt', + mimeType: 'text/plain', + size: 5n, + visibility: 'owner', + updatedAt: { microsSinceUnixEpoch: 1_000n }, + } as FileSummary; + + try { + const opening = viewer.open(row.path, [row]); + viewer.close(); + resolveBlob?.(new Blob(['hello'], { type: 'text/plain' })); + await opening; + assert.equal(elements.get('lb-stage')?.innerHTML, ''); + assert.equal(viewer.path, null); + } finally { + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: previousDocument, + }); + } +} + +await testClosingViewerCancelsPendingLoad(); +console.log('files viewer tests passed'); diff --git a/spacetime-files-ts/example/scripts/test-selection.ts b/spacetime-files-ts/example/scripts/test-selection.ts new file mode 100644 index 00000000000..488ddbb5540 --- /dev/null +++ b/spacetime-files-ts/example/scripts/test-selection.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { VaultSelection } from '../src/selection'; + +const selection = new VaultSelection(); +selection.setEntries([ + { type: 'folder', path: '/docs' }, + { type: 'file', path: '/a.txt' }, + { type: 'file', path: '/b.txt' }, + { type: 'file', path: '/c.txt' }, +]); + +assert.equal(selection.focus('/a.txt'), true); +assert.equal(selection.focusPath, '/a.txt'); +assert.equal(selection.focus('/a.txt'), false); + +selection.toggle('/a.txt'); +selection.selectRange('/c.txt'); +assert.deepEqual([...selection.selected], ['/a.txt', '/b.txt', '/c.txt']); + +selection.toggle('/b.txt'); +assert.deepEqual([...selection.selected], ['/a.txt', '/c.txt']); + +selection.selected.clear(); +selection.setAnchor('/c.txt'); +selection.selectRange('/a.txt'); +assert.deepEqual([...selection.selected], ['/a.txt', '/b.txt', '/c.txt']); + +selection.selected.clear(); +selection.selected.add('/a.txt'); +selection.selected.add('/c.txt'); +selection.setAnchor('/missing.txt'); +selection.selectRange('/b.txt'); +assert.deepEqual([...selection.selected], ['/a.txt', '/c.txt', '/b.txt']); + +selection.focus('/docs'); +selection.prune( + new Set(['/b.txt', '/c.txt']), + new Set(['/docs', '/b.txt', '/c.txt']) +); +assert.deepEqual([...selection.selected], ['/c.txt', '/b.txt']); +assert.equal(selection.focusPath, '/docs'); + +selection.prune(new Set(['/b.txt', '/c.txt']), new Set(['/b.txt', '/c.txt'])); +assert.equal(selection.focusPath, null); + +selection.clearFocus(); +assert.equal(selection.focusPath, null); + +console.log('files selection tests passed'); diff --git a/spacetime-files-ts/example/server.ts b/spacetime-files-ts/example/server.ts new file mode 100644 index 00000000000..e3b0bf79603 --- /dev/null +++ b/spacetime-files-ts/example/server.ts @@ -0,0 +1,106 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { existsSync, readFileSync } from 'node:fs'; +import express, { type Request, type Response } from 'express'; +import dotenv from 'dotenv'; +import { exampleUiAssetsDir } from '@spacetimedb/submodule-shared/server'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const inheritedEnv = new Set(Object.keys(process.env)); + +function loadEnv(pathname: string, override: boolean): void { + if (!existsSync(pathname)) return; + + const parsed = dotenv.parse(readFileSync(pathname)); + for (const [key, value] of Object.entries(parsed)) { + if (value.trim() === '') continue; + if (inheritedEnv.has(key)) continue; + if (override || process.env[key] === undefined) { + process.env[key] = value; + } + } +} + +loadEnv(path.resolve(__dirname, '..', '..', '.env'), false); +loadEnv(path.resolve(__dirname, '..', '.env'), false); +loadEnv(path.resolve(__dirname, '.env'), true); + +const PORT = Number.parseInt(process.env.PORT ?? '8799', 10); +const HOST = process.env.HOST?.trim() || '127.0.0.1'; +const STDB_URI = process.env.STDB_URI ?? 'ws://127.0.0.1:3000'; +const STDB_HTTP = process.env.STDB_HTTP ?? 'http://127.0.0.1:3000'; +const DB_NAME = process.env.SPACETIMEDB_DB_NAME ?? 'spacetime-files-example'; + +const app = express(); +app.use(express.json({ limit: '256kb' })); + +function proxyStdbRoute(prefix: string) { + return async (req: Request, res: Response) => { + const mountedUrl = req.url.startsWith('/?') ? req.url.slice(1) : req.url; + let fullPath = `${prefix}${mountedUrl}`; + if (prefix === '/files' && mountedUrl.startsWith('/')) { + const qIdx = mountedUrl.indexOf('?'); + const rawPath = qIdx < 0 ? mountedUrl : mountedUrl.slice(0, qIdx); + const originalQuery = qIdx < 0 ? '' : mountedUrl.slice(qIdx + 1); + const pathQuery = `path=${encodeURIComponent(decodeURIComponent(rawPath))}`; + fullPath = `/files?${originalQuery ? `${pathQuery}&${originalQuery}` : pathQuery}`; + } + const qIdx = fullPath.indexOf('?'); + const routePath = qIdx < 0 ? fullPath : fullPath.slice(0, qIdx); + const query = qIdx < 0 ? '' : fullPath.slice(qIdx); + const upstreamUrl = `${STDB_HTTP}/v1/database/${DB_NAME}/route${routePath}${query}`; + + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (typeof value === 'string') headers[key] = value; + else if (Array.isArray(value)) headers[key] = value.join(', '); + } + delete headers.host; + delete headers['content-length']; + + try { + const upstream = await fetch(upstreamUrl, { + method: req.method, + headers, + redirect: 'manual', + }); + res.status(upstream.status); + upstream.headers.forEach((value, key) => { + const lower = key.toLowerCase(); + if (lower === 'transfer-encoding' || lower === 'content-encoding') + return; + res.setHeader(key, value); + }); + if (req.method === 'HEAD') { + res.end(); + return; + } + res.send(Buffer.from(await upstream.arrayBuffer())); + } catch (err) { + res.status(502).json({ + error: 'upstream_unreachable', + detail: (err as Error).message, + }); + } + }; +} + +app.use('/files', proxyStdbRoute('/files')); +app.use('/assets', express.static(exampleUiAssetsDir)); +app.use(express.static(path.join(__dirname, 'public'))); + +app.get('/api/health', (_req: Request, res: Response) => { + res.json({ ok: true, databaseName: DB_NAME }); +}); + +app.get('/api/config', (_req: Request, res: Response) => { + res.json({ spacetimeUri: STDB_URI, databaseName: DB_NAME }); +}); + +app.listen(PORT, HOST, () => { + console.log(`Vault example running at http://${HOST}:${PORT}`); + console.log(` STDB ws -> ${STDB_URI}`); + console.log(` STDB http-> ${STDB_HTTP} (proxy /files/*)`); + console.log(` Database -> ${DB_NAME}`); +}); diff --git a/spacetime-files-ts/example/spacetimedb/.npmrc b/spacetime-files-ts/example/spacetimedb/.npmrc new file mode 100644 index 00000000000..44bdf80d1df --- /dev/null +++ b/spacetime-files-ts/example/spacetimedb/.npmrc @@ -0,0 +1 @@ +minimum-release-age=1440 diff --git a/spacetime-files-ts/example/spacetimedb/package.json b/spacetime-files-ts/example/spacetimedb/package.json new file mode 100644 index 00000000000..8782895e2a8 --- /dev/null +++ b/spacetime-files-ts/example/spacetimedb/package.json @@ -0,0 +1,19 @@ +{ + "name": "spacetime-files-example-module", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish:local": "spacetime publish --server local --yes spacetime-files-example", + "publish:local:reset": "spacetime publish --server local --yes --delete-data=always spacetime-files-example" + }, + "dependencies": { + "@spacetimedb/files": "workspace:*", + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-files-ts/example/spacetimedb/src/index.ts b/spacetime-files-ts/example/spacetimedb/src/index.ts new file mode 100644 index 00000000000..e8e477e9e9e --- /dev/null +++ b/spacetime-files-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,444 @@ +import { + Router, + SenderError, + schema, + table, + t, + type InferSchema, + type ReducerCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { + FILE_VISIBILITY_OWNER, + FILE_VISIBILITY_PUBLIC, + FILE_BYTES_MAX, + fileSummary, + fileSha256Hex, + createFileHttpHandler, + ownerPathKey, + readFileBytesParams, + readFileBytesReturn, + readFileBytes, + validateMimeType, +} from '@spacetimedb/files/submodule'; +import * as files from '@spacetimedb/files/submodule'; + +const PATH_MAX = 1024; +const NAME_MAX = 128; +const VALID_VISIBILITIES = new Set([ + FILE_VISIBILITY_OWNER, + FILE_VISIBILITY_PUBLIC, +]); + +const folder = table( + { + name: 'folder', + public: false, + indexes: [ + { + accessor: 'ownerPath', + algorithm: 'btree', + columns: ['ownerUserId', 'path'] as const, + }, + ] as const, + }, + { + // Reducers enforce per-owner folder uniqueness. + id: t.u64().primaryKey().autoInc(), + ownerUserId: t.string().index(), + path: t.string(), + name: t.string(), + parentPath: t.string().index(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), + } +); + +const spacetimedb = schema({ + files, + folder, +}); +export default spacetimedb; + +type Schema = InferSchema; +type Tx = ReducerCtx; + +const folderRow = folder.rowType; + +function senderError(message: string): never { + throw new SenderError(message); +} + +function ownerUserId(ctx: { sender: { toHexString(): string } }): string { + return ctx.sender.toHexString(); +} + +function normalizePath(input: string, kind: 'file' | 'folder'): string { + let path = input.trim().replace(/\\/g, '/').replace(/\/+/g, '/'); + if (!path.startsWith('/')) path = `/${path}`; + if (path.length > 1 && path.endsWith('/')) + senderError('vault.invalid_path:trailing_slash'); + if (path.length === 0 || path.length > PATH_MAX) + senderError('vault.invalid_path:length'); + if (kind === 'file' && path === '/') senderError('vault.invalid_file_path'); + const parts = path.split('/').filter(Boolean); + for (const part of parts) { + if (part === '.' || part === '..') + senderError('vault.invalid_path:segment'); + if (part.trim() !== part || part.length === 0 || part.length > NAME_MAX) { + senderError('vault.invalid_path:segment'); + } + } + return path; +} + +function parentPathFor(path: string): string { + if (path === '/') return '/'; + const idx = path.lastIndexOf('/'); + return idx <= 0 ? '/' : path.slice(0, idx); +} + +function basename(path: string): string { + if (path === '/') return '/'; + return path.slice(path.lastIndexOf('/') + 1); +} + +function findOwnedFolder(tx: Tx, path: string, owner: string) { + for (const row of tx.db.folder.ownerPath.filter([owner, path])) return row; + return undefined; +} + +function assertParentFolderExists(tx: Tx, path: string, owner: string): void { + const parent = parentPathFor(path); + if (parent === '/') return; + if (!findOwnedFolder(tx, parent, owner)) + senderError(`vault.parent_not_found:${parent}`); +} + +function assertNoFolderCollision(tx: Tx, path: string, owner: string): void { + if (findOwnedFolder(tx, path, owner)) + senderError(`vault.folder_exists:${path}`); +} + +function assertNoOwnedFileCollision(tx: Tx, path: string, owner: string): void { + if (tx.db.files.file.ownerPathKey.find(ownerPathKey(owner, path))) { + senderError(`vault.file_exists:${path}`); + } +} + +function requireOwnedFolder(tx: Tx, path: string, owner: string) { + const row = findOwnedFolder(tx, path, owner); + if (!row) senderError(`vault.folder_not_found:${path}`); + return row; +} + +function requireOwnedFile(tx: Tx, path: string, owner: string) { + const row = tx.db.files.file.ownerPathKey.find(ownerPathKey(owner, path)); + if (!row) senderError(`vault.file_not_found:${path}`); + return row; +} + +function childPrefix(path: string): string { + return path === '/' ? '/' : `${path}/`; +} + +function folderHasChildren(tx: Tx, path: string, owner: string): boolean { + for (const row of tx.db.folder.parentPath.filter(path)) { + if (row.ownerUserId === owner) return true; + } + const prefix = childPrefix(path); + for (const row of tx.db.files.file.ownerUserId.filter(owner)) { + if (row.path.startsWith(prefix)) return true; + } + return false; +} + +function renameOwnedFile( + tx: Tx, + owner: string, + oldPath: string, + newPath: string +): void { + if (oldPath === newPath) return; + const row = requireOwnedFile(tx, oldPath, owner); + assertParentFolderExists(tx, newPath, owner); + assertNoFolderCollision(tx, newPath, owner); + assertNoOwnedFileCollision(tx, newPath, owner); + tx.db.files.file.id.update({ + ...row, + ownerPathKey: ownerPathKey(owner, newPath), + path: newPath, + updatedAt: tx.timestamp, + }); +} + +export const myFolders = spacetimedb.view( + { name: 'my_folders', public: true }, + t.array(folderRow), + (ctx: ViewCtx) => { + const owner = ownerUserId(ctx); + return [...ctx.db.folder.ownerUserId.filter(owner)].sort((a, b) => + a.path.localeCompare(b.path) + ); + } +); + +export const myFileSummaries = spacetimedb.view( + { name: 'my_file_summaries', public: true }, + t.array(fileSummary), + (ctx: ViewCtx) => { + const owner = ownerUserId(ctx); + const out = []; + for (const row of ctx.db.files.file.ownerUserId.filter(owner)) { + out.push({ + id: row.id, + path: row.path, + mimeType: row.mimeType, + size: row.size, + sha256Hex: row.sha256Hex, + visibility: row.visibility, + updatedAt: row.updatedAt, + }); + } + out.sort((a, b) => a.path.localeCompare(b.path)); + return out; + } +); + +export const create_folder = spacetimedb.reducer( + { path: t.string() }, + (ctx, args) => { + const owner = ownerUserId(ctx); + const path = normalizePath(args.path, 'folder'); + if (path === '/') return; + assertParentFolderExists(ctx, path, owner); + assertNoFolderCollision(ctx, path, owner); + assertNoOwnedFileCollision(ctx, path, owner); + ctx.db.folder.insert({ + id: 0n, + path, + ownerUserId: owner, + name: basename(path), + parentPath: parentPathFor(path), + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + } +); + +export const delete_folder = spacetimedb.reducer( + { path: t.string() }, + (ctx, args) => { + const owner = ownerUserId(ctx); + const path = normalizePath(args.path, 'folder'); + if (path === '/') senderError('vault.cannot_delete_root'); + const row = requireOwnedFolder(ctx, path, owner); + if (folderHasChildren(ctx, path, owner)) + senderError(`vault.folder_not_empty:${path}`); + ctx.db.folder.delete(row); + } +); + +export const rename_folder = spacetimedb.reducer( + { path: t.string(), newName: t.string() }, + (ctx, args) => { + const owner = ownerUserId(ctx); + const oldPath = normalizePath(args.path, 'folder'); + if (oldPath === '/') senderError('vault.cannot_rename_root'); + const newName = args.newName.trim(); + if (newName.length === 0 || newName.includes('/')) + senderError('vault.invalid_path:segment'); + const parent = parentPathFor(oldPath); + const newPath = parent === '/' ? `/${newName}` : `${parent}/${newName}`; + // Require the name to survive path normalization unchanged. + if (normalizePath(newPath, 'folder') !== newPath) + senderError('vault.invalid_path:segment'); + if (newPath === oldPath) return; + + const row = requireOwnedFolder(ctx, oldPath, owner); + assertNoFolderCollision(ctx, newPath, owner); + assertNoOwnedFileCollision(ctx, newPath, owner); + + // Validate every re-pathed descendant before mutating anything. + const prefix = childPrefix(oldPath); + const rePath = (p: string) => newPath + p.slice(oldPath.length); + const childFolders = [...ctx.db.folder.ownerUserId.filter(owner)].filter( + f => f.path.startsWith(prefix) + ); + const childFiles = [...ctx.db.files.file.ownerUserId.filter(owner)].filter( + f => f.path.startsWith(prefix) + ); + for (const f of childFolders) { + const p = rePath(f.path); + if (p.length > PATH_MAX) senderError('vault.invalid_path:length'); + assertNoOwnedFileCollision(ctx, p, owner); + } + for (const f of childFiles) { + const p = rePath(f.path); + if (p.length > PATH_MAX) senderError('vault.invalid_path:length'); + assertNoOwnedFileCollision(ctx, p, owner); + } + + ctx.db.folder.id.update({ + ...row, + path: newPath, + name: newName, + updatedAt: ctx.timestamp, + }); + for (const f of childFolders) { + const p = rePath(f.path); + ctx.db.folder.id.update({ + ...f, + path: p, + parentPath: parentPathFor(p), + updatedAt: ctx.timestamp, + }); + } + for (const f of childFiles) { + const path = rePath(f.path); + ctx.db.files.file.id.update({ + ...f, + ownerPathKey: ownerPathKey(owner, path), + path, + updatedAt: ctx.timestamp, + }); + } + } +); + +export const upload_file = spacetimedb.reducer( + { + path: t.string(), + mimeType: t.string(), + bytes: t.array(t.u8()), + visibility: t.string(), + }, + (ctx, args) => { + const owner = ownerUserId(ctx); + const path = normalizePath(args.path, 'file'); + if (!VALID_VISIBILITIES.has(args.visibility)) + senderError(`vault.invalid_visibility:${args.visibility}`); + if (args.bytes.length > FILE_BYTES_MAX) + senderError(`files.too_large:${args.bytes.length}/${FILE_BYTES_MAX}`); + assertParentFolderExists(ctx, path, owner); + assertNoFolderCollision(ctx, path, owner); + const key = ownerPathKey(owner, path); + const existing = ctx.db.files.file.ownerPathKey.find(key); + let mimeType: string; + try { + mimeType = validateMimeType(args.mimeType || 'application/octet-stream'); + } catch (error) { + senderError( + error instanceof Error ? error.message : 'files.invalid_mime_type' + ); + } + const sha256Hex = fileSha256Hex(args.bytes); + if (existing) { + ctx.db.files.file.id.update({ + ...existing, + mimeType, + size: BigInt(args.bytes.length), + sha256Hex, + visibility: args.visibility, + updatedAt: ctx.timestamp, + }); + ctx.db.files.fileBlob.fileId.update({ + fileId: existing.id, + bytes: args.bytes, + }); + return; + } + const row = ctx.db.files.file.insert({ + id: 0n, + ownerPathKey: key, + path, + ownerUserId: owner, + mimeType, + size: BigInt(args.bytes.length), + sha256Hex, + visibility: args.visibility, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + ctx.db.files.fileBlob.insert({ fileId: row.id, bytes: args.bytes }); + } +); + +export const delete_file = spacetimedb.reducer( + { path: t.string() }, + (ctx, args) => { + const owner = ownerUserId(ctx); + const path = normalizePath(args.path, 'file'); + const row = ctx.db.files.file.ownerPathKey.find(ownerPathKey(owner, path)); + if (!row) return; + const blob = ctx.db.files.fileBlob.fileId.find(row.id); + if (blob) ctx.db.files.fileBlob.delete(blob); + ctx.db.files.file.id.delete(row.id); + } +); + +export const rename_file = spacetimedb.reducer( + { oldPath: t.string(), newPath: t.string() }, + (ctx, args) => { + const owner = ownerUserId(ctx); + const oldPath = normalizePath(args.oldPath, 'file'); + const newPath = normalizePath(args.newPath, 'file'); + renameOwnedFile(ctx, owner, oldPath, newPath); + } +); + +export const move_file = spacetimedb.reducer( + { oldPath: t.string(), targetFolderPath: t.string() }, + (ctx, args) => { + const targetFolderPath = normalizePath(args.targetFolderPath, 'folder'); + const oldPath = normalizePath(args.oldPath, 'file'); + const filename = basename(oldPath); + const newPath = + targetFolderPath === '/' + ? `/${filename}` + : `${targetFolderPath}/${filename}`; + renameOwnedFile(ctx, ownerUserId(ctx), oldPath, newPath); + } +); + +export const set_file_visibility = spacetimedb.reducer( + { path: t.string(), visibility: t.string() }, + (ctx, args) => { + if (!VALID_VISIBILITIES.has(args.visibility)) + senderError(`files.invalid_visibility:${args.visibility}`); + const owner = ownerUserId(ctx); + const path = normalizePath(args.path, 'file'); + const row = ctx.db.files.file.ownerPathKey.find(ownerPathKey(owner, path)); + if (!row) senderError(`files.not_found:${path}`); + ctx.db.files.file.id.update({ + ...row, + visibility: args.visibility, + updatedAt: ctx.timestamp, + }); + } +); + +// Private bytes travel over the authenticated connection. HTTP handlers +// never see the caller's identity. +export const read_file_bytes = spacetimedb.procedure( + readFileBytesParams, + readFileBytesReturn, + (ctx, args) => + readFileBytes( + ctx, + { path: normalizePath(args.path, 'file') }, + ctx.sender.toHexString() + ) +); + +const serveFile = createFileHttpHandler({ + getOwner: ctx => ctx.identity?.toHexString?.(), +}); + +export const file_serve = spacetimedb.httpHandler((ctx, req) => { + return serveFile(ctx, req); +}); + +export const router = spacetimedb.httpRouter( + new Router().get('/files', file_serve).head('/files', file_serve) +); diff --git a/spacetime-files-ts/example/spacetimedb/tsconfig.json b/spacetime-files-ts/example/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..f004a6cbc79 --- /dev/null +++ b/spacetime-files-ts/example/spacetimedb/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "noEmit": true, + "skipLibCheck": true, + "moduleResolution": "Bundler", + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/spacetime-files-ts/example/src/app.ts b/spacetime-files-ts/example/src/app.ts new file mode 100644 index 00000000000..246adc5aa5d --- /dev/null +++ b/spacetime-files-ts/example/src/app.ts @@ -0,0 +1,1168 @@ +import { DbConnection, tables, type ErrorContext } from './module_bindings/app'; +import type { FileSummary, Folder } from './module_bindings/app/types'; +import { + loadStdbToken, + saveStdbToken, + clearStdbToken, + type ServerConfig, +} from './session'; +import { + parentPath, + baseName, + joinPath, + childPrefix, + fileUrl, + type Visibility, +} from './paths'; +import { + formatFileSize, + timestampMilliseconds, + escapeHtml, + humanError, +} from './presentation'; +import { + downloadArchive, + downloadFile as saveDownloadedFile, + getFileBlob as loadFileBlob, +} from './downloads'; +import { zipStamp } from './zip'; +import { FileViewer } from './viewer'; +import { DialogController } from './dialog'; +import { collectDropped, UploadController } from './uploads'; +import { ContextMenu } from './context-menu'; +import { bindListActions as bindListInteractions } from './list-actions'; +import { handleListKey } from './keyboard'; +import { uploadDropped, registerFolderDropTarget } from './drop-target'; +import { + createVaultRendering, + fileDetailsHtml, + folderDetailsHtml, + icon, + selectionDetailsHtml, + type SortKey, +} from './rendering'; +import { VaultSelection } from './selection'; + +let conn: DbConnection | null = null; +let authToken: string | undefined = loadStdbToken(); + +async function loadServerConfig(): Promise { + const res = await fetch('/api/config'); + if (!res.ok) throw new Error(`/api/config returned ${res.status}`); + return (await res.json()) as ServerConfig; +} + +function connect(config: ServerConfig): Promise { + return new Promise((resolve, reject) => { + DbConnection.builder() + .withUri(config.spacetimeUri) + .withDatabaseName(config.databaseName) + .withToken(authToken) + .onConnect((connection, _identity, token) => { + authToken = token; + saveStdbToken(token); + resolve(connection); + }) + .onDisconnect((_ctx, err) => { + conn = null; + toast( + 'err', + err?.message ? `Connection lost: ${err.message}` : 'Connection lost' + ); + }) + .onConnectError((_ctx, err) => { + reject(err); + }) + .build(); + }); +} + +const vault = { + createFolder: (path: string) => conn!.reducers.createFolder({ path }), + deleteFolder: (path: string) => conn!.reducers.deleteFolder({ path }), + uploadFile: (args: { + path: string; + mimeType: string; + bytes: Uint8Array; + visibility: Visibility; + }) => conn!.reducers.uploadFile(args), + deleteFile: (path: string) => conn!.reducers.deleteFile({ path }), + renameFile: (oldPath: string, newPath: string) => + conn!.reducers.renameFile({ oldPath, newPath }), + renameFolder: (path: string, newName: string) => + conn!.reducers.renameFolder({ path, newName }), + moveFile: (oldPath: string, targetFolderPath: string) => + conn!.reducers.moveFile({ oldPath, targetFolderPath }), + setFileVisibility: (path: string, visibility: Visibility) => + conn!.reducers.setFileVisibility({ path, visibility }), + /** Reads file bytes over the authenticated connection (works for private files). */ + readFileBytes: async ( + path: string + ): Promise<{ bytes: Uint8Array; mimeType: string }> => { + const result = await conn!.procedures.readFileBytes({ path }); + // Bytes may arrive as a plain number[] over the wire; normalize. + return { bytes: new Uint8Array(result.bytes), mimeType: result.mimeType }; + }, + getToken: () => authToken, +}; + +declare global { + interface Window { + vault?: typeof vault; + } +} + +function requireVault(): typeof vault | null { + if (!conn) { + toast('err', 'Vault is not connected yet.'); + return null; + } + return vault; +} + +const $ = (id: string): T => + document.getElementById(id) as T; + +const PREFS_KEY = 'vault:prefs'; +interface Prefs { + viewMode?: 'list' | 'grid'; + tileSize?: number | 's' | 'm' | 'l'; + sortKey?: SortKey; + sortDir?: 1 | -1; + detailsOpen?: boolean; +} +function loadPrefs(): Prefs { + try { + return ( + (JSON.parse(localStorage.getItem(PREFS_KEY) ?? 'null') as Prefs) ?? {} + ); + } catch { + return {}; + } +} +function savePrefs(): void { + try { + localStorage.setItem( + PREFS_KEY, + JSON.stringify({ viewMode, tileSize, sortKey, sortDir, detailsOpen }) + ); + } catch { + /* ignore */ + } +} +const prefs = loadPrefs(); + +let folders: Folder[] = []; +let files: FileSummary[] = []; +let currentPath = '/'; +let uploading = false; +let dragDepth = 0; +let sortKey: SortKey = prefs.sortKey ?? 'name'; +let sortDir: 1 | -1 = prefs.sortDir ?? 1; +let viewMode: 'list' | 'grid' = prefs.viewMode ?? 'list'; +let tileSize: number = + typeof prefs.tileSize === 'number' + ? prefs.tileSize + : ({ s: 110, m: 150, l: 205 }[prefs.tileSize ?? 'm'] ?? 150); +let detailsOpen: boolean = prefs.detailsOpen ?? false; +let searchQuery = ''; +const selection = new VaultSelection(); +const selected = selection.selected; + +const { + allFolderPaths, + fileRowHtml, + fileTileHtml, + folderRowHtml, + folderTileHtml, + immediateFolders, + subtreeStats, + visibleEntries, +} = createVaultRendering(() => ({ + folders, + files, + currentPath, + searchQuery, + sortKey, + sortDir, + selected, + focusPath: selection.focusPath, +})); + +function freeName(candidate: string): string { + const taken = (p: string) => + files.some(f => f.path === p) || folders.some(f => f.path === p); + if (!taken(candidate)) return candidate; + const dir = parentPath(candidate); + const base = baseName(candidate); + const dot = base.lastIndexOf('.'); + const stem = dot > 0 ? base.slice(0, dot) : base; + const ext = dot > 0 ? base.slice(dot) : ''; + for (let k = 1; k < 1000; k++) { + const next = joinPath(dir, `${stem} (${k})${ext}`); + if (!taken(next)) return next; + } + return candidate; +} + +function toast(kind: 'ok' | 'err', message: string): void { + const el = $('toast'); + const node = document.createElement('div'); + node.className = `toast ${kind}`; + node.textContent = message; + el.appendChild(node); + setTimeout(() => node.remove(), 3600); +} + +const dialogs = new DialogController(error => toast('err', humanError(error))); +const openDialog = dialogs.open; +const closeDialog = dialogs.close; +const commitDialog = dialogs.commit; +const confirmDialog = dialogs.confirm.bind(dialogs); + +const uploads = new UploadController({ + ready: () => requireVault() != null, + files: () => files, + createFolder: path => vault.createFolder(path), + uploadFile: args => vault.uploadFile(args), + freeName, + openDialog, + setProgress: (next, label) => { + uploading = next; + $('upload').disabled = next; + $('upload-label').textContent = next && label ? label : 'Upload'; + }, + toast, +}); + +const contextMenu = new ContextMenu({ + file: path => files.find(file => file.path === path), + preview: path => void openViewer(path), + viewDetails: path => { + setFocus(path); + if (!detailsOpen) { + detailsOpen = true; + savePrefs(); + } + render(); + }, + downloadFile: file => void downloadFile(file), + copyLink, + duplicateFile: path => void duplicateFile(path), + renameFile: openRename, + moveFile: path => openMove([path]), + toggleVisibility: path => void toggleVisibility(path), + deleteFile: confirmDeleteFile, + openFolder: path => { + currentPath = path; + selection.clearFocus(); + clearSearch(); + render(); + }, + downloadFolder: path => void downloadFolderZip(path), + renameFolder: openRenameFolder, + deleteFolder: confirmDeleteFolder, + newFolder: openNewFolder, + chooseFiles: () => $('file-input').click(), + chooseFolder: () => $('folder-input').click(), +}); +const openCtxMenu = contextMenu.open; +const closeCtxMenu = contextMenu.close; + +const downloadServices = { + readFileBytes: vault.readFileBytes, + toast, +}; + +function getFileBlob(row: FileSummary): Promise { + return loadFileBlob(row, downloadServices); +} + +const thumbCache = new Map(); +let thumbGeneration = 0; +// Object-URL cache keyed by path@mtime; older revisions revoked on refresh. +async function getThumbUrl(row: FileSummary): Promise { + const key = `${row.path}@${timestampMilliseconds(row.updatedAt)}`; + const cached = thumbCache.get(key); + if (cached) return cached; + const url = URL.createObjectURL(await getFileBlob(row)); + for (const [k, v] of thumbCache) { + if (k.startsWith(row.path + '@') && k !== key) { + URL.revokeObjectURL(v); + thumbCache.delete(k); + } + } + thumbCache.set(key, url); + return url; +} +async function loadThumbs(): Promise { + const gen = ++thumbGeneration; + const slots = [...document.querySelectorAll('[data-thumb]')]; + for (const slot of slots) { + if (gen !== thumbGeneration) return; // a newer render superseded us + const row = files.find(f => f.path === slot.dataset.thumb); + if (!row) continue; + let url: string; + try { + url = await getThumbUrl(row); + } catch { + continue; + } + if (gen !== thumbGeneration) return; + const img = document.createElement('img'); + img.src = url; + img.alt = ''; + slot.replaceChildren(img); + } +} + +function renderCrumbs(): void { + if (searchQuery) { + $('crumbs').innerHTML = + `${icon('search')} Search results`; + return; + } + const parts = + currentPath === '/' ? [] : currentPath.split('/').filter(Boolean); + let acc = ''; + const html = [``]; + for (const part of parts) { + acc += '/' + part; + html.push( + `/` + ); + } + $('crumbs').innerHTML = html.join(''); + $('crumbs') + .querySelectorAll('[data-cd]') + .forEach(btn => { + btn.addEventListener('click', () => { + currentPath = btn.dataset.cd!; + clearSearch(); + render(); + }); + }); +} +function renderTree(): void { + const rows: Array<{ path: string; name: string; depth: number }> = [ + { path: '/', name: 'Root', depth: 0 }, + ]; + (function walk(path: string, depth: number) { + for (const f of immediateFolders(path).sort((a, b) => + a.name.localeCompare(b.name) + )) { + rows.push({ path: f.path, name: f.name, depth }); + walk(f.path, depth + 1); + } + })('/', 1); + $('tree').innerHTML = rows + .map( + row => ` +
    • + +
    • ` + ) + .join(''); + $('tree') + .querySelectorAll('[data-path]') + .forEach(btn => { + btn.addEventListener('click', () => { + currentPath = btn.dataset.path!; + clearSearch(); + render(); + }); + btn.addEventListener('contextmenu', e => + openCtxMenu(e, { type: 'folder', path: btn.dataset.path! }) + ); + registerFolderDropCallbacks(btn, btn.dataset.path!); + }); +} +function renderHead(): void { + $('list-head').classList.toggle('grid-mode', viewMode === 'grid'); + $('list-head') + .querySelectorAll('[data-sort]') + .forEach(btn => { + const active = btn.dataset.sort === sortKey; + btn.classList.toggle('sorted', active); + const label = + btn.dataset.label ?? (btn.dataset.label = btn.textContent!.trim()); + btn.textContent = active ? `${label} ${sortDir > 0 ? '^' : 'v'}` : label; + }); + const { fs } = visibleEntries(); + const all = fs.length > 0 && fs.every(f => selected.has(f.path)); + $('select-all').checked = all; + $('view-toggle').innerHTML = icon(viewMode === 'grid' ? 'list' : 'grid'); + $('view-toggle').title = viewMode === 'grid' ? 'List view' : 'Grid view'; + $('zoom-ctl').hidden = viewMode !== 'grid'; + $('tile-slider').value = String(tileSize); +} +function renderList(): void { + const { dirs, fs } = visibleEntries(); + selection.setEntries([ + ...dirs.map(f => ({ type: 'folder' as const, path: f.path })), + ...fs.map(f => ({ type: 'file' as const, path: f.path })), + ]); + const isEmpty = selection.entries.length === 0; + $('empty').hidden = !isEmpty; + if (isEmpty) { + $('empty').innerHTML = searchQuery + ? `
      ${icon('search')}No matches
      Nothing named "${escapeHtml(searchQuery)}".
      ` + : `
      ${icon('upload')}This folder is empty
      Drag files anywhere on the page, or hit Upload.
      `; + } + const list = $('list'); + list.classList.toggle('grid', viewMode === 'grid'); + list.classList.toggle('has-selection', selected.size > 0); + list.style.setProperty('--tile', `${tileSize}px`); + list.innerHTML = + viewMode === 'grid' + ? [...dirs.map(folderTileHtml), ...fs.map(fileTileHtml)].join('') + : [...dirs.map(folderRowHtml), ...fs.map(fileRowHtml)].join(''); + bindListActions(); + if (viewMode === 'grid') void loadThumbs(); +} +function renderBulkbar(): void { + const n = selected.size; + $('bulkbar').hidden = n === 0; + $('toolbar-main').style.display = n === 0 ? '' : 'none'; + if (n) $('bulk-count').textContent = `${n} selected`; +} +function renderStorage(): void { + const total = files.reduce((sum, f) => sum + Number(f.size), 0); + $('storage').textContent = files.length + ? `${files.length} file${files.length === 1 ? '' : 's'} | ${formatFileSize(total)} stored` + : 'No files stored yet'; +} + +function registerDetailsNavigationHandlers(scope: HTMLElement): void { + scope.querySelectorAll('[data-goto]').forEach(btn => + btn.addEventListener('click', () => { + currentPath = btn.dataset.goto!; + clearSearch(); + selected.clear(); + render(); + }) + ); +} +async function loadDetailsThumb(row: FileSummary): Promise { + const slot = document.querySelector( + `[data-dthumb="${CSS.escape(row.path)}"]` + ); + if (!slot) return; + let url: string; + try { + url = await getThumbUrl(row); + } catch { + return; + } + if (!document.body.contains(slot)) return; + const img = document.createElement('img'); + img.src = url; + img.alt = ''; + slot.replaceChildren(img); +} +function renderFileDetails(body: HTMLElement, row: FileSummary): void { + const isImage = (row.mimeType || '').startsWith('image/'); + body.innerHTML = fileDetailsHtml(row); + registerDetailsNavigationHandlers(body); + if (isImage) void loadDetailsThumb(row); +} +function renderFolderDetails(body: HTMLElement, folderPath: string): void { + const isRoot = folderPath === '/'; + const row = isRoot ? null : folders.find(f => f.path === folderPath); + body.innerHTML = folderDetailsHtml( + folderPath, + row ?? undefined, + subtreeStats(folderPath) + ); + registerDetailsNavigationHandlers(body); +} +function renderDetails(): void { + document + .querySelector('.main')! + .classList.toggle('details-open', detailsOpen); + $('details-toggle').classList.toggle('active', detailsOpen); + if (!detailsOpen) return; + const body = $('details-body'); + if (selected.size > 1) { + const rows = files.filter(f => selected.has(f.path)); + body.innerHTML = selectionDetailsHtml(rows); + return; + } + if (selected.size === 1) { + const row = files.find(f => f.path === [...selected][0]); + if (row) return renderFileDetails(body, row); + } + if (selection.focusPath) { + const row = files.find(f => f.path === selection.focusPath); + if (row) return renderFileDetails(body, row); + if (folders.some(f => f.path === selection.focusPath)) + return renderFolderDetails(body, selection.focusPath); + } + // Nothing focused or selected: summarize the current folder (or root). + renderFolderDetails(body, currentPath); +} +function render(): void { + if (currentPath !== '/' && !folders.some(f => f.path === currentPath)) + currentPath = '/'; + renderCrumbs(); + renderTree(); + renderHead(); + renderList(); + renderBulkbar(); + renderStorage(); + renderDetails(); +} + +function setFocus(path: string): void { + // No re-render if already focused: dblclick's second click must hit the same node. + if (selection.focus(path)) render(); +} +function toggleSelect(path: string): void { + selection.toggle(path); + render(); +} +function rangeSelect(path: string): void { + selection.selectRange(path); + render(); +} + +function bindListActions(): void { + bindListInteractions($('list'), { + selected, + files: () => files, + setAnchor: path => selection.setAnchor(path), + toggleSelect, + rangeSelect, + focus: setFocus, + openFile: path => void openViewer(path), + openFolder: path => { + currentPath = path; + selection.clearFocus(); + clearSearch(); + render(); + }, + openContext: openCtxMenu, + render, + toggleVisibility: path => void toggleVisibility(path), + copyLink, + downloadFile: file => void downloadFile(file), + downloadFolder: path => void downloadFolderZip(path), + renameFile: openRename, + renameFolder: openRenameFolder, + moveFile: path => openMove([path]), + deleteFile: confirmDeleteFile, + deleteFolder: confirmDeleteFolder, + registerFolderDropCallbacks, + }); +} + +function toggleVisibility(path: string): Promise | void { + const row = files.find(f => f.path === path); + const v = requireVault(); + if (!row || !v) return; + return runAction('Visibility updated', () => + v.setFileVisibility(path, row.visibility === 'public' ? 'owner' : 'public') + ); +} +function confirmDeleteFile(path: string): void { + confirmDialog( + 'Delete file', + `Delete "${baseName(path)}"? This can't be undone.`, + async () => { + await runAction('File deleted', () => vault.deleteFile(path)); + if (viewer.path === path) closeViewer(); + } + ); +} +function confirmDeleteFolder(path: string): void { + confirmDialog( + 'Delete folder', + `Delete folder "${baseName(path)}"? It must be empty.`, + () => runAction('Folder deleted', () => vault.deleteFolder(path)) + ); +} + +function registerFolderDropCallbacks( + el: HTMLElement, + folderPath: string +): void { + registerFolderDropTarget(el, folderPath, { + currentPath: () => currentPath, + endFileDrag, + upload: (dataTransfer, path) => + uploadDropped(dataTransfer, path, (entries, target) => + uploads.upload(entries, target) + ), + move: moveFiles, + }); +} +async function moveFiles(paths: string[], targetFolder: string): Promise { + const v = requireVault(); + if (!paths.length || !v) return; + const toMove = paths.filter(p => parentPath(p) !== targetFolder); + await bulkOp(toMove, p => v.moveFile(p, targetFolder), 'moved'); + selected.clear(); + // No-op moves produce no data event, so sync the bulk bar here. + render(); +} + +function openNewFolder(): void { + openDialog( + 'New folder', + ``, + async () => { + const name = $('folder-name').value.trim(); + if (!name) throw new Error('vault.invalid_path:name'); + await vault.createFolder(joinPath(currentPath, name)); + toast('ok', 'Folder created'); + } + ); +} +function openRename(path: string): void { + openDialog( + 'Rename file', + ``, + async () => { + const name = $('rename-name').value.trim(); + if (!name) throw new Error('vault.invalid_file_path'); + await vault.renameFile(path, joinPath(parentPath(path), name)); + toast('ok', 'File renamed'); + } + ); +} +function openRenameFolder(path: string): void { + openDialog( + 'Rename folder', + ``, + async () => { + const name = $('rename-name').value.trim(); + if (!name) throw new Error('vault.invalid_path:name'); + await vault.renameFolder(path, name); + // Follow a rename within the active subtree. + const newPath = joinPath(parentPath(path), name); + if (currentPath === path) currentPath = newPath; + else if (currentPath.startsWith(childPrefix(path))) + currentPath = newPath + currentPath.slice(path.length); + toast('ok', 'Folder renamed'); + } + ); +} +function openMove(paths: string[]): void { + const from = paths.length === 1 ? parentPath(paths[0]!) : null; + openDialog( + paths.length === 1 ? 'Move file' : `Move ${paths.length} files`, + ` + `, + () => moveFiles(paths, $('move-target').value) + ); +} + +async function writeClipboard(text: string): Promise { + await navigator.clipboard.writeText(text); +} +function copyLink(path: string): void { + const row = files.find(f => f.path === path); + if (!row) return; + const url = location.origin + fileUrl(row.id); + if (row && row.visibility === 'public') { + void writeClipboard(url) + .then(() => toast('ok', 'Public link copied')) + .catch(() => toast('err', 'Copy failed. Select the URL manually.')); + return; + } + // A private link is dead even for the owner (HTTP has no caller identity). + openDialog( + 'Copy link', + `

      This file is private. Public links require public visibility. Make it public and copy the link?

      `, + async () => { + await vault.setFileVisibility(path, 'public'); + try { + await writeClipboard(url); + toast('ok', 'File made public, link copied'); + } catch { + toast('err', 'File made public, but the link could not be copied.'); + } + }, + { okLabel: 'Make public & copy' } + ); +} + +async function runAction( + okMessage: string, + fn: () => Promise +): Promise { + if (!requireVault()) return; + try { + await fn(); + toast('ok', okMessage); + } catch (err) { + toast('err', humanError(err)); + } +} +async function duplicateFile(path: string): Promise { + const row = files.find(f => f.path === path); + const v = requireVault(); + if (!row || !v) return; + try { + const { bytes, mimeType } = await v.readFileBytes(path); + const base = baseName(path); + const dot = base.lastIndexOf('.'); + const copyName = + dot > 0 + ? `${base.slice(0, dot)} (copy)${base.slice(dot)}` + : `${base} (copy)`; + const target = freeName(joinPath(parentPath(path), copyName)); + await v.uploadFile({ + path: target, + mimeType, + bytes, + visibility: row.visibility as Visibility, + }); + toast('ok', `Copied to ${baseName(target)}`); + } catch (err) { + toast('err', humanError(err)); + } +} + +async function downloadFile(row: FileSummary): Promise { + return saveDownloadedFile(row, downloadServices); +} + +async function zipAndSave( + fileRows: FileSummary[], + dirNames: Array<{ name: string; mtimeMs: number }>, + entryName: (f: FileSummary) => string, + zipName: string +): Promise { + return downloadArchive( + { fileRows, dirNames, entryName, zipName }, + downloadServices + ); +} +function downloadFolderZip(folderPath: string): Promise { + const prefix = childPrefix(folderPath); + // Name the root archive explicitly to avoid paths that start with "//". + const root = folderPath === '/' ? 'vault' : baseName(folderPath); + const inFiles = files.filter(f => f.path.startsWith(prefix)); + // Directory entries preserve empty folders inside the zip. + const inDirs = folders + .filter(f => f.path !== folderPath && f.path.startsWith(prefix)) + .map(f => ({ + name: `${root}/${f.path.slice(prefix.length)}/`, + mtimeMs: timestampMilliseconds(f.updatedAt), + })); + return zipAndSave( + inFiles, + inDirs, + f => `${root}/${f.path.slice(prefix.length)}`, + `${root}-${zipStamp()}.zip` + ); +} +function downloadSelectionZip(): Promise { + const rows = files.filter(f => selected.has(f.path)); + // Keep full vault paths so structure survives a mixed selection. + return zipAndSave( + rows, + [], + f => f.path.slice(1), + `vault-download-${zipStamp()}.zip` + ); +} + +const viewer = new FileViewer({ + loadBlob: getFileBlob, + download: downloadFile, + iconHtml: icon, +}); + +function viewerOpen(): boolean { + return viewer.isOpen(); +} +function openViewer(path: string): Promise { + return viewer.open(path, visibleEntries().fs); +} +function vStep(delta: number): void { + viewer.step(delta); +} +function closeViewer(): void { + viewer.close(); +} + +function clearSearch(): void { + if (!searchQuery) return; + searchQuery = ''; + $('search').value = ''; +} + +// Continue-on-error loop: one summary toast, one toast per failure. +async function bulkOp( + paths: string[], + op: (path: string) => Promise, + okVerb: string +): Promise { + let done = 0; + const failures: string[] = []; + for (const p of paths) { + try { + await op(p); + done++; + } catch (err) { + failures.push(`${baseName(p)}: ${humanError(err)}`); + } + } + if (done) toast('ok', `${done} file${done === 1 ? '' : 's'} ${okVerb}`); + for (const msg of failures) toast('err', msg); + return done; +} +function bulkDelete(): void { + const paths = [...selected]; + if (!paths.length) return; + confirmDialog( + `Delete ${paths.length} file${paths.length === 1 ? '' : 's'}`, + `Delete ${paths.length} file${paths.length === 1 ? '' : 's'}? This can't be undone.`, + async () => { + await bulkOp(paths, p => vault.deleteFile(p), 'deleted'); + selected.clear(); + if (viewer.path && paths.includes(viewer.path)) closeViewer(); + } + ); +} +async function bulkVisibility(visibility: Visibility): Promise { + // Visibility changes preserve the current selection because files stay in place. + await bulkOp( + [...selected], + p => vault.setFileVisibility(p, visibility), + `made ${visibility === 'public' ? 'public' : 'private'}` + ); +} + +function handleListKeys(e: KeyboardEvent): void { + handleListKey(e, { + entries: () => selection.entries, + focusPath: () => selection.focusPath, + selected, + visibleFilePaths: () => visibleEntries().fs.map(file => file.path), + setFocus, + clearFocus: () => { + selection.clearFocus(); + }, + openFolder: path => { + currentPath = path; + selection.clearFocus(); + clearSearch(); + render(); + }, + openFile: path => { + selection.setAnchor(path); + void openViewer(path); + }, + toggleSelect, + deleteSelection: bulkDelete, + deleteFile: confirmDeleteFile, + deleteFolder: confirmDeleteFolder, + hasSearch: () => Boolean(searchQuery), + clearSearch, + render, + }); +} + +function endFileDrag(): void { + dragDepth = 0; + document.body.classList.remove('dragging-files'); +} + +function registerUiHandlers(): void { + $('new-folder').addEventListener('click', openNewFolder); + $('upload').addEventListener('click', () => { + if (!uploading) $('file-input').click(); + }); + $('file-input').addEventListener('change', e => { + void (async () => { + const input = e.target as HTMLInputElement; + const picked = [...(input.files ?? [])]; + input.value = ''; + if (picked.length) + await uploads.upload( + { files: picked.map(f => ({ file: f, rel: f.name })), dirs: [] }, + currentPath + ); + })(); + }); + $('folder-input').addEventListener('change', e => { + void (async () => { + const input = e.target as HTMLInputElement; + const picked = [...(input.files ?? [])]; + input.value = ''; + if (picked.length) { + await uploads.upload( + { + files: picked.map(f => ({ + file: f, + rel: f.webkitRelativePath || f.name, + })), + dirs: [], + }, + currentPath + ); + } + })(); + }); + + $('search').addEventListener('input', () => { + searchQuery = $('search').value.trim(); + render(); + }); + $('search').addEventListener('keydown', e => { + if ((e as KeyboardEvent).key === 'Escape') { + clearSearch(); + render(); + } + }); + + $('list-head') + .querySelectorAll('[data-sort]') + .forEach(btn => { + btn.addEventListener('click', () => { + const key = btn.dataset.sort as SortKey; + if (sortKey === key) sortDir = sortDir === 1 ? -1 : 1; + else { + sortKey = key; + sortDir = 1; + } + savePrefs(); + render(); + }); + }); + $('view-toggle').addEventListener('click', () => { + viewMode = viewMode === 'grid' ? 'list' : 'grid'; + savePrefs(); + render(); + }); + $('details-toggle').addEventListener('click', () => { + detailsOpen = !detailsOpen; + savePrefs(); + render(); + }); + $('details-close').addEventListener('click', () => { + detailsOpen = false; + savePrefs(); + render(); + }); + $('tile-slider').addEventListener('input', () => { + tileSize = Number($('tile-slider').value); + $('list').style.setProperty('--tile', `${tileSize}px`); + savePrefs(); + }); + $('select-all').addEventListener('change', () => { + const { fs } = visibleEntries(); + if ($('select-all').checked) + fs.forEach(f => selected.add(f.path)); + else fs.forEach(f => selected.delete(f.path)); + render(); + }); + + $('bulk-clear').addEventListener('click', () => { + selected.clear(); + render(); + }); + $('bulk-move').addEventListener('click', () => openMove([...selected])); + $('bulk-download').addEventListener( + 'click', + () => void downloadSelectionZip() + ); + $('bulk-delete').addEventListener('click', bulkDelete); + $('bulk-public').addEventListener( + 'click', + () => void bulkVisibility('public') + ); + $('bulk-private').addEventListener( + 'click', + () => void bulkVisibility('owner') + ); + + // One-time list-background handlers (the
        persists across renders). + $('list').addEventListener('contextmenu', e => { + if ((e.target as HTMLElement).closest('[data-file], [data-folder]')) return; + openCtxMenu(e, { type: 'background' }); + }); + $('list').addEventListener('click', e => { + if (e.target === $('list') && selection.focusPath) { + selection.clearFocus(); + render(); + } + }); + + document.addEventListener('click', e => { + if (!(e.target as HTMLElement).closest('#ctx')) closeCtxMenu(); + }); + document.addEventListener('scroll', closeCtxMenu, true); + + $('lb-prev').addEventListener('click', () => vStep(-1)); + $('lb-next').addEventListener('click', () => vStep(1)); + $('lb-in').addEventListener('click', () => viewer.zoom(1.25)); + $('lb-out').addEventListener('click', () => viewer.zoom(0.8)); + $('lb-fit').addEventListener('click', () => viewer.fit()); + $('lb-full').addEventListener('click', () => viewer.fullSize()); + $('lb-download').addEventListener('click', () => { + const row = viewer.currentFile(); + if (row) void downloadFile(row); + }); + $('lb-close').addEventListener('click', closeViewer); + $('lb-stage').addEventListener('click', e => { + if (e.target === $('lb-stage')) closeViewer(); + }); + $('lightbox').addEventListener( + 'wheel', + e => { + if (!$('lb-stage').querySelector('img')) return; + e.preventDefault(); + viewer.zoom(e.deltaY < 0 ? 1.1 : 0.9); + }, + { passive: false } + ); + + $('dialog-ok').addEventListener('click', () => void commitDialog()); + $('dialog-cancel').addEventListener('click', closeDialog); + $('dialog').addEventListener('click', e => { + if (e.target === $('dialog')) closeDialog(); + }); + document.addEventListener('keydown', e => { + if (viewerOpen()) { + if (e.key === 'Escape') return closeViewer(); + if (e.key === 'ArrowLeft') return vStep(-1); + if (e.key === 'ArrowRight') return vStep(1); + return; + } + if ($('ctx').classList.contains('open') && e.key === 'Escape') + return closeCtxMenu(); + if ($('dialog').classList.contains('open')) { + if (e.key === 'Escape') closeDialog(); + else if ( + e.key === 'Enter' && + (e.target as HTMLElement).tagName !== 'TEXTAREA' + ) { + e.preventDefault(); + void commitDialog(); + } + return; + } + handleListKeys(e); + }); + + // OS-file drag only ('Files' type); internal row drags carry a custom type. + const hasFiles = (e: DragEvent) => + [...(e.dataTransfer?.types ?? [])].includes('Files'); + window.addEventListener('dragenter', e => { + if (!hasFiles(e)) return; + e.preventDefault(); + dragDepth++; + $('drop-path').textContent = currentPath; + document.body.classList.add('dragging-files'); + }); + window.addEventListener('dragover', e => { + if (!hasFiles(e)) return; + e.preventDefault(); + e.dataTransfer!.dropEffect = 'copy'; + }); + window.addEventListener('dragleave', e => { + if (!hasFiles(e)) return; + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) document.body.classList.remove('dragging-files'); + }); + window.addEventListener('drop', e => { + void (async () => { + if (!hasFiles(e)) return; + e.preventDefault(); + endFileDrag(); + const dropped = await collectDropped(e.dataTransfer!); + await uploads.upload(dropped, currentPath); + })(); + }); +} + +function refreshData(): void { + if (!conn) return; + folders = [...conn.db.myFolders.iter()]; + files = [...conn.db.myFileSummaries.iter()]; + const filePaths = new Set(files.map(file => file.path)); + selection.prune( + filePaths, + new Set([...filePaths, ...folders.map(folder => folder.path)]) + ); + render(); + // Close the viewer if the file it's showing was deleted out from under it. + if (viewer.path && viewerOpen() && !files.some(f => f.path === viewer.path)) + closeViewer(); +} + +// Row callbacks fire synchronously per transaction; coalesce the burst into one render. +let refreshScheduled = false; +function scheduleRefresh(): void { + if (refreshScheduled) return; + refreshScheduled = true; + queueMicrotask(() => { + refreshScheduled = false; + refreshData(); + }); +} + +function registerRowCallbacks(connection: DbConnection): void { + connection.db.myFolders.onInsert(scheduleRefresh); + connection.db.myFolders.onUpdate(scheduleRefresh); + connection.db.myFolders.onDelete(scheduleRefresh); + connection.db.myFileSummaries.onInsert(scheduleRefresh); + connection.db.myFileSummaries.onUpdate(scheduleRefresh); + connection.db.myFileSummaries.onDelete(scheduleRefresh); +} + +function subscribeToTables(connection: DbConnection): void { + connection + .subscriptionBuilder() + .onApplied(() => refreshData()) + .onError((ctx: ErrorContext) => + console.error('subscription error', ctx.event) + ) + .subscribe([tables.myFolders, tables.myFileSummaries]); +} + +async function main(): Promise { + registerUiHandlers(); + render(); + let config: ServerConfig; + try { + config = await loadServerConfig(); + try { + conn = await connect(config); + } catch (err) { + // Stale stored token (server wiped/rekeyed): drop it, retry anonymously. + if (!authToken) throw err; + authToken = undefined; + clearStdbToken(); + conn = await connect(config); + } + } catch (err) { + toast('err', `Couldn't connect: ${humanError(err)}`); + return; + } + + registerRowCallbacks(conn); + subscribeToTables(conn); + + window.vault = vault; +} + +main().catch(err => { + console.error(err); + toast('err', humanError(err)); +}); diff --git a/spacetime-files-ts/example/src/context-menu.ts b/spacetime-files-ts/example/src/context-menu.ts new file mode 100644 index 00000000000..4b17faa2d55 --- /dev/null +++ b/spacetime-files-ts/example/src/context-menu.ts @@ -0,0 +1,135 @@ +import type { FileSummary } from './module_bindings/app/types'; +import { icon } from './rendering'; +import { escapeHtml } from './presentation'; + +export type ContextTarget = + | { type: 'file' | 'folder'; path: string } + | { type: 'background' }; + +type ContextItem = { + label: string; + iconName: string; + run: () => void; + danger?: boolean; +} | null; + +export interface ContextMenuServices { + file(path: string): FileSummary | undefined; + preview(path: string): void; + viewDetails(path: string): void; + downloadFile(file: FileSummary): void; + copyLink(path: string): void; + duplicateFile(path: string): void; + renameFile(path: string): void; + moveFile(path: string): void; + toggleVisibility(path: string): void; + deleteFile(path: string): void; + openFolder(path: string): void; + downloadFolder(path: string): void; + renameFolder(path: string): void; + deleteFolder(path: string): void; + newFolder(): void; + chooseFiles(): void; + chooseFolder(): void; +} + +const item = ( + label: string, + iconName: string, + run: () => void, + danger = false +): ContextItem => ({ label, iconName, run, danger }); + +export class ContextMenu { + constructor(private readonly services: ContextMenuServices) {} + + open = (event: MouseEvent, target: ContextTarget): void => { + event.preventDefault(); + event.stopPropagation(); + const items = this.itemsFor(target); + if (!items) return; + const menu = document.getElementById('ctx')!; + menu.innerHTML = items + .map(entry => + entry === null + ? '
        ' + : `` + ) + .join(''); + const buttons = [...menu.querySelectorAll('button')]; + let buttonIndex = 0; + for (const entry of items) { + if (!entry) continue; + buttons[buttonIndex++]!.addEventListener('click', () => { + this.close(); + entry.run(); + }); + } + menu.classList.add('open'); + const rect = menu.getBoundingClientRect(); + menu.style.left = `${Math.min(event.clientX, window.innerWidth - rect.width - 8)}px`; + menu.style.top = `${Math.min(event.clientY, window.innerHeight - rect.height - 8)}px`; + }; + + close = (): void => { + document.getElementById('ctx')!.classList.remove('open'); + }; + + private itemsFor(target: ContextTarget): ContextItem[] | undefined { + if (target.type === 'file') { + const file = this.services.file(target.path); + if (!file) return undefined; + const isPublic = file.visibility === 'public'; + return [ + item('Preview', 'eye', () => this.services.preview(target.path)), + item('View details', 'info', () => + this.services.viewDetails(target.path) + ), + item('Download', 'download', () => this.services.downloadFile(file)), + item('Copy link', 'link', () => this.services.copyLink(target.path)), + item('Make a copy', 'copy', () => + this.services.duplicateFile(target.path) + ), + null, + item('Rename', 'pencil', () => this.services.renameFile(target.path)), + item('Move...', 'move', () => this.services.moveFile(target.path)), + item( + isPublic ? 'Make private' : 'Make public', + isPublic ? 'lock' : 'globe', + () => this.services.toggleVisibility(target.path) + ), + null, + item( + 'Delete', + 'trash', + () => this.services.deleteFile(target.path), + true + ), + ]; + } + if (target.type === 'folder') { + return [ + item('Open', 'folder', () => this.services.openFolder(target.path)), + item('View details', 'info', () => + this.services.viewDetails(target.path) + ), + item('Download as zip', 'download', () => + this.services.downloadFolder(target.path) + ), + null, + item('Rename', 'pencil', () => this.services.renameFolder(target.path)), + item( + 'Delete', + 'trash', + () => this.services.deleteFolder(target.path), + true + ), + ]; + } + return [ + item('New folder', 'plus', () => this.services.newFolder()), + item('Upload files', 'upload', () => this.services.chooseFiles()), + item('Upload folder', 'folder-up', () => this.services.chooseFolder()), + ]; + } +} diff --git a/spacetime-files-ts/example/src/dialog.ts b/spacetime-files-ts/example/src/dialog.ts new file mode 100644 index 00000000000..9986464646e --- /dev/null +++ b/spacetime-files-ts/example/src/dialog.ts @@ -0,0 +1,97 @@ +import { escapeHtml } from './presentation'; + +const element = (id: string): T => + document.getElementById(id) as T; + +export interface DialogOptions { + okLabel?: string; + danger?: boolean; + altLabel?: string | null; + onAlt?: (() => void | Promise) | null; +} + +export class DialogController { + private onSave: (() => void | Promise) | null = null; + + constructor(private readonly reportError: (error: unknown) => void) {} + + open = ( + title: string, + bodyHtml: string, + onSave: (() => void | Promise) | null, + options: DialogOptions = {} + ): void => { + const { + okLabel = 'Save', + danger = false, + altLabel = null, + onAlt = null, + } = options; + element('dialog-title').textContent = title; + element('dialog-body').innerHTML = bodyHtml; + const ok = element('dialog-ok'); + ok.textContent = okLabel; + ok.classList.toggle('danger', danger); + ok.classList.toggle('primary', !danger); + const alt = element('dialog-alt'); + alt.hidden = !altLabel; + if (altLabel) { + alt.textContent = altLabel; + alt.onclick = () => { + void (async () => { + try { + await onAlt?.(); + this.close(); + } catch (error) { + this.reportError(error); + } + })(); + }; + } + this.onSave = onSave; + element('dialog').classList.add('open'); + setTimeout( + () => + element('dialog-body') + .querySelector('input,select,button') + ?.focus(), + 20 + ); + }; + + close = (): void => { + element('dialog').classList.remove('open'); + this.resetChrome(); + this.onSave = null; + }; + + commit = async (): Promise => { + if (!this.onSave) return this.close(); + try { + await this.onSave(); + this.close(); + } catch (error) { + this.reportError(error); + } + }; + + confirm( + title: string, + message: string, + onConfirm: () => void | Promise + ): void { + this.open(title, `

        ${escapeHtml(message)}

        `, onConfirm, { + okLabel: 'Delete', + danger: true, + }); + } + + private resetChrome(): void { + element('dialog-body').className = 'card-body'; + element('dialog-ok').style.display = ''; + element('dialog-cancel').textContent = 'Cancel'; + const alt = element('dialog-alt'); + alt.hidden = true; + alt.onclick = null; + } +} diff --git a/spacetime-files-ts/example/src/downloads.ts b/spacetime-files-ts/example/src/downloads.ts new file mode 100644 index 00000000000..873560278b3 --- /dev/null +++ b/spacetime-files-ts/example/src/downloads.ts @@ -0,0 +1,127 @@ +import type { FileSummary } from './module_bindings/app/types'; +import { buildZip, type ZipEntry } from './zip'; +import { baseName, fileUrl } from './paths'; +import { humanError, timestampMilliseconds } from './presentation'; + +export const ARCHIVE_FILE_COUNT_MAX = 250; +export const ARCHIVE_ENTRY_COUNT_MAX = 1_000; +export const ARCHIVE_TOTAL_BYTES_MAX = 64 * 1024 * 1024; + +export interface DownloadServices { + readFileBytes(path: string): Promise<{ bytes: Uint8Array; mimeType: string }>; + toast(kind: 'ok' | 'err', message: string): void; +} + +export interface ArchiveRequest { + fileRows: FileSummary[]; + dirNames: Array<{ name: string; mtimeMs: number }>; + entryName(file: FileSummary): string; + zipName: string; +} + +export function archiveSelectionError( + fileRows: readonly FileSummary[], + directoryCount = 0 +): string | undefined { + if (fileRows.length > ARCHIVE_FILE_COUNT_MAX) { + return `Select at most ${ARCHIVE_FILE_COUNT_MAX} files for one archive.`; + } + if (fileRows.length + directoryCount > ARCHIVE_ENTRY_COUNT_MAX) { + return `Archive contents exceed the ${ARCHIVE_ENTRY_COUNT_MAX} entry limit.`; + } + const totalBytes = fileRows.reduce((total, file) => total + file.size, 0n); + if (totalBytes > BigInt(ARCHIVE_TOTAL_BYTES_MAX)) { + return `Archive contents exceed the ${ARCHIVE_TOTAL_BYTES_MAX / 1024 / 1024} MiB limit.`; + } + return undefined; +} + +export async function getFileBlob( + row: FileSummary, + services: DownloadServices +): Promise { + if (row.visibility === 'public') { + try { + const response = await fetch(fileUrl(row.id)); + if (response.ok) return await response.blob(); + } catch { + // The authenticated procedure below also serves public files. + } + } + const { bytes, mimeType } = await services.readFileBytes(row.path); + return new Blob([bytes as BlobPart], { + type: mimeType || row.mimeType || 'application/octet-stream', + }); +} + +export function saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 4000); +} + +export async function downloadFile( + row: FileSummary, + services: DownloadServices +): Promise { + try { + saveBlob(await getFileBlob(row, services), baseName(row.path)); + } catch (error) { + services.toast('err', humanError(error)); + } +} + +export async function downloadArchive( + request: ArchiveRequest, + services: DownloadServices +): Promise { + const { fileRows, dirNames, entryName, zipName } = request; + if (fileRows.length === 0 && dirNames.length === 0) { + services.toast('err', 'Nothing to download.'); + return; + } + const selectionError = archiveSelectionError(fileRows, dirNames.length); + if (selectionError) { + services.toast('err', selectionError); + return; + } + if (fileRows.length > 3) + services.toast('ok', `Zipping ${fileRows.length} files...`); + + const entries: ZipEntry[] = dirNames.map(directory => ({ + name: directory.name, + isDir: true, + mtimeMs: directory.mtimeMs, + })); + const failures: string[] = []; + let loadedBytes = 0; + for (const file of fileRows) { + try { + const blob = await getFileBlob(file, services); + loadedBytes += blob.size; + if (loadedBytes > ARCHIVE_TOTAL_BYTES_MAX) { + services.toast( + 'err', + `Downloaded contents exceed the ${ARCHIVE_TOTAL_BYTES_MAX / 1024 / 1024} MiB limit.` + ); + return; + } + entries.push({ + name: entryName(file), + bytes: new Uint8Array(await blob.arrayBuffer()), + mtimeMs: timestampMilliseconds(file.updatedAt), + }); + } catch (error) { + failures.push(`${baseName(file.path)}: ${humanError(error)}`); + } + } + for (const message of failures) services.toast('err', message); + if (entries.length === 0) return; + saveBlob(buildZip(entries), zipName); + services.toast('ok', `${zipName} ready`); +} diff --git a/spacetime-files-ts/example/src/drop-target.ts b/spacetime-files-ts/example/src/drop-target.ts new file mode 100644 index 00000000000..07c4dba514c --- /dev/null +++ b/spacetime-files-ts/example/src/drop-target.ts @@ -0,0 +1,68 @@ +import { collectDropped } from './uploads'; + +export interface FolderDropServices { + currentPath(): string; + endFileDrag(): void; + upload(dataTransfer: DataTransfer, folderPath: string): Promise; + move(paths: string[], folderPath: string): Promise; +} + +export function registerFolderDropTarget( + element: HTMLElement, + folderPath: string, + services: FolderDropServices +): void { + const dropPath = document.getElementById('drop-path')!; + element.addEventListener('dragover', event => { + const types = [...event.dataTransfer!.types]; + if (!types.includes('application/x-vault-path') && !types.includes('Files')) + return; + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer!.dropEffect = types.includes('Files') ? 'copy' : 'move'; + element.classList.add('drag-over'); + if (types.includes('Files')) dropPath.textContent = folderPath; + }); + element.addEventListener('dragleave', () => { + element.classList.remove('drag-over'); + dropPath.textContent = services.currentPath(); + }); + element.addEventListener('drop', event => { + void (async () => { + const types = [...event.dataTransfer!.types]; + if ( + !types.includes('application/x-vault-path') && + !types.includes('Files') + ) + return; + event.preventDefault(); + event.stopPropagation(); + element.classList.remove('drag-over'); + services.endFileDrag(); + if (types.includes('Files')) { + await services.upload(event.dataTransfer!, folderPath); + return; + } + let paths: string[] = []; + try { + paths = JSON.parse( + event.dataTransfer!.getData('application/x-vault-path') + ) as string[]; + } catch { + // Ignore malformed drag data. + } + await services.move(paths, folderPath); + })(); + }); +} + +export async function uploadDropped( + dataTransfer: DataTransfer, + folderPath: string, + upload: ( + entries: Awaited>, + path: string + ) => Promise +): Promise { + await upload(await collectDropped(dataTransfer), folderPath); +} diff --git a/spacetime-files-ts/example/src/keyboard.ts b/spacetime-files-ts/example/src/keyboard.ts new file mode 100644 index 00000000000..8a6beca0620 --- /dev/null +++ b/spacetime-files-ts/example/src/keyboard.ts @@ -0,0 +1,96 @@ +import type { Entry } from './rendering'; + +export interface KeyboardServices { + entries(): readonly Entry[]; + focusPath(): string | null; + selected: Set; + visibleFilePaths(): string[]; + setFocus(path: string): void; + clearFocus(): void; + openFolder(path: string): void; + openFile(path: string): void; + toggleSelect(path: string): void; + deleteSelection(): void; + deleteFile(path: string): void; + deleteFolder(path: string): void; + hasSearch(): boolean; + clearSearch(): void; + render(): void; +} + +function focusedEntry(services: KeyboardServices): Entry | undefined { + const focusPath = services.focusPath(); + return focusPath + ? services.entries().find(entry => entry.path === focusPath) + : undefined; +} + +function moveFocus(services: KeyboardServices, delta: number): void { + const entries = services.entries(); + if (entries.length === 0) return; + const currentIndex = entries.findIndex( + entry => entry.path === services.focusPath() + ); + const nextIndex = + currentIndex < 0 + ? delta > 0 + ? 0 + : entries.length - 1 + : Math.min(entries.length - 1, Math.max(0, currentIndex + delta)); + const entry = entries[nextIndex]!; + services.setFocus(entry.path); + document + .querySelector( + entry.type === 'file' + ? `[data-file="${CSS.escape(entry.path)}"]` + : `[data-folder="${CSS.escape(entry.path)}"]` + ) + ?.scrollIntoView({ block: 'nearest' }); +} + +export function handleListKey( + event: KeyboardEvent, + services: KeyboardServices +): void { + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if ( + document.getElementById('dialog')!.classList.contains('open') || + document.getElementById('lightbox')!.classList.contains('open') || + document.getElementById('ctx')!.classList.contains('open') + ) + return; + + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { + event.preventDefault(); + for (const path of services.visibleFilePaths()) services.selected.add(path); + services.render(); + return; + } + const entry = focusedEntry(services); + if (event.key === 'ArrowDown' || event.key === 'ArrowRight') { + event.preventDefault(); + moveFocus(services, 1); + } else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') { + event.preventDefault(); + moveFocus(services, -1); + } else if (event.key === 'Enter' && entry) { + event.preventDefault(); + if (entry.type === 'folder') services.openFolder(entry.path); + else services.openFile(entry.path); + } else if (event.key === ' ' && entry?.type === 'file') { + event.preventDefault(); + services.toggleSelect(entry.path); + } else if (event.key === 'Delete') { + event.preventDefault(); + if (services.selected.size > 0) services.deleteSelection(); + else if (entry?.type === 'file') services.deleteFile(entry.path); + else if (entry) services.deleteFolder(entry.path); + } else if (event.key === 'Escape') { + if (services.selected.size > 0) services.selected.clear(); + else if (services.focusPath()) services.clearFocus(); + else if (services.hasSearch()) services.clearSearch(); + else return; + services.render(); + } +} diff --git a/spacetime-files-ts/example/src/list-actions.ts b/spacetime-files-ts/example/src/list-actions.ts new file mode 100644 index 00000000000..3e5889d4372 --- /dev/null +++ b/spacetime-files-ts/example/src/list-actions.ts @@ -0,0 +1,157 @@ +import type { FileSummary } from './module_bindings/app/types'; +import type { ContextTarget } from './context-menu'; + +export interface ListActionServices { + selected: Set; + files(): readonly FileSummary[]; + setAnchor(path: string): void; + toggleSelect(path: string): void; + rangeSelect(path: string): void; + focus(path: string): void; + openFile(path: string): void; + openFolder(path: string): void; + openContext(event: MouseEvent, target: ContextTarget): void; + render(): void; + toggleVisibility(path: string): void; + copyLink(path: string): void; + downloadFile(file: FileSummary): void; + downloadFolder(path: string): void; + renameFile(path: string): void; + renameFolder(path: string): void; + moveFile(path: string): void; + deleteFile(path: string): void; + deleteFolder(path: string): void; + registerFolderDropCallbacks(element: HTMLElement, path: string): void; +} + +export function bindListActions( + list: HTMLElement, + services: ListActionServices +): void { + list + .querySelectorAll('[data-file], [data-folder]') + .forEach(row => { + row.addEventListener('click', event => { + if ( + (event.target as HTMLElement).closest( + '.row-actions, .badge, .sel, .badge-cell, .vis-dot' + ) + ) + return; + const file = row.dataset.file; + if (!file) return services.focus(row.dataset.folder!); + if (event.ctrlKey || event.metaKey) return services.toggleSelect(file); + if (event.shiftKey) return services.rangeSelect(file); + services.focus(file); + }); + row.addEventListener('dblclick', event => { + if ( + (event.target as HTMLElement).closest( + '.row-actions, .badge, .sel, .badge-cell, .vis-dot' + ) + ) + return; + if (row.dataset.file) services.openFile(row.dataset.file); + else services.openFolder(row.dataset.folder!); + }); + row.addEventListener('contextmenu', event => { + const file = row.dataset.file; + services.openContext( + event, + file + ? { type: 'file', path: file } + : { type: 'folder', path: row.dataset.folder! } + ); + }); + }); + + list.querySelectorAll('[data-select]').forEach(checkbox => { + checkbox.addEventListener('change', () => { + const path = checkbox.dataset.select!; + if (checkbox.checked) services.selected.add(path); + else services.selected.delete(path); + services.setAnchor(path); + services.render(); + }); + }); + list + .querySelectorAll('[data-visibility]') + .forEach(button => + button.addEventListener('click', () => + services.toggleVisibility(button.dataset.visibility!) + ) + ); + list + .querySelectorAll('[data-link]') + .forEach(button => + button.addEventListener('click', () => + services.copyLink(button.dataset.link!) + ) + ); + list.querySelectorAll('[data-download]').forEach(button => { + button.addEventListener('click', () => { + const file = services + .files() + .find(row => row.path === button.dataset.download); + if (file) services.downloadFile(file); + }); + }); + list.querySelectorAll('[data-download-folder]').forEach(button => + button.addEventListener('click', event => { + event.stopPropagation(); + services.downloadFolder(button.dataset.downloadFolder!); + }) + ); + list + .querySelectorAll('[data-rename]') + .forEach(button => + button.addEventListener('click', () => + services.renameFile(button.dataset.rename!) + ) + ); + list.querySelectorAll('[data-rename-folder]').forEach(button => + button.addEventListener('click', event => { + event.stopPropagation(); + services.renameFolder(button.dataset.renameFolder!); + }) + ); + list + .querySelectorAll('[data-move]') + .forEach(button => + button.addEventListener('click', () => + services.moveFile(button.dataset.move!) + ) + ); + list + .querySelectorAll('[data-delete-file]') + .forEach(button => + button.addEventListener('click', () => + services.deleteFile(button.dataset.deleteFile!) + ) + ); + list.querySelectorAll('[data-delete-folder]').forEach(button => + button.addEventListener('click', event => { + event.stopPropagation(); + services.deleteFolder(button.dataset.deleteFolder!); + }) + ); + + list.querySelectorAll('[data-drag]').forEach(row => { + row.addEventListener('dragstart', event => { + const path = row.dataset.drag!; + const paths = services.selected.has(path) + ? [...services.selected] + : [path]; + event.dataTransfer!.setData( + 'application/x-vault-path', + JSON.stringify(paths) + ); + event.dataTransfer!.effectAllowed = 'move'; + }); + }); + list + .querySelectorAll('[data-drop-folder]') + .forEach(row => + services.registerFolderDropCallbacks(row, row.dataset.dropFolder!) + ); +} diff --git a/spacetime-files-ts/example/src/module_bindings/app/create_folder_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/create_folder_reducer.ts new file mode 100644 index 00000000000..8a2a9b08001 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/create_folder_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + path: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/module_bindings/app/delete_file_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/delete_file_reducer.ts new file mode 100644 index 00000000000..8a2a9b08001 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/delete_file_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + path: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/module_bindings/app/delete_folder_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/delete_folder_reducer.ts new file mode 100644 index 00000000000..8a2a9b08001 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/delete_folder_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + path: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/module_bindings/app/files/types.ts b/spacetime-files-ts/example/src/module_bindings/app/files/types.ts new file mode 100644 index 00000000000..a8336b9566f --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/files/types.ts @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const File = __t.object("File", { + id: __t.u64(), + ownerPathKey: __t.string(), + path: __t.string(), + ownerUserId: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type File = __Infer; + +export const FileBlob = __t.object("FileBlob", { + fileId: __t.u64(), + bytes: __t.byteArray(), +}); +export type FileBlob = __Infer; + diff --git a/spacetime-files-ts/example/src/module_bindings/app/index.ts b/spacetime-files-ts/example/src/module_bindings/app/index.ts new file mode 100644 index 00000000000..9f863bb3fde --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/index.ts @@ -0,0 +1,142 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.8.3 (commit 8e410d2842147bd8e5a32a9589cc00c19f7478e2). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from "spacetimedb"; + +// Import all reducer arg schemas +import CreateFolderReducer from "./create_folder_reducer"; +import DeleteFileReducer from "./delete_file_reducer"; +import DeleteFolderReducer from "./delete_folder_reducer"; +import MoveFileReducer from "./move_file_reducer"; +import RenameFileReducer from "./rename_file_reducer"; +import RenameFolderReducer from "./rename_folder_reducer"; +import SetFileVisibilityReducer from "./set_file_visibility_reducer"; +import UploadFileReducer from "./upload_file_reducer"; + +// Import all procedure arg schemas +import * as ReadFileBytesProcedure from "./read_file_bytes_procedure"; + +// Import all table schema definitions +import MyFileSummariesRow from "./my_file_summaries_table"; +import MyFoldersRow from "./my_folders_table"; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + myFileSummaries: __table({ + name: 'my_file_summaries', + indexes: [ + ], + constraints: [ + ], + }, MyFileSummariesRow), + myFolders: __table({ + name: 'my_folders', + indexes: [ + ], + constraints: [ + ], + }, MyFoldersRow), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema("create_folder", CreateFolderReducer), + __reducerSchema("delete_file", DeleteFileReducer), + __reducerSchema("delete_folder", DeleteFolderReducer), + __reducerSchema("move_file", MoveFileReducer), + __reducerSchema("rename_file", RenameFileReducer), + __reducerSchema("rename_folder", RenameFolderReducer), + __reducerSchema("set_file_visibility", SetFileVisibilityReducer), + __reducerSchema("upload_file", UploadFileReducer), +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( + __procedureSchema("read_file_bytes", ReadFileBytesProcedure.params, ReadFileBytesProcedure.returnType), +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.8.3" as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +export const tables: __QueryBuilder = __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); + +/** The procedures available in this remote SpacetimeDB module. */ +export const procedures = __convertToAccessorMap(proceduresSchema.procedures); + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} + diff --git a/spacetime-files-ts/example/src/module_bindings/app/move_file_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/move_file_reducer.ts new file mode 100644 index 00000000000..39be1981f74 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/move_file_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + oldPath: __t.string(), + targetFolderPath: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/module_bindings/app/my_file_summaries_table.ts b/spacetime-files-ts/example/src/module_bindings/app/my_file_summaries_table.ts new file mode 100644 index 00000000000..52eae105682 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/my_file_summaries_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64(), + path: __t.string(), + mimeType: __t.string().name("mime_type"), + size: __t.u64(), + sha256Hex: __t.string().name("sha_256_hex"), + visibility: __t.string(), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-files-ts/example/src/module_bindings/app/my_folders_table.ts b/spacetime-files-ts/example/src/module_bindings/app/my_folders_table.ts new file mode 100644 index 00000000000..dc7c48b3c85 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/my_folders_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.u64().primaryKey(), + ownerUserId: __t.string().name("owner_user_id"), + path: __t.string(), + name: __t.string(), + parentPath: __t.string().name("parent_path"), + createdAt: __t.timestamp().name("created_at"), + updatedAt: __t.timestamp().name("updated_at"), +}); diff --git a/spacetime-files-ts/example/src/module_bindings/app/read_file_bytes_procedure.ts b/spacetime-files-ts/example/src/module_bindings/app/read_file_bytes_procedure.ts new file mode 100644 index 00000000000..902cf595193 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/read_file_bytes_procedure.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +import { + FileBytes, +} from "./types"; + +export const params = { + path: __t.string(), +}; +export const returnType = FileBytes \ No newline at end of file diff --git a/spacetime-files-ts/example/src/module_bindings/app/rename_file_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/rename_file_reducer.ts new file mode 100644 index 00000000000..50145dc0f1d --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/rename_file_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + oldPath: __t.string(), + newPath: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/module_bindings/app/rename_folder_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/rename_folder_reducer.ts new file mode 100644 index 00000000000..83dd1a2c22b --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/rename_folder_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + path: __t.string(), + newName: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/module_bindings/app/set_file_visibility_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/set_file_visibility_reducer.ts new file mode 100644 index 00000000000..f6ffa1c4f04 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/set_file_visibility_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + path: __t.string(), + visibility: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/module_bindings/app/types.ts b/spacetime-files-ts/example/src/module_bindings/app/types.ts new file mode 100644 index 00000000000..0d89dc6fd01 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/types.ts @@ -0,0 +1,46 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const FileBytes = __t.object("FileBytes", { + bytes: __t.byteArray(), + mimeType: __t.string(), +}); +export type FileBytes = __Infer; + +export const FileSummary = __t.object("FileSummary", { + id: __t.u64(), + path: __t.string(), + mimeType: __t.string(), + size: __t.u64(), + sha256Hex: __t.string(), + visibility: __t.string(), + updatedAt: __t.timestamp(), +}); +export type FileSummary = __Infer; + +export const Folder = __t.object("Folder", { + id: __t.u64(), + ownerUserId: __t.string(), + path: __t.string(), + name: __t.string(), + parentPath: __t.string(), + createdAt: __t.timestamp(), + updatedAt: __t.timestamp(), +}); +export type Folder = __Infer; + +export const MyFileSummaries = __t.object("MyFileSummaries", {}); +export type MyFileSummaries = __Infer; + +export const MyFolders = __t.object("MyFolders", {}); +export type MyFolders = __Infer; + diff --git a/spacetime-files-ts/example/src/module_bindings/app/types/procedures.ts b/spacetime-files-ts/example/src/module_bindings/app/types/procedures.ts new file mode 100644 index 00000000000..f6c24f082b1 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/types/procedures.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas +import * as ReadFileBytesProcedure from "../read_file_bytes_procedure"; + +export type ReadFileBytesArgs = __Infer; +export type ReadFileBytesResult = __Infer; + diff --git a/spacetime-files-ts/example/src/module_bindings/app/types/reducers.ts b/spacetime-files-ts/example/src/module_bindings/app/types/reducers.ts new file mode 100644 index 00000000000..4584b8cc134 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/types/reducers.ts @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import CreateFolderReducer from "../create_folder_reducer"; +import DeleteFileReducer from "../delete_file_reducer"; +import DeleteFolderReducer from "../delete_folder_reducer"; +import MoveFileReducer from "../move_file_reducer"; +import RenameFileReducer from "../rename_file_reducer"; +import RenameFolderReducer from "../rename_folder_reducer"; +import SetFileVisibilityReducer from "../set_file_visibility_reducer"; +import UploadFileReducer from "../upload_file_reducer"; + +export type CreateFolderParams = __Infer; +export type DeleteFileParams = __Infer; +export type DeleteFolderParams = __Infer; +export type MoveFileParams = __Infer; +export type RenameFileParams = __Infer; +export type RenameFolderParams = __Infer; +export type SetFileVisibilityParams = __Infer; +export type UploadFileParams = __Infer; + diff --git a/spacetime-files-ts/example/src/module_bindings/app/upload_file_reducer.ts b/spacetime-files-ts/example/src/module_bindings/app/upload_file_reducer.ts new file mode 100644 index 00000000000..9d3d5519978 --- /dev/null +++ b/spacetime-files-ts/example/src/module_bindings/app/upload_file_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + path: __t.string(), + mimeType: __t.string(), + bytes: __t.byteArray(), + visibility: __t.string(), +}; diff --git a/spacetime-files-ts/example/src/paths.ts b/spacetime-files-ts/example/src/paths.ts new file mode 100644 index 00000000000..9d83db50127 --- /dev/null +++ b/spacetime-files-ts/example/src/paths.ts @@ -0,0 +1,43 @@ +export type Visibility = 'owner' | 'public'; + +export function normalizePath( + path: string, + kind: 'file' | 'folder' = 'folder' +): string { + let normalized = String(path || '') + .trim() + .replaceAll('\\', '/') + .replace(/\/+/g, '/'); + if (!normalized.startsWith('/')) normalized = '/' + normalized; + if (normalized.length > 1 && normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + if (kind === 'file' && normalized === '/') { + throw new Error('file path required'); + } + return normalized; +} + +export function parentPath(path: string): string { + if (path === '/') return '/'; + const separatorIndex = path.lastIndexOf('/'); + return separatorIndex <= 0 ? '/' : path.slice(0, separatorIndex); +} + +export function baseName(path: string): string { + if (path === '/') return '/'; + return path.slice(path.lastIndexOf('/') + 1); +} + +export function joinPath(directory: string, name: string): string { + return directory === '/' ? `/${name}` : `${directory}/${name}`; +} + +// '/docs' must not match '/docs2'. +export function childPrefix(path: string): string { + return path === '/' ? '/' : path + '/'; +} + +export function fileUrl(id: bigint): string { + return `/files?id=${encodeURIComponent(String(id))}`; +} diff --git a/spacetime-files-ts/example/src/presentation.ts b/spacetime-files-ts/example/src/presentation.ts new file mode 100644 index 00000000000..2e0782bd6b0 --- /dev/null +++ b/spacetime-files-ts/example/src/presentation.ts @@ -0,0 +1,101 @@ +import type { Timestamp } from 'spacetimedb'; + +export function formatFileSize( + value: number | bigint | string | undefined +): string { + const bytes = Number(value ?? 0); + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(2)} MB`; +} + +export function timestampMilliseconds( + timestamp: Timestamp | undefined +): number { + if (!timestamp) return 0; + try { + return Number(timestamp.microsSinceUnixEpoch / 1000n); + } catch { + return 0; + } +} + +export function formatTimestamp(timestamp: Timestamp | undefined): string { + const milliseconds = timestampMilliseconds(timestamp); + if (!milliseconds) return ''; + const date = new Date(milliseconds); + if (date.toDateString() === new Date().toDateString()) { + return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + } + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} + +export function escapeHtml(value: unknown): string { + return String(value ?? '').replace( + /[&<>"']/g, + character => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ + character + ]! + ); +} + +export function fileKindPresentation(mimeType: string | undefined): { + className: string; + iconName: string; +} { + if (!mimeType) return { className: 'generic', iconName: 'file' }; + if (mimeType.startsWith('image/')) { + return { className: 'image', iconName: 'file-image' }; + } + if (mimeType.startsWith('audio/') || mimeType.startsWith('video/')) { + return { className: 'media', iconName: 'file-media' }; + } + if (mimeType.startsWith('text/') || mimeType === 'application/json') { + return { className: 'text', iconName: 'file-text' }; + } + return { className: 'generic', iconName: 'file' }; +} + +const ERROR_MESSAGES: Record = { + 'vault.folder_not_empty': + "That folder isn't empty. Delete its contents first.", + 'vault.folder_exists': 'A folder with that name already exists here.', + 'vault.file_exists': + 'A file with that name already exists at the destination.', + 'vault.parent_not_found': "That destination folder doesn't exist.", + 'vault.folder_not_found': "That folder doesn't exist.", + 'vault.file_not_found': "That file doesn't exist.", + 'vault.cannot_delete_root': "The root folder can't be deleted.", + 'vault.cannot_rename_root': "The root folder can't be renamed.", + 'vault.invalid_file_path': 'A file needs a name.', + 'vault.invalid_path': "That name isn't allowed.", + 'vault.invalid_visibility': 'That visibility value is invalid.', + 'files.invalid_path': "That name isn't allowed.", + 'files.invalid_visibility': 'That visibility value is invalid.', + 'files.not_found': "That file doesn't exist.", + 'files.invalid_mime_type': 'That file type is invalid.', +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error ?? ''); +} + +export function errorCode(error: unknown): string { + return ( + (errorMessage(error).match(/\b(?:vault|files)\.[a-z_]+/) ?? [])[0] ?? '' + ); +} + +export function humanError( + error: unknown, + context: { name?: string } = {} +): string { + const rawMessage = errorMessage(error) || 'Something went wrong'; + const sizeMatch = rawMessage.match(/^files\.too_large:(\d+)\/(\d+)/); + if (sizeMatch) { + const name = context.name ? `"${context.name}"` : 'That file'; + return `${name} is ${formatFileSize(sizeMatch[1])}. Vault caps files at ${formatFileSize(sizeMatch[2])}.`; + } + return ERROR_MESSAGES[errorCode(error)] ?? rawMessage; +} diff --git a/spacetime-files-ts/example/src/rendering.ts b/spacetime-files-ts/example/src/rendering.ts new file mode 100644 index 00000000000..6bb71932c37 --- /dev/null +++ b/spacetime-files-ts/example/src/rendering.ts @@ -0,0 +1,229 @@ +import type { FileSummary, Folder } from './module_bindings/app/types'; +import { baseName, childPrefix, parentPath } from './paths'; +import { + escapeHtml, + formatFileSize, + formatTimestamp, + fileKindPresentation, + timestampMilliseconds, +} from './presentation'; + +export type SortKey = 'name' | 'size' | 'updated' | 'visibility'; +export type Entry = { type: 'file' | 'folder'; path: string }; + +export interface VaultRenderState { + folders: Folder[]; + files: FileSummary[]; + currentPath: string; + searchQuery: string; + sortKey: SortKey; + sortDir: 1 | -1; + selected: ReadonlySet; + focusPath: string | null; +} + +export const icon = (name: string): string => + ``; + +export function createVaultRendering(getState: () => VaultRenderState) { + const immediateFolders = (path: string): Folder[] => + getState().folders.filter(folder => folder.parentPath === path); + + const immediateFiles = (path: string): FileSummary[] => + getState().files.filter(file => parentPath(file.path) === path); + + const allFolderPaths = (): string[] => [ + '/', + ...getState() + .folders.map(folder => folder.path) + .sort(), + ]; + + const fileCmp = (a: FileSummary, b: FileSummary): number => { + const { sortKey, sortDir } = getState(); + let result = 0; + if (sortKey === 'size') result = Number(a.size) - Number(b.size); + else if (sortKey === 'updated') + result = + timestampMilliseconds(a.updatedAt) - timestampMilliseconds(b.updatedAt); + else if (sortKey === 'visibility') + result = a.visibility.localeCompare(b.visibility); + if (result === 0) result = baseName(a.path).localeCompare(baseName(b.path)); + return result * sortDir; + }; + + const folderCmp = (a: Folder, b: Folder): number => { + const { sortKey, sortDir } = getState(); + let result = + sortKey === 'updated' + ? timestampMilliseconds(a.updatedAt) - + timestampMilliseconds(b.updatedAt) + : 0; + if (result === 0) result = a.name.localeCompare(b.name); + return result * sortDir; + }; + + const visibleEntries = (): { dirs: Folder[]; fs: FileSummary[] } => { + const { folders, files, currentPath, searchQuery } = getState(); + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return { + dirs: folders + .filter(folder => folder.name.toLowerCase().includes(query)) + .sort(folderCmp), + fs: files + .filter(file => baseName(file.path).toLowerCase().includes(query)) + .sort(fileCmp), + }; + } + return { + dirs: immediateFolders(currentPath).sort(folderCmp), + fs: immediateFiles(currentPath).sort(fileCmp), + }; + }; + + const stateClasses = (path: string, selectable: boolean): string => { + const { selected, focusPath } = getState(); + return `${selectable && selected.has(path) ? 'selected' : ''} ${path === focusPath ? 'focused' : ''}`; + }; + + const fileLiOpen = (file: FileSummary, kind: 'row' | 'tile'): string => + `
      • `; + + const folderLiOpen = (folder: Folder, kind: 'row' | 'tile'): string => + `
      • `; + + const selectionCheckbox = (file: FileSummary): string => + ``; + + const fileRowHtml = (file: FileSummary): string => { + const kind = fileKindPresentation(file.mimeType); + const isPublic = file.visibility === 'public'; + return ` + ${fileLiOpen(file, 'row')} + ${selectionCheckbox(file)} + ${icon(kind.iconName)}${escapeHtml(baseName(file.path))} + ${getState().searchQuery ? escapeHtml(parentPath(file.path)) : formatFileSize(file.size)} + ${formatTimestamp(file.updatedAt)} + + + + + + + + + + +
      • `; + }; + + const folderRowHtml = (folder: Folder): string => ` + ${folderLiOpen(folder, 'row')} + + ${icon('folder')}${escapeHtml(folder.name)} + ${getState().searchQuery ? escapeHtml(parentPath(folder.path)) : 'Folder'} + ${formatTimestamp(folder.updatedAt)} + + + + + + + `; + + const fileTileHtml = (file: FileSummary): string => { + const kind = fileKindPresentation(file.mimeType); + const isPublic = file.visibility === 'public'; + const isImage = (file.mimeType || '').startsWith('image/'); + return ` + ${fileLiOpen(file, 'tile')} + ${selectionCheckbox(file)} + ${icon(isPublic ? 'globe' : 'lock')} +
        ${icon(kind.iconName)}
        +
        ${icon(kind.iconName)}${escapeHtml(baseName(file.path))}
        + `; + }; + + const folderTileHtml = (folder: Folder): string => ` + ${folderLiOpen(folder, 'tile')} +
        ${icon('folder')}
        +
        ${icon('folder')}${escapeHtml(folder.name)}
        + `; + + const subtreeStats = ( + folderPath: string + ): { fileCount: number; folderCount: number; bytes: number } => { + const { files, folders } = getState(); + const prefix = childPrefix(folderPath); + const childFiles = files.filter(file => file.path.startsWith(prefix)); + const childFolders = folders.filter( + folder => folder.path !== folderPath && folder.path.startsWith(prefix) + ); + return { + fileCount: childFiles.length, + folderCount: childFolders.length, + bytes: childFiles.reduce((total, file) => total + Number(file.size), 0), + }; + }; + + return { + allFolderPaths, + fileRowHtml, + fileTileHtml, + folderRowHtml, + folderTileHtml, + immediateFiles, + immediateFolders, + subtreeStats, + visibleEntries, + }; +} + +export function fileDetailsHtml(row: FileSummary): string { + const kind = fileKindPresentation(row.mimeType); + const isImage = (row.mimeType || '').startsWith('image/'); + const updatedAtMs = timestampMilliseconds(row.updatedAt); + return ` +
        ${icon(kind.iconName)}
        +
        ${icon(kind.iconName)}${escapeHtml(baseName(row.path))}
        +
        +
        Type${escapeHtml(row.mimeType || 'file')}
        +
        Size${formatFileSize(row.size)}
        +
        Location
        + ${updatedAtMs ? `
        Modified${escapeHtml(new Date(updatedAtMs).toLocaleString())}
        ` : ''} +
        Visibility${row.visibility === 'public' ? 'Public' : 'Private (owner only)'}
        +
        SHA-256${escapeHtml((row.sha256Hex ?? '').slice(0, 16))}...
        +
        `; +} + +export function folderDetailsHtml( + folderPath: string, + folder: Folder | undefined, + stats: { fileCount: number; folderCount: number; bytes: number } +): string { + const isRoot = folderPath === '/'; + const updatedAtMs = folder ? timestampMilliseconds(folder.updatedAt) : 0; + return ` +
        ${icon('folder')}
        +
        ${icon('folder')}${escapeHtml(isRoot ? 'Root' : (folder?.name ?? ''))}
        +
        +
        TypeFolder
        +
        Contents${stats.fileCount} file${stats.fileCount === 1 ? '' : 's'}, ${stats.folderCount} folder${stats.folderCount === 1 ? '' : 's'}
        +
        Size${formatFileSize(stats.bytes)}
        + ${!isRoot ? `
        Location
        ` : ''} + ${updatedAtMs ? `
        Modified${escapeHtml(new Date(updatedAtMs).toLocaleString())}
        ` : ''} +
        `; +} + +export function selectionDetailsHtml(rows: readonly FileSummary[]): string { + const bytes = rows.reduce((total, file) => total + Number(file.size), 0); + return ` +
        ${icon('copy')}${rows.length} files selected
        +
        +
        Total size${formatFileSize(bytes)}
        +
        Public${rows.filter(row => row.visibility === 'public').length} of ${rows.length}
        +
        `; +} diff --git a/spacetime-files-ts/example/src/selection.ts b/spacetime-files-ts/example/src/selection.ts new file mode 100644 index 00000000000..750f08adfed --- /dev/null +++ b/spacetime-files-ts/example/src/selection.ts @@ -0,0 +1,71 @@ +import type { Entry } from './rendering'; + +export class VaultSelection { + readonly selected = new Set(); + focusPath: string | null = null; + private anchorPath: string | null = null; + private renderedEntries: Entry[] = []; + + get entries(): readonly Entry[] { + return this.renderedEntries; + } + + setEntries(entries: readonly Entry[]): void { + this.renderedEntries = [...entries]; + } + + setAnchor(path: string): void { + this.anchorPath = path; + } + + focus(path: string): boolean { + this.anchorPath = path; + if (this.focusPath === path) return false; + this.focusPath = path; + return true; + } + + clearFocus(): void { + this.focusPath = null; + } + + toggle(path: string): void { + if (this.selected.has(path)) this.selected.delete(path); + else this.selected.add(path); + this.anchorPath = path; + } + + selectRange(path: string): void { + const filePaths = this.renderedEntries + .filter(entry => entry.type === 'file') + .map(entry => entry.path); + const anchorIndex = filePaths.indexOf(this.anchorPath ?? ''); + const targetIndex = filePaths.indexOf(path); + if (anchorIndex < 0 || targetIndex < 0) { + this.toggle(path); + return; + } + for ( + let index = Math.min(anchorIndex, targetIndex); + index <= Math.max(anchorIndex, targetIndex); + index++ + ) { + this.selected.add(filePaths[index]!); + } + } + + prune( + validSelectedPaths: ReadonlySet, + validFocusPaths: ReadonlySet + ): void { + for (const path of this.selected) { + if (!validSelectedPaths.has(path)) this.selected.delete(path); + } + if (this.focusPath && !validFocusPaths.has(this.focusPath)) { + this.focusPath = null; + } + if (this.anchorPath && !validSelectedPaths.has(this.anchorPath)) { + this.anchorPath = null; + } + } +} diff --git a/spacetime-files-ts/example/src/session.ts b/spacetime-files-ts/example/src/session.ts new file mode 100644 index 00000000000..47b039e53b9 --- /dev/null +++ b/spacetime-files-ts/example/src/session.ts @@ -0,0 +1,30 @@ +export interface ServerConfig { + spacetimeUri: string; + databaseName: string; +} + +const STDB_TOKEN_KEY = 'vault:auth-token'; + +export function loadStdbToken(): string | undefined { + try { + return localStorage.getItem(STDB_TOKEN_KEY) ?? undefined; + } catch { + return undefined; + } +} + +export function saveStdbToken(token: string | undefined): void { + try { + if (token) localStorage.setItem(STDB_TOKEN_KEY, token); + } catch { + // The connection keeps the token in memory when storage is unavailable. + } +} + +export function clearStdbToken(): void { + try { + localStorage.removeItem(STDB_TOKEN_KEY); + } catch { + // Storage is optional. + } +} diff --git a/spacetime-files-ts/example/src/uploads.ts b/spacetime-files-ts/example/src/uploads.ts new file mode 100644 index 00000000000..eee3bba6168 --- /dev/null +++ b/spacetime-files-ts/example/src/uploads.ts @@ -0,0 +1,215 @@ +import { FILE_BYTES_MAX } from '@spacetimedb/files/constants'; +import type { FileSummary } from './module_bindings/app/types'; +import type { DialogOptions } from './dialog'; +import { joinPath, normalizePath, type Visibility } from './paths'; +import { + errorCode, + escapeHtml, + formatFileSize, + humanError, +} from './presentation'; + +export interface DroppedEntries { + files: Array<{ file: File; rel: string }>; + dirs: string[]; +} + +function walkEntry( + entry: FileSystemEntry, + prefix: string, + output: DroppedEntries +): Promise { + return new Promise(resolve => { + if (entry.isFile) { + (entry as FileSystemFileEntry).file( + file => { + output.files.push({ file, rel: prefix + entry.name }); + resolve(); + }, + () => resolve() + ); + return; + } + if (!entry.isDirectory) { + resolve(); + return; + } + + const relativePath = prefix + entry.name; + output.dirs.push(relativePath); + const reader = (entry as FileSystemDirectoryEntry).createReader(); + const children: FileSystemEntry[] = []; + const readBatch = () => + reader.readEntries( + entries => { + void (async () => { + if (entries.length > 0) { + children.push(...entries); + readBatch(); + return; + } + for (const child of children) + await walkEntry(child, `${relativePath}/`, output); + resolve(); + })(); + }, + () => resolve() + ); + readBatch(); + }); +} + +export async function collectDropped( + dataTransfer: DataTransfer +): Promise { + const output: DroppedEntries = { files: [], dirs: [] }; + const items = [...(dataTransfer.items ?? [])]; + const entries = items + .map(item => item.webkitGetAsEntry?.()) + .filter((entry): entry is FileSystemEntry => Boolean(entry)); + if (entries.length > 0) { + for (const entry of entries) await walkEntry(entry, '', output); + } else { + for (const file of [...(dataTransfer.files ?? [])]) { + output.files.push({ file, rel: file.name }); + } + } + return output; +} + +export interface UploadServices { + ready(): boolean; + files(): readonly FileSummary[]; + createFolder(path: string): Promise; + uploadFile(args: { + path: string; + mimeType: string; + bytes: Uint8Array; + visibility: Visibility; + }): Promise; + freeName(path: string): string; + openDialog( + title: string, + bodyHtml: string, + onSave: (() => void | Promise) | null, + options?: DialogOptions + ): void; + setProgress(uploading: boolean, label?: string): void; + toast(kind: 'ok' | 'err', message: string): void; +} + +export class UploadController { + constructor(private readonly services: UploadServices) {} + + async upload(entries: DroppedEntries, targetFolder: string): Promise { + if (!this.services.ready()) return; + const { files, dirs } = entries; + if (files.length === 0 && dirs.length === 0) return; + const conflicts = files.filter(entry => + this.services + .files() + .some(file => file.path === joinPath(targetFolder, entry.rel)) + ); + if (conflicts.length > 0) { + const listHtml = + conflicts + .slice(0, 6) + .map( + conflict => `
        ${escapeHtml(conflict.rel)}
        ` + ) + .join('') + + (conflicts.length > 6 + ? `
        ...and ${conflicts.length - 6} more
        ` + : ''); + this.services.openDialog( + `${conflicts.length} file${conflicts.length === 1 ? '' : 's'} already exist${conflicts.length === 1 ? 's' : ''}`, + `

        Replace the existing file${conflicts.length === 1 ? '' : 's'}, or keep both by renaming the new one${conflicts.length === 1 ? '' : 's'}?

        ${listHtml}`, + () => this.perform(entries, targetFolder, 'replace'), + { + okLabel: 'Replace', + altLabel: 'Keep both', + onAlt: () => this.perform(entries, targetFolder, 'keep-both'), + } + ); + return; + } + await this.perform(entries, targetFolder, 'replace'); + } + + private async perform( + entries: DroppedEntries, + targetFolder: string, + conflictMode: 'replace' | 'keep-both' + ): Promise { + const directories = new Set(entries.dirs); + for (const entry of entries.files) { + const parts = entry.rel.split('/').slice(0, -1); + let path = ''; + for (const part of parts) { + path = path ? `${path}/${part}` : part; + directories.add(path); + } + } + for (const relativePath of [...directories].sort( + (a, b) => a.split('/').length - b.split('/').length + )) { + try { + await this.services.createFolder(joinPath(targetFolder, relativePath)); + } catch (error) { + if (errorCode(error) !== 'vault.folder_exists') { + this.services.toast('err', humanError(error)); + return; + } + } + } + + const failures: string[] = []; + const accepted = entries.files.filter(entry => { + if (entry.file.size <= FILE_BYTES_MAX) return true; + failures.push( + `${entry.rel}: ${formatFileSize(entry.file.size)} exceeds the ${formatFileSize(FILE_BYTES_MAX)} cap` + ); + return false; + }); + let completed = 0; + this.services.setProgress( + true, + accepted.length ? `Uploading 0/${accepted.length}...` : undefined + ); + for (const entry of accepted) { + try { + let path = normalizePath(joinPath(targetFolder, entry.rel), 'file'); + const existingFiles = this.services.files(); + if ( + conflictMode === 'keep-both' && + existingFiles.some(file => file.path === path) + ) { + path = this.services.freeName(path); + } + const existing = existingFiles.find(file => file.path === path); + await this.services.uploadFile({ + path, + mimeType: entry.file.type || 'application/octet-stream', + bytes: new Uint8Array(await entry.file.arrayBuffer()), + visibility: + (existing?.visibility as Visibility | undefined) ?? 'owner', + }); + completed++; + this.services.setProgress( + true, + `Uploading ${completed}/${accepted.length}...` + ); + } catch (error) { + failures.push(humanError(error, { name: entry.rel })); + } + } + this.services.setProgress(false); + if (completed > 0) { + this.services.toast( + 'ok', + `${completed} file${completed === 1 ? '' : 's'} uploaded` + ); + } + for (const failure of failures) this.services.toast('err', failure); + } +} diff --git a/spacetime-files-ts/example/src/viewer.ts b/spacetime-files-ts/example/src/viewer.ts new file mode 100644 index 00000000000..8cb30204a8f --- /dev/null +++ b/spacetime-files-ts/example/src/viewer.ts @@ -0,0 +1,186 @@ +import type { FileSummary } from './module_bindings/app/types'; +import { baseName } from './paths'; +import { + escapeHtml, + formatFileSize, + humanError, + timestampMilliseconds, +} from './presentation'; + +const element = (id: string): T => + document.getElementById(id) as T; + +export interface FileViewerServices { + loadBlob(file: FileSummary): Promise; + download(file: FileSummary): Promise; + iconHtml(name: 'file' | 'download'): string; +} + +export class FileViewer { + private files: FileSummary[] = []; + private index = -1; + private ownedUrl: string | null = null; + private generation = 0; + private scale: number | null = null; + + path: string | null = null; + + constructor(private readonly services: FileViewerServices) {} + + isOpen(): boolean { + return element('lightbox').classList.contains('open'); + } + + currentFile(): FileSummary | undefined { + return this.path + ? this.files.find(file => file.path === this.path) + : undefined; + } + + async open(path: string, files: FileSummary[]): Promise { + this.files = files.slice(); + const index = Math.max( + 0, + this.files.findIndex(file => file.path === path) + ); + this.scale = null; + element('lightbox').classList.add('open'); + await this.load(index); + } + + step(delta: number): void { + if (this.files.length < 2) return; + this.scale = null; + void this.load( + (this.index + delta + this.files.length) % this.files.length + ); + } + + close(): void { + this.generation++; + element('lightbox').classList.remove('open'); + element('lb-stage').innerHTML = ''; + this.setZoomControls(false); + this.releaseUrl(); + this.path = null; + } + + zoom(factor: number): void { + const image = element('lb-stage').querySelector('img'); + if (!image) return; + if (this.scale === null) this.scale = image.width / image.naturalWidth || 1; + this.scale = Math.min(8, Math.max(0.1, this.scale * factor)); + this.applyScale(); + } + + fit(): void { + this.scale = null; + this.applyScale(); + } + + fullSize(): void { + this.scale = 1; + this.applyScale(); + } + + private releaseUrl(): void { + if (!this.ownedUrl) return; + URL.revokeObjectURL(this.ownedUrl); + this.ownedUrl = null; + } + + private applyScale = (): void => { + const image = element('lb-stage').querySelector('img'); + if (!image) return; + if (this.scale === null) { + image.classList.add('fit'); + image.style.width = ''; + } else { + image.classList.remove('fit'); + image.style.width = `${image.naturalWidth * this.scale}px`; + } + }; + + private setZoomControls(visible: boolean): void { + for (const id of ['lb-out', 'lb-in', 'lb-fit', 'lb-full']) { + element(id).style.display = visible ? '' : 'none'; + } + } + + private async load(index: number): Promise { + const row = this.files[index]; + if (!row) return; + const generation = ++this.generation; + this.index = index; + this.path = row.path; + element('lb-title').textContent = baseName(row.path); + const updatedAtMs = timestampMilliseconds(row.updatedAt); + element('lb-meta').textContent = [ + row.mimeType || 'file', + formatFileSize(row.size), + row.visibility === 'public' ? 'Public' : 'Private', + updatedAtMs ? new Date(updatedAtMs).toLocaleString() : '', + this.files.length > 1 ? `${index + 1}/${this.files.length}` : '', + ] + .filter(Boolean) + .join(' | '); + element('lb-meta').title = row.sha256Hex ? `SHA-256 ${row.sha256Hex}` : ''; + element('lb-prev').disabled = this.files.length < 2; + element('lb-next').disabled = this.files.length < 2; + await this.buildStage(row, generation); + } + + private async buildStage( + row: FileSummary, + generation: number + ): Promise { + const stage = element('lb-stage'); + const mime = row.mimeType || ''; + const previewable = + mime.startsWith('image/') || + mime.startsWith('audio/') || + mime.startsWith('video/') || + mime.startsWith('text/') || + mime === 'application/json' || + mime === 'application/pdf'; + this.releaseUrl(); + this.setZoomControls(false); + if (!previewable) { + stage.innerHTML = `
        ${this.services.iconHtml('file')}
        No inline preview for this type.
        `; + stage + .querySelector('[data-vdl]') + ?.addEventListener('click', () => void this.services.download(row)); + return; + } + + let blob: Blob; + try { + blob = await this.services.loadBlob(row); + } catch (error) { + if (generation !== this.generation) return; + stage.innerHTML = `
        ${this.services.iconHtml('file')}
        Could not load this file: ${escapeHtml(humanError(error))}
        `; + return; + } + if (generation !== this.generation) return; + if (mime.startsWith('text/') || mime === 'application/json') { + const text = await blob.text(); + if (generation !== this.generation) return; + stage.innerHTML = `
        ${escapeHtml(text.slice(0, 20000))}
        `; + return; + } + + this.ownedUrl = URL.createObjectURL(blob); + if (mime.startsWith('image/')) { + stage.innerHTML = `${escapeHtml(baseName(row.path))}`; + this.scale = null; + stage.querySelector('img')!.onload = this.applyScale; + this.setZoomControls(true); + } else if (mime.startsWith('audio/')) { + stage.innerHTML = ``; + } else if (mime.startsWith('video/')) { + stage.innerHTML = ``; + } else { + stage.innerHTML = ``; + } + } +} diff --git a/spacetime-files-ts/example/src/zip.ts b/spacetime-files-ts/example/src/zip.ts new file mode 100644 index 00000000000..e4827ec7de2 --- /dev/null +++ b/spacetime-files-ts/example/src/zip.ts @@ -0,0 +1,129 @@ +// ZIP archives use STORE mode for small files and require no compression dependency. + +const crcTable = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); +function crc32(bytes: Uint8Array): number { + let c = 0xffffffff; + for (let i = 0; i < bytes.length; i++) + c = crcTable[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +function dosDateTime(ms: number | undefined): { time: number; date: number } { + const d = ms ? new Date(ms) : new Date(); + return { + time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1), + date: + (((d.getFullYear() - 1980) & 0x7f) << 9) | + ((d.getMonth() + 1) << 5) | + d.getDate(), + }; +} +export interface ZipEntry { + name: string; + bytes?: Uint8Array; + mtimeMs?: number; + isDir?: boolean; // dir names must end with '/' +} +export function buildZip(entries: ZipEntry[]): Blob { + const te = new TextEncoder(); + const u16 = (v: number) => new Uint8Array([v & 255, (v >>> 8) & 255]); + const u32 = (v: number) => + new Uint8Array([ + v & 255, + (v >>> 8) & 255, + (v >>> 16) & 255, + (v >>> 24) & 255, + ]); + const chunks: Uint8Array[] = []; + const central: Array<{ + name: Uint8Array; + crc: number; + size: number; + time: number; + date: number; + offset: number; + isDir: boolean; + }> = []; + let offset = 0; + for (const e of entries) { + const name = te.encode(e.name); + const data = e.bytes ?? new Uint8Array(); + const crc = e.isDir ? 0 : crc32(data); + const { time, date } = dosDateTime(e.mtimeMs); + // Local file header: flag 0x0800 = UTF-8 names, method 0 = store. + chunks.push( + u32(0x04034b50), + u16(20), + u16(0x0800), + u16(0), + u16(time), + u16(date), + u32(crc), + u32(data.length), + u32(data.length), + u16(name.length), + u16(0), + name, + data + ); + central.push({ + name, + crc, + size: data.length, + time, + date, + offset, + isDir: !!e.isDir, + }); + offset += 30 + name.length + data.length; + } + const cdStart = offset; + let cdSize = 0; + for (const c of central) { + chunks.push( + u32(0x02014b50), + u16(20), + u16(20), + u16(0x0800), + u16(0), + u16(c.time), + u16(c.date), + u32(c.crc), + u32(c.size), + u32(c.size), + u16(c.name.length), + u16(0), + u16(0), + u16(0), + u16(0), + u32(c.isDir ? 0x10 : 0), + u32(c.offset), + c.name + ); + cdSize += 46 + c.name.length; + } + chunks.push( + u32(0x06054b50), + u16(0), + u16(0), + u16(central.length), + u16(central.length), + u32(cdSize), + u32(cdStart), + u16(0) + ); + // BlobPart requires an ArrayBuffer-backed byte view under TS 5.7. + return new Blob(chunks as unknown as BlobPart[], { type: 'application/zip' }); +} +export function zipStamp(): string { + const d = new Date(); + const p = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; +} diff --git a/spacetime-files-ts/example/tsconfig.json b/spacetime-files-ts/example/tsconfig.json new file mode 100644 index 00000000000..ae0d0a4d3c3 --- /dev/null +++ b/spacetime-files-ts/example/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"], + "isolatedModules": true, + "allowImportingTsExtensions": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts", "server.ts"], + "exclude": ["node_modules", "spacetimedb"] +} diff --git a/spacetime-files-ts/package.json b/spacetime-files-ts/package.json new file mode 100644 index 00000000000..a05a0ce6e56 --- /dev/null +++ b/spacetime-files-ts/package.json @@ -0,0 +1,79 @@ +{ + "name": "@spacetimedb/files", + "description": "Transactional file storage, visibility, hashing, and serving primitives for SpacetimeDB TypeScript modules.", + "version": "0.1.0", + "license": "BUSL-1.1", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./constants": { + "types": "./src/constants.ts", + "default": "./src/constants.ts" + }, + "./rows": { + "types": "./src/rows.ts", + "default": "./src/rows.ts" + }, + "./procedures": { + "types": "./src/procedures.ts", + "default": "./src/procedures.ts" + }, + "./handlers": { + "types": "./src/handlers.ts", + "default": "./src/handlers.ts" + }, + "./submodule": { + "types": "./src/submodule.ts", + "default": "./src/submodule.ts" + } + }, + "files": [ + "src", + "LICENSE.txt", + "README.md" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clockworklabs/SpacetimeDB.git", + "directory": "spacetime-files-ts" + }, + "homepage": "https://github.com/clockworklabs/SpacetimeDB/tree/master/spacetime-files-ts#readme", + "bugs": { + "url": "https://github.com/clockworklabs/SpacetimeDB/issues" + }, + "keywords": [ + "spacetimedb", + "files", + "storage", + "typescript" + ], + "scripts": { + "format": "prettier . --write --ignore-path ../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../.prettierignore", + "typecheck": "tsc --noEmit", + "test": "tsx scripts/test.ts" + }, + "dependencies": { + "@spacetimedb/crypto": "workspace:^" + }, + "peerDependencies": { + "spacetimedb": "workspace:^" + }, + "devDependencies": { + "eslint": "^9.17.0", + "prettier": "^3.3.3", + "@types/node": "^22.10.2", + "spacetimedb": "workspace:*", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/spacetime-files-ts/scripts/test.ts b/spacetime-files-ts/scripts/test.ts new file mode 100644 index 00000000000..f9d50103839 --- /dev/null +++ b/spacetime-files-ts/scripts/test.ts @@ -0,0 +1,41 @@ +import * as assert from 'node:assert/strict'; +import { fileSha256Hex } from '../src/hash.ts'; +import { queryParam } from '../src/query.ts'; +import { + ownerPathKey, + validateFilePath, + validateMimeType, +} from '../src/validation.ts'; + +assert.equal( + fileSha256Hex(new TextEncoder().encode('abc')), + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad' +); +assert.equal( + fileSha256Hex([]), + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' +); +assert.equal(queryParam('/files?id=42', 'id'), '42'); +assert.equal(queryParam('/files?name=hello+world', 'name'), 'hello world'); +assert.equal(queryParam('/files?id=%ZZ', 'id'), undefined); +assert.equal(queryParam('/files?other=1', 'id'), undefined); + +assert.notEqual( + ownerPathKey('owner-a', '/avatar.png'), + ownerPathKey('owner-b', '/avatar.png') +); +assert.notEqual(ownerPathKey('a:b', '/c'), ownerPathKey('a', '/b/c')); +assert.equal(validateFilePath('/docs/readme.txt'), '/docs/readme.txt'); +assert.throws(() => validateFilePath('docs/readme.txt'), /files\.invalid_path/); +assert.throws(() => validateFilePath('/docs/../secret'), /files\.invalid_path/); +assert.throws( + () => validateFilePath('/docs/blocked\u007f.txt'), + /files\.invalid_path/ +); +assert.equal(validateMimeType('Image/SVG+XML'), 'image/svg+xml'); +assert.throws( + () => validateMimeType('text/plain\r\nx-injected: yes'), + /files\.invalid_mime_type/ +); + +console.log('files tests passed'); diff --git a/spacetime-files-ts/src/constants.ts b/spacetime-files-ts/src/constants.ts new file mode 100644 index 00000000000..5aa158a5af1 --- /dev/null +++ b/spacetime-files-ts/src/constants.ts @@ -0,0 +1,5 @@ +// Browser-safe: no server-side imports, so client bundles can share these limits. +export const FILE_BYTES_MAX = 4_000_000; +export const FILE_PATH_MAX = 1024; +export const FILE_MIME_TYPE_MAX = 127; +export const FILE_LIST_PAGE_MAX = 200; diff --git a/spacetime-files-ts/src/handlers.ts b/spacetime-files-ts/src/handlers.ts new file mode 100644 index 00000000000..abfb370bb83 --- /dev/null +++ b/spacetime-files-ts/src/handlers.ts @@ -0,0 +1,155 @@ +import { SyncResponse, type Infer, type Request } from 'spacetimedb/server'; +import { fileBlobRow, fileRow, FILE_VISIBILITY_PUBLIC } from './rows.ts'; +import { queryParam } from './query.ts'; +import { safeMimeType } from './validation.ts'; + +type FileRow = Infer; +type FileBlobRow = Infer; + +interface FileTableLike { + id: { find(id: bigint): FileRow | null | undefined }; +} + +interface FileBlobTableLike { + fileId: { find(id: bigint): FileBlobRow | null | undefined }; +} + +interface FileDbLike { + file?: FileTableLike; + fileBlob?: FileBlobTableLike; + files?: { + file?: FileTableLike; + fileBlob?: FileBlobTableLike; + }; +} + +interface FileTransactionLike { + db: FileDbLike; +} + +export interface FileHandlerContext { + identity?: { toHexString(): string }; + withTx( + body: (tx: Tx) => T + ): T; +} + +type FileMetadata = ReturnType; + +export interface FileServeOptions { + getOwner: (ctx: FileHandlerContext, req: Request) => string | undefined; + canAccess?: ( + ctx: FileHandlerContext, + req: Request, + file: FileMetadata, + owner: string | undefined + ) => boolean; +} + +function getFileTable(db: FileDbLike): FileTableLike | undefined { + return db.file ?? db.files?.file; +} + +function getFileBlobTable(db: FileDbLike): FileBlobTableLike | undefined { + return db.fileBlob ?? db.files?.fileBlob; +} + +function snapshotMetadata(file: FileRow) { + return { + id: file.id, + path: file.path, + ownerUserId: file.ownerUserId, + mimeType: safeMimeType(file.mimeType), + size: file.size, + sha256Hex: file.sha256Hex, + visibility: file.visibility, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + }; +} + +function snapshotWithBytes(file: FileRow, bytes: number[]) { + return { + ...snapshotMetadata(file), + bytes: new Uint8Array(bytes), + }; +} + +function responseHeaders( + file: ReturnType +): Record { + return { + 'content-type': file.mimeType, + 'content-length': String(file.size), + etag: `"${file.sha256Hex}"`, + 'cache-control': + file.visibility === FILE_VISIBILITY_PUBLIC + ? 'public, max-age=300, must-revalidate' + : 'private, max-age=60, must-revalidate', + }; +} + +export function createFileHttpHandler(opts: FileServeOptions) { + return (rawCtx: unknown, req: Request): SyncResponse => { + const ctx = rawCtx as FileHandlerContext; + const method = req.method.toUpperCase(); + if (method !== 'GET' && method !== 'HEAD') { + return new SyncResponse('method not allowed', { status: 405 }); + } + + const rawId = queryParam(String(req.uri), 'id'); + if (!rawId) return new SyncResponse('missing id', { status: 400 }); + let id: bigint; + try { + id = BigInt(rawId); + if (id <= 0n) return new SyncResponse('bad id', { status: 400 }); + } catch { + return new SyncResponse('bad id', { status: 400 }); + } + + const metadata = ctx.withTx(tx => { + const row = getFileTable(tx.db)?.id.find(id); + return row ? snapshotMetadata(row) : undefined; + }); + if (!metadata) return new SyncResponse('not found', { status: 404 }); + + const owner = opts.getOwner(ctx, req); + const canAccess = (file: FileMetadata) => + file.visibility === FILE_VISIBILITY_PUBLIC || + (opts.canAccess + ? opts.canAccess(ctx, req, file, owner) + : Boolean(owner && file.ownerUserId === owner)); + if (!canAccess(metadata)) + return new SyncResponse('forbidden', { status: 403 }); + + const headers = responseHeaders(metadata); + if (req.headers.get('if-none-match') === headers.etag) { + return new SyncResponse('', { + status: 304, + headers: { etag: headers.etag }, + }); + } + if (method === 'HEAD') { + return new SyncResponse('', { status: 200, headers }); + } + + // Load bytes only for a GET that needs a body. Recheck access against the + // same snapshot so a visibility change cannot race the metadata lookup. + const file = ctx.withTx(tx => { + const row = getFileTable(tx.db)?.id.find(id); + if (!row) return undefined; + const blob = getFileBlobTable(tx.db)?.fileId.find(id); + return blob ? snapshotWithBytes(row, blob.bytes) : undefined; + }); + if (!file) return new SyncResponse('not found', { status: 404 }); + if (!canAccess(file)) return new SyncResponse('forbidden', { status: 403 }); + const finalHeaders = responseHeaders(file); + if (req.headers.get('if-none-match') === finalHeaders.etag) { + return new SyncResponse('', { + status: 304, + headers: { etag: finalHeaders.etag }, + }); + } + return new SyncResponse(file.bytes, { status: 200, headers: finalHeaders }); + }; +} diff --git a/spacetime-files-ts/src/hash.ts b/spacetime-files-ts/src/hash.ts new file mode 100644 index 00000000000..a94e0f85462 --- /dev/null +++ b/spacetime-files-ts/src/hash.ts @@ -0,0 +1,10 @@ +import { sha256 } from '@spacetimedb/crypto'; + +export function fileSha256Hex(bytes: Uint8Array | number[]): string { + const digest = sha256( + bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes) + ); + let out = ''; + for (const byte of digest) out += byte.toString(16).padStart(2, '0'); + return out; +} diff --git a/spacetime-files-ts/src/index.ts b/spacetime-files-ts/src/index.ts new file mode 100644 index 00000000000..da8db5d799c --- /dev/null +++ b/spacetime-files-ts/src/index.ts @@ -0,0 +1,43 @@ +export { + fileRow, + fileBlobRow, + fileListPage, + fileSummary, + FILE_VISIBILITY_OWNER, + FILE_VISIBILITY_PUBLIC, +} from './rows.ts'; + +export { + FILE_BYTES_MAX, + FILE_LIST_PAGE_MAX, + FILE_MIME_TYPE_MAX, + FILE_PATH_MAX, +} from './constants.ts'; + +export { + FileValidationError, + ownerPathKey, + safeMimeType, + validateFileOwner, + validateFilePath, + validateFilePrefix, + validateMimeType, +} from './validation.ts'; + +export { + fileSha256Hex, + uploadFileParams, + uploadFile, + deleteFileParams, + deleteFile, + listFilesParams, + listFilesReturn, + listFiles, + readFileBytesParams, + readFileBytesReturn, + readFileBytes, + setFileVisibilityParams, + setFileVisibility, +} from './procedures.ts'; + +export { createFileHttpHandler } from './handlers.ts'; diff --git a/spacetime-files-ts/src/procedures.ts b/spacetime-files-ts/src/procedures.ts new file mode 100644 index 00000000000..4d937c62b6a --- /dev/null +++ b/spacetime-files-ts/src/procedures.ts @@ -0,0 +1,301 @@ +// Owner passed explicitly so the submodule is identity-scheme-agnostic. +import type { Timestamp } from 'spacetimedb'; +import { + Range, + t, + SenderError, + type InferTypeOfParams, +} from 'spacetimedb/server'; +import { + fileListPage, + FILE_VISIBILITY_OWNER, + FILE_VISIBILITY_PUBLIC, +} from './rows.ts'; +import { FILE_BYTES_MAX, FILE_LIST_PAGE_MAX } from './constants.ts'; +import { fileSha256Hex } from './hash.ts'; +import { + FileValidationError, + ownerPathKey, + validateFileOwner, + validateFilePath, + validateFilePrefix, + validateMimeType, +} from './validation.ts'; +import type { TransactionModuleCtx } from './submodule/schema.ts'; + +type FileTable = TransactionModuleCtx['db']['file']; +type FileBlobTable = TransactionModuleCtx['db']['fileBlob']; + +interface FileDbLike { + file?: FileTable; + fileBlob?: FileBlobTable; + files?: { + file?: FileTable; + fileBlob?: FileBlobTable; + }; +} + +interface FileTransactionLike { + db: FileDbLike; +} + +interface FileProcedureContext { + timestamp: Timestamp; + withTx(body: (tx: FileTransactionLike) => T): T; +} + +// Lowercase hex SHA-256, for consumers that write their own insert path. +export { fileSha256Hex } from './hash.ts'; + +const VALID_VISIBILITIES = new Set([ + FILE_VISIBILITY_OWNER, + FILE_VISIBILITY_PUBLIC, +]); + +// Direct `file` table or submodule namespace layout, as in handlers.ts. +function fileTable(db: FileDbLike): FileTable { + const table = db.file ?? db.files?.file; + if (!table) throw new Error('files.file table is unavailable'); + return table; +} + +function fileBlobTable(db: FileDbLike): FileBlobTable { + const table = db.fileBlob ?? db.files?.fileBlob; + if (!table) throw new Error('files.fileBlob table is unavailable'); + return table; +} + +function validated(fn: () => T): T { + try { + return fn(); + } catch (error) { + if (error instanceof FileValidationError) + throw new SenderError(error.message); + throw error; + } +} + +function prefixUpperBound(prefix: string): string | undefined { + if (prefix.length === 0) return undefined; + const units = Array.from(prefix); + for (let i = units.length - 1; i >= 0; i--) { + const code = units[i]!.codePointAt(0)!; + if (code < 0x10ffff) { + units[i] = String.fromCodePoint(code + 1); + return units.slice(0, i + 1).join(''); + } + } + return undefined; +} + +export const uploadFileParams = { + path: t.string(), + mimeType: t.string(), + bytes: t.array(t.u8()), + visibility: t.string(), +}; + +export function uploadFile( + rawCtx: unknown, + args: InferTypeOfParams, + owner: string +): bigint { + const ctx = rawCtx as FileProcedureContext; + owner = validated(() => validateFileOwner(owner)); + const path = validated(() => validateFilePath(args.path)); + const mimeType = validated(() => validateMimeType(args.mimeType)); + if (args.bytes.length > FILE_BYTES_MAX) { + throw new SenderError( + `files.too_large:${args.bytes.length}/${FILE_BYTES_MAX}` + ); + } + if (!VALID_VISIBILITIES.has(args.visibility)) { + throw new SenderError(`files.invalid_visibility:${args.visibility}`); + } + const sha256Hex = fileSha256Hex(args.bytes); + const key = ownerPathKey(owner, path); + return ctx.withTx(tx => { + const files = fileTable(tx.db); + const blobs = fileBlobTable(tx.db); + const existing = files.ownerPathKey.find(key); + if (existing) { + files.id.update({ + ...existing, + mimeType, + size: BigInt(args.bytes.length), + sha256Hex, + visibility: args.visibility, + updatedAt: ctx.timestamp, + }); + blobs.fileId.update({ fileId: existing.id, bytes: args.bytes }); + return existing.id; + } + const row = files.insert({ + id: 0n, + ownerPathKey: key, + path, + ownerUserId: owner, + mimeType, + size: BigInt(args.bytes.length), + sha256Hex, + visibility: args.visibility, + createdAt: ctx.timestamp, + updatedAt: ctx.timestamp, + }); + blobs.insert({ fileId: row.id, bytes: args.bytes }); + return row.id; + }); +} + +export const deleteFileParams = { + path: t.string(), +}; + +export function deleteFile( + rawCtx: unknown, + args: InferTypeOfParams, + owner: string +): void { + const ctx = rawCtx as FileProcedureContext; + owner = validated(() => validateFileOwner(owner)); + const path = validated(() => validateFilePath(args.path)); + ctx.withTx(tx => { + const files = fileTable(tx.db); + const blobs = fileBlobTable(tx.db); + const row = files.ownerPathKey.find(ownerPathKey(owner, path)); + if (!row) return; + const blob = blobs.fileId.find(row.id); + if (blob) blobs.delete(blob); + files.delete(row); + }); +} + +export const listFilesParams = { + prefix: t.string(), + cursor: t.option(t.string()), + limit: t.option(t.u32()), +}; + +export const listFilesReturn = fileListPage; + +// Caller's own files; bytes omitted (fetch via HTTP handler). +export function listFiles( + rawCtx: unknown, + args: InferTypeOfParams, + owner: string +) { + const ctx = rawCtx as FileProcedureContext; + owner = validated(() => validateFileOwner(owner)); + const prefix = validated(() => validateFilePrefix(args.prefix)); + const rawCursor = args.cursor; + const cursor = + rawCursor === undefined + ? undefined + : validated(() => validateFilePath(rawCursor)); + if (cursor !== undefined && !cursor.startsWith(prefix)) { + throw new SenderError('files.invalid_cursor'); + } + const limit = args.limit ?? 100; + if (!Number.isInteger(limit) || limit < 1 || limit > FILE_LIST_PAGE_MAX) { + throw new SenderError('files.invalid_page_size'); + } + return ctx.withTx(tx => { + const out: Array<{ + id: bigint; + path: string; + mimeType: string; + size: bigint; + sha256Hex: string; + visibility: string; + updatedAt: Timestamp; + }> = []; + const from = + cursor === undefined + ? prefix === '' + ? undefined + : { tag: 'included' as const, value: prefix } + : { tag: 'excluded' as const, value: cursor }; + const upper = prefixUpperBound(prefix); + const to = + upper === undefined + ? undefined + : { tag: 'excluded' as const, value: upper }; + for (const row of fileTable(tx.db).ownerPath.filter([ + owner, + new Range(from, to), + ])) { + out.push({ + id: row.id, + path: row.path, + mimeType: row.mimeType, + size: row.size, + sha256Hex: row.sha256Hex, + visibility: row.visibility, + updatedAt: row.updatedAt, + }); + if (out.length > limit) break; + } + const hasMore = out.length > limit; + if (hasMore) out.pop(); + return { + files: out, + nextCursor: hasMore ? out.at(-1)?.path : undefined, + }; + }); +} + +export const readFileBytesParams = { + path: t.string(), +}; + +export const readFileBytesReturn = t.object('FileBytes', { + bytes: t.array(t.u8()), + mimeType: t.string(), +}); + +// Owner-gated byte read. HTTP handlers never see the caller's identity, so +// private files can only be read here, over the authenticated connection. +export function readFileBytes( + rawCtx: unknown, + args: InferTypeOfParams, + owner: string +): { bytes: number[]; mimeType: string } { + const ctx = rawCtx as FileProcedureContext; + owner = validated(() => validateFileOwner(owner)); + const path = validated(() => validateFilePath(args.path)); + return ctx.withTx(tx => { + const row = fileTable(tx.db).ownerPathKey.find(ownerPathKey(owner, path)); + if (!row) throw new SenderError(`files.not_found:${path}`); + const blob = fileBlobTable(tx.db).fileId.find(row.id); + if (!blob) throw new SenderError(`files.not_found:${path}`); + return { bytes: blob.bytes, mimeType: row.mimeType }; + }); +} + +export const setFileVisibilityParams = { + path: t.string(), + visibility: t.string(), +}; + +export function setFileVisibility( + rawCtx: unknown, + args: InferTypeOfParams, + owner: string +): void { + const ctx = rawCtx as FileProcedureContext; + owner = validated(() => validateFileOwner(owner)); + const path = validated(() => validateFilePath(args.path)); + if (!VALID_VISIBILITIES.has(args.visibility)) { + throw new SenderError(`files.invalid_visibility:${args.visibility}`); + } + ctx.withTx(tx => { + const files = fileTable(tx.db); + const row = files.ownerPathKey.find(ownerPathKey(owner, path)); + if (!row) throw new SenderError(`files.not_found:${path}`); + files.id.update({ + ...row, + visibility: args.visibility, + updatedAt: ctx.timestamp, + }); + }); +} diff --git a/spacetime-files-ts/src/query.ts b/spacetime-files-ts/src/query.ts new file mode 100644 index 00000000000..4df7ca8d9c2 --- /dev/null +++ b/spacetime-files-ts/src/query.ts @@ -0,0 +1,17 @@ +export function queryParam(uri: string, name: string): string | undefined { + const queryIdx = uri.indexOf('?'); + if (queryIdx < 0) return undefined; + for (const part of uri.slice(queryIdx + 1).split('&')) { + if (!part) continue; + const eqIdx = part.indexOf('='); + const rawKey = eqIdx < 0 ? part : part.slice(0, eqIdx); + try { + if (decodeURIComponent(rawKey.replace(/\+/g, ' ')) !== name) continue; + const rawValue = eqIdx < 0 ? '' : part.slice(eqIdx + 1); + return decodeURIComponent(rawValue.replace(/\+/g, ' ')); + } catch { + return undefined; + } + } + return undefined; +} diff --git a/spacetime-files-ts/src/rows.ts b/spacetime-files-ts/src/rows.ts new file mode 100644 index 00000000000..2dc1df13820 --- /dev/null +++ b/spacetime-files-ts/src/rows.ts @@ -0,0 +1,39 @@ +import { t } from 'spacetimedb/server'; + +export const FILE_VISIBILITY_OWNER = 'owner'; +export const FILE_VISIBILITY_PUBLIC = 'public'; + +// Canonical submodule row shape. Applications with a custom file-like table may +// reuse these fields; standard integrations register @spacetimedb/files/submodule. +export const fileRow = { + id: t.u64().primaryKey().autoInc(), + ownerPathKey: t.string().unique(), + path: t.string().index(), + ownerUserId: t.string().index(), + mimeType: t.string(), + size: t.u64(), + sha256Hex: t.string(), + visibility: t.string().index(), + createdAt: t.timestamp(), + updatedAt: t.timestamp(), +}; + +export const fileBlobRow = { + fileId: t.u64().primaryKey(), + bytes: t.array(t.u8()), +}; + +export const fileSummary = t.object('FileSummary', { + id: t.u64(), + path: t.string(), + mimeType: t.string(), + size: t.u64(), + sha256Hex: t.string(), + visibility: t.string(), + updatedAt: t.timestamp(), +}); + +export const fileListPage = t.object('FileListPage', { + files: t.array(fileSummary), + nextCursor: t.option(t.string()), +}); diff --git a/spacetime-files-ts/src/submodule.ts b/spacetime-files-ts/src/submodule.ts new file mode 100644 index 00000000000..26c064b6979 --- /dev/null +++ b/spacetime-files-ts/src/submodule.ts @@ -0,0 +1,8 @@ +export { default, spacetimedb } from './submodule/schema.ts'; +export { file, fileBlob } from './submodule/schema.ts'; +export { installFiles } from './submodule/install.ts'; +export * from './rows.ts'; +export * from './validation.ts'; +export * from './procedures.ts'; +export * from './handlers.ts'; +export { FILE_BYTES_MAX, FILE_PATH_MAX } from './constants.ts'; diff --git a/spacetime-files-ts/src/submodule/install.ts b/spacetime-files-ts/src/submodule/install.ts new file mode 100644 index 00000000000..1d2b8578d69 --- /dev/null +++ b/spacetime-files-ts/src/submodule/install.ts @@ -0,0 +1,6 @@ +import type { ReducerModuleCtx } from './schema.ts'; + +export function installFiles(_ctx: ReducerModuleCtx) { + // Files has no scheduled jobs or singleton config. Host modules decide + // authorization and ownership before calling the submodule helpers. +} diff --git a/spacetime-files-ts/src/submodule/schema.ts b/spacetime-files-ts/src/submodule/schema.ts new file mode 100644 index 00000000000..251e034e830 --- /dev/null +++ b/spacetime-files-ts/src/submodule/schema.ts @@ -0,0 +1,42 @@ +import { + schema, + table, + type InferSchema, + type ProcedureCtx, + type ReducerCtx, + type TransactionCtx, + type ViewCtx, +} from 'spacetimedb/server'; +import { fileBlobRow, fileRow } from '../rows.ts'; + +export const file = table( + { + name: 'file', + public: false, + indexes: [ + { + accessor: 'ownerPath', + algorithm: 'btree', + columns: ['ownerUserId', 'path'] as const, + }, + ] as const, + }, + fileRow +); + +export const fileBlob = table( + { name: 'file_blob', public: false }, + fileBlobRow +); + +export const spacetimedb = schema({ + file, + fileBlob, +}); +export default spacetimedb; + +export type Schema = InferSchema; +export type ReducerModuleCtx = ReducerCtx; +export type ProcedureModuleCtx = ProcedureCtx; +export type TransactionModuleCtx = TransactionCtx; +export type ViewModuleCtx = ViewCtx; diff --git a/spacetime-files-ts/src/validation.ts b/spacetime-files-ts/src/validation.ts new file mode 100644 index 00000000000..6448871da29 --- /dev/null +++ b/spacetime-files-ts/src/validation.ts @@ -0,0 +1,79 @@ +import { FILE_MIME_TYPE_MAX, FILE_PATH_MAX } from './constants.ts'; + +const MIME_TYPE = /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/; + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +export class FileValidationError extends Error {} + +export function ownerPathKey(owner: string, path: string): string { + return `${owner.length}:${owner}${path}`; +} + +export function validateFileOwner(owner: string): string { + if (owner.length === 0 || owner.length > 512 || hasControlCharacter(owner)) { + throw new FileValidationError('files.invalid_owner'); + } + return owner; +} + +export function validateFilePath(path: string): string { + if ( + path.length < 2 || + path.length > FILE_PATH_MAX || + !path.startsWith('/') || + path.endsWith('/') || + path.includes('\\') || + path.includes('//') || + hasControlCharacter(path) + ) { + throw new FileValidationError('files.invalid_path'); + } + for (const segment of path.slice(1).split('/')) { + if (segment === '.' || segment === '..' || segment.length > 255) { + throw new FileValidationError('files.invalid_path'); + } + } + return path; +} + +export function validateFilePrefix(prefix: string): string { + if (prefix === '') return prefix; + if ( + prefix.length > FILE_PATH_MAX || + !prefix.startsWith('/') || + prefix.includes('\\') || + prefix.includes('//') || + hasControlCharacter(prefix) + ) { + throw new FileValidationError('files.invalid_prefix'); + } + return prefix; +} + +export function validateMimeType(mimeType: string): string { + const value = mimeType.trim(); + if ( + value.length === 0 || + value.length > FILE_MIME_TYPE_MAX || + !MIME_TYPE.test(value) + ) { + throw new FileValidationError('files.invalid_mime_type'); + } + return value.toLowerCase(); +} + +export function safeMimeType(mimeType: unknown): string { + if (typeof mimeType !== 'string') return 'application/octet-stream'; + try { + return validateMimeType(mimeType); + } catch { + return 'application/octet-stream'; + } +} diff --git a/spacetime-files-ts/tsconfig.json b/spacetime-files-ts/tsconfig.json new file mode 100644 index 00000000000..e6a8236bbab --- /dev/null +++ b/spacetime-files-ts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "scripts/**/*.ts"] +} From 19dcc8f08cfd4d5c1b32c3f8d5fa8a467d696fc8 Mon Sep 17 00:00:00 2001 From: bradleyshep Date: Wed, 9 Sep 2026 14:24:17 -0400 Subject: [PATCH 2/2] Share agent summary helpers with the example --- .../example/spacetimedb/scripts/test-loop.ts | 2 +- .../example/spacetimedb/src/agent-runner.ts | 2 +- .../example/spacetimedb/src/summarize.ts | 73 ------------------- spacetime-agents-ts/src/index.ts | 7 ++ 4 files changed, 9 insertions(+), 75 deletions(-) delete mode 100644 spacetime-agents-ts/example/spacetimedb/src/summarize.ts diff --git a/spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts b/spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts index c143e1f415c..5c2457ee654 100644 --- a/spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts +++ b/spacetime-agents-ts/example/spacetimedb/scripts/test-loop.ts @@ -18,7 +18,7 @@ import { buildSummarizerUserContent, augmentSystemWithSummary, formatMessagesForSummarizer, -} from '../src/summarize.ts'; +} from '@spacetimedb/agents'; import type { HttpLike } from '@spacetimedb/agents/openrouter'; import type { InvokeResult } from '@spacetimedb/agents'; diff --git a/spacetime-agents-ts/example/spacetimedb/src/agent-runner.ts b/spacetime-agents-ts/example/spacetimedb/src/agent-runner.ts index f318e7672c2..4ea3d426fcd 100644 --- a/spacetime-agents-ts/example/spacetimedb/src/agent-runner.ts +++ b/spacetime-agents-ts/example/spacetimedb/src/agent-runner.ts @@ -21,7 +21,7 @@ import { augmentSystemWithSummary, buildSummarizerUserContent, pickSummarizationCandidates, -} from './summarize'; +} from '@spacetimedb/agents'; import type { Tx } from './types'; type WriteCtx = Tx; diff --git a/spacetime-agents-ts/example/spacetimedb/src/summarize.ts b/spacetime-agents-ts/example/spacetimedb/src/summarize.ts deleted file mode 100644 index 0da8ebdbb13..00000000000 --- a/spacetime-agents-ts/example/spacetimedb/src/summarize.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { LoopMessage } from './loop'; - -export function pickSummarizationCandidates( - messages: LoopMessage[], // ascending by id - maxHistoryMessages: number, - summarizedThroughId: bigint | null -): { newDropped: LoopMessage[]; lastNewId: bigint } | null { - if (messages.length <= maxHistoryMessages) return null; - - const dropCount = messages.length - maxHistoryMessages; - const dropped = messages.slice(0, dropCount); - - const newDropped = - summarizedThroughId == null - ? dropped - : dropped.filter(m => m.id > summarizedThroughId); - - if (newDropped.length === 0) return null; - return { newDropped, lastNewId: newDropped[newDropped.length - 1].id }; -} - -export function formatMessagesForSummarizer(messages: LoopMessage[]): string { - const lines: string[] = []; - for (const m of messages) { - if (m.role === 'user') { - lines.push(`User: ${m.content}`); - } else if (m.role === 'assistant') { - if (m.toolCallsJson != null) { - try { - const calls = JSON.parse(m.toolCallsJson) as Array<{ - function?: { name?: string; arguments?: string }; - }>; - for (const c of calls) { - const name = c.function?.name ?? '?'; - const args = c.function?.arguments ?? ''; - lines.push(`[Assistant called tool ${name}(${args})]`); - } - } catch { - /* malformed */ - } - if (m.content) lines.push(`Assistant: ${m.content}`); - } else { - lines.push(`Assistant: ${m.content}`); - } - } else if (m.role === 'tool') { - lines.push(`[Tool result: ${m.content}]`); - } - } - return lines.join('\n'); -} - -export function buildSummarizerUserContent( - existingSummary: string | null, - newDropped: LoopMessage[] -): string { - const formatted = formatMessagesForSummarizer(newDropped); - if (existingSummary) { - return ( - `Existing summary:\n${existingSummary}\n\n` + - `Additional messages to fold into the summary:\n${formatted}` - ); - } - return `Messages to summarize:\n${formatted}`; -} - -export function augmentSystemWithSummary( - baseSystem: string | undefined, - summary: string | null -): string | undefined { - if (summary == null || summary.length === 0) return baseSystem; - const base = baseSystem ?? ''; - return `${base}\n\n## Summary of earlier conversation\n${summary}`.trim(); -} diff --git a/spacetime-agents-ts/src/index.ts b/spacetime-agents-ts/src/index.ts index 2176ea9e267..22d298476dd 100644 --- a/spacetime-agents-ts/src/index.ts +++ b/spacetime-agents-ts/src/index.ts @@ -43,3 +43,10 @@ export { BUILT_IN_EMBEDDING_PROVIDERS, } from './embeddings.ts'; export type { EmbeddingProvider, EmbeddingResult } from './embeddings.ts'; + +export { + pickSummarizationCandidates, + formatMessagesForSummarizer, + buildSummarizerUserContent, + augmentSystemWithSummary, +} from './submodule/summarize';