Mercurial
changeset 279:b3b547563ec7
Add Google connector service and agent wiki
Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code.
Co-authored-by: Copilot <[email protected]>
Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Mon, 17 Aug 2026 22:22:36 -0700 |
| parents | 8d560f50ed4c |
| children | 49e9e591c9bb |
| files | .hgignore AGENTS.md BUILD README.md auth/BUILD auth/auth_http.c auth/auth_http.h auth/test/BUILD auth/test/auth_http_test.c connectors/.config.development connectors/BUILD connectors/README.md connectors/auth_http_adapter.c connectors/auth_http_adapter.h connectors/auth_test_page.c connectors/auth_test_page.h connectors/connector.h connectors/core.c connectors/google.c connectors/main.c connectors/service.c connectors/store.c connectors/tests/auth_http_adapter_test.c connectors/tests/core_store_test.c connectors/tests/google_provider_test.c connectors/tests/route_test.c connectors/wiki/README.md gui_ze/README.md gui_ze/wiki/README.md mrjunejune/BUILD mrjunejune/auth_api.c mrjunejune/auth_api.h seobeo/s_http_client.c seobeo/tests/seobeo_http_framing_test.c tools/arena_policy_test.sh wiki/README.md |
| diffstat | 36 files changed, 4714 insertions(+), 287 deletions(-) [+] |
line wrap: on
line diff
--- a/.hgignore Mon Aug 17 22:16:14 2026 -0700 +++ b/.hgignore Mon Aug 17 22:22:36 2026 -0700 @@ -53,3 +53,8 @@ *.db-wal *.db-shm mrjunejune/data/ + +# Connector service secrets and local state +connectors/.config +connectors/config.local +connectors/*.db
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/AGENTS.md Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,32 @@ +# Zenbu agent instructions + +Use progressive disclosure. Do not recursively read every README, skill, or +wiki page in this monorepo. + +1. Read [`wiki/README.md`](wiki/README.md). +2. Identify the package paths involved in the task. +3. Follow only the documentation links that the repository wiki assigns to + those paths. +4. Read `BUILD` files before broad source searches when work crosses packages. +5. Use Mercurial commands (`hg status`, `hg diff`), not Git workflows. +6. This is a Bazel-only repository. Build, test, run, bundle, generate, and + fetch toolchains through Bazel targets; do not introduce Make, CMake, ad-hoc + compiler commands, or package-manager run workflows. +7. Prefer first-party Zenbu libraries over new dependencies. +8. Depend on third-party code through its Bazel label or `MODULE.bazel`, never + through an untracked system installation or a direct source-path include. +9. Use `Dowa_Arena` for first-party allocation. Do not add raw + `malloc/calloc/realloc/free`; arena-owned pointers are released only by + `Dowa_Arena_Free`. +10. Update the relevant canonical wiki when behavior, configuration, routes, + architecture, or agent workflows change. + +For a single-package task, the expected documentation set is: + +```text +AGENTS.md -> wiki/README.md -> one package wiki/README +``` + +Read multiple package wikis only when the dependency graph proves the task is +cross-cutting. Source code and tests remain the final authority when a wiki is +stale or incomplete; fix the wiki as part of the same change.
--- a/BUILD Mon Aug 17 22:16:14 2026 -0700 +++ b/BUILD Mon Aug 17 22:22:36 2026 -0700 @@ -1,3 +1,5 @@ +load("@rules_shell//shell:sh_test.bzl", "sh_test") + exports_files([".env"]) filegroup( @@ -5,3 +7,20 @@ srcs = [".env"], visibility = ["//visibility:public"], ) + +filegroup( + name = "first_party_c_sources", + srcs = glob( + [ + "**/*.c", + "**/*.h", + ], + exclude = ["third_party/**"], + ), +) + +sh_test( + name = "arena_policy_test", + srcs = ["tools/arena_policy_test.sh"], + data = [":first_party_c_sources"], +)
--- a/README.md Mon Aug 17 22:16:14 2026 -0700 +++ b/README.md Mon Aug 17 22:22:36 2026 -0700 @@ -1,47 +1,11 @@ # Zenbu -This is a mono repo where I will share all my codes and utilize them using bazel. I decied to do this since I re-use codes often and I don't want to deal with making make and cmake every time and this eliminates the problem that exists within C or C++ where using library is harder as we need to add gzillian stuff into it lmao. - -## Dependency... - -``` -clang -ffmpeg -``` +Mercurial/Bazel monorepo for reusable C libraries, servers, web applications, +frontend assets, and experiments. -## Install - -I decide to use mercurial because I got used to this over git and I frankly don't need different branches for each feature to be merged in. -I also decied to use the bazel for code sharing as I mentioned above. - -``` -# linux -wget https://github.com/bazelbuild/bazelisk/releases/download/v1.18.0/bazelisk-linux-amd64 # Hope it still works lmao -chmod +x bazelisk-linux-amd64 -sudo mv bazelisk-linux-amd64 /usr/local/bin/bazel -bazel version +**Repository knowledge index:** [`wiki/README.md`](wiki/README.md) -# mac -brew install bazel -``` - -I might move these binary into the repo so that it has full history of it. I assume mercurial is installed as well. - -## Debugging Command +**Agent entry point:** [`AGENTS.md`](AGENTS.md) -```bash -bazel build target -c dbg -i.e) bazel build //mrjunejune:mrjunejune_server -c dbg -``` - -And run whatever your favoriate debugging tools - -## MacOS Memory checks - -```bash -bazel run //dowa:dowa_test --run_under="valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes" -``` -brew install valgrind -# or use the unofficial ARM build: -arch -x86_64 brew install valgrind -arch -x86_64 valgrind ./test +Start at the repository wiki, then follow only the documentation link for the +project you are changing.
--- a/auth/BUILD Mon Aug 17 22:16:14 2026 -0700 +++ b/auth/BUILD Mon Aug 17 22:22:36 2026 -0700 @@ -27,9 +27,23 @@ ) cc_library( + name = "auth_http", + srcs = ["auth_http.c"], + hdrs = ["auth_http.h"], + deps = [ + ":auth_crypto", + ":auth_store", + "//dowa:dowa", + "//seobeo:seobeo", + "@openssl//:crypto", + ], +) + +cc_library( name = "auth", deps = [ ":auth_crypto", + ":auth_http", ":auth_store", ], )
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/auth/auth_http.c Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,232 @@ +#include "auth/auth_http.h" + +#include <openssl/crypto.h> +#include <openssl/evp.h> +#include <openssl/hmac.h> + +#include <string.h> + +#define AUTH_HTTP_COOKIE_VALUE_MAX 512 +#define AUTH_HTTP_CSRF_SUFFIX ":csrf:v1" +#define AUTH_HTTP_BINDING_INPUT_MAX (AUTH_CRYPTO_TOKEN_DIGEST_SIZE + 16) + +const char *Auth_HTTP_Request_Value( + Seobeo_Request_Entry *p_request, + const char *key) +{ + if (!p_request || !key) + return NULL; + void *p_value = Dowa_HashMap_Get_Ptr(p_request, (char *)key); + return p_value ? ((Seobeo_Request_Entry *)p_value)->value : NULL; +} + +boolean Auth_HTTP_Parse_Cookie( + const char *cookie_header, + const char *name, + char *value_out, + size_t capacity) +{ + if (!value_out || capacity == 0) + return FALSE; + value_out[0] = '\0'; + if (!cookie_header || !name || name[0] == '\0') + return FALSE; + size_t name_length = strlen(name); + const char *cursor = cookie_header; + + while (*cursor) + { + while (*cursor == ' ' || *cursor == '\t') + cursor++; + + if (strncmp(cursor, name, name_length) == 0 && + cursor[name_length] == '=') + { + cursor += name_length + 1; + const char *start = cursor; + while (*cursor && *cursor != ';') + cursor++; + size_t value_length = (size_t)(cursor - start); + if (value_length >= capacity) + return FALSE; + memcpy(value_out, start, value_length); + value_out[value_length] = '\0'; + return TRUE; + } + + while (*cursor && *cursor != ';') + cursor++; + if (*cursor == ';') + cursor++; + } + return FALSE; +} + +boolean Auth_HTTP_Same_Origin(Seobeo_Request_Entry *p_request) +{ + const char *host = Auth_HTTP_Request_Value(p_request, "Host"); + const char *origin = Auth_HTTP_Request_Value(p_request, "Origin"); + if (!host || !origin) + return FALSE; + + const char *origin_host = strstr(origin, "://"); + if (!origin_host) + return FALSE; + origin_host += 3; + + const char *end = strchr(origin_host, '/'); + size_t origin_host_length = + end ? (size_t)(end - origin_host) : strlen(origin_host); + return strlen(host) == origin_host_length && + strncmp(host, origin_host, origin_host_length) == 0; +} + +boolean Auth_HTTP_Derive_CSRF( + const uint8 *cookie_secret, + size_t cookie_secret_length, + const char *binding, + char *csrf_out, + size_t csrf_capacity) +{ + if (!cookie_secret || + cookie_secret_length < AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES || + cookie_secret_length > AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES || + !binding || !csrf_out || csrf_capacity < AUTH_CRYPTO_TOKEN_SIZE) + return FALSE; + + size_t binding_length = strlen(binding); + size_t suffix_length = strlen(AUTH_HTTP_CSRF_SUFFIX); + size_t input_length = binding_length + suffix_length; + if (input_length >= AUTH_HTTP_BINDING_INPUT_MAX) + return FALSE; + + char input[AUTH_HTTP_BINDING_INPUT_MAX] = {0}; + memcpy(input, binding, binding_length); + memcpy(input + binding_length, AUTH_HTTP_CSRF_SUFFIX, suffix_length); + + uint8 digest[32] = {0}; + uint32 digest_length = sizeof(digest); + if (!HMAC(EVP_sha256(), cookie_secret, (int)cookie_secret_length, + (const uint8 *)input, input_length, digest, &digest_length)) + { + OPENSSL_cleanse(input, sizeof(input)); + OPENSSL_cleanse(digest, sizeof(digest)); + return FALSE; + } + OPENSSL_cleanse(input, sizeof(input)); + + size_t encoded_length = Auth_Crypto_Base64url_Encode( + digest, sizeof(digest), csrf_out, csrf_capacity); + OPENSSL_cleanse(digest, sizeof(digest)); + return encoded_length == AUTH_CRYPTO_TOKEN_SIZE - 1; +} + +boolean Auth_HTTP_Verify_CSRF( + Seobeo_Request_Entry *p_request, + const uint8 *cookie_secret, + size_t cookie_secret_length, + const char *binding) +{ + if (!Auth_HTTP_Same_Origin(p_request) || !binding) + return FALSE; + + const char *provided = Auth_HTTP_Request_Value(p_request, "X-CSRF-Token"); + return Auth_HTTP_Verify_CSRF_Token( + cookie_secret, cookie_secret_length, binding, provided); +} + +boolean Auth_HTTP_Verify_CSRF_Token( + const uint8 *cookie_secret, + size_t cookie_secret_length, + const char *binding, + const char *provided_token) +{ + if (!binding || !provided_token || provided_token[0] == '\0') + return FALSE; + char expected[AUTH_CRYPTO_TOKEN_SIZE] = {0}; + char provided_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE] = {0}; + char expected_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE] = {0}; + boolean valid = FALSE; + + if (Auth_HTTP_Derive_CSRF( + cookie_secret, cookie_secret_length, binding, + expected, sizeof(expected)) && + Auth_Crypto_Token_Digest( + provided_token, provided_digest, sizeof(provided_digest)) == + AUTH_CRYPTO_OK && + Auth_Crypto_Token_Digest( + expected, expected_digest, sizeof(expected_digest)) == + AUTH_CRYPTO_OK) + { + valid = CRYPTO_memcmp( + provided_digest, expected_digest, sizeof(provided_digest)) == 0; + } + + OPENSSL_cleanse(expected, sizeof(expected)); + OPENSSL_cleanse(provided_digest, sizeof(provided_digest)); + OPENSSL_cleanse(expected_digest, sizeof(expected_digest)); + return valid; +} + +Auth_HTTP_Resolve_Result Auth_HTTP_Resolve_Authenticated_User( + Seobeo_Request_Entry *p_request, + Auth_Store *p_store, + const uint8 *cookie_secret, + size_t cookie_secret_length, + int64 current_unix, + int64 session_idle_ttl_secs, + Auth_HTTP_Authenticated_User *p_user) +{ + if (!p_user) + return AUTH_HTTP_RESOLVE_ERROR; + memset(p_user, 0, sizeof(*p_user)); + if (!p_request || !p_store || !cookie_secret || + cookie_secret_length < AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES || + cookie_secret_length > AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES || + current_unix < 0 || session_idle_ttl_secs <= 0) + return AUTH_HTTP_RESOLVE_ERROR; + + char session_token[AUTH_HTTP_COOKIE_VALUE_MAX] = {0}; + const char *cookie_header = Auth_HTTP_Request_Value(p_request, "Cookie"); + if (!cookie_header || + !Auth_HTTP_Parse_Cookie( + cookie_header, AUTH_HTTP_SESSION_COOKIE_NAME, + session_token, sizeof(session_token)) || + session_token[0] == '\0') + { + OPENSSL_cleanse(session_token, sizeof(session_token)); + return AUTH_HTTP_RESOLVE_NOT_FOUND; + } + + if (Auth_Crypto_Token_Digest( + session_token, p_user->token_digest, + sizeof(p_user->token_digest)) != AUTH_CRYPTO_OK) + { + OPENSSL_cleanse(session_token, sizeof(session_token)); + OPENSSL_cleanse(p_user, sizeof(*p_user)); + return AUTH_HTTP_RESOLVE_NOT_FOUND; + } + OPENSSL_cleanse(session_token, sizeof(session_token)); + + Auth_Store_Result result = Auth_Store_Find_Session( + p_store, p_user->token_digest, current_unix, + &p_user->session, &p_user->user); + if (result != AUTH_STORE_OK) + { + OPENSSL_cleanse(p_user, sizeof(*p_user)); + return result == AUTH_STORE_ERROR + ? AUTH_HTTP_RESOLVE_ERROR + : AUTH_HTTP_RESOLVE_NOT_FOUND; + } + + Auth_Store_Touch_Session( + p_store, p_user->token_digest, current_unix, session_idle_ttl_secs); + if (!Auth_HTTP_Derive_CSRF( + cookie_secret, cookie_secret_length, p_user->token_digest, + p_user->csrf_token, sizeof(p_user->csrf_token))) + { + OPENSSL_cleanse(p_user, sizeof(*p_user)); + return AUTH_HTTP_RESOLVE_ERROR; + } + return AUTH_HTTP_RESOLVE_OK; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/auth/auth_http.h Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,68 @@ +#ifndef ZENBU_AUTH_HTTP_H +#define ZENBU_AUTH_HTTP_H + +#include "auth/auth_store.h" +#include "dowa/dowa.h" +#include "seobeo/seobeo.h" + +#define AUTH_HTTP_SESSION_COOKIE_NAME "mjj_session" + +typedef enum { + AUTH_HTTP_RESOLVE_ERROR = -1, + AUTH_HTTP_RESOLVE_NOT_FOUND = 0, + AUTH_HTTP_RESOLVE_OK = 1, +} Auth_HTTP_Resolve_Result; + +typedef struct { + Auth_User_Record user; + Auth_Session_Record session; + /* Sensitive session bindings: cleanse this structure after use. */ + char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; + char csrf_token[AUTH_CRYPTO_TOKEN_SIZE]; +} Auth_HTTP_Authenticated_User; + +const char *Auth_HTTP_Request_Value( + Seobeo_Request_Entry *p_request, + const char *key); + +boolean Auth_HTTP_Parse_Cookie( + const char *cookie_header, + const char *name, + char *value_out, + size_t capacity); + +boolean Auth_HTTP_Same_Origin(Seobeo_Request_Entry *p_request); + +boolean Auth_HTTP_Derive_CSRF( + const uint8 *cookie_secret, + size_t cookie_secret_length, + const char *binding, + char *csrf_out, + size_t csrf_capacity); + +boolean Auth_HTTP_Verify_CSRF( + Seobeo_Request_Entry *p_request, + const uint8 *cookie_secret, + size_t cookie_secret_length, + const char *binding); + +boolean Auth_HTTP_Verify_CSRF_Token( + const uint8 *cookie_secret, + size_t cookie_secret_length, + const char *binding, + const char *provided_token); + +/* + * Resolve only an authenticated mjj_session user. Invalid, expired, revoked, + * and absent sessions return NOT_FOUND. This function never creates guests. + */ +Auth_HTTP_Resolve_Result Auth_HTTP_Resolve_Authenticated_User( + Seobeo_Request_Entry *p_request, + Auth_Store *p_store, + const uint8 *cookie_secret, + size_t cookie_secret_length, + int64 current_unix, + int64 session_idle_ttl_secs, + Auth_HTTP_Authenticated_User *p_user); + +#endif
--- a/auth/test/BUILD Mon Aug 17 22:16:14 2026 -0700 +++ b/auth/test/BUILD Mon Aug 17 22:22:36 2026 -0700 @@ -18,3 +18,16 @@ size = "medium", timeout = "moderate", ) + +cc_test( + name = "auth_http_test", + srcs = ["auth_http_test.c"], + deps = [ + "//auth:auth_crypto", + "//auth:auth_http", + "//auth:auth_store", + "//dowa:dowa", + "//seobeo:seobeo", + ], + size = "small", +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/auth/test/auth_http_test.c Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,123 @@ +#include "auth/auth_http.h" + +#include <assert.h> +#include <stdio.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +static const uint8 k_secret[] = + "0123456789abcdef0123456789abcdef"; + +static Seobeo_Request_Entry *make_request( + Dowa_Arena *p_arena, + const char *host, + const char *origin, + const char *cookie, + const char *csrf) +{ + Seobeo_Request_Entry *request = NULL; + if (host) + Dowa_HashMap_Push_Arena(request, "Host", (char *)host, p_arena); + if (origin) + Dowa_HashMap_Push_Arena(request, "Origin", (char *)origin, p_arena); + if (cookie) + Dowa_HashMap_Push_Arena(request, "Cookie", (char *)cookie, p_arena); + if (csrf) + Dowa_HashMap_Push_Arena( + request, "X-CSRF-Token", (char *)csrf, p_arena); + return request; +} + +int main(void) +{ + Dowa_Arena *p_arena = Dowa_Arena_Create(16 * 1024); + assert(p_arena); + + char cookie_value[16]; + assert(Auth_HTTP_Parse_Cookie( + "other=x; mjj_session=token; final=y", + AUTH_HTTP_SESSION_COOKIE_NAME, cookie_value, sizeof(cookie_value))); + assert(strcmp(cookie_value, "token") == 0); + assert(!Auth_HTTP_Parse_Cookie( + "not_mjj_session=token", AUTH_HTTP_SESSION_COOKIE_NAME, + cookie_value, sizeof(cookie_value))); + + Seobeo_Request_Entry *same_origin = make_request( + p_arena, "localhost:6969", "http://localhost:6969", NULL, NULL); + Seobeo_Request_Entry *wrong_origin = make_request( + p_arena, "localhost:6969", "https://example.com", NULL, NULL); + assert(Auth_HTTP_Same_Origin(same_origin)); + assert(!Auth_HTTP_Same_Origin(wrong_origin)); + + char csrf[AUTH_CRYPTO_TOKEN_SIZE] = {0}; + assert(Auth_HTTP_Derive_CSRF( + k_secret, sizeof(k_secret) - 1, "binding", csrf, sizeof(csrf))); + Seobeo_Request_Entry *csrf_request = make_request( + p_arena, "localhost", "https://localhost", NULL, csrf); + assert(Auth_HTTP_Verify_CSRF( + csrf_request, k_secret, sizeof(k_secret) - 1, "binding")); + assert(!Auth_HTTP_Verify_CSRF_Token( + k_secret, sizeof(k_secret) - 1, "binding", "wrong-token")); + assert(!Auth_HTTP_Verify_CSRF( + wrong_origin, k_secret, sizeof(k_secret) - 1, "binding")); + + char database_path[256]; + snprintf(database_path, sizeof(database_path), + "auth_http_test_%ld_%ld.db", (long)getpid(), (long)time(NULL)); + unlink(database_path); + Auth_Store *p_store = Auth_Store_Create(database_path); + assert(p_store); + + char password_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; + assert(Auth_Crypto_Password_Hash( + "test-password", password_hash, sizeof(password_hash)) == + AUTH_CRYPTO_OK); + char user_id[37]; + assert(Auth_Store_Create_User( + p_store, "httpuser", password_hash, "member", FALSE, user_id) == + AUTH_STORE_OK); + + char token[AUTH_CRYPTO_TOKEN_SIZE]; + char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; + char stored_csrf_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; + assert(Auth_Crypto_Token_Generate(token, sizeof(token)) == AUTH_CRYPTO_OK); + assert(Auth_Crypto_Token_Digest( + token, token_digest, sizeof(token_digest)) == AUTH_CRYPTO_OK); + assert(Auth_Crypto_Token_Digest( + csrf, stored_csrf_digest, sizeof(stored_csrf_digest)) == AUTH_CRYPTO_OK); + int64 now = (int64)time(NULL); + Auth_Session_Record session; + assert(Auth_Store_Create_Session( + p_store, user_id, token_digest, stored_csrf_digest, + 3600, 86400, now, &session) == AUTH_STORE_OK); + + char cookie_header[AUTH_CRYPTO_TOKEN_SIZE + 32]; + snprintf(cookie_header, sizeof(cookie_header), "%s=%s", + AUTH_HTTP_SESSION_COOKIE_NAME, token); + Seobeo_Request_Entry *user_request = make_request( + p_arena, "localhost", NULL, cookie_header, NULL); + Auth_HTTP_Authenticated_User user; + assert(Auth_HTTP_Resolve_Authenticated_User( + user_request, p_store, k_secret, sizeof(k_secret) - 1, + now + 1, 3600, &user) == AUTH_HTTP_RESOLVE_OK); + assert(strcmp(user.user.id, user_id) == 0); + assert(strcmp(user.token_digest, token_digest) == 0); + assert(user.csrf_token[0] != '\0'); + assert(Auth_Store_Revoke_Session(p_store, token_digest) == AUTH_STORE_OK); + assert(Auth_HTTP_Resolve_Authenticated_User( + user_request, p_store, k_secret, sizeof(k_secret) - 1, + now + 2, 3600, &user) == AUTH_HTTP_RESOLVE_NOT_FOUND); + + Seobeo_Request_Entry *anonymous_request = make_request( + p_arena, "localhost", NULL, NULL, NULL); + assert(Auth_HTTP_Resolve_Authenticated_User( + anonymous_request, p_store, k_secret, sizeof(k_secret) - 1, + now + 1, 3600, &user) == AUTH_HTTP_RESOLVE_NOT_FOUND); + + Auth_Store_Destroy(p_store); + unlink(database_path); + Dowa_Arena_Free(p_arena); + puts("auth_http_test: PASS"); + return 0; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/.config.development Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,28 @@ +# Connector Service Configuration - DEVELOPMENT TEMPLATE +# +# Copy this placeholder file to connectors/.config and fill in local values. +# The real .config is ignored by Mercurial and is the default runtime source. +# +# cp connectors/.config.development connectors/.config + +DATABASE=connectors/connectors.db + +# Existing Zenbu database containing users and auth_sessions. +AUTH_DATABASE=mrjunejune/data/mrjunejune.db + +# Must exactly match mrjunejune AUTH_COOKIE_SECRET (hex, at least 32 bytes). +AUTH_COOKIE_SECRET=REPLACE_WITH_ZENBU_AUTH_COOKIE_SECRET_HEX +AUTH_SESSION_IDLE_TTL=604800 + +SERVER_HOST=127.0.0.1 +SERVER_PORT=6981 +STATIC_DIR=connectors + +GOOGLE_CLIENT_ID=REPLACE_WITH_GOOGLE_CLIENT_ID +GOOGLE_CLIENT_SECRET=REPLACE_WITH_GOOGLE_CLIENT_SECRET +GOOGLE_REDIRECT_URI=http://127.0.0.1:6981/v1/oauth/google/callback + +MASTER_KEY_VERSION=1 +# 32 random bytes, unpadded base64url. Example generation: +# openssl rand 32 | openssl base64 -A | tr '+/' '-_' | tr -d '=' +MASTER_KEY_BASE64URL=REPLACE_WITH_32_BYTE_BASE64URL_KEY
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/BUILD Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,138 @@ +load("@rules_cc//cc:cc_binary.bzl", "cc_binary") +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +cc_library( + name = "connector_core", + srcs = ["core.c"], + hdrs = ["connector.h"], + deps = [ + "//deita:deita", + "//dowa:dowa", + "//seobeo:seobeo", + "@openssl//:crypto", + ], + visibility = ["//visibility:public"], +) + +cc_library( + name = "connector_store", + srcs = ["store.c"], + hdrs = ["connector.h"], + deps = [ + ":connector_core", + "//deita:deita", + "//dowa:dowa", + ], + visibility = ["//visibility:public"], +) + +cc_library( + name = "connector_auth_http", + srcs = ["auth_http_adapter.c"], + hdrs = ["auth_http_adapter.h"], + deps = [ + ":connector_core", + "//auth:auth_http", + "//dowa:dowa", + "@openssl//:crypto", + ], + visibility = ["//visibility:public"], +) + +cc_library( + name = "google_provider", + srcs = ["google.c"], + hdrs = ["connector.h"], + deps = [ + ":connector_core", + "//dowa:dowa", + "//seobeo:seobeo", + ], + visibility = ["//visibility:public"], +) + +cc_library( + name = "connector_service_lib", + srcs = [ + "auth_test_page.c", + "service.c", + ], + hdrs = [ + "auth_test_page.h", + "connector.h", + ], + deps = [ + ":connector_core", + ":connector_store", + ":google_provider", + "//dowa:dowa", + "//seobeo:seobeo", + ], +) + +cc_binary( + name = "connector_server", + srcs = ["main.c"], + deps = [ + ":connector_core", + ":connector_auth_http", + ":connector_service_lib", + ":connector_store", + ":google_provider", + "//dowa:dowa", + "//seobeo:seobeo", + "@openssl//:crypto", + ], +) + +cc_test( + name = "core_store_test", + srcs = ["tests/core_store_test.c"], + deps = [ + ":connector_core", + ":connector_store", + ], +) + +cc_test( + name = "auth_http_adapter_test", + srcs = ["tests/auth_http_adapter_test.c"], + deps = [ + ":connector_auth_http", + "//auth:auth_crypto", + "//auth:auth_http", + "//auth:auth_store", + "//dowa:dowa", + ], +) + +cc_test( + name = "google_provider_test", + srcs = ["tests/google_provider_test.c"], + deps = [ + ":connector_core", + ":google_provider", + "//dowa:dowa", + ], +) + +cc_test( + name = "route_test", + srcs = ["tests/route_test.c"], + deps = [ + ":connector_service_lib", + "//dowa:dowa", + "//seobeo:seobeo", + ], +) + +test_suite( + name = "connector_tests", + tests = [ + ":auth_http_adapter_test", + ":core_store_test", + ":google_provider_test", + ":route_test", + ], +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/README.md Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,12 @@ +# Zenbu connectors + +Zenbu's C/Seobeo service for connecting user-owned Google Drive and Gmail +accounts to browser and AI orchestration workflows. + +**Canonical documentation:** [`wiki/README.md`](wiki/README.md) + +**Machine-readable tool contract:** `GET /v1/ai/tools` + +Do not add another top-level connector guide. Update the canonical wiki instead, +and split a topic into another `wiki/` page only when the main page becomes +materially harder to navigate.
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/auth_http_adapter.c Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,141 @@ +#include "connectors/auth_http_adapter.h" + +#include <openssl/crypto.h> + +#include <string.h> +#include <time.h> + +static int32 hex_value(char value) +{ + if (value >= '0' && value <= '9') + return value - '0'; + if (value >= 'a' && value <= 'f') + return value - 'a' + 10; + if (value >= 'A' && value <= 'F') + return value - 'A' + 10; + return -1; +} + +static boolean decode_secret( + const char *hex, uint8 *output, size_t capacity, size_t *length_out) +{ + if (!hex || !output || !length_out) + return FALSE; + size_t length = strlen(hex); + if (length % 2 != 0 || length / 2 > capacity || + length / 2 < AUTH_CRYPTO_COOKIE_SECRET_MIN_BYTES) + return FALSE; + for (size_t i = 0; i < length; i += 2) { + int32 high = hex_value(hex[i]); + int32 low = hex_value(hex[i + 1]); + if (high < 0 || low < 0) { + OPENSSL_cleanse(output, capacity); + return FALSE; + } + output[i / 2] = (uint8)((high << 4) | low); + } + *length_out = length / 2; + return TRUE; +} + +boolean Connector_Auth_HTTP_Init( + Connector_Auth_HTTP_Context *context, + const char *database_path, + const char *cookie_secret_hex, + int64 session_idle_ttl_secs) +{ + if (!context || !database_path || !cookie_secret_hex || + session_idle_ttl_secs <= 0) + return FALSE; + memset(context, 0, sizeof(*context)); + if (!decode_secret( + cookie_secret_hex, context->cookie_secret, + sizeof(context->cookie_secret), &context->cookie_secret_length)) + return FALSE; + context->store = Auth_Store_Create(database_path); + if (!context->store) { + OPENSSL_cleanse(context->cookie_secret, sizeof(context->cookie_secret)); + context->cookie_secret_length = 0; + return FALSE; + } + context->session_idle_ttl_secs = session_idle_ttl_secs; + return TRUE; +} + +void Connector_Auth_HTTP_Destroy(Connector_Auth_HTTP_Context *context) +{ + if (!context) + return; + if (context->store) + Auth_Store_Destroy(context->store); + OPENSSL_cleanse(context, sizeof(*context)); +} + +static const char *resolve_user( + Seobeo_Request_Entry *request, + boolean require_csrf, + Dowa_Arena *arena, + void *opaque) +{ + Connector_Auth_HTTP_Context *context = opaque; + if (!context || !context->store || !arena) + return NULL; + Auth_HTTP_Authenticated_User authenticated; + Auth_HTTP_Resolve_Result result = Auth_HTTP_Resolve_Authenticated_User( + request, context->store, context->cookie_secret, + context->cookie_secret_length, (int64)time(NULL), + context->session_idle_ttl_secs, &authenticated); + if (result != AUTH_HTTP_RESOLVE_OK) + return NULL; + if (require_csrf && !Auth_HTTP_Verify_CSRF( + request, context->cookie_secret, context->cookie_secret_length, + authenticated.token_digest)) { + OPENSSL_cleanse(&authenticated, sizeof(authenticated)); + return NULL; + } + size_t id_length = strlen(authenticated.user.id); + char *user_id = Dowa_Arena_Allocate(arena, id_length + 1); + if (user_id) + memcpy(user_id, authenticated.user.id, id_length + 1); + OPENSSL_cleanse(&authenticated, sizeof(authenticated)); + return user_id; +} + +static boolean resolve_session( + Seobeo_Request_Entry *request, + Connector_Auth_Session *session, + void *opaque) +{ + Connector_Auth_HTTP_Context *context = opaque; + if (!context || !context->store || !session) + return FALSE; + Auth_HTTP_Authenticated_User authenticated; + Auth_HTTP_Resolve_Result result = Auth_HTTP_Resolve_Authenticated_User( + request, context->store, context->cookie_secret, + context->cookie_secret_length, (int64)time(NULL), + context->session_idle_ttl_secs, &authenticated); + if (result != AUTH_HTTP_RESOLVE_OK) + return FALSE; + memset(session, 0, sizeof(*session)); + snprintf(session->user_id, sizeof(session->user_id), "%s", + authenticated.user.id); + snprintf(session->username, sizeof(session->username), "%s", + authenticated.user.username); + snprintf(session->role, sizeof(session->role), "%s", + authenticated.user.role); + snprintf(session->csrf_token, sizeof(session->csrf_token), "%s", + authenticated.csrf_token); + OPENSSL_cleanse(&authenticated, sizeof(authenticated)); + return TRUE; +} + +Connector_Auth_Adapter Connector_Auth_HTTP_Create_Adapter( + Connector_Auth_HTTP_Context *context) +{ + Connector_Auth_Adapter adapter = { + .resolve_user = resolve_user, + .resolve_session = resolve_session, + .context = context + }; + return adapter; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/auth_http_adapter.h Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,25 @@ +#ifndef ZENBU_CONNECTOR_AUTH_HTTP_ADAPTER_H +#define ZENBU_CONNECTOR_AUTH_HTTP_ADAPTER_H + +#include "auth/auth_http.h" +#include "connectors/connector.h" + +typedef struct { + Auth_Store *store; + uint8 cookie_secret[AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES]; + size_t cookie_secret_length; + int64 session_idle_ttl_secs; +} Connector_Auth_HTTP_Context; + +boolean Connector_Auth_HTTP_Init( + Connector_Auth_HTTP_Context *context, + const char *database_path, + const char *cookie_secret_hex, + int64 session_idle_ttl_secs); + +void Connector_Auth_HTTP_Destroy(Connector_Auth_HTTP_Context *context); + +Connector_Auth_Adapter Connector_Auth_HTTP_Create_Adapter( + Connector_Auth_HTTP_Context *context); + +#endif
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/auth_test_page.c Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,85 @@ +#include "connectors/auth_test_page.h" + +const char *Connector_Auth_Test_Page(void) +{ + return + "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">" + "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">" + "<title>Zenbu Connector Test</title><style>" + ":root{color-scheme:dark;font-family:system-ui,sans-serif;background:#111;" + "color:#eee}main{max-width:820px;margin:32px auto;padding:24px}" + "section{margin:20px 0;padding:16px;border:1px solid #444;border-radius:8px}" + "button,a,input,textarea{margin:4px;padding:9px}input,textarea{width:95%}" + "button,a{display:inline-block}pre{min-height:140px;padding:16px;" + "overflow:auto;background:#1d1d1d;border:1px solid #444;border-radius:8px}" + "</style></head><body><main><h1>Zenbu connector test</h1>" + "<section><h2>1. Authentication</h2>" + "<a href=\"http://127.0.0.1:6969/login\" target=\"_blank\">Zenbu login</a>" + "<button id=\"check\">Check Zenbu session</button>" + "<button id=\"oauth\" disabled>Connect Google account</button></section>" + "<section><h2>2. Google account</h2>" + "<label>Account ID<input id=\"account\" placeholder=\"google:...\"></label>" + "<button id=\"drive-list\">List Drive files</button>" + "<button id=\"drive-create\">Create test Google Doc</button>" + "<button id=\"gmail-list\">List Gmail messages</button></section>" + "<section><h2>3. Gmail write test</h2>" + "<label>To<input id=\"to\" type=\"email\"></label>" + "<label>Subject<input id=\"subject\" value=\"Zenbu connector test\"></label>" + "<label>Message<textarea id=\"message\" rows=\"4\">Hello from Zenbu.</textarea></label>" + "<button id=\"gmail-draft\">Create draft</button>" + "<button id=\"gmail-send\">Send with confirmation</button></section>" + "<pre id=\"output\">Check the Zenbu session first.</pre></main><script>" + "const $=s=>document.querySelector(s),out=$('#output'),oauth=$('#oauth');" + "let csrf='';const params=new URLSearchParams(location.search);" + "$('#account').value=params.get('account_id')||localStorage.zenbuAccount||'';" + "if(params.get('email'))$('#to').value=params.get('email');" + "const show=v=>out.textContent=typeof v==='string'?v:JSON.stringify(v,null,2);" + "const account=()=>{const v=$('#account').value.trim();" + "if(!v)throw new Error('Connect Google or enter an account ID');" + "if(!/^[A-Za-z0-9_.:-]+$/.test(v))throw new Error('Invalid account ID');" + "localStorage.zenbuAccount=v;return v};" + "async function result(response){const text=await response.text();" + "let body;try{body=JSON.parse(text)}catch{body=text}" + "show({status:response.status,body});return{response,body}}" + "async function read(path){return result(await fetch(path,{credentials:'include'}))}" + "async function mutate(path,body,key,confirmation){" + "const headers={'Content-Type':'application/json','X-CSRF-Token':csrf," + "'Idempotency-Key':key};if(confirmation)" + "headers['X-Connector-Confirmation']=confirmation;" + "return result(await fetch(path,{method:'POST',credentials:'include'," + "headers,body:JSON.stringify(body)}))}" + "$('#check').onclick=async()=>{oauth.disabled=true;csrf='';" + "const r=await read('/v1/auth/session');" + "if(r.response.ok&&r.body.authenticated){csrf=r.body.csrfToken;" + "oauth.disabled=false}};" + "oauth.onclick=async()=>{const r=await result(await fetch(" + "'/v1/oauth/google/start',{method:'POST',credentials:'include'," + "headers:{'X-CSRF-Token':csrf}}));" + "if(r.response.ok&&r.body.authorization_url)" + "location.assign(r.body.authorization_url)};" + "$('#drive-list').onclick=()=>read('/v1/accounts/'+account()+" + "'/drive/files?pageSize=10&fields=files(id,name,mimeType),nextPageToken');" + "$('#gmail-list').onclick=()=>read('/v1/accounts/'+account()+" + "'/gmail/messages?maxResults=10');" + "$('#drive-create').onclick=()=>mutate('/v1/accounts/'+account()+" + "'/drive/files',{name:'Zenbu test '+new Date().toISOString()," + "mimeType:'application/vnd.google-apps.document'},crypto.randomUUID());" + "function rawMessage(){const mime='To: '+$('#to').value+'\\r\\nSubject: '+" + "$('#subject').value+'\\r\\nContent-Type: text/plain; charset=UTF-8\\r\\n\\r\\n'+" + "$('#message').value;const bytes=new TextEncoder().encode(mime);let binary='';" + "for(const byte of bytes)binary+=String.fromCharCode(byte);" + "return btoa(binary).replaceAll('+','-').replaceAll('/','_').replace(/=+$/,'')}" + "$('#gmail-draft').onclick=()=>mutate('/v1/accounts/'+account()+" + "'/gmail/drafts',{message:{raw:rawMessage()}},crypto.randomUUID());" + "$('#gmail-send').onclick=async()=>{if(!csrf){show('Check session first');return}" + "const path='/v1/accounts/'+account()+'/gmail/send',body={raw:rawMessage()}," + "key=crypto.randomUUID();let r=await mutate(path,body,key);" + "if(r.response.status!==409||r.body.error!=='confirmation_required')return;" + "if(!confirm('Send this email through Gmail?'))return;" + "const confirmation=await result(await fetch('/v1/confirmations',{" + "method:'POST',credentials:'include',headers:{'Content-Type':'application/json'," + "'X-CSRF-Token':csrf},body:JSON.stringify({request_digest:r.body.request_digest})}));" + "if(!confirmation.response.ok)return;" + "await mutate(path,body,key,confirmation.body.confirmation_token)};" + "</script></body></html>"; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/auth_test_page.h Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,6 @@ +#ifndef ZENBU_CONNECTOR_AUTH_TEST_PAGE_H +#define ZENBU_CONNECTOR_AUTH_TEST_PAGE_H + +const char *Connector_Auth_Test_Page(void); + +#endif
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/connector.h Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,253 @@ +#ifndef ZENBU_CONNECTOR_H +#define ZENBU_CONNECTOR_H + +#include "deita/deita.h" +#include "dowa/dowa.h" +#include "seobeo/seobeo.h" + +#include <stddef.h> + +#define CONNECTOR_MAX_JSON_BYTES (1024 * 1024) +#define CONNECTOR_ID_MAX 128 + +typedef enum { + CONNECTOR_OK = 0, + CONNECTOR_ERROR, + CONNECTOR_INVALID, + CONNECTOR_NOT_FOUND, + CONNECTOR_FORBIDDEN, + CONNECTOR_CONFIRMATION_REQUIRED, + CONNECTOR_CONFLICT, + CONNECTOR_PROVIDER_ERROR +} Connector_Status; + +typedef enum { + CONNECTOR_CONFIRM_NEVER = 0, + CONNECTOR_CONFIRM_ALWAYS = 1 +} Connector_Confirmation_Policy; + +typedef enum { + CONNECTOR_OP_DRIVE_LIST, + CONNECTOR_OP_DRIVE_GET, + CONNECTOR_OP_DRIVE_DOWNLOAD, + CONNECTOR_OP_DRIVE_CHANGES, + CONNECTOR_OP_DRIVE_CREATE, + CONNECTOR_OP_DRIVE_UPLOAD, + CONNECTOR_OP_DRIVE_UPDATE, + CONNECTOR_OP_GMAIL_LIST, + CONNECTOR_OP_GMAIL_GET, + CONNECTOR_OP_GMAIL_ATTACHMENT, + CONNECTOR_OP_GMAIL_HISTORY, + CONNECTOR_OP_GMAIL_DRAFT_CREATE, + CONNECTOR_OP_GMAIL_SEND +} Connector_Operation; + +typedef struct { + uint32 version; + uint8 key[32]; +} Connector_Master_Key; + +typedef struct { + char account_id[CONNECTOR_ID_MAX]; + char user_id[CONNECTOR_ID_MAX]; + char provider[32]; + char provider_subject[CONNECTOR_ID_MAX]; + char email[256]; + char access_token[2048]; + char refresh_token[2048]; + int64 expires_at; + char scopes[1024]; +} Connector_Account; + +typedef struct { + char account_id[CONNECTOR_ID_MAX]; + char provider[32]; + char email[256]; + int64 expires_at; + char scopes[1024]; +} Connector_Account_Summary; + +typedef struct { + char state[128]; + char code_verifier[128]; + char code_challenge[128]; +} Connector_OAuth_Start; + +typedef struct { + const char *method; + const char *url; + const char *content_type; + const char *body; + size_t body_length; + const char *download_path; +} Connector_HTTP_Request; + +typedef struct { + int32 status_code; + char *body; + size_t body_length; +} Connector_HTTP_Response; + +typedef struct { + int32 http_status; + char code[64]; + char description[256]; +} Connector_Provider_Error; + +typedef Connector_Status (*Connector_HTTP_Transport)( + const Connector_HTTP_Request *request, + const char *access_token, + Connector_HTTP_Response *response, + Dowa_Arena *arena, + void *context); + +typedef struct { + const char *client_id; + const char *client_secret; + const char *redirect_uri; + const char *oauth_authorize_url; + const char *oauth_token_url; + const char *oauth_revoke_url; + const char *identity_url; + const char *drive_api_url; + const char *drive_upload_url; + const char *gmail_api_url; + Connector_HTTP_Transport transport; + void *transport_context; +} Connector_Google_Config; + +typedef struct { + const char *method; + const char *path; + const char *query; + const char *content_type; + const char *body; + size_t body_length; + const char *download_path; + boolean overwrite; +} Connector_Provider_Request; + +typedef struct { + Connector_Status status; + int32 provider_status; + char *body; + size_t body_length; +} Connector_Provider_Response; + +typedef struct Connector_Store { + Deita_Connection *connection; + Connector_Master_Key master_key; +} Connector_Store; + +typedef const char *(*Connector_Auth_Resolve_User)( + Seobeo_Request_Entry *request, + boolean require_csrf, + Dowa_Arena *arena, + void *context); + +typedef struct { + char user_id[CONNECTOR_ID_MAX]; + char username[64]; + char role[16]; + char csrf_token[128]; +} Connector_Auth_Session; + +typedef boolean (*Connector_Auth_Resolve_Session)( + Seobeo_Request_Entry *request, + Connector_Auth_Session *session, + void *context); + +typedef struct { + Connector_Auth_Resolve_User resolve_user; + Connector_Auth_Resolve_Session resolve_session; + void *context; +} Connector_Auth_Adapter; + +boolean Connector_Base64Url_Encode( + const uint8 *input, size_t input_length, char *output, size_t output_size); +boolean Connector_Base64Url_Decode( + const char *input, uint8 *output, size_t output_size, size_t *output_length); +boolean Connector_Encrypt( + const Connector_Master_Key *key, const char *plaintext, + char *encoded, size_t encoded_size); +boolean Connector_Decrypt( + const Connector_Master_Key *key, const char *encoded, + char *plaintext, size_t plaintext_size); +boolean Connector_Form_Encode( + const char *input, char *output, size_t output_size); +boolean Connector_Request_Digest( + const char *user_id, const char *account_id, Connector_Operation operation, + const Connector_Provider_Request *request, char output[65]); +boolean Connector_OAuth_PKCE_Start(Connector_OAuth_Start *start); +const char *Connector_Operation_Name(Connector_Operation operation); +boolean Connector_Operation_Is_Mutation(Connector_Operation operation); +boolean Connector_Operation_Is_Allowed(Connector_Operation operation); + +boolean Connector_Store_Open( + Connector_Store *store, const char *database_path, + const Connector_Master_Key *master_key); +void Connector_Store_Close(Connector_Store *store); +boolean Connector_Store_Migrate(Connector_Store *store); +boolean Connector_Store_Create_State( + Connector_Store *store, const char *user_id, const Connector_OAuth_Start *start, + int64 expires_at); +boolean Connector_Store_Consume_State( + Connector_Store *store, const char *user_id, const char *state, int64 now, + char *verifier, size_t verifier_size); +boolean Connector_Store_Save_Account( + Connector_Store *store, const Connector_Account *account); +boolean Connector_Store_Get_Account( + Connector_Store *store, const char *user_id, const char *account_id, + Connector_Account *account); +boolean Connector_Store_List_Accounts( + Connector_Store *store, const char *user_id, + Connector_Account_Summary **accounts, Dowa_Arena *arena); +boolean Connector_Store_Delete_Account( + Connector_Store *store, const char *user_id, const char *account_id); +boolean Connector_Store_Set_Policy( + Connector_Store *store, const char *user_id, const char *action, + Connector_Confirmation_Policy policy); +Connector_Confirmation_Policy Connector_Store_Get_Policy( + Connector_Store *store, const char *user_id, const char *action); +boolean Connector_Store_Create_Confirmation( + Connector_Store *store, const char *user_id, const char *digest, + const char *token, int64 expires_at); +boolean Connector_Store_Consume_Confirmation( + Connector_Store *store, const char *user_id, const char *digest, + const char *token, int64 now); +boolean Connector_Store_Get_Idempotent( + Connector_Store *store, const char *user_id, const char *key, + const char *digest, int32 *status, char *body, size_t body_size); +boolean Connector_Store_Idempotency_Conflict( + Connector_Store *store, const char *user_id, const char *key, + const char *digest); +boolean Connector_Store_Put_Idempotent( + Connector_Store *store, const char *user_id, const char *key, + const char *digest, int32 status, const char *body); +boolean Connector_Store_Audit( + Connector_Store *store, const char *user_id, const char *account_id, + const char *operation, const char *digest, int32 status); + +char *Connector_Google_Authorization_URL( + const Connector_Google_Config *config, const Connector_OAuth_Start *start, + Dowa_Arena *arena); +Connector_Status Connector_Google_Exchange_Code( + const Connector_Google_Config *config, const char *code, + const char *verifier, Connector_Account *account, + Connector_Provider_Error *error, Dowa_Arena *arena); +Connector_Status Connector_Google_Refresh( + const Connector_Google_Config *config, Connector_Account *account, + Dowa_Arena *arena); +Connector_Status Connector_Google_Revoke( + const Connector_Google_Config *config, const char *token, Dowa_Arena *arena); +Connector_Status Connector_Google_Execute( + const Connector_Google_Config *config, Connector_Operation operation, + const Connector_Provider_Request *request, const char *access_token, + Connector_Provider_Response *response, Dowa_Arena *arena); + +void Connector_Service_Configure( + Connector_Store *store, const Connector_Google_Config *google, + Connector_Auth_Adapter auth); +void Connector_Service_Register_Routes(void); + +#endif
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/core.c Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,269 @@ +#include "connectors/connector.h" + +#include <openssl/evp.h> +#include <openssl/rand.h> +#include <stdio.h> +#include <string.h> + +static const char BASE64URL[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +boolean Connector_Base64Url_Encode( + const uint8 *input, size_t input_length, char *output, size_t output_size) +{ + size_t required = (input_length * 4 + 2) / 3; + if (!input || !output || output_size <= required) + return FALSE; + size_t i = 0, j = 0; + while (i + 3 <= input_length) { + uint32 value = ((uint32)input[i] << 16) | + ((uint32)input[i + 1] << 8) | input[i + 2]; + output[j++] = BASE64URL[(value >> 18) & 63]; + output[j++] = BASE64URL[(value >> 12) & 63]; + output[j++] = BASE64URL[(value >> 6) & 63]; + output[j++] = BASE64URL[value & 63]; + i += 3; + } + if (i < input_length) { + uint32 value = (uint32)input[i] << 16; + output[j++] = BASE64URL[(value >> 18) & 63]; + if (i + 1 < input_length) { + value |= (uint32)input[i + 1] << 8; + output[j++] = BASE64URL[(value >> 12) & 63]; + output[j++] = BASE64URL[(value >> 6) & 63]; + } else { + output[j++] = BASE64URL[(value >> 12) & 63]; + } + } + output[j] = '\0'; + return TRUE; +} + +static int32 base64url_value(char c) +{ + const char *position = strchr(BASE64URL, c); + return position ? (int32)(position - BASE64URL) : -1; +} + +boolean Connector_Base64Url_Decode( + const char *input, uint8 *output, size_t output_size, size_t *output_length) +{ + if (!input || !output || !output_length) + return FALSE; + size_t length = strlen(input); + if ((length % 4) == 1 || output_size < (length * 3) / 4) + return FALSE; + uint32 accumulator = 0; + int32 bits = 0; + size_t written = 0; + for (size_t i = 0; i < length; ++i) { + int32 value = base64url_value(input[i]); + if (value < 0) + return FALSE; + accumulator = (accumulator << 6) | (uint32)value; + bits += 6; + if (bits >= 8) { + bits -= 8; + if (written >= output_size) + return FALSE; + output[written++] = (uint8)((accumulator >> bits) & 255); + } + } + *output_length = written; + return TRUE; +} + +boolean Connector_Encrypt( + const Connector_Master_Key *key, const char *plaintext, + char *encoded, size_t encoded_size) +{ + if (!key || !plaintext || !encoded) + return FALSE; + size_t plaintext_length = strlen(plaintext); + if (plaintext_length > 4096) + return FALSE; + uint8 nonce[12], tag[16], ciphertext[4096]; + if (RAND_bytes(nonce, sizeof(nonce)) != 1) + return FALSE; + EVP_CIPHER_CTX *context = EVP_CIPHER_CTX_new(); + int32 length = 0, total = 0; + boolean ok = context && + EVP_EncryptInit_ex(context, EVP_aes_256_gcm(), NULL, NULL, NULL) == 1 && + EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_SET_IVLEN, sizeof(nonce), NULL) == 1 && + EVP_EncryptInit_ex(context, NULL, NULL, key->key, nonce) == 1 && + EVP_EncryptUpdate(context, ciphertext, &length, + (const uint8 *)plaintext, (int32)plaintext_length) == 1; + total = length; + ok = ok && EVP_EncryptFinal_ex(context, ciphertext + total, &length) == 1; + total += length; + ok = ok && EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_GET_TAG, sizeof(tag), tag) == 1; + EVP_CIPHER_CTX_free(context); + if (!ok) + return FALSE; + uint8 envelope[4 + 12 + 16 + 4096]; + envelope[0] = (uint8)(key->version >> 24); + envelope[1] = (uint8)(key->version >> 16); + envelope[2] = (uint8)(key->version >> 8); + envelope[3] = (uint8)key->version; + memcpy(envelope + 4, nonce, sizeof(nonce)); + memcpy(envelope + 16, tag, sizeof(tag)); + memcpy(envelope + 32, ciphertext, (size_t)total); + return Connector_Base64Url_Encode( + envelope, 32 + (size_t)total, encoded, encoded_size); +} + +boolean Connector_Decrypt( + const Connector_Master_Key *key, const char *encoded, + char *plaintext, size_t plaintext_size) +{ + uint8 envelope[4 + 12 + 16 + 4096]; + size_t envelope_length = 0; + if (!key || !encoded || !plaintext || + !Connector_Base64Url_Decode( + encoded, envelope, sizeof(envelope), &envelope_length) || + envelope_length < 32) + return FALSE; + uint32 version = ((uint32)envelope[0] << 24) | + ((uint32)envelope[1] << 16) | ((uint32)envelope[2] << 8) | envelope[3]; + size_t ciphertext_length = envelope_length - 32; + if (version != key->version || plaintext_size <= ciphertext_length) + return FALSE; + EVP_CIPHER_CTX *context = EVP_CIPHER_CTX_new(); + int32 length = 0, total = 0; + boolean ok = context && + EVP_DecryptInit_ex(context, EVP_aes_256_gcm(), NULL, NULL, NULL) == 1 && + EVP_CIPHER_CTX_ctrl(context, EVP_CTRL_GCM_SET_IVLEN, 12, NULL) == 1 && + EVP_DecryptInit_ex(context, NULL, NULL, key->key, envelope + 4) == 1 && + EVP_DecryptUpdate(context, (uint8 *)plaintext, &length, + envelope + 32, (int32)ciphertext_length) == 1; + total = length; + ok = ok && EVP_CIPHER_CTX_ctrl( + context, EVP_CTRL_GCM_SET_TAG, 16, envelope + 16) == 1 && + EVP_DecryptFinal_ex(context, (uint8 *)plaintext + total, &length) == 1; + total += length; + EVP_CIPHER_CTX_free(context); + if (!ok) { + memset(plaintext, 0, plaintext_size); + return FALSE; + } + plaintext[total] = '\0'; + return TRUE; +} + +boolean Connector_Form_Encode( + const char *input, char *output, size_t output_size) +{ + static const char hex[] = "0123456789ABCDEF"; + if (!input || !output) + return FALSE; + size_t j = 0; + for (size_t i = 0; input[i]; ++i) { + uint8 c = (uint8)input[i]; + boolean safe = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~'; + size_t needed = safe ? 1 : 3; + if (j + needed >= output_size) + return FALSE; + if (safe) + output[j++] = (char)c; + else { + output[j++] = '%'; + output[j++] = hex[c >> 4]; + output[j++] = hex[c & 15]; + } + } + output[j] = '\0'; + return TRUE; +} + +const char *Connector_Operation_Name(Connector_Operation operation) +{ + static const char *names[] = { + "drive.list", "drive.get", "drive.download", "drive.changes", + "drive.create", "drive.upload", "drive.update", "gmail.list", + "gmail.get", "gmail.attachment", "gmail.history", "gmail.draft.create", + "gmail.send" + }; + return operation >= CONNECTOR_OP_DRIVE_LIST && + operation <= CONNECTOR_OP_GMAIL_SEND ? names[operation] : "unknown"; +} + +boolean Connector_Operation_Is_Mutation(Connector_Operation operation) +{ + return operation == CONNECTOR_OP_DRIVE_CREATE || + operation == CONNECTOR_OP_DRIVE_UPLOAD || + operation == CONNECTOR_OP_DRIVE_UPDATE || + operation == CONNECTOR_OP_GMAIL_DRAFT_CREATE || + operation == CONNECTOR_OP_GMAIL_SEND; +} + +boolean Connector_Operation_Is_Allowed(Connector_Operation operation) +{ + return operation >= CONNECTOR_OP_DRIVE_LIST && + operation <= CONNECTOR_OP_GMAIL_SEND; +} + +boolean Connector_Request_Digest( + const char *user_id, const char *account_id, Connector_Operation operation, + const Connector_Provider_Request *request, char output[65]) +{ + if (!user_id || !account_id || !request || !output || + request->body_length > CONNECTOR_MAX_JSON_BYTES) + return FALSE; + EVP_MD_CTX *context = EVP_MD_CTX_new(); + uint8 digest[32]; + uint32 digest_length = 0; + const char separator = '\0'; +#define HASH_FIELD(value, length) do { \ + EVP_DigestUpdate(context, (value) ? (value) : "", (value) ? (length) : 0); \ + EVP_DigestUpdate(context, &separator, 1); \ +} while (0) + boolean ok = context && + EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1; + if (!ok) { + EVP_MD_CTX_free(context); + return FALSE; + } + const char *name = Connector_Operation_Name(operation); + HASH_FIELD(user_id, strlen(user_id)); + HASH_FIELD(account_id, strlen(account_id)); + HASH_FIELD(name, strlen(name)); + HASH_FIELD(request->method, request->method ? strlen(request->method) : 0); + HASH_FIELD(request->path, request->path ? strlen(request->path) : 0); + HASH_FIELD(request->query, request->query ? strlen(request->query) : 0); + HASH_FIELD(request->content_type, + request->content_type ? strlen(request->content_type) : 0); + HASH_FIELD(request->body, request->body_length); + uint8 overwrite = request->overwrite ? 1 : 0; + EVP_DigestUpdate(context, &overwrite, 1); + ok = EVP_DigestFinal_ex(context, digest, &digest_length) == 1; + EVP_MD_CTX_free(context); +#undef HASH_FIELD + if (!ok || digest_length != 32) + return FALSE; + for (size_t i = 0; i < sizeof(digest); ++i) + snprintf(output + i * 2, 3, "%02x", digest[i]); + output[64] = '\0'; + return TRUE; +} + +boolean Connector_OAuth_PKCE_Start(Connector_OAuth_Start *start) +{ + uint8 state[32], verifier[48], digest[32]; + uint32 digest_length = 0; + if (!start || RAND_bytes(state, sizeof(state)) != 1 || + RAND_bytes(verifier, sizeof(verifier)) != 1 || + !Connector_Base64Url_Encode( + state, sizeof(state), start->state, sizeof(start->state)) || + !Connector_Base64Url_Encode( + verifier, sizeof(verifier), start->code_verifier, + sizeof(start->code_verifier))) + return FALSE; + if (EVP_Digest( + start->code_verifier, strlen(start->code_verifier), digest, + &digest_length, EVP_sha256(), NULL) != 1 || digest_length != 32) + return FALSE; + return Connector_Base64Url_Encode( + digest, sizeof(digest), start->code_challenge, + sizeof(start->code_challenge)); +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/google.c Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,447 @@ +#include "connectors/connector.h" + +#include <stdio.h> +#include <string.h> +#include <time.h> + +static boolean json_is_bounded(const char *json, size_t length) +{ + if (!json || length > CONNECTOR_MAX_JSON_BYTES) + return FALSE; + int32 depth = 0; + boolean string = FALSE, escaped = FALSE; + for (size_t i = 0; i < length; ++i) { + char c = json[i]; + if (string) { + if (escaped) + escaped = FALSE; + else if (c == '\\') + escaped = TRUE; + else if (c == '"') + string = FALSE; + } else if (c == '"') + string = TRUE; + else if (c == '{' || c == '[') { + if (++depth > 32) + return FALSE; + } else if (c == '}' || c == ']') { + if (--depth < 0) + return FALSE; + } + } + return !string && depth == 0; +} + +static Connector_Status default_transport( + const Connector_HTTP_Request *request, const char *access_token, + Connector_HTTP_Response *response, Dowa_Arena *arena, void *context) +{ + (void)context; + if (!request || !response || !arena) + return CONNECTOR_INVALID; + Seobeo_Client_Request *client = Seobeo_Client_Request_Create(request->url); + if (!client) + return CONNECTOR_PROVIDER_ERROR; + Seobeo_Client_Request_Set_Method(client, request->method); + Seobeo_Client_Request_Set_Timeout_Milliseconds(client, 15000); + if (access_token && access_token[0]) { + char authorization[2300]; + int32 length = snprintf( + authorization, sizeof(authorization), "Authorization: Bearer %s", + access_token); + if (length <= 0 || (size_t)length >= sizeof(authorization)) { + Seobeo_Client_Request_Destroy(client); + return CONNECTOR_INVALID; + } + Seobeo_Client_Request_Add_Header_Array(client, authorization); + } + if (request->content_type) { + char header[256]; + int32 length = snprintf( + header, sizeof(header), "Content-Type: %s", request->content_type); + if (length <= 0 || (size_t)length >= sizeof(header)) { + Seobeo_Client_Request_Destroy(client); + return CONNECTOR_INVALID; + } + Seobeo_Client_Request_Add_Header_Array(client, header); + } + if (request->body && request->body_length) + Seobeo_Client_Request_Set_Body( + client, request->body, request->body_length); + if (request->download_path) + Seobeo_Client_Request_Set_Download_Path(client, request->download_path); + Seobeo_Client_Response *provider = Seobeo_Client_Request_Execute(client); + if (!provider) { + Seobeo_Client_Request_Destroy(client); + return CONNECTOR_PROVIDER_ERROR; + } + response->status_code = provider->status_code; + response->body_length = provider->body_length; + if (provider->body && !request->download_path) { + response->body = Dowa_Arena_Allocate(arena, provider->body_length + 1); + if (!response->body) { + Seobeo_Client_Response_Destroy(provider); + Seobeo_Client_Request_Destroy(client); + return CONNECTOR_ERROR; + } + memcpy(response->body, provider->body, provider->body_length); + response->body[provider->body_length] = '\0'; + } else + response->body = NULL; + Seobeo_Client_Response_Destroy(provider); + Seobeo_Client_Request_Destroy(client); + return CONNECTOR_OK; +} + +static Connector_Status transport( + const Connector_Google_Config *config, + const Connector_HTTP_Request *request, const char *access_token, + Connector_HTTP_Response *response, Dowa_Arena *arena) +{ + Connector_HTTP_Transport implementation = + config->transport ? config->transport : default_transport; + return implementation( + request, access_token, response, arena, config->transport_context); +} + +static char *url_join( + Dowa_Arena *arena, const char *base, const char *path, const char *query) +{ + if (!arena || !base) + return NULL; + size_t length = strlen(base) + (path ? strlen(path) : 0) + + (query && query[0] ? strlen(query) + 1 : 0) + 1; + char *url = Dowa_Arena_Allocate(arena, length); + if (!url) + return NULL; + snprintf( + url, length, "%s%s%s%s", base, path ? path : "", + query && query[0] ? "?" : "", query && query[0] ? query : ""); + return url; +} + +char *Connector_Google_Authorization_URL( + const Connector_Google_Config *config, const Connector_OAuth_Start *start, + Dowa_Arena *arena) +{ + if (!config || !start || !arena) + return NULL; + char client[1024], redirect[2048]; + if (!Connector_Form_Encode(config->client_id, client, sizeof(client)) || + !Connector_Form_Encode( + config->redirect_uri, redirect, sizeof(redirect))) + return NULL; + const char *scopes = + "openid%20email%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdrive" + "%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgmail.readonly" + "%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fgmail.compose"; + size_t length = strlen(config->oauth_authorize_url) + strlen(client) + + strlen(redirect) + strlen(start->state) + strlen(start->code_challenge) + + strlen(scopes) + 256; + char *url = Dowa_Arena_Allocate(arena, length); + if (!url) + return NULL; + snprintf( + url, length, + "%s?client_id=%s&redirect_uri=%s&response_type=code&scope=%s" + "&access_type=offline&prompt=consent&state=%s" + "&code_challenge=%s&code_challenge_method=S256", + config->oauth_authorize_url, client, redirect, scopes, start->state, + start->code_challenge); + return url; +} + +static Connector_Status token_request( + const Connector_Google_Config *config, const char *body, + Connector_HTTP_Response *response, Connector_Provider_Error *error, + Dowa_Arena *arena) +{ + Connector_HTTP_Request request = { + .method = "POST", + .url = config->oauth_token_url, + .content_type = "application/x-www-form-urlencoded", + .body = body, + .body_length = strlen(body), + .download_path = NULL + }; + Connector_Status status = transport( + config, &request, NULL, response, arena); + if (status != CONNECTOR_OK) { + if (error) + snprintf(error->code, sizeof(error->code), "transport_error"); + return status; + } + if (response->status_code >= 200 && response->status_code < 300) + return CONNECTOR_OK; + if (error) { + error->http_status = response->status_code; + snprintf(error->code, sizeof(error->code), "token_endpoint_error"); + if (json_is_bounded(response->body, response->body_length)) { + Dowa_JSON_Value parsed = Dowa_JSON_Parse( + response->body, (int32)response->body_length, arena); + if (parsed.type == DOWA_JSON_OBJECT) { + char *code = Dowa_JSON_Get_String(parsed.object_val, "error"); + char *description = + Dowa_JSON_Get_String(parsed.object_val, "error_description"); + if (code) + snprintf(error->code, sizeof(error->code), "%s", code); + if (description) + snprintf( + error->description, sizeof(error->description), "%s", + description); + } + } + } + return CONNECTOR_PROVIDER_ERROR; +} + +static boolean copy_json_string( + Dowa_JSON_Entry *object, const char *key, char *output, size_t output_size) +{ + char *value = Dowa_JSON_Get_String(object, key); + if (!value || strlen(value) >= output_size) + return FALSE; + strcpy(output, value); + return TRUE; +} + +Connector_Status Connector_Google_Exchange_Code( + const Connector_Google_Config *config, const char *code, + const char *verifier, Connector_Account *account, + Connector_Provider_Error *error, Dowa_Arena *arena) +{ + if (!config || !code || !verifier || !account || !arena) + return CONNECTOR_INVALID; + if (error) + memset(error, 0, sizeof(*error)); + char encoded_code[4096], encoded_verifier[512], encoded_client[1024]; + char encoded_secret[2048], encoded_redirect[2048]; + if (!Connector_Form_Encode(code, encoded_code, sizeof(encoded_code))) { + if (error) + snprintf(error->code, sizeof(error->code), "encode_code_failed"); + return CONNECTOR_INVALID; + } + if (!Connector_Form_Encode( + verifier, encoded_verifier, sizeof(encoded_verifier))) { + if (error) + snprintf(error->code, sizeof(error->code), "encode_verifier_failed"); + return CONNECTOR_INVALID; + } + if (!Connector_Form_Encode( + config->client_id, encoded_client, sizeof(encoded_client))) { + if (error) + snprintf(error->code, sizeof(error->code), "encode_client_id_failed"); + return CONNECTOR_INVALID; + } + if (!Connector_Form_Encode( + config->client_secret, encoded_secret, sizeof(encoded_secret))) { + if (error) + snprintf(error->code, sizeof(error->code), "encode_client_secret_failed"); + return CONNECTOR_INVALID; + } + if (!Connector_Form_Encode( + config->redirect_uri, encoded_redirect, sizeof(encoded_redirect))) { + if (error) + snprintf(error->code, sizeof(error->code), "encode_redirect_uri_failed"); + return CONNECTOR_INVALID; + } + char body[12288]; + int32 body_length = snprintf( + body, sizeof(body), + "code=%s&client_id=%s&client_secret=%s&redirect_uri=%s" + "&code_verifier=%s&grant_type=authorization_code", + encoded_code, encoded_client, encoded_secret, encoded_redirect, + encoded_verifier); + if (body_length <= 0 || (size_t)body_length >= sizeof(body)) { + if (error) + snprintf(error->code, sizeof(error->code), "token_request_too_large"); + return CONNECTOR_INVALID; + } + Connector_HTTP_Response token = {0}; + Connector_Status status = token_request(config, body, &token, error, arena); + if (status != CONNECTOR_OK) + return status; + if (error) + error->http_status = token.status_code; + if (!token.body || token.body_length == 0) { + if (error) { + snprintf(error->code, sizeof(error->code), "empty_token_response"); + snprintf( + error->description, sizeof(error->description), + "Google token endpoint returned no response body"); + } + return CONNECTOR_PROVIDER_ERROR; + } + if (!json_is_bounded(token.body, token.body_length)) { + if (error) { + snprintf(error->code, sizeof(error->code), "invalid_token_body"); + snprintf( + error->description, sizeof(error->description), + "Google token response was not bounded JSON (%zu bytes)", + token.body_length); + } + return CONNECTOR_PROVIDER_ERROR; + } + Dowa_JSON_Value parsed = Dowa_JSON_Parse( + token.body, (int32)token.body_length, arena); + if (parsed.type != DOWA_JSON_OBJECT) { + if (error) { + snprintf(error->code, sizeof(error->code), "invalid_token_json"); + snprintf( + error->description, sizeof(error->description), + "Google token response JSON could not be parsed (%zu bytes)", + token.body_length); + } + return CONNECTOR_PROVIDER_ERROR; + } + Dowa_JSON_Entry *object = parsed.object_val; + memset(account, 0, sizeof(*account)); + strcpy(account->provider, "google"); + if (!copy_json_string( + object, "access_token", account->access_token, + sizeof(account->access_token))) { + if (error) + snprintf(error->code, sizeof(error->code), "invalid_token_response"); + return CONNECTOR_PROVIDER_ERROR; + } + char *refresh = Dowa_JSON_Get_String(object, "refresh_token"); + if (refresh && strlen(refresh) < sizeof(account->refresh_token)) + strcpy(account->refresh_token, refresh); + char *scope = Dowa_JSON_Get_String(object, "scope"); + if (scope && strlen(scope) < sizeof(account->scopes)) + strcpy(account->scopes, scope); + double expires = Dowa_JSON_Get_Number(object, "expires_in"); + account->expires_at = (int64)time(NULL) + (int64)expires; + + Connector_HTTP_Request identity_request = { + .method = "GET", .url = config->identity_url + }; + Connector_HTTP_Response identity = {0}; + status = transport( + config, &identity_request, account->access_token, &identity, arena); + if (status != CONNECTOR_OK || identity.status_code < 200 || + identity.status_code >= 300 || + !json_is_bounded(identity.body, identity.body_length)) { + if (error) { + error->http_status = identity.status_code; + snprintf(error->code, sizeof(error->code), "userinfo_failed"); + snprintf( + error->description, sizeof(error->description), + "Google user-info lookup failed after token exchange"); + } + return CONNECTOR_PROVIDER_ERROR; + } + parsed = Dowa_JSON_Parse( + identity.body, (int32)identity.body_length, arena); + if (parsed.type != DOWA_JSON_OBJECT) + return CONNECTOR_PROVIDER_ERROR; + object = parsed.object_val; + if (!copy_json_string( + object, "sub", account->provider_subject, + sizeof(account->provider_subject)) || + !copy_json_string(object, "email", account->email, sizeof(account->email))) { + if (error) + snprintf(error->code, sizeof(error->code), "invalid_userinfo_response"); + return CONNECTOR_PROVIDER_ERROR; + } + int32 written = snprintf( + account->account_id, sizeof(account->account_id), "google:%s", + account->provider_subject); + return written > 0 && (size_t)written < sizeof(account->account_id) + ? CONNECTOR_OK : CONNECTOR_PROVIDER_ERROR; +} + +Connector_Status Connector_Google_Refresh( + const Connector_Google_Config *config, Connector_Account *account, + Dowa_Arena *arena) +{ + if (!config || !account || !account->refresh_token[0] || !arena) + return CONNECTOR_INVALID; + char refresh[4096], client[1024], secret[2048], body[8192]; + if (!Connector_Form_Encode( + account->refresh_token, refresh, sizeof(refresh)) || + !Connector_Form_Encode(config->client_id, client, sizeof(client)) || + !Connector_Form_Encode( + config->client_secret, secret, sizeof(secret))) + return CONNECTOR_INVALID; + int32 length = snprintf( + body, sizeof(body), + "refresh_token=%s&client_id=%s&client_secret=%s" + "&grant_type=refresh_token", + refresh, client, secret); + if (length <= 0 || (size_t)length >= sizeof(body)) + return CONNECTOR_INVALID; + Connector_HTTP_Response token = {0}; + Connector_Status status = token_request(config, body, &token, NULL, arena); + if (status != CONNECTOR_OK || !json_is_bounded(token.body, token.body_length)) + return status == CONNECTOR_OK ? CONNECTOR_PROVIDER_ERROR : status; + Dowa_JSON_Value parsed = Dowa_JSON_Parse( + token.body, (int32)token.body_length, arena); + if (parsed.type != DOWA_JSON_OBJECT || + !copy_json_string( + parsed.object_val, "access_token", account->access_token, + sizeof(account->access_token))) + return CONNECTOR_PROVIDER_ERROR; + account->expires_at = (int64)time(NULL) + + (int64)Dowa_JSON_Get_Number(parsed.object_val, "expires_in"); + return CONNECTOR_OK; +} + +Connector_Status Connector_Google_Revoke( + const Connector_Google_Config *config, const char *token, Dowa_Arena *arena) +{ + if (!config || !token || !arena) + return CONNECTOR_INVALID; + char encoded[4096], body[4200]; + if (!Connector_Form_Encode(token, encoded, sizeof(encoded))) + return CONNECTOR_INVALID; + snprintf(body, sizeof(body), "token=%s", encoded); + Connector_HTTP_Request request = { + .method = "POST", .url = config->oauth_revoke_url, + .content_type = "application/x-www-form-urlencoded", + .body = body, .body_length = strlen(body) + }; + Connector_HTTP_Response response = {0}; + Connector_Status status = transport(config, &request, NULL, &response, arena); + return status == CONNECTOR_OK && response.status_code >= 200 && + response.status_code < 300 ? CONNECTOR_OK : CONNECTOR_PROVIDER_ERROR; +} + +Connector_Status Connector_Google_Execute( + const Connector_Google_Config *config, Connector_Operation operation, + const Connector_Provider_Request *request, const char *access_token, + Connector_Provider_Response *response, Dowa_Arena *arena) +{ + if (!config || !request || !access_token || !response || !arena || + !Connector_Operation_Is_Allowed(operation) || + request->body_length > CONNECTOR_MAX_JSON_BYTES) + return CONNECTOR_INVALID; + const char *base = operation <= CONNECTOR_OP_DRIVE_UPDATE + ? ((operation == CONNECTOR_OP_DRIVE_UPLOAD || + operation == CONNECTOR_OP_DRIVE_UPDATE) && request->body_length + ? config->drive_upload_url : config->drive_api_url) + : config->gmail_api_url; + char *url = url_join(arena, base, request->path, request->query); + if (!url) + return CONNECTOR_ERROR; + Connector_HTTP_Request provider_request = { + .method = request->method, + .url = url, + .content_type = request->content_type, + .body = request->body, + .body_length = request->body_length, + .download_path = request->download_path + }; + Connector_HTTP_Response provider_response = {0}; + Connector_Status status = transport( + config, &provider_request, access_token, &provider_response, arena); + if (status != CONNECTOR_OK) + return status; + response->provider_status = provider_response.status_code; + response->body = provider_response.body; + response->body_length = provider_response.body_length; + response->status = provider_response.status_code >= 200 && + provider_response.status_code < 300 + ? CONNECTOR_OK : CONNECTOR_PROVIDER_ERROR; + return response->status; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/main.c Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,176 @@ +#include "connectors/connector.h" +#include "connectors/auth_http_adapter.h" + +#include <openssl/crypto.h> + +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +typedef struct { + char database[1024]; + char auth_database[1024]; + char auth_cookie_secret_hex[AUTH_CRYPTO_COOKIE_SECRET_MAX_BYTES * 2 + 1]; + int64 auth_session_idle_ttl; + char bind[128]; + char port[16]; + char static_dir[1024]; + char client_id[1024]; + char client_secret[2048]; + char redirect_uri[2048]; + char master_key[256]; + uint32 master_key_version; +} Service_Config; + +static boolean set_value(Service_Config *config, const char *key, const char *value) +{ +#define SET(name, field) if (!strcmp(key, name)) { \ + if (strlen(value) >= sizeof(config->field)) return FALSE; \ + strcpy(config->field, value); return TRUE; \ +} + SET("DATABASE", database) + SET("DB_PATH", database) + SET("database", database) + SET("AUTH_DATABASE", auth_database) + SET("AUTH_DB_PATH", auth_database) + SET("auth_database", auth_database) + SET("AUTH_COOKIE_SECRET", auth_cookie_secret_hex) + SET("AUTH_COOKIE_SECRET_HEX", auth_cookie_secret_hex) + SET("auth_cookie_secret_hex", auth_cookie_secret_hex) + SET("BIND", bind) + SET("SERVER_HOST", bind) + SET("bind", bind) + SET("PORT", port) + SET("SERVER_PORT", port) + SET("port", port) + SET("STATIC_DIR", static_dir) + SET("static_dir", static_dir) + SET("GOOGLE_CLIENT_ID", client_id) + SET("google_client_id", client_id) + SET("GOOGLE_CLIENT_SECRET", client_secret) + SET("google_client_secret", client_secret) + SET("GOOGLE_REDIRECT_URI", redirect_uri) + SET("google_redirect_uri", redirect_uri) + SET("MASTER_KEY_BASE64URL", master_key) + SET("master_key_base64url", master_key) + if (!strcmp(key, "MASTER_KEY_VERSION") || + !strcmp(key, "master_key_version")) { + config->master_key_version = (uint32)strtoul(value, NULL, 10); + return config->master_key_version > 0; + } + if (!strcmp(key, "AUTH_SESSION_IDLE_TTL") || + !strcmp(key, "auth_session_idle_ttl")) { + char *end = NULL; + long long parsed = strtoll(value, &end, 10); + if (!end || *end || parsed <= 0) + return FALSE; + config->auth_session_idle_ttl = (int64)parsed; + return TRUE; + } +#undef SET + return TRUE; +} + +static boolean load_config(const char *path, Service_Config *config) +{ + FILE *file = fopen(path, "r"); + char workspace_path[2048] = {0}; + if (!file) { + const char *workspace = getenv("BUILD_WORKSPACE_DIRECTORY"); + if (workspace && workspace[0]) { + int written = snprintf( + workspace_path, sizeof(workspace_path), "%s/%s", workspace, path); + if (written > 0 && (size_t)written < sizeof(workspace_path)) + file = fopen(workspace_path, "r"); + } + } + if (!file) + return FALSE; + memset(config, 0, sizeof(*config)); + strcpy(config->bind, "127.0.0.1"); + strcpy(config->port, "6981"); + strcpy(config->static_dir, "connectors"); + config->auth_session_idle_ttl = 604800; + char line[4096]; + boolean ok = TRUE; + while (ok && fgets(line, sizeof(line), file)) { + char *newline = strpbrk(line, "\r\n"); + if (newline) + *newline = '\0'; + if (!line[0] || line[0] == '#') + continue; + char *equals = strchr(line, '='); + if (!equals) { + ok = FALSE; + break; + } + *equals = '\0'; + ok = set_value(config, line, equals + 1); + } + fclose(file); + return ok && config->database[0] && config->auth_database[0] && + config->auth_cookie_secret_hex[0] && config->client_id[0] && + config->client_secret[0] && config->redirect_uri[0] && + config->master_key[0] && config->master_key_version; +} + +int main(int argc, char **argv) +{ + const char *config_path = argc > 1 ? argv[1] : getenv("CONNECTOR_CONFIG_PATH"); + if (!config_path || !config_path[0]) + config_path = "connectors/.config"; + Service_Config service; + if (!load_config(config_path, &service)) { + fprintf(stderr, "Invalid connector config: %s\n", config_path); + return 1; + } + Connector_Master_Key key = {.version = service.master_key_version}; + size_t key_length = 0; + if (!Connector_Base64Url_Decode( + service.master_key, key.key, sizeof(key.key), &key_length) || + key_length != sizeof(key.key)) { + fprintf(stderr, "MASTER_KEY_BASE64URL must decode to 32 bytes\n"); + return 1; + } + Connector_Store store; + if (!Connector_Store_Open(&store, service.database, &key)) { + fprintf(stderr, "Unable to open connector database\n"); + return 1; + } + Connector_Auth_HTTP_Context auth_context; + if (!Connector_Auth_HTTP_Init( + &auth_context, service.auth_database, + service.auth_cookie_secret_hex, service.auth_session_idle_ttl)) { + OPENSSL_cleanse( + service.auth_cookie_secret_hex, + sizeof(service.auth_cookie_secret_hex)); + fprintf(stderr, "Unable to initialize shared HTTP authentication\n"); + Connector_Store_Close(&store); + return 1; + } + OPENSSL_cleanse( + service.auth_cookie_secret_hex, sizeof(service.auth_cookie_secret_hex)); + Connector_Google_Config google = { + .client_id = service.client_id, + .client_secret = service.client_secret, + .redirect_uri = service.redirect_uri, + .oauth_authorize_url = "https://accounts.google.com/o/oauth2/v2/auth", + .oauth_token_url = "https://oauth2.googleapis.com/token", + .oauth_revoke_url = "https://oauth2.googleapis.com/revoke", + .identity_url = "https://openidconnect.googleapis.com/v1/userinfo", + .drive_api_url = "https://www.googleapis.com", + .drive_upload_url = "https://www.googleapis.com", + .gmail_api_url = "https://gmail.googleapis.com" + }; + Connector_Auth_Adapter auth = + Connector_Auth_HTTP_Create_Adapter(&auth_context); + Connector_Service_Configure(&store, &google, auth); + Seobeo_Router_Init(); + Connector_Service_Register_Routes(); + int result = Seobeo_Web_Server_Start_On( + service.bind, service.static_dir, service.port, SEOBEO_MODE_EDGE, 4); + Seobeo_Router_Destroy(); + Connector_Auth_HTTP_Destroy(&auth_context); + Connector_Store_Close(&store); + return result; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/service.c Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,590 @@ +#include "connectors/connector.h" +#include "connectors/auth_test_page.h" + +#include <ctype.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> +#include <time.h> + +static Connector_Store *g_store; +static const Connector_Google_Config *g_google; +static Connector_Auth_Adapter g_auth; + +static const char *map_value(Seobeo_Request_Entry *map, const char *key) +{ + if (!map || !key) + return NULL; + for (size_t i = 0; i < Dowa_Array_Length(map); ++i) { + if (map[i].key && !strcasecmp(map[i].key, key)) + return map[i].value; + } + return NULL; +} + +static Seobeo_Request_Entry *json_response( + Dowa_Arena *arena, const char *status, const char *body) +{ + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", (char *)status, arena); + Dowa_HashMap_Push_Arena( + response, "content-type", "application/json", arena); + Dowa_HashMap_Push_Arena(response, "body", (char *)body, arena); + return response; +} + +static Seobeo_Request_Entry *Auth_Test_Page( + Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + (void)request; + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", "200", arena); + Dowa_HashMap_Push_Arena( + response, "content-type", "text/html; charset=utf-8", arena); + Dowa_HashMap_Push_Arena( + response, "body", (char *)Connector_Auth_Test_Page(), arena); + return response; +} + +static const char *current_user( + Seobeo_Request_Entry *request, boolean require_csrf, Dowa_Arena *arena) +{ + return g_auth.resolve_user + ? g_auth.resolve_user( + request, require_csrf, arena, g_auth.context) : NULL; +} + +static boolean safe_component(const char *value) +{ + if (!value || !value[0] || strlen(value) >= CONNECTOR_ID_MAX) + return FALSE; + for (size_t i = 0; value[i]; ++i) + if (!(isalnum((uint8)value[i]) || value[i] == '-' || + value[i] == '_' || value[i] == ':' || value[i] == '.')) + return FALSE; + return TRUE; +} + +static Seobeo_Request_Entry *Health( + Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + (void)request; + return json_response(arena, "200", "{\"ok\":true}"); +} + +static Seobeo_Request_Entry *Auth_Session( + Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + Connector_Auth_Session session; + if (!g_auth.resolve_session || + !g_auth.resolve_session(request, &session, g_auth.context)) + return json_response( + arena, "401", "{\"authenticated\":false,\"error\":\"unauthorized\"}"); + char *user_id = Dowa_JSON_Escape_String(session.user_id, 0, arena); + char *username = Dowa_JSON_Escape_String(session.username, 0, arena); + char *role = Dowa_JSON_Escape_String(session.role, 0, arena); + char *csrf = Dowa_JSON_Escape_String(session.csrf_token, 0, arena); + if (!user_id || !username || !role || !csrf) + return json_response(arena, "500", "{\"error\":\"session_failed\"}"); + size_t length = strlen(user_id) + strlen(username) + strlen(role) + + strlen(csrf) + 128; + char *body = Dowa_Arena_Allocate(arena, length); + snprintf( + body, length, + "{\"authenticated\":true,\"userId\":\"%s\",\"username\":\"%s\"," + "\"role\":\"%s\",\"csrfToken\":\"%s\"}", + user_id, username, role, csrf); + return json_response(arena, "200", body); +} + +static Seobeo_Request_Entry *List_Accounts( + Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *user = current_user(request, FALSE, arena); + if (!user) + return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); + Connector_Account_Summary *accounts = NULL; + if (!Connector_Store_List_Accounts(g_store, user, &accounts, arena)) + return json_response(arena, "500", "{\"error\":\"account_list_failed\"}"); + size_t capacity = 64 + Dowa_Array_Length(accounts) * 1600; + char *body = Dowa_Arena_Allocate(arena, capacity); + size_t offset = (size_t)snprintf(body, capacity, "{\"accounts\":["); + for (size_t i = 0; i < Dowa_Array_Length(accounts); i++) { + char *account_id = + Dowa_JSON_Escape_String(accounts[i].account_id, 0, arena); + char *provider = Dowa_JSON_Escape_String(accounts[i].provider, 0, arena); + char *email = Dowa_JSON_Escape_String(accounts[i].email, 0, arena); + char *scopes = Dowa_JSON_Escape_String(accounts[i].scopes, 0, arena); + if (!account_id || !provider || !email || !scopes) + return json_response(arena, "500", "{\"error\":\"account_list_failed\"}"); + offset += (size_t)snprintf( + body + offset, capacity - offset, + "%s{\"accountId\":\"%s\",\"provider\":\"%s\",\"email\":\"%s\"," + "\"expiresAt\":%lld,\"scopes\":\"%s\"}", + i ? "," : "", account_id, provider, email, + (long long)accounts[i].expires_at, scopes); + } + snprintf(body + offset, capacity - offset, "]}"); + return json_response(arena, "200", body); +} + +static Seobeo_Request_Entry *AI_Tools( + Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + (void)request; + return json_response( + arena, "200", + "{\"version\":1," + "\"documentation\":\"connectors/wiki/README.md\"," + "\"contextStrategy\":[" + "\"discover a connection with connector.accounts.list\"," + "\"search or list lightweight references\"," + "\"hydrate only the most relevant IDs with get operations\"," + "\"normalize bounded text with source IDs before model inference\"]," + "\"tools\":[" + "{\"name\":\"connector.accounts.list\",\"method\":\"GET\"," + "\"path\":\"/v1/accounts\"}," + "{\"name\":\"connector.gmail.search\",\"method\":\"GET\"," + "\"path\":\"/v1/accounts/{account_id}/gmail/messages\"," + "\"query\":[\"q\",\"maxResults\",\"pageToken\"]}," + "{\"name\":\"connector.gmail.get\",\"method\":\"GET\"," + "\"path\":\"/v1/accounts/{account_id}/gmail/messages/{message_id}\"," + "\"query\":[\"format\",\"metadataHeaders\"]}," + "{\"name\":\"connector.drive.search\",\"method\":\"GET\"," + "\"path\":\"/v1/accounts/{account_id}/drive/files\"," + "\"query\":[\"q\",\"pageSize\",\"pageToken\",\"fields\"]}," + "{\"name\":\"connector.drive.get\",\"method\":\"GET\"," + "\"path\":\"/v1/accounts/{account_id}/drive/files/{file_id}\"}," + "{\"name\":\"connector.gmail.draft\",\"method\":\"POST\"," + "\"path\":\"/v1/accounts/{account_id}/gmail/drafts\"}," + "{\"name\":\"connector.gmail.send\",\"method\":\"POST\"," + "\"path\":\"/v1/accounts/{account_id}/gmail/send\"," + "\"confirmation\":\"policy-controlled\"}]}"); +} + +static Seobeo_Request_Entry *OAuth_Start( + Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *user = current_user(request, TRUE, arena); + if (!user) + return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); + Connector_OAuth_Start start; + if (!Connector_OAuth_PKCE_Start(&start) || + !Connector_Store_Create_State( + g_store, user, &start, (int64)time(NULL) + 600)) + return json_response(arena, "500", "{\"error\":\"oauth_start_failed\"}"); + char *url = Connector_Google_Authorization_URL(g_google, &start, arena); + if (!url) + return json_response(arena, "500", "{\"error\":\"oauth_start_failed\"}"); + size_t length = strlen(url) + 32; + char *body = Dowa_Arena_Allocate(arena, length); + snprintf(body, length, "{\"authorization_url\":\"%s\"}", url); + return json_response(arena, "200", body); +} + +static Seobeo_Request_Entry *OAuth_Callback( + Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *user = current_user(request, FALSE, arena); + const char *state = map_value(request, "query_state"); + const char *code = map_value(request, "query_code"); + if (!user) + return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); + if (!state || !code) + return json_response(arena, "400", "{\"error\":\"missing_oauth_fields\"}"); + char verifier[128]; + if (!Connector_Store_Consume_State( + g_store, user, state, (int64)time(NULL), verifier, sizeof(verifier))) + return json_response(arena, "400", "{\"error\":\"invalid_oauth_state\"}"); + Connector_Account account; + Connector_Provider_Error provider_error; + Connector_Status status = Connector_Google_Exchange_Code( + g_google, code, verifier, &account, &provider_error, arena); + if (status != CONNECTOR_OK) { + char *code_safe = Dowa_JSON_Escape_String( + provider_error.code[0] ? provider_error.code : "unknown", 0, arena); + char *description_safe = Dowa_JSON_Escape_String( + provider_error.description, 0, arena); + char *body = Dowa_Arena_Allocate(arena, 512); + if (!code_safe || !description_safe || !body) + return json_response( + arena, "502", "{\"error\":\"token_exchange_failed\"}"); + snprintf( + body, 512, + "{\"error\":\"token_exchange_failed\",\"provider_error\":\"%s\"," + "\"provider_description\":\"%s\",\"provider_status\":%d}", + code_safe, description_safe, provider_error.http_status); + return json_response(arena, "502", body); + } + if (strlen(user) >= sizeof(account.user_id)) + return json_response(arena, "400", "{\"error\":\"invalid_user\"}"); + strcpy(account.user_id, user); + if (!Connector_Store_Save_Account(g_store, &account)) + return json_response(arena, "500", "{\"error\":\"account_save_failed\"}"); + char encoded_account[CONNECTOR_ID_MAX * 3]; + char encoded_email[sizeof(account.email) * 3]; + if (!Connector_Form_Encode( + account.account_id, encoded_account, sizeof(encoded_account)) || + !Connector_Form_Encode( + account.email, encoded_email, sizeof(encoded_email))) + return json_response(arena, "500", "{\"error\":\"redirect_failed\"}"); + char *location = Dowa_Arena_Allocate(arena, 1024); + snprintf( + location, 1024, "/auth-test.html?account_id=%s&email=%s", + encoded_account, encoded_email); + Seobeo_Request_Entry *response = NULL; + Dowa_HashMap_Push_Arena(response, "status", "303", arena); + Dowa_HashMap_Push_Arena(response, "Location", location, arena); + Dowa_HashMap_Push_Arena(response, "content-type", "text/plain", arena); + Dowa_HashMap_Push_Arena(response, "body", "Google account connected", arena); + return response; +} + +static Seobeo_Request_Entry *Disconnect( + Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *user = current_user(request, TRUE, arena); + const char *account_id = map_value(request, ":account_id"); + if (!user) + return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); + Connector_Account account; + if (!safe_component(account_id) || + !Connector_Store_Get_Account(g_store, user, account_id, &account)) + return json_response(arena, "404", "{\"error\":\"account_not_found\"}"); + const char *token = account.refresh_token[0] + ? account.refresh_token : account.access_token; + if (Connector_Google_Revoke(g_google, token, arena) != CONNECTOR_OK) + return json_response(arena, "502", "{\"error\":\"revoke_failed\"}"); + if (!Connector_Store_Delete_Account(g_store, user, account_id)) + return json_response(arena, "500", "{\"error\":\"disconnect_failed\"}"); + return json_response(arena, "200", "{\"disconnected\":true}"); +} + +static const char *confirmation_action( + Connector_Operation operation, boolean overwrite) +{ + if (operation == CONNECTOR_OP_GMAIL_SEND) + return "gmail.send"; + if (operation == CONNECTOR_OP_DRIVE_UPDATE && overwrite) + return "drive.overwrite"; + return Connector_Operation_Name(operation); +} + +static Seobeo_Request_Entry *execute_operation( + Seobeo_Request_Entry *request, Dowa_Arena *arena, + Connector_Operation operation, const char *method, + const char *path_prefix, boolean append_resource, boolean overwrite) +{ + const char *user = current_user( + request, Connector_Operation_Is_Mutation(operation), arena); + const char *account_id = map_value(request, ":account_id"); + if (!user) + return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); + if (!safe_component(account_id)) + return json_response(arena, "400", "{\"error\":\"invalid_account\"}"); + Connector_Account account; + if (!Connector_Store_Get_Account(g_store, user, account_id, &account)) + return json_response(arena, "404", "{\"error\":\"account_not_found\"}"); + if (account.expires_at <= (int64)time(NULL) + 60) { + if (Connector_Google_Refresh(g_google, &account, arena) != CONNECTOR_OK || + !Connector_Store_Save_Account(g_store, &account)) + return json_response(arena, "502", "{\"error\":\"token_refresh_failed\"}"); + } + + const char *resource = map_value(request, ":resource_id"); + char path[1024]; + if (append_resource) { + if (!safe_component(resource)) + return json_response(arena, "400", "{\"error\":\"invalid_resource\"}"); + snprintf(path, sizeof(path), "%s/%s", path_prefix, resource); + } else + snprintf(path, sizeof(path), "%s", path_prefix); + const char *body = map_value(request, "Body"); + const char *content_length = map_value(request, "Content-Length"); + size_t body_length = body ? strlen(body) : 0; + if (content_length) { + char *end = NULL; + unsigned long parsed = strtoul(content_length, &end, 10); + if (!end || *end || parsed > CONNECTOR_MAX_JSON_BYTES || + (parsed && !body)) + return json_response(arena, "400", "{\"error\":\"invalid_body\"}"); + body_length = parsed; + } + Connector_Provider_Request provider_request = { + .method = method, + .path = path, + .query = map_value(request, "QueryString"), + .content_type = map_value(request, "Content-Type"), + .body = body, + .body_length = body_length, + .download_path = NULL, + .overwrite = overwrite + }; + char digest[65]; + if (!Connector_Request_Digest( + user, account_id, operation, &provider_request, digest)) + return json_response(arena, "400", "{\"error\":\"invalid_request\"}"); + + const char *idempotency = map_value(request, "Idempotency-Key"); + if (Connector_Operation_Is_Mutation(operation)) { + if (!idempotency || !safe_component(idempotency)) + return json_response( + arena, "400", "{\"error\":\"idempotency_key_required\"}"); + int32 cached_status = 0; + char cached_body[8192]; + if (Connector_Store_Get_Idempotent( + g_store, user, idempotency, digest, &cached_status, + cached_body, sizeof(cached_body))) { + char status_text[16]; + snprintf(status_text, sizeof(status_text), "%d", cached_status); + char *copy = Dowa_String_Copy_Arena(cached_body, arena); + return json_response(arena, status_text, copy); + } + if (Connector_Store_Idempotency_Conflict( + g_store, user, idempotency, digest)) + return json_response( + arena, "409", "{\"error\":\"idempotency_key_conflict\"}"); + } + + if (operation == CONNECTOR_OP_DRIVE_UPDATE && body && + (strstr(body, "\"trashed\"") || strstr(body, "\"permissions\""))) + return json_response( + arena, "403", "{\"error\":\"drive_mutation_not_allowed\"}"); + + const char *action = confirmation_action(operation, overwrite); + if (Connector_Store_Get_Policy(g_store, user, action) == + CONNECTOR_CONFIRM_ALWAYS) { + const char *token = map_value(request, "X-Connector-Confirmation"); + if (!token || !Connector_Store_Consume_Confirmation( + g_store, user, digest, token, (int64)time(NULL))) { + char *response = Dowa_Arena_Allocate(arena, 160); + snprintf( + response, 160, + "{\"error\":\"confirmation_required\",\"request_digest\":\"%s\"}", + digest); + return json_response(arena, "409", response); + } + } + + Connector_Provider_Response provider_response = {0}; + Connector_Status status = Connector_Google_Execute( + g_google, operation, &provider_request, account.access_token, + &provider_response, arena); + int32 http_status = status == CONNECTOR_OK + ? provider_response.provider_status : 502; + const char *response_body = provider_response.body + ? provider_response.body : "{\"error\":\"provider_error\"}"; + if (Connector_Operation_Is_Mutation(operation)) { + Connector_Store_Put_Idempotent( + g_store, user, idempotency, digest, http_status, response_body); + Connector_Store_Audit( + g_store, user, account_id, Connector_Operation_Name(operation), + digest, http_status); + } + char status_text[16]; + snprintf(status_text, sizeof(status_text), "%d", http_status); + if (operation == CONNECTOR_OP_DRIVE_DOWNLOAD && + provider_response.body && status == CONNECTOR_OK) { + Seobeo_Request_Entry *download = NULL; + char *content_length = Dowa_Arena_Allocate(arena, 32); + snprintf( + content_length, 32, "%zu", provider_response.body_length); + Dowa_HashMap_Push_Arena(download, "status", status_text, arena); + Dowa_HashMap_Push_Arena( + download, "content-type", "application/octet-stream", arena); + Dowa_HashMap_Push_Arena(download, "body", provider_response.body, arena); + Dowa_HashMap_Push_Arena( + download, "content-length", content_length, arena); + return download; + } + return json_response(arena, status_text, response_body); +} + +#define OP_HANDLER(name, operation, method, path, resource, overwrite) \ +static Seobeo_Request_Entry *name( \ + Seobeo_Request_Entry *request, Dowa_Arena *arena) { \ + return execute_operation( \ + request, arena, operation, method, path, resource, overwrite); \ +} + +OP_HANDLER(Drive_List, CONNECTOR_OP_DRIVE_LIST, "GET", "/drive/v3/files", FALSE, FALSE) +OP_HANDLER(Drive_Get, CONNECTOR_OP_DRIVE_GET, "GET", "/drive/v3/files", TRUE, FALSE) +OP_HANDLER(Drive_Changes, CONNECTOR_OP_DRIVE_CHANGES, "GET", "/drive/v3/changes", FALSE, FALSE) +OP_HANDLER(Drive_Create, CONNECTOR_OP_DRIVE_CREATE, "POST", "/drive/v3/files", FALSE, FALSE) +OP_HANDLER(Drive_Upload, CONNECTOR_OP_DRIVE_UPLOAD, "POST", "/upload/drive/v3/files", FALSE, FALSE) +OP_HANDLER(Drive_Update, CONNECTOR_OP_DRIVE_UPDATE, "PATCH", "/upload/drive/v3/files", TRUE, TRUE) +OP_HANDLER(Gmail_List, CONNECTOR_OP_GMAIL_LIST, "GET", "/gmail/v1/users/me/messages", FALSE, FALSE) +OP_HANDLER(Gmail_Get, CONNECTOR_OP_GMAIL_GET, "GET", "/gmail/v1/users/me/messages", TRUE, FALSE) +OP_HANDLER(Gmail_History, CONNECTOR_OP_GMAIL_HISTORY, "GET", "/gmail/v1/users/me/history", FALSE, FALSE) +OP_HANDLER(Gmail_Draft, CONNECTOR_OP_GMAIL_DRAFT_CREATE, "POST", "/gmail/v1/users/me/drafts", FALSE, FALSE) +OP_HANDLER(Gmail_Send, CONNECTOR_OP_GMAIL_SEND, "POST", "/gmail/v1/users/me/messages/send", FALSE, FALSE) + +static Seobeo_Request_Entry *Drive_Download( + Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + Seobeo_Request_Entry *query = Dowa_HashMap_Get_Ptr(request, "QueryString"); + if (query && query->value && query->value[0]) { + size_t length = strlen(query->value) + 11; + char *with_media = Dowa_Arena_Allocate(arena, length); + snprintf(with_media, length, "%s&alt=media", query->value); + query->value = with_media; + } else + Dowa_HashMap_Push_Arena(request, "QueryString", "alt=media", arena); + return execute_operation( + request, arena, CONNECTOR_OP_DRIVE_DOWNLOAD, "GET", + "/drive/v3/files", TRUE, FALSE); +} + +static Seobeo_Request_Entry *Gmail_Attachment( + Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *message = map_value(request, ":message_id"); + const char *attachment = map_value(request, ":resource_id"); + if (!safe_component(message) || !safe_component(attachment)) + return json_response(arena, "400", "{\"error\":\"invalid_resource\"}"); + char path[512]; + snprintf( + path, sizeof(path), "/gmail/v1/users/me/messages/%s/attachments", + message); + return execute_operation( + request, arena, CONNECTOR_OP_GMAIL_ATTACHMENT, "GET", path, TRUE, FALSE); +} + +static Seobeo_Request_Entry *Create_Confirmation( + Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *user = current_user(request, TRUE, arena); + const char *body = map_value(request, "Body"); + if (!user) + return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); + if (!body || strlen(body) > 4096) + return json_response(arena, "400", "{\"error\":\"invalid_body\"}"); + Dowa_JSON_Value parsed = Dowa_JSON_Parse(body, (int32)strlen(body), arena); + if (parsed.type != DOWA_JSON_OBJECT) + return json_response(arena, "400", "{\"error\":\"invalid_json\"}"); + char *digest = Dowa_JSON_Get_String(parsed.object_val, "request_digest"); + if (!digest || strlen(digest) != 64) + return json_response(arena, "400", "{\"error\":\"invalid_digest\"}"); + Connector_OAuth_Start random; + if (!Connector_OAuth_PKCE_Start(&random) || + !Connector_Store_Create_Confirmation( + g_store, user, digest, random.state, (int64)time(NULL) + 300)) + return json_response(arena, "500", "{\"error\":\"confirmation_failed\"}"); + char *response = Dowa_Arena_Allocate(arena, 180); + snprintf( + response, 180, "{\"confirmation_token\":\"%s\",\"expires_in\":300}", + random.state); + return json_response(arena, "201", response); +} + +static boolean configurable_confirmation_action(const char *action) +{ + return action && + (!strcmp(action, "gmail.send") || !strcmp(action, "drive.overwrite")); +} + +static Seobeo_Request_Entry *Get_Confirmation_Policy( + Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *user = current_user(request, FALSE, arena); + const char *action = map_value(request, ":action"); + if (!user) + return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); + if (!configurable_confirmation_action(action)) + return json_response(arena, "400", "{\"error\":\"invalid_action\"}"); + Connector_Confirmation_Policy policy = + Connector_Store_Get_Policy(g_store, user, action); + char *response = Dowa_Arena_Allocate(arena, 96); + snprintf( + response, 96, "{\"action\":\"%s\",\"policy\":\"%s\"}", + action, policy == CONNECTOR_CONFIRM_ALWAYS ? "always" : "never"); + return json_response(arena, "200", response); +} + +static Seobeo_Request_Entry *Set_Confirmation_Policy( + Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + const char *user = current_user(request, TRUE, arena); + const char *action = map_value(request, ":action"); + const char *body = map_value(request, "Body"); + if (!user) + return json_response(arena, "401", "{\"error\":\"unauthorized\"}"); + if (!configurable_confirmation_action(action)) + return json_response(arena, "400", "{\"error\":\"invalid_action\"}"); + if (!body || strlen(body) > 256) + return json_response(arena, "400", "{\"error\":\"invalid_body\"}"); + Dowa_JSON_Value parsed = Dowa_JSON_Parse(body, (int32)strlen(body), arena); + if (parsed.type != DOWA_JSON_OBJECT) + return json_response(arena, "400", "{\"error\":\"invalid_json\"}"); + const char *policy_text = + Dowa_JSON_Get_String(parsed.object_val, "policy"); + Connector_Confirmation_Policy policy; + if (policy_text && !strcmp(policy_text, "always")) + policy = CONNECTOR_CONFIRM_ALWAYS; + else if (policy_text && !strcmp(policy_text, "never")) + policy = CONNECTOR_CONFIRM_NEVER; + else + return json_response(arena, "400", "{\"error\":\"invalid_policy\"}"); + if (!Connector_Store_Set_Policy(g_store, user, action, policy)) + return json_response(arena, "500", "{\"error\":\"policy_save_failed\"}"); + return json_response(arena, "200", policy == CONNECTOR_CONFIRM_ALWAYS + ? "{\"policy\":\"always\"}" : "{\"policy\":\"never\"}"); +} + +static Seobeo_Request_Entry *Rejected_Mutation( + Seobeo_Request_Entry *request, Dowa_Arena *arena) +{ + (void)request; + return json_response( + arena, "403", + "{\"error\":\"operation_not_allowed\",\"allowed_mutations\":[" + "\"drive.create\",\"drive.upload\",\"drive.update\"," + "\"gmail.draft.create\",\"gmail.send\"]}"); +} + +void Connector_Service_Configure( + Connector_Store *store, const Connector_Google_Config *google, + Connector_Auth_Adapter auth) +{ + g_store = store; + g_google = google; + g_auth = auth; +} + +void Connector_Service_Register_Routes(void) +{ + Seobeo_Router_Register("GET", "/health", Health); + Seobeo_Router_Register("GET", "/auth-test.html", Auth_Test_Page); + Seobeo_Router_Register("GET", "/v1/ai/tools", AI_Tools); + Seobeo_Router_Register("GET", "/v1/auth/session", Auth_Session); + Seobeo_Router_Register("GET", "/v1/accounts", List_Accounts); + Seobeo_Router_Register("POST", "/v1/oauth/google/start", OAuth_Start); + Seobeo_Router_Register("GET", "/v1/oauth/google/callback", OAuth_Callback); + Seobeo_Router_Register("DELETE", "/v1/accounts/:account_id", Disconnect); + Seobeo_Router_Register("POST", "/v1/confirmations", Create_Confirmation); + Seobeo_Router_Register( + "GET", "/v1/settings/confirmations/:action", + Get_Confirmation_Policy); + Seobeo_Router_Register( + "PUT", "/v1/settings/confirmations/:action", + Set_Confirmation_Policy); + Seobeo_Router_Register("GET", "/v1/accounts/:account_id/drive/files", Drive_List); + Seobeo_Router_Register("GET", "/v1/accounts/:account_id/drive/files/:resource_id", Drive_Get); + Seobeo_Router_Register("GET", "/v1/accounts/:account_id/drive/files/:resource_id/download", Drive_Download); + Seobeo_Router_Register("GET", "/v1/accounts/:account_id/drive/changes", Drive_Changes); + Seobeo_Router_Register("POST", "/v1/accounts/:account_id/drive/files", Drive_Create); + Seobeo_Router_Register("POST", "/v1/accounts/:account_id/drive/uploads", Drive_Upload); + Seobeo_Router_Register("PATCH", "/v1/accounts/:account_id/drive/files/:resource_id", Drive_Update); + Seobeo_Router_Register("DELETE", "/v1/accounts/:account_id/drive/files/:resource_id", Rejected_Mutation); + Seobeo_Router_Register("POST", "/v1/accounts/:account_id/drive/files/:resource_id/permissions", Rejected_Mutation); + Seobeo_Router_Register("GET", "/v1/accounts/:account_id/gmail/messages", Gmail_List); + Seobeo_Router_Register("GET", "/v1/accounts/:account_id/gmail/messages/:resource_id", Gmail_Get); + Seobeo_Router_Register("GET", "/v1/accounts/:account_id/gmail/messages/:message_id/attachments/:resource_id", Gmail_Attachment); + Seobeo_Router_Register("GET", "/v1/accounts/:account_id/gmail/history", Gmail_History); + Seobeo_Router_Register("POST", "/v1/accounts/:account_id/gmail/drafts", Gmail_Draft); + Seobeo_Router_Register("POST", "/v1/accounts/:account_id/gmail/send", Gmail_Send); + Seobeo_Router_Register("DELETE", "/v1/accounts/:account_id/gmail/messages/:resource_id", Rejected_Mutation); + Seobeo_Router_Register("PATCH", "/v1/accounts/:account_id/gmail/messages/:resource_id/labels", Rejected_Mutation); + Seobeo_Router_Register("PATCH", "/v1/accounts/:account_id/gmail/messages/:resource_id/read-state", Rejected_Mutation); +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/store.c Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,407 @@ +#include "connectors/connector.h" + +#include <stdio.h> +#include <string.h> + +static boolean execute(Connector_Store *store, const char *sql) +{ + return store && store->connection && + Deita_Query_Execute_Update(store->connection, sql) >= 0; +} + +static boolean update( + Connector_Store *store, const char *sql, int32 count, const char **values) +{ + return store && store->connection && + Deita_Query_Execute_Update_Prepared( + store->connection, sql, count, values) >= 0; +} + +boolean Connector_Store_Open( + Connector_Store *store, const char *database_path, + const Connector_Master_Key *master_key) +{ + if (!store || !database_path || !master_key) + return FALSE; + memset(store, 0, sizeof(*store)); + store->connection = Deita_Connection_Create( + DEITA_DATABASE_TYPE_SQLITE3, database_path); + if (!store->connection) + return FALSE; + store->master_key = *master_key; + return Connector_Store_Migrate(store); +} + +void Connector_Store_Close(Connector_Store *store) +{ + if (!store) + return; + if (store->connection) + Deita_Connection_Close(store->connection); + memset(store, 0, sizeof(*store)); +} + +boolean Connector_Store_Migrate(Connector_Store *store) +{ + static const char *migration = + "BEGIN;" + "CREATE TABLE IF NOT EXISTS connector_migrations(" + "version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL);" + "CREATE TABLE IF NOT EXISTS connector_oauth_states(" + "state TEXT PRIMARY KEY, user_id TEXT NOT NULL, verifier TEXT NOT NULL," + "expires_at INTEGER NOT NULL, consumed_at INTEGER);" + "CREATE TABLE IF NOT EXISTS connector_accounts(" + "account_id TEXT PRIMARY KEY, user_id TEXT NOT NULL, provider TEXT NOT NULL," + "provider_subject TEXT NOT NULL, email TEXT NOT NULL," + "access_token_enc TEXT NOT NULL, refresh_token_enc TEXT NOT NULL," + "expires_at INTEGER NOT NULL, scopes TEXT NOT NULL," + "UNIQUE(user_id, provider, provider_subject));" + "CREATE INDEX IF NOT EXISTS connector_accounts_owner " + "ON connector_accounts(user_id, account_id);" + "CREATE TABLE IF NOT EXISTS connector_policies(" + "user_id TEXT NOT NULL, action TEXT NOT NULL, policy INTEGER NOT NULL," + "PRIMARY KEY(user_id, action));" + "CREATE TABLE IF NOT EXISTS connector_confirmations(" + "token TEXT PRIMARY KEY, user_id TEXT NOT NULL, digest TEXT NOT NULL," + "expires_at INTEGER NOT NULL, consumed_at INTEGER);" + "CREATE TABLE IF NOT EXISTS connector_idempotency(" + "user_id TEXT NOT NULL, idem_key TEXT NOT NULL, digest TEXT NOT NULL," + "status INTEGER NOT NULL, response_body TEXT NOT NULL," + "created_at INTEGER NOT NULL DEFAULT (unixepoch())," + "PRIMARY KEY(user_id, idem_key));" + "CREATE TABLE IF NOT EXISTS connector_audit(" + "id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL," + "account_id TEXT NOT NULL, operation TEXT NOT NULL, digest TEXT NOT NULL," + "status INTEGER NOT NULL, created_at INTEGER NOT NULL DEFAULT (unixepoch()));" + "INSERT OR IGNORE INTO connector_migrations(version, applied_at)" + "VALUES(1, unixepoch());" + "COMMIT;"; + return execute(store, "PRAGMA foreign_keys=ON;") && + execute(store, migration); +} + +boolean Connector_Store_Create_State( + Connector_Store *store, const char *user_id, const Connector_OAuth_Start *start, + int64 expires_at) +{ + char expiry[32]; + snprintf(expiry, sizeof(expiry), "%lld", expires_at); + const char *values[] = { + start ? start->state : NULL, user_id, + start ? start->code_verifier : NULL, expiry + }; + return start && user_id && update( + store, + "INSERT INTO connector_oauth_states" + "(state,user_id,verifier,expires_at) VALUES(?,?,?,?)", + 4, values); +} + +boolean Connector_Store_Consume_State( + Connector_Store *store, const char *user_id, const char *state, int64 now, + char *verifier, size_t verifier_size) +{ + if (!store || !user_id || !state || !verifier || verifier_size == 0) + return FALSE; + char now_text[32]; + snprintf(now_text, sizeof(now_text), "%lld", now); + const char *values[] = {state, user_id, now_text}; + Dowa_Arena *arena = Dowa_Arena_Create(4096); + Deita_Result_Set *result = Deita_Query_Execute_Prepared( + store->connection, + "SELECT verifier FROM connector_oauth_states " + "WHERE state=? AND user_id=? AND consumed_at IS NULL AND expires_at>=?", + 3, values, arena); + boolean found = result && Deita_Result_Set_Next(result); + if (found) { + const char *value = Deita_Result_Set_Get_Text(result, 0); + if (!value || strlen(value) >= verifier_size) + found = FALSE; + else + strcpy(verifier, value); + } + if (result) + Deita_Result_Set_Free(result); + Dowa_Arena_Free(arena); + if (!found) + return FALSE; + const char *consume_values[] = {now_text, state, user_id}; + return Deita_Query_Execute_Update_Prepared( + store->connection, + "UPDATE connector_oauth_states SET consumed_at=? " + "WHERE state=? AND user_id=? AND consumed_at IS NULL", + 3, consume_values) == 1; +} + +boolean Connector_Store_Save_Account( + Connector_Store *store, const Connector_Account *account) +{ + if (!store || !account || !account->account_id[0] || !account->user_id[0]) + return FALSE; + char access[4096], refresh[4096], expires[32]; + if (!Connector_Encrypt(&store->master_key, account->access_token, access, + sizeof(access)) || + !Connector_Encrypt(&store->master_key, account->refresh_token, refresh, + sizeof(refresh))) + return FALSE; + snprintf(expires, sizeof(expires), "%lld", account->expires_at); + const char *values[] = { + account->account_id, account->user_id, account->provider, + account->provider_subject, account->email, access, refresh, expires, + account->scopes + }; + return update( + store, + "INSERT INTO connector_accounts(account_id,user_id,provider," + "provider_subject,email,access_token_enc,refresh_token_enc,expires_at,scopes)" + "VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(account_id) DO UPDATE SET " + "email=excluded.email,access_token_enc=excluded.access_token_enc," + "refresh_token_enc=excluded.refresh_token_enc," + "expires_at=excluded.expires_at,scopes=excluded.scopes " + "WHERE connector_accounts.user_id=excluded.user_id", + 9, values); +} + +boolean Connector_Store_Get_Account( + Connector_Store *store, const char *user_id, const char *account_id, + Connector_Account *account) +{ + if (!store || !user_id || !account_id || !account) + return FALSE; + const char *values[] = {user_id, account_id}; + Dowa_Arena *arena = Dowa_Arena_Create(16384); + Deita_Result_Set *result = Deita_Query_Execute_Prepared( + store->connection, + "SELECT account_id,user_id,provider,provider_subject,email," + "access_token_enc,refresh_token_enc,expires_at,scopes " + "FROM connector_accounts WHERE user_id=? AND account_id=?", + 2, values, arena); + boolean found = result && Deita_Result_Set_Next(result); + if (found) { + memset(account, 0, sizeof(*account)); +#define COPY_COLUMN(field, index) do { \ + const char *value = Deita_Result_Set_Get_Text(result, index); \ + if (!value || strlen(value) >= sizeof(account->field)) found = FALSE; \ + else strcpy(account->field, value); \ +} while (0) + COPY_COLUMN(account_id, 0); + COPY_COLUMN(user_id, 1); + COPY_COLUMN(provider, 2); + COPY_COLUMN(provider_subject, 3); + COPY_COLUMN(email, 4); + const char *access = Deita_Result_Set_Get_Text(result, 5); + const char *refresh = Deita_Result_Set_Get_Text(result, 6); + account->expires_at = Deita_Result_Set_Get_Integer(result, 7); + COPY_COLUMN(scopes, 8); + if (!access || !refresh || + !Connector_Decrypt(&store->master_key, access, account->access_token, + sizeof(account->access_token)) || + !Connector_Decrypt(&store->master_key, refresh, account->refresh_token, + sizeof(account->refresh_token))) + found = FALSE; +#undef COPY_COLUMN + } + if (result) + Deita_Result_Set_Free(result); + Dowa_Arena_Free(arena); + return found; +} + +boolean Connector_Store_List_Accounts( + Connector_Store *store, const char *user_id, + Connector_Account_Summary **accounts, Dowa_Arena *arena) +{ + if (!store || !user_id || !accounts || !arena) + return FALSE; + *accounts = NULL; + const char *values[] = {user_id}; + Deita_Result_Set *result = Deita_Query_Execute_Prepared( + store->connection, + "SELECT account_id,provider,email,expires_at,scopes " + "FROM connector_accounts WHERE user_id=? ORDER BY provider,email", + 1, values, arena); + if (!result || Deita_Result_Set_Has_Error(result)) { + if (result) + Deita_Result_Set_Free(result); + return FALSE; + } + while (Deita_Result_Set_Next(result)) { + Connector_Account_Summary account = {0}; + const char *account_id = Deita_Result_Set_Get_Text(result, 0); + const char *provider = Deita_Result_Set_Get_Text(result, 1); + const char *email = Deita_Result_Set_Get_Text(result, 2); + const char *scopes = Deita_Result_Set_Get_Text(result, 4); + if (!account_id || !provider || !email || !scopes || + strlen(account_id) >= sizeof(account.account_id) || + strlen(provider) >= sizeof(account.provider) || + strlen(email) >= sizeof(account.email) || + strlen(scopes) >= sizeof(account.scopes)) { + Deita_Result_Set_Free(result); + return FALSE; + } + strcpy(account.account_id, account_id); + strcpy(account.provider, provider); + strcpy(account.email, email); + account.expires_at = Deita_Result_Set_Get_Integer(result, 3); + strcpy(account.scopes, scopes); + Dowa_Array_Push_Arena(*accounts, account, arena); + } + Deita_Result_Set_Free(result); + return TRUE; +} + +boolean Connector_Store_Delete_Account( + Connector_Store *store, const char *user_id, const char *account_id) +{ + const char *values[] = {user_id, account_id}; + return user_id && account_id && + Deita_Query_Execute_Update_Prepared( + store->connection, + "DELETE FROM connector_accounts WHERE user_id=? AND account_id=?", + 2, values) == 1; +} + +boolean Connector_Store_Set_Policy( + Connector_Store *store, const char *user_id, const char *action, + Connector_Confirmation_Policy policy) +{ + char policy_text[8]; + snprintf(policy_text, sizeof(policy_text), "%d", policy); + const char *values[] = {user_id, action, policy_text}; + return user_id && action && update( + store, + "INSERT INTO connector_policies(user_id,action,policy) VALUES(?,?,?) " + "ON CONFLICT(user_id,action) DO UPDATE SET policy=excluded.policy", + 3, values); +} + +Connector_Confirmation_Policy Connector_Store_Get_Policy( + Connector_Store *store, const char *user_id, const char *action) +{ + Connector_Confirmation_Policy fallback = + action && (!strcmp(action, "gmail.send") || + !strcmp(action, "drive.overwrite")) + ? CONNECTOR_CONFIRM_ALWAYS : CONNECTOR_CONFIRM_NEVER; + if (!store || !user_id || !action) + return fallback; + const char *values[] = {user_id, action}; + Dowa_Arena *arena = Dowa_Arena_Create(2048); + Deita_Result_Set *result = Deita_Query_Execute_Prepared( + store->connection, + "SELECT policy FROM connector_policies WHERE user_id=? AND action=?", + 2, values, arena); + if (result && Deita_Result_Set_Next(result)) + fallback = (Connector_Confirmation_Policy) + Deita_Result_Set_Get_Integer(result, 0); + if (result) + Deita_Result_Set_Free(result); + Dowa_Arena_Free(arena); + return fallback; +} + +boolean Connector_Store_Create_Confirmation( + Connector_Store *store, const char *user_id, const char *digest, + const char *token, int64 expires_at) +{ + char expiry[32]; + snprintf(expiry, sizeof(expiry), "%lld", expires_at); + const char *values[] = {token, user_id, digest, expiry}; + return token && user_id && digest && update( + store, + "INSERT INTO connector_confirmations(token,user_id,digest,expires_at)" + "VALUES(?,?,?,?)", 4, values); +} + +boolean Connector_Store_Consume_Confirmation( + Connector_Store *store, const char *user_id, const char *digest, + const char *token, int64 now) +{ + char now_text[32]; + snprintf(now_text, sizeof(now_text), "%lld", now); + const char *values[] = {now_text, token, user_id, digest, now_text}; + return token && user_id && digest && + Deita_Query_Execute_Update_Prepared( + store->connection, + "UPDATE connector_confirmations SET consumed_at=? WHERE token=? " + "AND user_id=? AND digest=? AND consumed_at IS NULL AND expires_at>=?", + 5, values) == 1; +} + +boolean Connector_Store_Get_Idempotent( + Connector_Store *store, const char *user_id, const char *key, + const char *digest, int32 *status, char *body, size_t body_size) +{ + if (!store || !user_id || !key || !digest || !status || !body) + return FALSE; + const char *values[] = {user_id, key, digest}; + Dowa_Arena *arena = Dowa_Arena_Create(4096 + body_size); + Deita_Result_Set *result = Deita_Query_Execute_Prepared( + store->connection, + "SELECT status,response_body FROM connector_idempotency " + "WHERE user_id=? AND idem_key=? AND digest=?", + 3, values, arena); + boolean found = result && Deita_Result_Set_Next(result); + if (found) { + const char *value = Deita_Result_Set_Get_Text(result, 1); + if (!value || strlen(value) >= body_size) + found = FALSE; + else { + *status = (int32)Deita_Result_Set_Get_Integer(result, 0); + strcpy(body, value); + } + } + if (result) + Deita_Result_Set_Free(result); + Dowa_Arena_Free(arena); + return found; +} + +boolean Connector_Store_Put_Idempotent( + Connector_Store *store, const char *user_id, const char *key, + const char *digest, int32 status, const char *body) +{ + char status_text[16]; + snprintf(status_text, sizeof(status_text), "%d", status); + const char *values[] = {user_id, key, digest, status_text, body ? body : ""}; + return user_id && key && digest && update( + store, + "INSERT OR IGNORE INTO connector_idempotency" + "(user_id,idem_key,digest,status,response_body) VALUES(?,?,?,?,?)", + 5, values); +} + +boolean Connector_Store_Idempotency_Conflict( + Connector_Store *store, const char *user_id, const char *key, + const char *digest) +{ + if (!store || !user_id || !key || !digest) + return FALSE; + const char *values[] = {user_id, key, digest}; + Dowa_Arena *arena = Dowa_Arena_Create(2048); + Deita_Result_Set *result = Deita_Query_Execute_Prepared( + store->connection, + "SELECT 1 FROM connector_idempotency " + "WHERE user_id=? AND idem_key=? AND digest<>?", + 3, values, arena); + boolean conflict = result && Deita_Result_Set_Next(result); + if (result) + Deita_Result_Set_Free(result); + Dowa_Arena_Free(arena); + return conflict; +} + +boolean Connector_Store_Audit( + Connector_Store *store, const char *user_id, const char *account_id, + const char *operation, const char *digest, int32 status) +{ + char status_text[16]; + snprintf(status_text, sizeof(status_text), "%d", status); + const char *values[] = { + user_id, account_id, operation, digest, status_text + }; + return user_id && account_id && operation && digest && update( + store, + "INSERT INTO connector_audit" + "(user_id,account_id,operation,digest,status) VALUES(?,?,?,?,?)", + 5, values); +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/tests/auth_http_adapter_test.c Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,112 @@ +#include "connectors/auth_http_adapter.h" + +#include <assert.h> +#include <stdio.h> +#include <string.h> +#include <time.h> +#include <unistd.h> + +static const uint8 k_secret[] = + "0123456789abcdef0123456789abcdef"; +static const char k_secret_hex[] = + "30313233343536373839616263646566" + "30313233343536373839616263646566"; + +static Seobeo_Request_Entry *request_with( + Dowa_Arena *arena, const char *cookie, const char *origin, + const char *csrf) +{ + Seobeo_Request_Entry *request = NULL; + Dowa_HashMap_Push_Arena(request, "Host", "localhost:6981", arena); + if (cookie) + Dowa_HashMap_Push_Arena(request, "Cookie", (char *)cookie, arena); + if (origin) + Dowa_HashMap_Push_Arena(request, "Origin", (char *)origin, arena); + if (csrf) + Dowa_HashMap_Push_Arena( + request, "X-CSRF-Token", (char *)csrf, arena); + return request; +} + +int main(void) +{ + char database[256]; + snprintf( + database, sizeof(database), "connector_auth_%ld_%lld.db", + (long)getpid(), (long long)time(NULL)); + unlink(database); + Auth_Store *store = Auth_Store_Create(database); + assert(store); + char password_hash[AUTH_CRYPTO_PASSWORD_HASH_ENCODED_SIZE]; + assert(Auth_Crypto_Password_Hash( + "test-password", password_hash, sizeof(password_hash)) == + AUTH_CRYPTO_OK); + char user_id[37]; + assert(Auth_Store_Create_User( + store, "connectoruser", password_hash, "member", FALSE, user_id) == + AUTH_STORE_OK); + char token[AUTH_CRYPTO_TOKEN_SIZE]; + char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; + char csrf[AUTH_CRYPTO_TOKEN_SIZE]; + char csrf_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; + assert(Auth_Crypto_Token_Generate(token, sizeof(token)) == AUTH_CRYPTO_OK); + assert(Auth_Crypto_Token_Digest( + token, token_digest, sizeof(token_digest)) == AUTH_CRYPTO_OK); + assert(Auth_HTTP_Derive_CSRF( + k_secret, sizeof(k_secret) - 1, token_digest, csrf, sizeof(csrf))); + assert(Auth_Crypto_Token_Digest( + csrf, csrf_digest, sizeof(csrf_digest)) == AUTH_CRYPTO_OK); + Auth_Session_Record session; + int64 now = (int64)time(NULL); + assert(Auth_Store_Create_Session( + store, user_id, token_digest, csrf_digest, 3600, 86400, now, + &session) == AUTH_STORE_OK); + Auth_Store_Destroy(store); + + Connector_Auth_HTTP_Context context; + assert(Connector_Auth_HTTP_Init( + &context, database, k_secret_hex, 3600)); + Connector_Auth_Adapter adapter = + Connector_Auth_HTTP_Create_Adapter(&context); + Dowa_Arena *arena = Dowa_Arena_Create(32 * 1024); + char cookie[AUTH_CRYPTO_TOKEN_SIZE + 32]; + snprintf(cookie, sizeof(cookie), "mjj_session=%s", token); + + Seobeo_Request_Entry *read_request = + request_with(arena, cookie, NULL, NULL); + const char *resolved = adapter.resolve_user( + read_request, FALSE, arena, adapter.context); + assert(resolved && !strcmp(resolved, user_id)); + Connector_Auth_Session connector_session; + assert(adapter.resolve_session( + read_request, &connector_session, adapter.context)); + assert(!strcmp(connector_session.user_id, user_id)); + assert(!strcmp(connector_session.username, "connectoruser")); + assert(!strcmp(connector_session.role, "member")); + assert(!strcmp(connector_session.csrf_token, csrf)); + assert(!adapter.resolve_user( + read_request, TRUE, arena, adapter.context)); + + Seobeo_Request_Entry *write_request = request_with( + arena, cookie, "http://localhost:6981", csrf); + resolved = adapter.resolve_user( + write_request, TRUE, arena, adapter.context); + assert(resolved && !strcmp(resolved, user_id)); + + Seobeo_Request_Entry *wrong_origin = request_with( + arena, cookie, "https://evil.example", csrf); + assert(!adapter.resolve_user( + wrong_origin, TRUE, arena, adapter.context)); + Seobeo_Request_Entry *wrong_cookie = request_with( + arena, "mjj_session=invalid", NULL, NULL); + assert(!adapter.resolve_user( + wrong_cookie, FALSE, arena, adapter.context)); + assert(!adapter.resolve_session( + wrong_cookie, &connector_session, adapter.context)); + + Dowa_Arena_Free(arena); + Connector_Auth_HTTP_Destroy(&context); + unlink(database); + puts("auth_http_adapter_test: ok"); + return 0; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/tests/core_store_test.c Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,185 @@ +#include "connectors/connector.h" + +#include <assert.h> +#include <stdio.h> +#include <string.h> + +static Connector_Master_Key key_with(uint8 value, uint32 version) +{ + Connector_Master_Key key = {.version = version}; + memset(key.key, value, sizeof(key.key)); + return key; +} + +static Connector_Account account_for( + const char *user, const char *account_id, const char *subject) +{ + Connector_Account account; + memset(&account, 0, sizeof(account)); + strcpy(account.account_id, account_id); + strcpy(account.user_id, user); + strcpy(account.provider, "google"); + strcpy(account.provider_subject, subject); + strcpy(account.email, "[email protected]"); + strcpy(account.access_token, "access-secret"); + strcpy(account.refresh_token, "refresh-secret"); + strcpy(account.scopes, "scope"); + account.expires_at = 123456; + return account; +} + +static void test_crypto(void) +{ + Connector_Master_Key key = key_with(7, 3); + char encrypted[4096], decrypted[128]; + assert(Connector_Encrypt(&key, "highly-secret", encrypted, sizeof(encrypted))); + assert(strcmp(encrypted, "highly-secret") != 0); + assert(Connector_Decrypt(&key, encrypted, decrypted, sizeof(decrypted))); + assert(!strcmp(decrypted, "highly-secret")); + + Connector_Master_Key wrong = key_with(8, 3); + assert(!Connector_Decrypt(&wrong, encrypted, decrypted, sizeof(decrypted))); + Connector_Master_Key wrong_version = key; + wrong_version.version = 4; + assert(!Connector_Decrypt( + &wrong_version, encrypted, decrypted, sizeof(decrypted))); + encrypted[strlen(encrypted) - 1] = + encrypted[strlen(encrypted) - 1] == 'A' ? 'B' : 'A'; + assert(!Connector_Decrypt(&key, encrypted, decrypted, sizeof(decrypted))); +} + +static void test_store(void) +{ + Connector_Master_Key key = key_with(17, 1); + Connector_Store store; + assert(Connector_Store_Open(&store, ":memory:", &key)); + + Connector_OAuth_Start state = {0}; + strcpy(state.state, "state-1"); + strcpy(state.code_verifier, "verifier-1"); + assert(Connector_Store_Create_State(&store, "user-a", &state, 100)); + char verifier[128]; + assert(!Connector_Store_Consume_State( + &store, "user-a", "state-1", 101, verifier, sizeof(verifier))); + strcpy(state.state, "state-2"); + assert(Connector_Store_Create_State(&store, "user-a", &state, 200)); + assert(!Connector_Store_Consume_State( + &store, "user-b", "state-2", 150, verifier, sizeof(verifier))); + assert(Connector_Store_Consume_State( + &store, "user-a", "state-2", 150, verifier, sizeof(verifier))); + assert(!strcmp(verifier, "verifier-1")); + assert(!Connector_Store_Consume_State( + &store, "user-a", "state-2", 150, verifier, sizeof(verifier))); + + Connector_Account account = account_for("user-a", "google:one", "one"); + assert(Connector_Store_Save_Account(&store, &account)); + Connector_Account second = account_for("user-a", "google:two", "two"); + strcpy(second.email, "[email protected]"); + assert(Connector_Store_Save_Account(&store, &second)); + Connector_Account loaded; + assert(Connector_Store_Get_Account( + &store, "user-a", "google:one", &loaded)); + assert(!strcmp(loaded.access_token, "access-secret")); + assert(!Connector_Store_Get_Account( + &store, "user-b", "google:one", &loaded)); + assert(!Connector_Store_Delete_Account( + &store, "user-b", "google:one")); + assert(Connector_Store_Get_Account( + &store, "user-a", "google:two", &loaded)); + assert(!strcmp(loaded.email, "[email protected]")); + Dowa_Arena *list_arena = Dowa_Arena_Create(64 * 1024); + Connector_Account_Summary *accounts = NULL; + assert(Connector_Store_List_Accounts( + &store, "user-a", &accounts, list_arena)); + assert(Dowa_Array_Length(accounts) == 2); + assert(!strcmp(accounts[0].account_id, "google:one")); + assert(!strcmp(accounts[1].account_id, "google:two")); + Dowa_Arena_Free(list_arena); + Dowa_Arena *query_arena = Dowa_Arena_Create(4096); + Deita_Result_Set *ciphertext = Deita_Query_Execute( + store.connection, + "SELECT access_token_enc FROM connector_accounts " + "WHERE account_id='google:one'", + query_arena); + assert(ciphertext && Deita_Result_Set_Next(ciphertext)); + assert(!strstr(Deita_Result_Set_Get_Text(ciphertext, 0), "access-secret")); + Deita_Result_Set_Free(ciphertext); + Dowa_Arena_Free(query_arena); + + assert(Connector_Store_Get_Policy( + &store, "user-a", "gmail.send") == CONNECTOR_CONFIRM_ALWAYS); + assert(Connector_Store_Get_Policy( + &store, "user-a", "drive.overwrite") == CONNECTOR_CONFIRM_ALWAYS); + assert(Connector_Store_Get_Policy( + &store, "user-a", "drive.create") == CONNECTOR_CONFIRM_NEVER); + assert(Connector_Store_Set_Policy( + &store, "user-a", "gmail.send", CONNECTOR_CONFIRM_NEVER)); + assert(Connector_Store_Get_Policy( + &store, "user-a", "gmail.send") == CONNECTOR_CONFIRM_NEVER); + + assert(Connector_Store_Create_Confirmation( + &store, "user-a", "digest-a", "confirm-a", 300)); + assert(!Connector_Store_Consume_Confirmation( + &store, "user-b", "digest-a", "confirm-a", 250)); + assert(!Connector_Store_Consume_Confirmation( + &store, "user-a", "digest-b", "confirm-a", 250)); + assert(Connector_Store_Consume_Confirmation( + &store, "user-a", "digest-a", "confirm-a", 250)); + assert(!Connector_Store_Consume_Confirmation( + &store, "user-a", "digest-a", "confirm-a", 250)); + assert(Connector_Store_Create_Confirmation( + &store, "user-a", "digest-c", "confirm-c", 300)); + assert(!Connector_Store_Consume_Confirmation( + &store, "user-a", "digest-c", "confirm-c", 301)); + + assert(Connector_Store_Put_Idempotent( + &store, "user-a", "idem-1", "digest-1", 201, "{\"id\":\"x\"}")); + int32 status = 0; + char body[128]; + assert(Connector_Store_Get_Idempotent( + &store, "user-a", "idem-1", "digest-1", &status, body, sizeof(body))); + assert(status == 201 && !strcmp(body, "{\"id\":\"x\"}")); + assert(!Connector_Store_Get_Idempotent( + &store, "user-a", "idem-1", "digest-other", + &status, body, sizeof(body))); + assert(!Connector_Store_Get_Idempotent( + &store, "user-b", "idem-1", "digest-1", &status, body, sizeof(body))); + assert(Connector_Store_Idempotency_Conflict( + &store, "user-a", "idem-1", "digest-other")); + assert(!Connector_Store_Idempotency_Conflict( + &store, "user-b", "idem-1", "digest-other")); + assert(Connector_Store_Audit( + &store, "user-a", "google:one", "gmail.send", "digest-1", 200)); + + Connector_Store_Close(&store); +} + +static void test_digest(void) +{ + Connector_Provider_Request request = { + .method = "POST", + .path = "/gmail/v1/users/me/messages/send", + .content_type = "application/json", + .body = "{\"raw\":\"abc\"}", + .body_length = 13 + }; + char first[65], second[65], changed[65]; + assert(Connector_Request_Digest( + "user-a", "google:one", CONNECTOR_OP_GMAIL_SEND, &request, first)); + assert(Connector_Request_Digest( + "user-a", "google:one", CONNECTOR_OP_GMAIL_SEND, &request, second)); + request.body = "{\"raw\":\"xyz\"}"; + assert(Connector_Request_Digest( + "user-a", "google:one", CONNECTOR_OP_GMAIL_SEND, &request, changed)); + assert(!strcmp(first, second)); + assert(strcmp(first, changed)); +} + +int main(void) +{ + test_crypto(); + test_store(); + test_digest(); + printf("core_store_test: ok\n"); + return 0; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/tests/google_provider_test.c Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,125 @@ +#include "connectors/connector.h" + +#include <assert.h> +#include <stdio.h> +#include <string.h> + +typedef struct { + int32 calls; + char method[16]; + char url[2048]; + char body[4096]; + char token_body[4096]; +} Fake_Google; + +static Connector_Status fake_transport( + const Connector_HTTP_Request *request, const char *access_token, + Connector_HTTP_Response *response, Dowa_Arena *arena, void *context) +{ + Fake_Google *fake = context; + ++fake->calls; + strcpy(fake->method, request->method); + strcpy(fake->url, request->url); + if (request->body && request->body_length < sizeof(fake->body)) { + memcpy(fake->body, request->body, request->body_length); + fake->body[request->body_length] = '\0'; + } else + fake->body[0] = '\0'; + const char *body; + if (strstr(request->url, "userinfo")) { + assert(access_token && !strcmp(access_token, "new-access")); + body = "{\"sub\":\"subject-7\",\"email\":\"[email protected]\"}"; + } else if (strstr(request->url, "token")) { + strcpy(fake->token_body, fake->body); + body = strstr(fake->body, "grant_type=refresh_token") + ? "{\"access_token\":\"refreshed\",\"expires_in\":3600}" + : "{\"access_token\":\"new-access\",\"refresh_token\":\"refresh\"," + "\"expires_in\":3600,\"scope\":\"drive gmail\"}"; + } else if (strstr(request->url, "revoke")) + body = "{}"; + else + body = "{\"ok\":true}"; + response->status_code = 200; + response->body_length = strlen(body); + response->body = Dowa_Arena_Allocate(arena, response->body_length + 1); + strcpy(response->body, body); + return CONNECTOR_OK; +} + +static Connector_Google_Config config(Fake_Google *fake) +{ + Connector_Google_Config config = { + .client_id = "client id", + .client_secret = "secret&value", + .redirect_uri = "https://example.test/callback", + .oauth_authorize_url = "https://fake/authorize", + .oauth_token_url = "https://fake/token", + .oauth_revoke_url = "https://fake/revoke", + .identity_url = "https://fake/userinfo", + .drive_api_url = "https://fake", + .drive_upload_url = "https://fake", + .gmail_api_url = "https://fake", + .transport = fake_transport, + .transport_context = fake + }; + return config; +} + +int main(void) +{ + Dowa_Arena *arena = Dowa_Arena_Create(256 * 1024); + Fake_Google fake = {0}; + Connector_Google_Config google = config(&fake); + Connector_OAuth_Start start; + assert(Connector_OAuth_PKCE_Start(&start)); + char *authorization = Connector_Google_Authorization_URL( + &google, &start, arena); + assert(authorization && strstr(authorization, "code_challenge_method=S256")); + assert(strstr(authorization, "client_id=client%20id")); + + Connector_Account account; + Connector_Provider_Error error; + assert(Connector_Google_Exchange_Code( + &google, "code+value", start.code_verifier, &account, &error, arena) == + CONNECTOR_OK); + assert(!strcmp(account.account_id, "google:subject-7")); + assert(!strcmp(account.email, "[email protected]")); + assert(strstr(fake.token_body, "code=code%2Bvalue")); + assert(strstr(fake.token_body, "client_secret=secret%26value")); + + assert(Connector_Google_Refresh(&google, &account, arena) == CONNECTOR_OK); + assert(!strcmp(account.access_token, "refreshed")); + assert(strstr(fake.token_body, "grant_type=refresh_token")); + assert(Connector_Google_Revoke(&google, account.refresh_token, arena) == + CONNECTOR_OK); + + Connector_Provider_Request request = { + .method = "GET", + .path = "/drive/v3/files", + .query = "pageSize=10" + }; + Connector_Provider_Response response; + assert(Connector_Google_Execute( + &google, CONNECTOR_OP_DRIVE_LIST, &request, account.access_token, + &response, arena) == CONNECTOR_OK); + assert(!strcmp(fake.method, "GET")); + assert(!strcmp(fake.url, "https://fake/drive/v3/files?pageSize=10")); + + request.method = "POST"; + request.path = "/gmail/v1/users/me/messages/send"; + request.query = NULL; + request.body = "{\"raw\":\"abc\"}"; + request.body_length = strlen(request.body); + assert(Connector_Google_Execute( + &google, CONNECTOR_OP_GMAIL_SEND, &request, account.access_token, + &response, arena) == CONNECTOR_OK); + assert(!strcmp(fake.method, "POST")); + assert(strstr(fake.url, "/gmail/v1/users/me/messages/send")); + + assert(Connector_Operation_Is_Allowed(CONNECTOR_OP_DRIVE_UPDATE)); + assert(Connector_Operation_Is_Mutation(CONNECTOR_OP_GMAIL_SEND)); + assert(!Connector_Operation_Is_Mutation(CONNECTOR_OP_GMAIL_HISTORY)); + Dowa_Arena_Free(arena); + printf("google_provider_test: ok\n"); + return 0; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/tests/route_test.c Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,141 @@ +#include "connectors/connector.h" + +#include <assert.h> +#include <stdio.h> +#include <string.h> + +static const char *value(Seobeo_Request_Entry *map, const char *key) +{ + Seobeo_Request_Entry *entry = Dowa_HashMap_Get_Ptr(map, (char *)key); + return entry ? entry->value : NULL; +} + +typedef struct { + boolean required_csrf; +} Test_Auth_Context; + +static const char *test_only_auth( + Seobeo_Request_Entry *request, boolean require_csrf, + Dowa_Arena *arena, void *opaque) +{ + (void)request; + (void)arena; + Test_Auth_Context *context = opaque; + context->required_csrf = require_csrf; + return NULL; +} + +int main(void) +{ + Dowa_Arena *arena = Dowa_Arena_Create(64 * 1024); + Test_Auth_Context auth_context = {0}; + Connector_Auth_Adapter test_adapter = { + .resolve_user = test_only_auth, + .context = &auth_context + }; + Connector_Service_Configure(NULL, NULL, test_adapter); + Seobeo_Router_Init(); + Connector_Service_Register_Routes(); + + Seobeo_Request_Entry *request = NULL; + Seobeo_Route_Handler health = Seobeo_Router_Find_Handler( + "GET", "/health", &request, arena); + assert(health); + Seobeo_Request_Entry *response = health(request, arena); + assert(!strcmp(value(response, "status"), "200")); + + request = NULL; + Seobeo_Route_Handler auth_page = Seobeo_Router_Find_Handler( + "GET", "/auth-test.html", &request, arena); + assert(auth_page); + response = auth_page(request, arena); + assert(!strcmp(value(response, "status"), "200")); + assert(strstr(value(response, "content-type"), "text/html")); + assert(strstr(value(response, "body"), "Check Zenbu session")); + + request = NULL; + Seobeo_Route_Handler ai_tools = Seobeo_Router_Find_Handler( + "GET", "/v1/ai/tools", &request, arena); + assert(ai_tools); + response = ai_tools(request, arena); + assert(!strcmp(value(response, "status"), "200")); + assert(strstr(value(response, "body"), "connector.gmail.get")); + + request = NULL; + Seobeo_Route_Handler oauth_start = Seobeo_Router_Find_Handler( + "POST", "/v1/oauth/google/start", &request, arena); + assert(oauth_start); + response = oauth_start(request, arena); + assert(!strcmp(value(response, "status"), "401")); + assert(auth_context.required_csrf); + request = NULL; + assert(!Seobeo_Router_Find_Handler( + "GET", "/v1/oauth/google/start", &request, arena)); + + request = NULL; + Seobeo_Route_Handler auth_session = Seobeo_Router_Find_Handler( + "GET", "/v1/auth/session", &request, arena); + assert(auth_session); + response = auth_session(request, arena); + assert(!strcmp(value(response, "status"), "401")); + assert(strstr(value(response, "body"), "\"authenticated\":false")); + + request = NULL; + Seobeo_Route_Handler rejected = Seobeo_Router_Find_Handler( + "DELETE", "/v1/accounts/google:one/drive/files/file-1", + &request, arena); + assert(rejected); + response = rejected(request, arena); + assert(!strcmp(value(response, "status"), "403")); + assert(strstr(value(response, "body"), "operation_not_allowed")); + + request = NULL; + Seobeo_Route_Handler confirmation = Seobeo_Router_Find_Handler( + "POST", "/v1/confirmations", &request, arena); + assert(confirmation); + response = confirmation(request, arena); + assert(!strcmp(value(response, "status"), "401")); + assert(auth_context.required_csrf); + + request = NULL; + Seobeo_Route_Handler get_policy = Seobeo_Router_Find_Handler( + "GET", "/v1/settings/confirmations/gmail.send", &request, arena); + assert(get_policy); + response = get_policy(request, arena); + assert(!strcmp(value(response, "status"), "401")); + assert(!auth_context.required_csrf); + + request = NULL; + Seobeo_Route_Handler set_policy = Seobeo_Router_Find_Handler( + "PUT", "/v1/settings/confirmations/gmail.send", &request, arena); + assert(set_policy); + response = set_policy(request, arena); + assert(!strcmp(value(response, "status"), "401")); + assert(auth_context.required_csrf); + + request = NULL; + Seobeo_Route_Handler drive_list = Seobeo_Router_Find_Handler( + "GET", "/v1/accounts/google:one/drive/files", &request, arena); + assert(drive_list); + response = drive_list(request, arena); + assert(!strcmp(value(response, "status"), "401")); + assert(!auth_context.required_csrf); + + request = NULL; + assert(Seobeo_Router_Find_Handler( + "POST", "/v1/accounts/google:one/gmail/send", &request, arena)); + request = NULL; + assert(Seobeo_Router_Find_Handler( + "GET", + "/v1/accounts/google:one/gmail/messages/msg/attachments/attachment", + &request, arena)); + request = NULL; + assert(!Seobeo_Router_Find_Handler( + "POST", "/v1/accounts/google:one/gmail/messages/msg/labels", + &request, arena)); + + Seobeo_Router_Destroy(); + Dowa_Arena_Free(arena); + printf("route_test: ok\n"); + return 0; +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/connectors/wiki/README.md Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,346 @@ +--- +title: Zenbu connectors +status: canonical +audience: + - humans + - AI agents +last_reviewed: 2026-08-17 +--- + +# Zenbu connectors wiki + +This is the single source of truth for the connector service. Humans use it for +setup and operations. AI agents use it to understand tool semantics, retrieval +order, context normalization, and mutation safety. + +## Mental model + +The connector service does not directly "give Gmail to an AI." It provides +authenticated tools for discovering accounts, searching lightweight resource +references, hydrating selected resources, and performing controlled writes. + +The core AI loop is: + +```text +discover account -> search/list -> select IDs -> hydrate -> normalize +-> rank and bound context -> invoke model -> cite source IDs +``` + +A Gmail list response containing only `id` and `threadId` is a candidate list, +not useful prompt context. The orchestrator must fetch selected messages before +asking the model to reason about them. + +## Quick start + +```sh +cp connectors/.config.development connectors/.config +# Fill in local values. The real file is Mercurial-ignored. + +bazel test //connectors:connector_tests +bazel run //connectors:connector_server +``` + +The service defaults to `connectors/.config`. Override it with an explicit first +argument or `CONNECTOR_CONFIG_PATH`. + +```sh +bazel run //connectors:connector_server -- /absolute/path/to/config +CONNECTOR_CONFIG_PATH=/absolute/path/to/config \ + bazel run //connectors:connector_server +``` + +With mrjunejune running on port 6969 and connectors on 6981: + +1. Log in at `http://127.0.0.1:6969/login`. +2. Open `http://127.0.0.1:6981/auth-test.html`. +3. Check the Zenbu session. +4. Connect Google or select an existing account. +5. Exercise Drive and Gmail reads/writes from the test console. + +## Configuration + +Canonical keys: + +| Key | Purpose | +| --- | --- | +| `DATABASE` | Connector SQLite database | +| `AUTH_DATABASE` | Existing Zenbu auth SQLite database | +| `AUTH_COOKIE_SECRET` | Same hex secret used by mrjunejune | +| `AUTH_SESSION_IDLE_TTL` | Session idle extension in seconds | +| `SERVER_HOST` | Bind address; local default is `127.0.0.1` | +| `SERVER_PORT` | Connector port; local default is `6981` | +| `STATIC_DIR` | Optional Seobeo static directory | +| `GOOGLE_CLIENT_ID` | Google OAuth web client ID | +| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | +| `GOOGLE_REDIRECT_URI` | Exact registered callback URI | +| `MASTER_KEY_VERSION` | Credential-encryption key version | +| `MASTER_KEY_BASE64URL` | Unpadded base64url encoding of 32 random bytes | + +Generate a connector master key: + +```sh +openssl rand 32 | openssl base64 -A | tr '+/' '-_' | tr -d '=' +``` + +Lowercase legacy aliases remain accepted, but new configuration should use the +uppercase names above. + +## Authentication and OAuth + +- Zenbu remains the primary identity system. +- Google accounts are connections owned by a Zenbu `users.id`. +- Browser requests reuse the `mjj_session` cookie across localhost ports. +- `GET /v1/auth/session` returns the resolved user and derived CSRF token. +- OAuth start is a CSRF-protected `POST`. +- OAuth callback is protected by one-time state and PKCE. +- Tokens are AES-256-GCM encrypted in SQLite and are never returned by account + discovery routes. +- Multiple Google accounts may be connected to one Zenbu user. + +Local Google OAuth redirect: + +```text +http://127.0.0.1:6981/v1/oauth/google/callback +``` + +Google Cloud testing-mode apps must list the Google account under **Google Auth +Platform -> Audience -> Test users**. + +## Tool discovery + +`GET /v1/ai/tools` is the runtime machine-readable manifest. An orchestrator +should fetch or version this contract rather than infer tool semantics from URL +names. + +`GET /v1/accounts` returns safe connection summaries: + +```json +{ + "accounts": [ + { + "accountId": "google:116932985844341173188", + "provider": "google", + "email": "[email protected]", + "expiresAt": 1787020000, + "scopes": "..." + } + ] +} +``` + +The authenticated user is always derived from the Zenbu session. A model must +not supply or override a Zenbu owner ID. + +## Gmail retrieval + +### Search or list candidates + +```text +GET /v1/accounts/{account_id}/gmail/messages + ?q=from:[email protected] newer_than:30d + &maxResults=10 +``` + +Useful Gmail search examples: + +```text +from:[email protected] newer_than:30d +subject:(quarterly planning) has:attachment +in:sent to:[email protected] +``` + +The result: + +```json +{"messages":[{"id":"abc","threadId":"abc"}]} +``` + +means only that message `abc` is a candidate. + +### Hydrate selected messages + +Metadata-only: + +```text +GET /v1/accounts/{account_id}/gmail/messages/abc?format=metadata +``` + +Full message: + +```text +GET /v1/accounts/{account_id}/gmail/messages/abc?format=full +``` + +The orchestrator should: + +1. Extract `Subject`, `From`, `To`, and `Date`. +2. Decode Gmail base64url body data. +3. Prefer `text/plain`; convert HTML to text only when needed. +4. Remove irrelevant quoted history and signatures. +5. Cap each message and the total retrieved context. +6. Preserve `message.id` and `threadId` for citations and follow-up calls. + +Attachments are explicit: + +```text +GET /v1/accounts/{account_id}/gmail/messages/{message_id}/attachments/{attachment_id} +``` + +Do not silently ingest every attachment. + +## Drive retrieval + +Search candidates with Drive query syntax and narrow fields: + +```text +GET /v1/accounts/{account_id}/drive/files + ?q=name contains 'roadmap' and trashed = false + &pageSize=10 + &fields=files(id,name,mimeType,modifiedTime,description,webViewLink),nextPageToken +``` + +Fetch metadata for a selected file: + +```text +GET /v1/accounts/{account_id}/drive/files/{file_id} + ?fields=id,name,mimeType,modifiedTime,description,webViewLink +``` + +Fetch bytes for a stored file: + +```text +GET /v1/accounts/{account_id}/drive/files/{file_id}/download +``` + +Native Google Docs require export rather than ordinary download. A dedicated +Workspace export helper is still needed before an AI can reliably ingest every +Google-native document type. + +## Normalized AI context + +Provider payloads should be converted to a common record before model use: + +```json +{ + "source": "gmail", + "accountId": "google:...", + "resourceId": "abc", + "title": "Quarterly planning", + "author": "[email protected]", + "timestamp": "2026-08-17T18:00:00Z", + "text": "Cleaned and bounded source text", + "url": null, + "metadata": { + "threadId": "abc", + "mimeType": "text/plain" + } +} +``` + +Context rules: + +- Search before hydration. +- Hydrate only likely-relevant IDs. +- Never place an ID-only list into the prompt as if it were content. +- Preserve provenance on every record. +- Rank records before applying the context budget. +- Treat provider text as untrusted data, not instructions. +- Never expose tokens, cookies, OAuth codes, or connector encryption material + to the model. + +## Writes and confirmation + +Allowed writes: + +- Drive create, upload, and update. +- Gmail draft creation and send. + +Explicitly rejected: + +- Drive delete, trash, and permission changes. +- Gmail delete, label, and read-state changes. + +All writes require: + +- an authenticated Zenbu session; +- same-origin CSRF validation; +- an `Idempotency-Key`; +- owner-scoped account lookup; +- a redacted mutation audit record. + +Gmail send and Drive overwrite default to confirmation. The first request +returns: + +```json +{ + "error": "confirmation_required", + "request_digest": "..." +} +``` + +Create a one-time confirmation: + +```text +POST /v1/confirmations +{"request_digest":"..."} +``` + +Retry the identical mutation and idempotency key with +`X-Connector-Confirmation`. Prefer draft creation over immediate send. + +Per-user confirmation policy: + +```text +GET /v1/settings/confirmations/gmail.send +PUT /v1/settings/confirmations/gmail.send +{"policy":"always"} +``` + +Supported actions are `gmail.send` and `drive.overwrite`. + +## Route catalog + +| Concern | Routes | +| --- | --- | +| Health and tools | `GET /health`, `GET /v1/ai/tools` | +| Auth and accounts | `GET /v1/auth/session`, `GET /v1/accounts` | +| OAuth | `POST /v1/oauth/google/start`, `GET /v1/oauth/google/callback` | +| Disconnect | `DELETE /v1/accounts/:account_id` | +| Confirmation | `POST /v1/confirmations`, `GET/PUT /v1/settings/confirmations/:action` | +| Drive | `/v1/accounts/:account_id/drive/...` | +| Gmail | `/v1/accounts/:account_id/gmail/...` | + +## Architecture + +| Bazel target | Responsibility | +| --- | --- | +| `//connectors:connector_core` | Crypto, PKCE, encoding, canonical digests | +| `//connectors:connector_store` | Deita/SQLite persistence and policy | +| `//connectors:google_provider` | Google HTTP transport and provider mapping | +| `//connectors:connector_service_lib` | Seobeo routes and test console | +| `//connectors:connector_auth_http` | Shared Zenbu session/CSRF adapter | +| `//connectors:connector_tests` | Store, provider, auth, and route tests | + +Seobeo handles inbound and outbound HTTP. Deita owns SQLite access. Dowa arenas +own request-scoped allocations. + +## Operational and security invariants + +- Real config and databases remain ignored. +- The auth cookie secret must match mrjunejune exactly. +- OAuth redirect URIs must match Google configuration exactly. +- Credentials remain encrypted at rest. +- Logs and errors must not contain tokens, cookies, authorization codes, PKCE + verifiers, client secrets, message bodies, or file contents. +- Provider calls are bounded and time out. +- Chunked HTTP responses are decoded before JSON parsing. +- Account access always includes the authenticated Zenbu user ID. + +## Documentation maintenance + +This page is canonical. Update it whenever routes, configuration, security +rules, or AI retrieval behavior change. + +Only split a topic into another `wiki/` page when this page becomes difficult +to navigate. Any split page must be linked from this index, state its scope, and +avoid duplicating normative rules.
--- a/gui_ze/README.md Mon Aug 17 22:16:14 2026 -0700 +++ b/gui_ze/README.md Mon Aug 17 22:22:36 2026 -0700 @@ -1,29 +1,6 @@ # gui_ze -Bazel rules for building GUI applications and web frontends. - -## Files - -| File | Description | -|------|-------------| -| `gui_ze.bzl` | Starlark rules for web/GUI builds | -| `time_to_first_byte.sh` | TTFB measurement script | - -## Rules +Zenbu's Bazel/Starlark rules for frontend builds, generated assets, binary +bundles, and macOS application packaging. -Provides Bazel rules for: -- Bundling JavaScript/TypeScript with esbuild -- Building WASM modules with Emscripten -- Packaging web applications - -## Usage - -```starlark -load("//gui_ze:gui_ze.bzl", "web_bundle", "wasm_cc_binary") - -web_bundle( - name = "app", - entry_point = "src/main.tsx", - ... -) -``` +**Canonical documentation:** [`wiki/README.md`](wiki/README.md)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/gui_ze/wiki/README.md Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,106 @@ +--- +title: gui_ze +status: canonical +audience: + - humans + - AI agents +last_reviewed: 2026-08-17 +--- + +# gui_ze wiki + +`gui_ze` owns reusable Bazel rules and macros for build-time asset and +application packaging. Load symbols from: + +```starlark +load("//gui_ze:gui_ze.bzl", "bundle", "move_files_into_dir") +``` + +There is no `web_bundle` rule and no `wasm_cc_binary` defined by gui_ze. +JavaScript bundling uses the Bun rules below. WASM compilation comes from the +Emscripten toolchain, such as +`@emsdk//emscripten_toolchain:wasm_rules.bzl`. + +## Rule selection + +| Need | Symbol | Notes | +| --- | --- | --- | +| Copy a target's runfiles and binary into a distributable directory | `bundle` | Takes a `binary` label | +| Bundle TS/JS with Bun and declared dependencies | `bun_bundle` | Preferred Bun build rule for an entry source | +| Legacy Bun folder build | `bun_build` | Existing compatibility rule; avoid for new work when `bun_bundle` fits | +| Run a JS/TS source with the pinned Bun runtime | `bun_run` | Executable Bazel target with declared `data` | +| Materialize files under a destination directory | `move_files_into_dir` | Preserves only source basenames | +| Symlink declared data into a directory structure | `move_to_directory` | Uses paths derived from Bazel outputs | +| Convert an image to WebP | `webp_image` | Uses pinned `cwebp`; supports quality and lossless mode | +| Package/sign a binary as a macOS app and DMG | `macos_app_and_dmg` | Generates app, signed app, and DMG targets | +| Expose a pinned Bun executable | `bun_binary` | Infrastructure rule, normally used by the Bun wrapper package | +| Trivial rule-development example | `foo_binary` | Example only; do not use in production targets | + +## Examples + +Bundle browser code: + +```starlark +load("//gui_ze:gui_ze.bzl", "bun_bundle") + +bun_bundle( + name = "app_js", + src = "src/main.ts", + deps = [ + "//design_system:components", + ], +) +``` + +Copy generated/static assets: + +```starlark +load("//gui_ze:gui_ze.bzl", "move_files_into_dir") + +move_files_into_dir( + name = "public_icons", + srcs = ["//assets:icons"], + dest = "public/icons", +) +``` + +Convert an image: + +```starlark +load("//gui_ze:gui_ze.bzl", "webp_image") + +webp_image( + name = "logo_webp", + src = "logo.png", + out = "generated/logo.webp", + lossless = True, +) +``` + +Bundle a server binary and runfiles: + +```starlark +load("//gui_ze:gui_ze.bzl", "bundle") + +bundle( + name = "server_bundle", + binary = ":server", +) +``` + +## Rules for agents + +- Do not add shell copy commands when `move_files_into_dir`, + `move_to_directory`, or `bundle` owns the operation. +- Declare every source, dependency, runtime file, and tool in the Bazel rule. +- Use the pinned Bun target rather than a host-installed Bun executable. +- Use `webp_image` rather than calling `cwebp` from application scripts. +- Keep application-specific asset composition in the consuming package's + `BUILD`; keep reusable action logic in `gui_ze.bzl`. +- Prefer a new focused Starlark rule over a script that writes undeclared files. +- Preserve platform constraints for macOS packaging rules. + +## Validation + +Build the consuming target that instantiates the rule. Starlark helpers are +validated through their real consumers rather than a generic gui_ze build.
--- a/mrjunejune/BUILD Mon Aug 17 22:16:14 2026 -0700 +++ b/mrjunejune/BUILD Mon Aug 17 22:22:36 2026 -0700 @@ -295,6 +295,7 @@ deps = [ ":template_renderer", "//auth:auth_crypto", + "//auth:auth_http", "//auth:auth_store", "//dowa:dowa", "//seobeo:seobeo", @@ -313,6 +314,7 @@ deps = [ ":template_renderer", "//auth:auth_crypto", + "//auth:auth_http", "//auth:auth_store", "//dowa:dowa", "//seobeo:seobeo",
--- a/mrjunejune/auth_api.c Mon Aug 17 22:16:14 2026 -0700 +++ b/mrjunejune/auth_api.c Mon Aug 17 22:22:36 2026 -0700 @@ -2,6 +2,7 @@ #include "mrjunejune/template_renderer.h" #include "auth/auth_crypto.h" +#include "auth/auth_http.h" #include "auth/auth_store.h" #include "seobeo/seobeo.h" #include "dowa/dowa.h" @@ -25,7 +26,6 @@ #define BODY_MAX_BYTES 4096 #define COOKIE_VALUE_MAX 512 -#define CSRF_SUFFIX ":csrf:v1" #define RATE_TABLE_SIZE 512 /* must be power of 2 */ #define RATE_PROBE_LIMIT 16 #define RATE_LIMIT_MAX_FAILURES 5 @@ -33,7 +33,6 @@ #define SESSION_COOKIE_MAX 512 #define GUEST_COOKIE_MAX (AUTH_CRYPTO_GUEST_COOKIE_SIZE + 256) #define RATE_KEY_MAX 65 -#define BINDING_INPUT_MAX (AUTH_CRYPTO_TOKEN_DIGEST_SIZE + 16) /* * Fixed precomputed scrypt hash of the constant string "dummy-zenbu-timing". @@ -112,23 +111,6 @@ return TRUE; } -/* - * base64url encode src_len bytes from src into dst. - * dst must have capacity ceil(src_len * 4 / 3) + 1. - * Returns length of encoded string (without NUL). - * - * Delegates to Auth_Crypto_Base64url_Encode which uses the correct loop - * boundary (processes remaining bytes after full 3-byte groups). - */ -static size_t auth_base64url_encode( - const uint8 *src, - size_t src_len, - char *dst, - size_t dst_capacity) -{ - return Auth_Crypto_Base64url_Encode(src, src_len, dst, dst_capacity); -} - static void auth_hex_encode( const uint8 *src, size_t src_len, @@ -156,37 +138,9 @@ char *csrf_out, size_t csrf_capacity) { - if (!binding || !csrf_out || csrf_capacity < AUTH_CRYPTO_TOKEN_SIZE) - return FALSE; - - size_t binding_len = strlen(binding); - size_t suffix_len = strlen(CSRF_SUFFIX); - size_t input_len = binding_len + suffix_len; - - if (input_len >= BINDING_INPUT_MAX) - return FALSE; - - char input[BINDING_INPUT_MAX]; - memcpy(input, binding, binding_len); - memcpy(input + binding_len, CSRF_SUFFIX, suffix_len); - - uint8 digest[32]; - uint32 digest_len = 32; - if (!HMAC(EVP_sha256(), - g_cookie_secret, (int)g_cookie_secret_length, - (const uint8 *)input, input_len, - digest, &digest_len)) - { - OPENSSL_cleanse(input, sizeof(input)); - OPENSSL_cleanse(digest, sizeof(digest)); - return FALSE; - } - OPENSSL_cleanse(input, sizeof(input)); - - size_t encoded_length = - auth_base64url_encode(digest, 32, csrf_out, csrf_capacity); - OPENSSL_cleanse(digest, sizeof(digest)); - return encoded_length == AUTH_CRYPTO_TOKEN_SIZE - 1; + return Auth_HTTP_Derive_CSRF( + g_cookie_secret, g_cookie_secret_length, + binding, csrf_out, csrf_capacity); } /* @@ -396,35 +350,8 @@ char *value_out, size_t capacity) { - if (!cookie_header || !name || !value_out || capacity == 0) - return FALSE; - - size_t name_len = strlen(name); - const char *p = cookie_header; - - while (*p) - { - /* skip whitespace */ - while (*p == ' ' || *p == '\t') p++; - - /* check for name= */ - if (strncmp(p, name, name_len) == 0 && p[name_len] == '=') - { - p += name_len + 1; - const char *start = p; - while (*p && *p != ';') p++; - size_t vlen = (size_t)(p - start); - if (vlen >= capacity) return FALSE; - memcpy(value_out, start, vlen); - value_out[vlen] = '\0'; - return TRUE; - } - - /* skip to next ; */ - while (*p && *p != ';') p++; - if (*p == ';') p++; - } - return FALSE; + return Auth_HTTP_Parse_Cookie( + cookie_header, name, value_out, capacity); } /* @@ -462,17 +389,7 @@ static boolean auth_same_origin(Seobeo_Request_Entry *p_req) { - const char *host = auth_req_value(p_req, "Host"); - const char *origin = auth_req_value(p_req, "Origin"); - if (!host || !origin) return FALSE; - - const char *host_in_origin = strstr(origin, "://"); - if (!host_in_origin) return FALSE; - host_in_origin += 3; - - const char *end = strchr(host_in_origin, '/'); - size_t len = end ? (size_t)(end - host_in_origin) : strlen(host_in_origin); - return strlen(host) == len && strncmp(host, host_in_origin, len) == 0; + return Auth_HTTP_Same_Origin(p_req); } /* ------------------------------------------------------------------ */ @@ -588,46 +505,30 @@ int64 now = auth_now(); /* --- Try authenticated session first --- */ - char session_token[COOKIE_VALUE_MAX] = {0}; - if (cookie_header && - auth_parse_cookie(cookie_header, AUTH_API_SESSION_COOKIE_NAME, - session_token, sizeof(session_token)) && - session_token[0] != '\0') + Auth_HTTP_Authenticated_User authenticated_user; + Auth_HTTP_Resolve_Result user_result = + Auth_HTTP_Resolve_Authenticated_User( + p_request, g_auth_store, g_cookie_secret, g_cookie_secret_length, + now, g_session_idle_ttl, &authenticated_user); + if (user_result == AUTH_HTTP_RESOLVE_ERROR) + return FALSE; + if (user_result == AUTH_HTTP_RESOLVE_OK) { - char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; - if (Auth_Crypto_Token_Digest(session_token, token_digest, - sizeof(token_digest)) == AUTH_CRYPTO_OK) - { - Auth_Session_Record session; - Auth_User_Record user; - Auth_Store_Result result = Auth_Store_Find_Session( - g_auth_store, token_digest, now, &session, &user); - - if (result == AUTH_STORE_OK) - { - Auth_Store_Touch_Session( - g_auth_store, token_digest, now, g_session_idle_ttl); - - p_principal->kind = AUTH_PRINCIPAL_USER; - strncpy(p_principal->user_id, user.id, sizeof(p_principal->user_id) - 1); - strncpy(p_principal->username, user.username, sizeof(p_principal->username) - 1); - strncpy(p_principal->role, user.role, sizeof(p_principal->role) - 1); - p_principal->must_change_password = user.must_change_password; - strncpy(p_principal->_binding, token_digest, sizeof(p_principal->_binding) - 1); - if (!auth_derive_csrf(token_digest, p_principal->csrf_token, - sizeof(p_principal->csrf_token))) - { - OPENSSL_cleanse(session_token, sizeof(session_token)); - OPENSSL_cleanse(token_digest, sizeof(token_digest)); - memset(p_principal, 0, sizeof(*p_principal)); - return FALSE; - } - OPENSSL_cleanse(session_token, sizeof(session_token)); - OPENSSL_cleanse(token_digest, sizeof(token_digest)); - return TRUE; - } - } - OPENSSL_cleanse(session_token, sizeof(session_token)); + p_principal->kind = AUTH_PRINCIPAL_USER; + strncpy(p_principal->user_id, authenticated_user.user.id, + sizeof(p_principal->user_id) - 1); + strncpy(p_principal->username, authenticated_user.user.username, + sizeof(p_principal->username) - 1); + strncpy(p_principal->role, authenticated_user.user.role, + sizeof(p_principal->role) - 1); + p_principal->must_change_password = + authenticated_user.user.must_change_password; + strncpy(p_principal->_binding, authenticated_user.token_digest, + sizeof(p_principal->_binding) - 1); + strncpy(p_principal->csrf_token, authenticated_user.csrf_token, + sizeof(p_principal->csrf_token) - 1); + OPENSSL_cleanse(&authenticated_user, sizeof(authenticated_user)); + return TRUE; } /* --- Try guest cookie --- */ @@ -761,47 +662,31 @@ int64 now = auth_now(); /* --- Try authenticated session first --- */ - char session_token[COOKIE_VALUE_MAX] = {0}; - if (cookie_header && - auth_parse_cookie(cookie_header, AUTH_API_SESSION_COOKIE_NAME, - session_token, sizeof(session_token)) && - session_token[0] != '\0') + Auth_HTTP_Authenticated_User authenticated_user; + Auth_HTTP_Resolve_Result user_result = + Auth_HTTP_Resolve_Authenticated_User( + p_request, g_auth_store, g_cookie_secret, g_cookie_secret_length, + now, g_session_idle_ttl, &authenticated_user); + if (user_result == AUTH_HTTP_RESOLVE_ERROR) + return FALSE; + if (user_result == AUTH_HTTP_RESOLVE_OK) { - char token_digest[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; - if (Auth_Crypto_Token_Digest(session_token, token_digest, - sizeof(token_digest)) == AUTH_CRYPTO_OK) - { - Auth_Session_Record session; - Auth_User_Record user; - Auth_Store_Result result = Auth_Store_Find_Session( - g_auth_store, token_digest, now, &session, &user); - - if (result == AUTH_STORE_OK) - { - Auth_Store_Touch_Session( - g_auth_store, token_digest, now, g_session_idle_ttl); - - p_principal->kind = AUTH_PRINCIPAL_USER; - strncpy(p_principal->user_id, user.id, sizeof(p_principal->user_id) - 1); - strncpy(p_principal->username, user.username, sizeof(p_principal->username) - 1); - strncpy(p_principal->role, user.role, sizeof(p_principal->role) - 1); - p_principal->must_change_password = user.must_change_password; - strncpy(p_principal->_binding, token_digest, sizeof(p_principal->_binding) - 1); - if (!auth_derive_csrf(token_digest, p_principal->csrf_token, - sizeof(p_principal->csrf_token))) - { - OPENSSL_cleanse(session_token, sizeof(session_token)); - OPENSSL_cleanse(token_digest, sizeof(token_digest)); - memset(p_principal, 0, sizeof(*p_principal)); - return FALSE; - } - OPENSSL_cleanse(session_token, sizeof(session_token)); - OPENSSL_cleanse(token_digest, sizeof(token_digest)); - *p_found = TRUE; - return TRUE; - } - } - OPENSSL_cleanse(session_token, sizeof(session_token)); + p_principal->kind = AUTH_PRINCIPAL_USER; + strncpy(p_principal->user_id, authenticated_user.user.id, + sizeof(p_principal->user_id) - 1); + strncpy(p_principal->username, authenticated_user.user.username, + sizeof(p_principal->username) - 1); + strncpy(p_principal->role, authenticated_user.user.role, + sizeof(p_principal->role) - 1); + p_principal->must_change_password = + authenticated_user.user.must_change_password; + strncpy(p_principal->_binding, authenticated_user.token_digest, + sizeof(p_principal->_binding) - 1); + strncpy(p_principal->csrf_token, authenticated_user.csrf_token, + sizeof(p_principal->csrf_token) - 1); + OPENSSL_cleanse(&authenticated_user, sizeof(authenticated_user)); + *p_found = TRUE; + return TRUE; } /* --- Try existing guest cookie (no new guest created) --- */ @@ -886,34 +771,8 @@ const char *provided_token, const char *binding) { - if (!provided_token || !binding || provided_token[0] == '\0') - return FALSE; - - char expected[AUTH_CRYPTO_TOKEN_SIZE]; - if (!auth_derive_csrf(binding, expected, sizeof(expected))) - return FALSE; - - /* Compare SHA-256 digests of both tokens (constant-time length comparison) */ - char digest_provided[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; - char digest_expected[AUTH_CRYPTO_TOKEN_DIGEST_SIZE]; - - if (Auth_Crypto_Token_Digest(provided_token, digest_provided, - sizeof(digest_provided)) != AUTH_CRYPTO_OK || - Auth_Crypto_Token_Digest(expected, digest_expected, - sizeof(digest_expected)) != AUTH_CRYPTO_OK) - { - OPENSSL_cleanse(expected, sizeof(expected)); - OPENSSL_cleanse(digest_provided, sizeof(digest_provided)); - OPENSSL_cleanse(digest_expected, sizeof(digest_expected)); - return FALSE; - } - - int match = CRYPTO_memcmp(digest_provided, digest_expected, - sizeof(digest_provided)); - OPENSSL_cleanse(expected, sizeof(expected)); - OPENSSL_cleanse(digest_provided, sizeof(digest_provided)); - OPENSSL_cleanse(digest_expected, sizeof(digest_expected)); - return match == 0; + return Auth_HTTP_Verify_CSRF_Token( + g_cookie_secret, g_cookie_secret_length, binding, provided_token); } /* ------------------------------------------------------------------ */ @@ -924,14 +783,11 @@ Seobeo_Request_Entry *p_request, const Auth_Principal *p_principal) { - if (!p_request || !p_principal) - return FALSE; - if (!auth_same_origin(p_request)) + if (!p_principal) return FALSE; - const char *csrf = auth_req_value(p_request, "X-CSRF-Token"); - if (!csrf || csrf[0] == '\0') - return FALSE; - return auth_verify_csrf(csrf, p_principal->_binding); + return Auth_HTTP_Verify_CSRF( + p_request, g_cookie_secret, g_cookie_secret_length, + p_principal->_binding); } /* ------------------------------------------------------------------ */
--- a/mrjunejune/auth_api.h Mon Aug 17 22:16:14 2026 -0700 +++ b/mrjunejune/auth_api.h Mon Aug 17 22:22:36 2026 -0700 @@ -2,11 +2,12 @@ #define MRJUNEJUNE_AUTH_API_H #include "dowa/dowa.h" +#include "auth/auth_http.h" #include "auth/auth_store.h" #include "seobeo/seobeo.h" /* Cookie names */ -#define AUTH_API_SESSION_COOKIE_NAME "mjj_session" +#define AUTH_API_SESSION_COOKIE_NAME AUTH_HTTP_SESSION_COOKIE_NAME #define AUTH_API_GUEST_COOKIE_NAME "mjj_guest" /* Default TTLs (seconds) */
--- a/seobeo/s_http_client.c Mon Aug 17 22:16:14 2026 -0700 +++ b/seobeo/s_http_client.c Mon Aug 17 22:22:36 2026 -0700 @@ -42,6 +42,90 @@ return NULL; } +static boolean Seobeo_Client_Header_Has_Token( + const char *value, const char *token) +{ + if (!value || !token) + return FALSE; + size_t token_length = strlen(token); + const char *cursor = value; + while (*cursor) + { + while (*cursor == ' ' || *cursor == '\t' || *cursor == ',') + cursor++; + const char *end = cursor; + while (*end && *end != ',') + end++; + const char *trimmed_end = end; + while (trimmed_end > cursor && + (trimmed_end[-1] == ' ' || trimmed_end[-1] == '\t')) + trimmed_end--; + if ((size_t)(trimmed_end - cursor) == token_length && + strncasecmp(cursor, token, token_length) == 0) + return TRUE; + cursor = end; + } + return FALSE; +} + +static boolean Seobeo_Client_Decode_Chunked( + const char *encoded, size_t encoded_length, + char *decoded, size_t decoded_capacity, size_t *decoded_length) +{ + if (!encoded || !decoded || !decoded_length) + return FALSE; + size_t input_offset = 0; + size_t output_offset = 0; + while (input_offset < encoded_length) + { + size_t line_end = input_offset; + while (line_end + 1 < encoded_length && + !(encoded[line_end] == '\r' && encoded[line_end + 1] == '\n')) + line_end++; + if (line_end + 1 >= encoded_length) + return FALSE; + + size_t chunk_size = 0; + boolean have_digit = FALSE; + for (size_t i = input_offset; i < line_end && encoded[i] != ';'; i++) + { + uint8 c = (uint8)encoded[i]; + int32 digit; + if (c >= '0' && c <= '9') + digit = c - '0'; + else if (c >= 'a' && c <= 'f') + digit = c - 'a' + 10; + else if (c >= 'A' && c <= 'F') + digit = c - 'A' + 10; + else + return FALSE; + if (chunk_size > (SIZE_MAX - (size_t)digit) / 16) + return FALSE; + chunk_size = chunk_size * 16 + (size_t)digit; + have_digit = TRUE; + } + if (!have_digit) + return FALSE; + input_offset = line_end + 2; + if (chunk_size == 0) + { + *decoded_length = output_offset; + return TRUE; + } + if (chunk_size > encoded_length - input_offset || + chunk_size > decoded_capacity - output_offset) + return FALSE; + memcpy(decoded + output_offset, encoded + input_offset, chunk_size); + output_offset += chunk_size; + input_offset += chunk_size; + if (input_offset + 1 >= encoded_length || + encoded[input_offset] != '\r' || encoded[input_offset + 1] != '\n') + return FALSE; + input_offset += 2; + } + return FALSE; +} + static void Seobeo_Client_Parse_Url(const char *url, char **p_host, char **p_port, char **p_path, boolean *p_use_tls, Dowa_Arena *p_arena) { @@ -405,7 +489,11 @@ size_t body_len = 0; const char *content_length = Seobeo_Client_Header_Value( p_resp->headers, "Content-Length"); - if (content_length) + const char *transfer_encoding = Seobeo_Client_Header_Value( + p_resp->headers, "Transfer-Encoding"); + boolean is_chunked = Seobeo_Client_Header_Has_Token( + transfer_encoding, "chunked"); + if (content_length && !is_chunked) { body_len = (size_t)strtoull(content_length, NULL, 10); } @@ -484,7 +572,8 @@ { size_t cap = 1024 * 1024 * 5; size_t used = 0; - char *body = download_path ? NULL : Dowa_Arena_Allocate(p_resp->p_arena, cap); + char *body = (!download_path || is_chunked) + ? Dowa_Arena_Allocate(p_resp->p_arena, cap) : NULL; while (1) { @@ -494,7 +583,7 @@ if (n > 0) { last_progress_ms = Seobeo_Client_Monotonic_Milliseconds(); - if (download_path) + if (download_path && !is_chunked) { fwrite(p_handle->read_buffer, 1, p_handle->read_buffer_len, p_file); used += p_handle->read_buffer_len; @@ -533,7 +622,31 @@ } } - if (!download_path) + if (is_chunked) + { + char *decoded = Dowa_Arena_Allocate(p_resp->p_arena, used + 1); + size_t decoded_length = 0; + if (!decoded || + !Seobeo_Client_Decode_Chunked( + body, used, decoded, used, &decoded_length)) + { + if (p_file) fclose(p_file); + Seobeo_Client_Response_Destroy(p_resp); + return NULL; + } + decoded[decoded_length] = '\0'; + if (download_path) + { + fwrite(decoded, 1, decoded_length, p_file); + p_resp->body_length = decoded_length; + } + else + { + p_resp->body = decoded; + p_resp->body_length = decoded_length; + } + } + else if (!download_path) { p_resp->body = body; p_resp->body_length = used;
--- a/seobeo/tests/seobeo_http_framing_test.c Mon Aug 17 22:16:14 2026 -0700 +++ b/seobeo/tests/seobeo_http_framing_test.c Mon Aug 17 22:22:36 2026 -0700 @@ -12,17 +12,36 @@ static void *serve_close_delimited_response(void *argument) { Framing_Server *server = argument; - int client = accept(server->listener, NULL, NULL); - if (client >= 0) { + for (int response_index = 0; response_index < 2; response_index++) { + int client = accept(server->listener, NULL, NULL); + if (client < 0) + continue; char request[1024]; (void)read(client, request, sizeof(request)); - const char response[] = - "HTTP/1.1 200 OK\r\n" - "Content-Type: text/plain\r\n" - "Connection: close\r\n" - "\r\n" - "buffered-body"; - (void)write(client, response, sizeof(response) - 1); + if (response_index == 0) { + const char response[] = + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain\r\n" + "Connection: close\r\n" + "\r\n" + "buffered-body"; + (void)write(client, response, sizeof(response) - 1); + } else { + const char response[] = + "HTTP/1.1 200 OK\r\n" + "Content-Type: application/json\r\n" + "Transfer-Encoding: chunked\r\n" + "Connection: close\r\n" + "\r\n" + "7;sample=yes\r\n" + "{\"ok\":t\r\n" + "4\r\n" + "rue}\r\n" + "0\r\n" + "X-Trailer: ignored\r\n" + "\r\n"; + (void)write(client, response, sizeof(response) - 1); + } close(client); } return NULL; @@ -59,7 +78,9 @@ } char url[128]; - snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port)); + snprintf( + url, sizeof(url), "http://127.0.0.1:%u/close", + ntohs(address.sin_port)); Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url); Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 1000); Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request); @@ -73,6 +94,23 @@ Seobeo_Client_Response_Destroy(response); Seobeo_Client_Request_Destroy(request); + + snprintf( + url, sizeof(url), "http://127.0.0.1:%u/chunked", + ntohs(address.sin_port)); + request = Seobeo_Client_Request_Create(url); + Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 1000); + response = Seobeo_Client_Request_Execute(request); + int chunked_failed = !response || + response->status_code != 200 || + response->body_length != strlen("{\"ok\":true}") || + memcmp(response->body, "{\"ok\":true}", strlen("{\"ok\":true}")) != 0; + if (chunked_failed) + fprintf(stderr, "Chunked response body was not decoded\n"); + failed = failed || chunked_failed; + + Seobeo_Client_Response_Destroy(response); + Seobeo_Client_Request_Destroy(request); pthread_join(thread, NULL); close(listener); return failed;
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/tools/arena_policy_test.sh Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="${TEST_SRCDIR}/${TEST_WORKSPACE}" + +declare -A baseline=( + ["auth/auth_store.c"]=4 + ["deita/d_sqlite.c"]=4 + ["design_system/main.c"]=2 + ["dowa/d_memory.c"]=15 + ["dowa/dowa.h"]=1 + ["dowa/d_string.c"]=3 + ["dowa/stb_ds.h"]=4 + ["markdown_converter/markdown_to_html.c"]=40 + ["mrjunejune/conversation_api.c"]=7 + ["mrjunejune/conversation_store.c"]=4 + ["mrjunejune/inference_bridge.c"]=5 + ["mrjunejune/latex_renderer.c"]=15 + ["mrjunejune/main.c"]=8 + ["mrjunejune/template_renderer.c"]=4 + ["mrjunejune/test/inference_bridge_fake_sidecar.c"]=1 + ["mrjunejune/test/integration_test.c"]=9 + ["mrjunejune/test/template_renderer_test.c"]=6 + ["postdog/main.c"]=14 + ["s3/s3_uploader.c"]=2 + ["s3/tests/s3_uploader_test.c"]=5 + ["seobeo/os/s_linux_edge.c"]=1 + ["seobeo/os/s_macos_edge.c"]=1 + ["seobeo/s_http_client.c"]=8 + ["seobeo/snapshot_creator.c"]=1 + ["seobeo/s_network.c"]=11 + ["seobeo/s_sse.c"]=3 + ["seobeo/s_web.c"]=4 + ["seobeo/s_websocket.c"]=24 + ["seobeo/s_websocket_common.c"]=2 + ["seobeo/s_websocket_server.c"]=18 + ["seobeo/s_worker.c"]=22 + ["seobeo/tests/seobeo_request_context_test.c"]=3 + ["seobeo/tests/seobeo_response_test.c"]=4 + ["seobeo/tests/seobeo_sigpipe_test.c"]=2 + ["sori/main.c"]=3 +) + +failed=0 +while IFS= read -r -d '' file; do + relative="${file#${root}/}" + if [[ "${relative}" == third_party/* ]]; then + continue + fi + count="$( + { grep -Eo '\b(malloc|calloc|realloc|free)[[:space:]]*\(' "${file}" || true; } | + wc -l | + tr -d ' ' + )" + allowed="${baseline[${relative}]:-0}" + if (( count > allowed )); then + printf '%s: raw allocation count increased from %d to %d\n' \ + "${relative}" "${allowed}" "${count}" >&2 + failed=1 + fi +done < <( + find -L "${root}" \ + \( -path "${root}/bazel-*" -o -path "${root}/external" -o -path "${root}/third_party" \) \ + -prune -o \ + -type f \( -name '*.c' -o -name '*.h' \) -print0 +) + +if (( failed )); then + cat >&2 <<'EOF' +First-party C must use Dowa_Arena for scoped allocation. +Do not add malloc/calloc/realloc/free. Refactor existing ownership or, for a +genuine allocator/runtime boundary, document the exception and deliberately +update the reviewed baseline. +EOF + exit 1 +fi
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/wiki/README.md Mon Aug 17 22:22:36 2026 -0700 @@ -0,0 +1,302 @@ +--- +title: Zenbu repository wiki +status: canonical +audience: + - humans + - AI agents +last_reviewed: 2026-08-17 +--- + +# Zenbu repository wiki + +This page is the monorepo knowledge router. It explains what Zenbu contains and +directs humans and agents to the smallest canonical documentation set needed +for a task. + +## Reading protocol + +```text +repository index -> relevant project wiki -> BUILD graph -> source and tests +``` + +Do not scan every project document by default. Choose documentation from the +routing table below. For cross-project work, inspect Bazel dependencies first, +then read only the wikis owned by actual producers and consumers. + +## Documentation routing + +| Task paths or topic | Canonical documentation | +| --- | --- | +| `connectors/`, Google OAuth, Drive, Gmail, AI context tools | [`connectors/wiki/README.md`](../connectors/wiki/README.md) | +| `design_system/`, shared components, tokens, HTML authoring | [`design_system/wiki/README.md`](../design_system/wiki/README.md) | +| `seobeo/`, HTTP, TLS, servers, clients, SSE, WebSockets | [`seobeo/README.md`](../seobeo/README.md), then only the relevant page in [`seobeo/docs/`](../seobeo/docs/) | +| `mrjunejune/`, personal site, auth UI, conversations, media | [`mrjunejune/README.md`](../mrjunejune/README.md) | +| `hg-web/`, Mercurial repository browser | [`hg-web/README.md`](../hg-web/README.md) | +| `dowa/`, arenas, strings, arrays, hash maps, math | [`dowa/README.md`](../dowa/README.md) | +| `deita/`, SQLite wrapper | [`deita/README.md`](../deita/README.md) | +| `s3/`, presigned uploads | Source header and `BUILD`; no dedicated wiki yet | +| `markdown_converter/` | Package `BUILD`, public headers, and tests | +| `gui_ze/`, asset bundling macros | [`gui_ze/wiki/README.md`](../gui_ze/wiki/README.md) | +| Experiments and standalone prototypes | The package README when present, then its `BUILD` | + +## Monorepo map + +### Shared foundations + +- `dowa`: core C types, arena allocation, strings, dynamic arrays/hash maps, + and math. +- `deita`: SQLite connection, prepared query, and result-set wrapper. +- `seobeo`: networking, HTTP client/server, TLS, SSE, and WebSockets. +- `auth`: Zenbu users, sessions, cryptography, and reusable HTTP auth. +- `config`: Bazel platform configuration. + +### Services and applications + +- `connectors`: Google data connector service and AI tool boundary. +- `mrjunejune`: personal website and production server. +- `hg-web`: Mercurial repository browser. +- `npc`: MCP-oriented C server experiment. +- `dictation`, `schwab_trader`, `infinite_canvas`, and other package roots: + focused applications or prototypes. + +### Web and asset systems + +- `design_system`: shared light-DOM Web Components and design tokens. +- `gui_ze`: Bazel macros for asset copying, bundling, and transformations. +- `assets`: shared icons and other reusable assets. +- `markdown_converter`: C/WASM markdown conversion. +- `rich_editor` and `react_games`: browser bundles consumed by applications. + +## Build and workflow + +Zenbu is a Mercurial repository. Use: + +```sh +hg status +hg diff +``` + +### Bazel-only rule + +Zenbu is Bazel-only. Bazel is not just the C build tool; it is the repository's +execution and dependency boundary. + +Use Bazel for: + +- C/C++ libraries, binaries, and tests; +- shell and Python entry points; +- Node/Bun-driven frontend generation; +- WASM compilation; +- asset copying, image conversion, and bundles; +- downloaded tools, browser runtimes, and model runtimes; +- integration and end-to-end tests. + +Do not add or rely on: + +- Makefiles or direct `make` workflows; +- CMake project files; +- manual `cc`, `clang`, or `gcc` build commands; +- `npm run`, `bun run`, or `pip install` as the normal repository interface; +- undocumented system packages or `/usr/local` libraries; +- scripts that bypass Bazel runfiles and declared `data`. + +An upstream ecosystem command may run inside a Bazel rule or a narrowly scoped +maintenance workflow, but the committed user/agent interface must be a Bazel +target. + +Build and test from the repository root: + +```sh +bazel build //seobeo:seobeo +bazel test //seobeo/tests:seobeo_http_framing_test + +bazel build //mrjunejune:mrjunejune_server +bazel test //mrjunejune/test:integration_test + +bazel build //connectors:connector_server +bazel test //connectors:connector_tests + +bazel build //hg-web:hg_web_server +``` + +Inspect a package's `BUILD` file before broad searches. Keep dependencies on the +smallest target that needs them. + +## Package selection guide + +Choose an existing first-party package before writing a new helper or directly +depending on third-party code. + +| Need | Use | Typical Bazel label | Do not substitute by default | +| --- | --- | --- | --- | +| Integer aliases, booleans, arenas, strings, arrays, hash maps, JSON helpers, math | `dowa` | `//dowa:dowa` | libc allocation scattered through request code, a second containers library | +| SQLite connections and prepared queries | `deita` | `//deita:deita` | direct `sqlite3_*` calls in application packages | +| TCP/TLS primitives without HTTP | `seobeo_min` | `//seobeo:seobeo_min` | raw sockets/OpenSSL setup duplicated in applications | +| HTTP server without WebSockets | `seobeo_tcp_server` | `//seobeo:seobeo_tcp_server` | another embedded HTTP server | +| HTTP server with WebSockets | `seobeo_tcp_server_ws` | `//seobeo:seobeo_tcp_server_ws` | direct WebSocket framing | +| Outbound HTTP/TLS only | `seobeo_tcp_client` or compatibility alias `seobeo_client` | `//seobeo:seobeo_tcp_client` | libcurl subprocesses or hand-built HTTP clients | +| Full HTTP/TLS/WebSocket stack | `seobeo` | `//seobeo:seobeo` | depending on every network source manually | +| Verbose Seobeo diagnostics | `seobeo_debug` | `//seobeo:seobeo_debug` | permanent `printf` debugging | +| User, password, session, cookie, CSRF, and auth crypto | `auth` | `//auth:auth`, `//auth:auth_http` | creating another login/session store | +| Google OAuth, Drive, Gmail, encrypted provider credentials, AI connector tools | `connectors` | `//connectors:connector_core`, `//connectors:connector_service_lib` | provider token logic inside unrelated applications | +| S3 signing and presigned uploads | `s3` | `//s3:s3` | ad-hoc AWS signature code | +| Native and WASM markdown conversion | `markdown_converter` | `//markdown_converter:markdown_to_html_c`, `//markdown_converter:markdown_to_html_wasm` | a second markdown pipeline | +| Shared Web Components, tokens, and UI primitives | `design_system` | package targets in `//design_system` | application-local copies of shared controls | +| Shared icons and generated image assets | `assets` | `//assets:icons` | copying asset files between applications | +| Web asset moves, bundles, WebP conversion, Bun actions, macOS bundles | `gui_ze` macros | `//gui_ze:gui_ze.bzl` | custom copy scripts and undeclared output folders | +| Shared rich text editor browser bundle | `rich_editor` | package targets in `//rich_editor` | forking editor code into an app | +| Personal site server and its production bundle | `mrjunejune` | `//mrjunejune:mrjunejune_server` | treating site-specific code as a generic library | +| Mercurial browser service | `hg-web` | `//hg-web:hg_web_server` | shelling out from unrelated UI code without its API boundary | +| MCP-style C HTTP server experiment | `npc` | `//npc:npc` | assuming it is a general connector framework | +| Raylib application rules | `third_party/raylib` macro plus first-party app package | `//third_party/raylib:raylib.bzl` | manual platform linker flags | +| Qwen/llama.cpp runtime orchestration | `qwen3_vl` | `//qwen3_vl:model`, `//qwen3_vl:serve` | untracked local model commands | + +### Package boundary rules + +- Put reusable behavior in the narrowest shared package that owns the concept. +- Applications may compose libraries; libraries must not depend on applications. +- Avoid circular ownership such as `dowa` knowing about Seobeo or a site. +- Expose public headers through the owning library target. +- Add deps to the smallest target that compiles the source using them. +- Use platform-aware aliases already provided by packages such as Seobeo and S3. +- Search for an existing helper or macro before creating a sibling implementation. + +## Third-party dependency policy + +Third-party code enters Zenbu through one of two Bazel-controlled paths: + +1. A pinned Bzlmod dependency or repository rule in `MODULE.bazel`. +2. A vendored wrapper under `third_party/<name>/BUILD`. + +Applications should depend on public Bazel labels, not include vendored source +paths or reproduce platform flags. + +### Prefer first-party wrappers + +- Use Deita instead of direct SQLite APIs unless changing Deita itself. +- Use Seobeo instead of raw sockets, OpenSSL transport code, or curl processes. +- Use the S3 package instead of implementing AWS signing in an application. +- Use `gui_ze` macros instead of manual generated-asset copying. +- Use `markdown_converter` rather than adding another markdown dependency. +- Use `auth` for Zenbu identity; external OAuth credentials belong in + `connectors`, not a parallel user system. + +### Adding an external dependency + +Before adding one: + +1. Search first-party packages and existing `third_party/` wrappers. +2. Confirm the behavior cannot be implemented safely with an existing library. +3. Add the dependency through `MODULE.bazel` when Bzlmod support is suitable; + otherwise add a focused `third_party/<name>/BUILD` wrapper. +4. Pin versions and SHA-256 hashes for downloaded archives/files. +5. Preserve license files and upstream notices. +6. Expose only the smallest useful Bazel target and visibility. +7. Keep platform selection inside the wrapper, not every consumer. +8. Add or update lockfiles when the ecosystem requires them. +9. Document why the dependency exists and which first-party package owns its + abstraction boundary. + +Do not fetch executable code at ordinary runtime when it can be a declared +Bazel dependency or data artifact. + +### Existing external systems + +| External system | Bazel integration and intended use | +| --- | --- | +| OpenSSL | Bzlmod `@openssl`; TLS and crypto, normally behind Seobeo/auth/connectors | +| SQLite | `//third_party/sqlite3`; normally consumed through Deita | +| Emscripten | `@emsdk` local override; WASM targets such as markdown conversion | +| Node/npm | rules_nodejs and aspect_rules_js; locked frontend/test dependencies | +| Python/pip | rules_python toolchain and package-specific locked hubs | +| Bun | pinned platform archives and `gui_ze` actions | +| Chromium/Playwright | pinned browser runtime for browser and integration tests | +| Tectonic | pinned runtime for LaTeX rendering | +| FFmpeg | `//third_party/ffmpeg` wrapper for media processing | +| Raylib | `//third_party/raylib` plus `raylib_binary` for native/web applications | +| libuv | `//third_party/libuv` for event-loop experiments such as Postdog | +| LuaJIT | `//third_party/luajit` for packages that explicitly declare it | +| Mercurial | wheel/runtime wrappers for hg-web tooling | +| CEF | repository rule under `third_party/cef` for the infinite-canvas native surface | +| llama.cpp/CUDA runtime | pinned archives exposed through `qwen3_vl` Bazel targets | + +## First-party conventions + +- Prefer Dowa integer and boolean aliases plus `TRUE`/`FALSE`. +- Allocation is arena-first. New first-party C code must not call raw + `malloc`, `calloc`, `realloc`, or `free`. +- Create a `Dowa_Arena` at the owner boundary, pass it into helpers, and use + `Dowa_Arena_Allocate`, arena string helpers, and the `_Arena` array/hash-map + macros. +- Free each locally owned arena exactly once with `Dowa_Arena_Free` on every + return path. Never individually free an arena-owned pointer. +- Borrowed request arenas are never freed by callees. +- Long-lived objects should own an arena whose lifetime matches the object. + A non-arena allocation is permitted only at a true allocator/runtime or + external-API ownership boundary, must be narrowly documented, and must use + the matching first-party/external destructor. It is not a convenience escape + hatch. +- Use Seobeo logging and response-map conventions in Seobeo services. +- Keep runtime secrets in one ignored, documented service config file. +- Commit placeholder templates only; do not commit real credentials. +- Preserve platform-aware Bazel aliases and precise dependencies. + +### Arena enforcement + +Run: + +```sh +bazel test //:arena_policy_test +``` + +The policy target fails when: + +- a new first-party C/H file introduces raw allocation calls; or +- a legacy file increases its reviewed raw-allocation baseline. + +The baseline is technical debt, not permission to add more allocation in those +files. Changes should reduce it. Updating the baseline upward requires an +explicitly documented allocator/runtime boundary and review. + +Typical ownership: + +```c +Dowa_Arena *p_arena = Dowa_Arena_Create(64 * 1024); +if (!p_arena) + return FALSE; + +char *copy = Dowa_Arena_Allocate(p_arena, length + 1); +if (!copy) +{ + Dowa_Arena_Free(p_arena); + return FALSE; +} + +/* All arena-owned values die together. */ +Dowa_Arena_Free(p_arena); +``` + +## Wiki model + +The repository index is the schema and router. Project wikis are synthesized, +canonical knowledge for a bounded domain. Source code, `BUILD` files, tests, +and committed config templates are evidence. + +When a task changes knowledge: + +1. Update the owning project wiki. +2. Update this routing page only if ownership, package boundaries, or canonical + documentation locations changed. +3. Do not create one-off top-level notes or duplicate normative instructions. +4. Split a project wiki only when its index becomes hard to navigate; every new + page must be linked from the project wiki. + +## Current wiki coverage + +| Domain | State | +| --- | --- | +| Repository map and agent routing | Canonical | +| Connectors and AI retrieval | Canonical | +| Design system | Canonical multi-page wiki | +| Seobeo | README plus focused protocol docs | +| Other packages | Existing README or source-first; promote to a wiki when the domain accumulates durable operational knowledge |