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..4e61a2122e7 --- /dev/null +++ b/spacetime-agents-ts/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Clockwork Labs, Inc + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/spacetime-agents-ts/README.md b/spacetime-agents-ts/README.md new file mode 100644 index 00000000000..10d23986521 --- /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 +``` + +`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 + +Apache-2.0. 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..47d3fec2e37 --- /dev/null +++ b/spacetime-agents-ts/example/README.md @@ -0,0 +1,227 @@ +# 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. +- A SpacetimeDB CLI and server built from this checkout, with the CLI available as `spacetime`. +- 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. + +This example uses the workspace SDK. Keep the matching local server running in +a separate terminal: + +```powershell +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 +``` + +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..5c2457ee654 --- /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 '@spacetimedb/agents'; +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..4ea3d426fcd --- /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 '@spacetimedb/agents'; +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..5287edee23d --- /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 { linkConnection as bindAuthConnection } from '@spacetimedb/auth'; +import { + setAuthConfigParams, + getPublicKeyPemParams, + linkConnectionParams, + 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 setAuthConfig = spacetimedb.reducer( + setAuthConfigParams, + (ctx, args) => { + auth.setAuthConfig(ctx.as.auth, args); + } +); + +export const getAuthPublicKey = spacetimedb.procedure( + getPublicKeyPemParams, + t.object('AuthPubKey', { + publicKeyPem: t.string(), + keyId: t.string(), + issuerUrl: t.string(), + }), + (ctx, args) => + auth.getAuthPublicKey(ctx.as.auth, args) as { + publicKeyPem: string; + keyId: string; + issuerUrl: string; + } +); + +// Procedure (not reducer) so the client can await commit before subscribing. +export const linkConnection = spacetimedb.procedure( + linkConnectionParams, + t.object('LinkConnectionResult', { userId: t.string() }), + (ctx, args) => bindAuthConnection(ctx.as.auth, args) +); + +export const unlinkConnection = spacetimedb.reducer( + unlinkConnectionParams, + (ctx, args) => { + auth.unlinkConnection(ctx.as.auth, args); + } +); + +export const updateProfile = spacetimedb.reducer( + updateProfileParams, + (ctx, args) => { + auth.updateProfile(ctx.as.auth, args); + } +); + +export const revokeSession = spacetimedb.reducer( + revokeSessionParams, + (ctx, args) => { + auth.revokeSession(ctx.as.auth, args); + } +); + +export const listMySessions = 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.listMySessions(ctx.as.auth, args) as { + sessions: Array<{ + sessionId: string; + expiresAt: Timestamp; + createdAt: Timestamp; + ipAddress: string | undefined; + userAgent: string | undefined; + isCurrent: boolean; + }>; + } +); + +export const revokeMySession = spacetimedb.reducer( + revokeMySessionParams, + (ctx, args) => { + auth.revokeMySession(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 setAgentSecret = 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 setApiKey = 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 clearApiKey = 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 setAgentOverride = 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 clearAgentOverride = 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 getAgentConfigStatus = 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 addAgentAdminIdentity = 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 removeAgentAdminIdentity = 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 startThread = 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 updateThread = 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 deleteThread = 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 clearThreadLock = 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 generateThreadTitle = 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 requestCancel = 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 sendMessage = 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 regenerateResponse = 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 threadLockSweep = 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/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..efc4896c67b --- /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.10.1 (commit 5418e2f229c57f503d4306ae9e5fb6df29533f7d). + +/* 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.10.1" 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..d959ddb3993 --- /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": "Apache-2.0", + "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..d8fdb5f5f40 --- /dev/null +++ b/spacetime-agents-ts/scripts/test.ts @@ -0,0 +1,1249 @@ +// 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 { t, 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', 'value'], + }), + `sum variant shape: {tag, value}` + ); +} + +process.stdout.write('\nagentTool + makeAgentDispatch tests\n'); + +// A unit variant does not make a tagged union optional. +{ + const args = t.object('ActionArgs', { + action: t.enum('Action', { stop: t.unit(), say: t.string() }), + note: t.option(t.string()), + }); + const tool = agentTool('Select an action', args, (_ctx, { action }) => + action.tag === 'say' ? action.value : action.tag + ); + const { invoke } = makeAgentDispatch({ action: tool }); + const schema = typeBuilderToJsonSchema(args); + assert(eq(schema.required, ['action']), 'only the true option is optional'); + assert( + eq(schema.properties.action, { + oneOf: [ + { + type: 'object', + properties: { tag: { type: 'string', enum: ['stop'] } }, + required: ['tag'], + }, + { + type: 'object', + properties: { + tag: { type: 'string', enum: ['say'] }, + value: { type: 'string' }, + }, + required: ['tag', 'value'], + }, + ], + }), + 'schema preserves tags and requires each payload' + ); + for (const [action, expected] of [ + [{ tag: 'stop' }, 'stop'], + [{ tag: 'say', value: 'hello' }, 'hello'], + ] as const) { + const result = invoke({}, 'action', JSON.stringify({ action, note: 'ok' })); + assert( + !result.isError && result.result === expected, + `dispatch ${action.tag}` + ); + } + for (const input of [{}, { action: 'hello' }, { action: { tag: 'say' } }]) { + assert( + invoke({}, 'action', JSON.stringify(input)).isError, + 'reject invalid union input' + ); + } +} + +// 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..da55eb585c9 --- /dev/null +++ b/spacetime-agents-ts/src/agent.ts @@ -0,0 +1,613 @@ +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); + return variants.length === 2 && + variants[0].name === 'some' && + variants[1].name === 'none' && + isUnitType(variants[1].algebraicType) + ? variants[0].algebraicType + : undefined; +} + +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); + const payload = optionPayload(at); + if (payload !== undefined) { + return { + schema: algebraicTypeToJsonSchema(payload).schema, + required: false, + }; + } + return { + schema: { + oneOf: variants.map(v => { + const unit = isUnitType(v.algebraicType); + return { + type: 'object', + properties: { + tag: { type: 'string', enum: [v.name] }, + ...(unit + ? {} + : { + value: algebraicTypeToJsonSchema(v.algebraicType).schema, + }), + }, + required: unit ? ['tag'] : ['tag', 'value'], + }; + }), + }, + 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..22d298476dd --- /dev/null +++ b/spacetime-agents-ts/src/index.ts @@ -0,0 +1,52 @@ +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'; + +export { + pickSummarizationCandidates, + formatMessagesForSummarizer, + buildSummarizerUserContent, + augmentSystemWithSummary, +} from './submodule/summarize'; 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..4ee9f4aa2ea --- /dev/null +++ b/spacetime-agents-ts/src/submodule.ts @@ -0,0 +1,25 @@ +export { default } from './submodule/index'; +export { installAgents } from './submodule/install'; +export { + addAgentAdminIdentity, + clearAgentOverride, + clearApiKey, + clearThreadLock, + deleteThread, + generateThreadTitle, + getAgentConfigStatus, + myMessageEmbeddings, + myMessages, + myThreadLocks, + myThreads, + regenerateResponse, + removeAgentAdminIdentity, + requestCancel, + sendMessage, + setAgentOverride, + setAgentSecret, + setApiKey, + startThread, + threadLockSweep, + updateThread, +} 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..f09a1f6759d --- /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 setAgentSecret = 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 setApiKey = 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 clearApiKey = 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 setAgentOverride = 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 clearAgentOverride = 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 addAgentAdminIdentity = 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 removeAgentAdminIdentity = 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 getAgentConfigStatus = 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 startThread = 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 updateThread = 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 deleteThread = 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 clearThreadLock = 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 requestCancel = 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 sendMessage = 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 regenerateResponse = 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 generateThreadTitle = 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 threadLockSweep = 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..4e61a2122e7 --- /dev/null +++ b/spacetime-auth-ts/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Clockwork Labs, Inc + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/spacetime-auth-ts/README.md b/spacetime-auth-ts/README.md new file mode 100644 index 00000000000..daacd75cc64 --- /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 +``` + +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 linkConnection = spacetimedb.reducer( + auth.linkConnectionParams, + (ctx, args) => auth.linkConnection(ctx.as.auth, args) +); + +export const updateProfile = spacetimedb.reducer( + auth.updateProfileParams, + (ctx, args) => auth.updateProfile(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 + +[Apache-2.0](./LICENSE.txt). 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..2b22c925d3b --- /dev/null +++ b/spacetime-auth-ts/example/README.md @@ -0,0 +1,193 @@ +# 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. +- A SpacetimeDB CLI and server built from this checkout, with the CLI available as `spacetime`. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity. A fresh publish seeds the publisher as the initial auth + administrator. + +This example uses the workspace SDK. Keep the matching local server running in +a separate terminal: + +```powershell +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 +``` + +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..55585e381f8 --- /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 setAuthConfig = spacetimedb.reducer( + setAuthConfigParams, + (ctx, args) => { + auth.setAuthConfig(ctx.as.auth, args); + } +); + +export const getAuthPublicKey = spacetimedb.procedure( + getPublicKeyPemParams, + t.object('AuthPubKey', { + publicKeyPem: t.string(), + keyId: t.string(), + issuerUrl: t.string(), + }), + (ctx, args) => + auth.getAuthPublicKey(ctx.as.auth, args) as { + publicKeyPem: string; + keyId: string; + issuerUrl: string; + } +); + +export const linkConnection = spacetimedb.reducer( + linkConnectionParams, + (ctx, args) => { + auth.linkConnection(ctx.as.auth, args); + } +); + +export const unlinkConnection = spacetimedb.reducer( + unlinkConnectionParams, + (ctx, args) => { + auth.unlinkConnection(ctx.as.auth, args); + } +); + +export const updateProfile = spacetimedb.reducer( + updateProfileParams, + (ctx, args) => { + auth.updateProfile(ctx.as.auth, args); + } +); + +export const revokeSession = spacetimedb.reducer( + revokeSessionParams, + (ctx, args) => { + auth.revokeSession(ctx.as.auth, args); + } +); + +export const listMySessions = 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.listMySessions(ctx.as.auth, args) as { + sessions: Array<{ + sessionId: string; + expiresAt: Timestamp; + createdAt: Timestamp; + ipAddress: string | undefined; + userAgent: string | undefined; + isCurrent: boolean; + }>; + } +); + +export const revokeMySession = spacetimedb.reducer( + revokeMySessionParams, + (ctx, args) => { + auth.revokeMySession(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 createNote = 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 deleteNote = 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 updateNote = 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..25a8a003bdd --- /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.10.1 (commit 5418e2f229c57f503d4306ae9e5fb6df29533f7d). + +/* 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.10.1" 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..321703ed440 --- /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": "Apache-2.0", + "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..11250404a3e --- /dev/null +++ b/spacetime-auth-ts/src/submodule.ts @@ -0,0 +1,47 @@ +export { default } from './submodule/index'; +export { installAuth } from './submodule/install'; +export { + authSweep, + getAuthPublicKey, + linkConnection, + listMySessions, + myAuthUser, + revokeMySession, + revokeSession, + setAuthConfig, + unlinkConnection, + updateProfile, + whoami, +} from './submodule/index'; + +export { + setAuthConfigParams, + getPublicKeyPemParams, + linkConnectionParams, + 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..b4427beda12 --- /dev/null +++ b/spacetime-auth-ts/src/submodule/index.ts @@ -0,0 +1,142 @@ +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 * as auth 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); +}); + +// Generate an ES256 keypair when the first configuration has no PEM. +export const setAuthConfig = spacetimedb.reducer( + auth.setAuthConfigParams, + (ctx, args) => { + auth.setAuthConfig(ctx, args); + } +); + +export const getAuthPublicKey = spacetimedb.procedure( + auth.getPublicKeyPemParams, + t.object('AuthPubKey', { + publicKeyPem: t.string(), + keyId: t.string(), + issuerUrl: t.string(), + }), + auth.getPublicKeyPem +); + +export const linkConnection = spacetimedb.reducer( + auth.linkConnectionParams, + (ctx, args) => { + auth.linkConnection(ctx, args); + } +); + +export const unlinkConnection = spacetimedb.reducer( + auth.unlinkConnectionParams, + (ctx, args) => { + auth.unlinkConnection(ctx, args); + } +); + +export const updateProfile = spacetimedb.reducer( + auth.updateProfileParams, + auth.updateProfile +); + +export const revokeSession = spacetimedb.reducer( + auth.revokeSessionParams, + (ctx, args) => { + auth.revokeSession(ctx, args); + } +); + +export const listMySessions = spacetimedb.procedure( + auth.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(), + }) + ), + }), + auth.listMySessions +); + +export const revokeMySession = spacetimedb.reducer( + auth.revokeMySessionParams, + (ctx, args) => { + auth.revokeMySession(ctx, args); + } +); + +export const authSweep = spacetimedb.reducer( + { onSchedule: authSweeperTick }, + { arg: authSweeperTick.rowType }, + (ctx, _arg) => { + auth.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 = auth.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..4e61a2122e7 --- /dev/null +++ b/spacetime-files-ts/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Clockwork Labs, Inc + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/spacetime-files-ts/README.md b/spacetime-files-ts/README.md new file mode 100644 index 00000000000..6d2807a1089 --- /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 +``` + +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 uploadFile = 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 readFileBytes = 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 + +[Apache-2.0](./LICENSE.txt). 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..abda242d87b --- /dev/null +++ b/spacetime-files-ts/example/README.md @@ -0,0 +1,162 @@ +# 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. +- A SpacetimeDB CLI and server built from this checkout, with the CLI available as `spacetime`. +- A local SpacetimeDB server registered as `local`. +- A logged-in CLI identity for publishing the example. + +This example uses the workspace SDK. Keep the matching local server running in +a separate terminal: + +```powershell +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 +``` + +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..1e70c35c814 --- /dev/null +++ b/spacetime-files-ts/example/spacetimedb/src/index.ts @@ -0,0 +1,443 @@ +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, + 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 createFolder = 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 deleteFolder = 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 renameFolder = 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 uploadFile = 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 deleteFile = 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 renameFile = 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 moveFile = 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 setFileVisibility = 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 readFileBytes = spacetimedb.procedure( + readFileBytesParams, + readFileBytesReturn, + (ctx, args) => + files.readFileBytes( + ctx, + { path: normalizePath(args.path, 'file') }, + ctx.sender.toHexString() + ) +); + +const serveFile = createFileHttpHandler({ + getOwner: ctx => ctx.identity?.toHexString?.(), +}); + +export const fileServe = spacetimedb.httpHandler((ctx, req) => { + return serveFile(ctx, req); +}); + +export const router = spacetimedb.httpRouter( + new Router().get('/files', fileServe).head('/files', fileServe) +); 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..1b853ab0930 --- /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.10.1 (commit 5418e2f229c57f503d4306ae9e5fb6df29533f7d). + +/* 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.10.1" 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..0bae944a0aa --- /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": "Apache-2.0", + "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"] +}