-
-
+
+
+
+
+
+
+
+
+ {{ profile.bio }}
+
+
+
+
+ {{ (profile as any).location }}
+
+
+
+ {{ (profile as any).pronouns }}
+
+
+
+
+
+
+
+ {{ stat.value }}
+ {{ stat.label }}
+
- Sign in to Tangled
-
- Use your AT Protocol handle to sign in and access your starred repos, follow developers, and get a
- personalized activity feed.
-
-
-
-
- Sign in with AT Protocol
-
-
-
- Don't have a handle?
- Get one at bsky.app
-
-
+
+ Repos
+ Strings
+ Issues
+ PRs
+ Following
+
+
+
+
+ Pinned
+
+
+
+ Repositories
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Switch Account
+
+
+ {{ accountDid }}
+
+
+
+
+
+
+
+
+
+
+
+
Sign in to Tangled
+
+ Use your AT Protocol handle to sign in and access your starred repos, follow developers, and get a
+ personalized activity feed.
+
+
+
+
+ Sign in with AT Protocol
+
+
+
+ Don't have a handle?
+ Get one at bsky.app
+
+
+
diff --git a/apps/twisted/src/main.ts b/apps/twisted/src/main.ts
index 67d6d94..602ad61 100644
--- a/apps/twisted/src/main.ts
+++ b/apps/twisted/src/main.ts
@@ -9,6 +9,7 @@ import { queryClient } from "./core/query/client.js";
import { persistQueryClient } from "@tanstack/query-persist-client-core";
import { createIdbPersister } from "./core/query/persister.js";
import { initializeThemePreference } from "./core/theme/preferences.js";
+import { useAuthStore } from "./core/auth/store.js";
import "@ionic/vue/css/core.css";
import "@ionic/vue/css/normalize.css";
@@ -42,8 +43,13 @@ if (import.meta.env.DEV) {
persistQueryClient({ queryClient: queryClient as any, persister: createIdbPersister(), maxAge: 30 * 60 * 1000 });
}
-const app = createApp(App).use(IonicVue).use(router).use(createPinia()).use(VueQueryPlugin, { queryClient });
+const pinia = createPinia();
+const app = createApp(App).use(IonicVue).use(router).use(pinia).use(VueQueryPlugin, { queryClient });
-router.isReady().then(() => {
+const authStore = useAuthStore(pinia);
+authStore.initialize();
+
+router.isReady().then(async () => {
+ await authStore.restoreSession();
app.mount("#app");
});
diff --git a/apps/twisted/vite.config.ts b/apps/twisted/vite.config.ts
index 1f3a26e..0e3d34c 100644
--- a/apps/twisted/vite.config.ts
+++ b/apps/twisted/vite.config.ts
@@ -9,5 +9,6 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [vue(), legacy()],
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
+ server: { host: "127.0.0.1", port: 5173 },
test: { globals: true, environment: "jsdom", watch: false, ui: false },
});
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 8ca520e..4d323eb 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -67,11 +67,11 @@ Bluesky OAuth and authenticated actions.
**Depends on:** App: Search & Discovery (for Constellation service), API: Constellation Integration
-- [ ] OAuth setup with `@atcute/oauth-browser-client`
-- [ ] Login page, OAuth flow, callback handling
-- [ ] Capacitor deep link configuration
-- [ ] Session management (restore, refresh, logout, account switcher)
-- [ ] Auth-aware XRPC client using dpopFetch
+- [x] OAuth setup with `@atcute/oauth-browser-client`
+- [x] Login page, OAuth flow, callback handling
+- [x] Capacitor deep link configuration
+- [x] Session management (restore, refresh, logout, account switcher)
+- [x] Auth-aware XRPC client using dpopFetch
- [ ] Star repos (write to PDS, count from Constellation)
- [ ] Follow users (write to PDS, count from Constellation)
- [ ] React to content (write to PDS, count from Constellation)
diff --git a/packages/api/.env.example b/packages/api/.env.example
index 7447a37..cd33e11 100644
--- a/packages/api/.env.example
+++ b/packages/api/.env.example
@@ -25,3 +25,8 @@ LOG_LEVEL=info
LOG_FORMAT=json
ENABLE_ADMIN_ENDPOINTS=false
# ADMIN_AUTH_TOKEN=
+
+# OAuth client configuration for the Twisted mobile app
+# VITE_OAUTH_CLIENT_ID must be a publicly accessible URL (use a tunnel for local dev)
+# OAUTH_CLIENT_ID=https://your-tunnel.example.com/oauth/client-metadata.json
+# OAUTH_REDIRECT_URIS=http://127.0.0.1:5173/oauth-callback,io.ionic.starter://oauth-callback
diff --git a/packages/api/internal/api/api.go b/packages/api/internal/api/api.go
index 9328588..1b22706 100644
--- a/packages/api/internal/api/api.go
+++ b/packages/api/internal/api/api.go
@@ -48,6 +48,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /healthz", s.handleHealthz)
mux.HandleFunc("GET /readyz", s.handleReadyz)
+ mux.HandleFunc("GET /oauth/client-metadata.json", s.handleOAuthClientMetadata)
mux.HandleFunc("GET /search", s.handleSearch)
mux.HandleFunc("GET /search/keyword", s.handleSearchKeyword)
mux.HandleFunc("GET /search/semantic", s.handleNotImplemented)
diff --git a/packages/api/internal/api/oauth.go b/packages/api/internal/api/oauth.go
new file mode 100644
index 0000000..116ff46
--- /dev/null
+++ b/packages/api/internal/api/oauth.go
@@ -0,0 +1,48 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+)
+
+type OAuthClientMetadata struct {
+ ClientID string `json:"client_id"`
+ ClientName string `json:"client_name"`
+ ClientURI string `json:"client_uri,omitempty"`
+ LogoURI string `json:"logo_uri,omitempty"`
+ TosURI string `json:"tos_uri,omitempty"`
+ PolicyURI string `json:"policy_uri,omitempty"`
+ RedirectURIs []string `json:"redirect_uris"`
+ Scope string `json:"scope"`
+ GrantTypes []string `json:"grant_types"`
+ ResponseTypes []string `json:"response_types"`
+ ApplicationType string `json:"application_type"`
+ DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
+ TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
+ DpopSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"`
+}
+
+func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) {
+ if s.cfg.OAuthClientID == "" {
+ writeJSON(w, http.StatusNotFound, errorBody("not_configured", "OAuth is not configured on this server"))
+ return
+ }
+
+ metadata := OAuthClientMetadata{
+ ClientID: s.cfg.OAuthClientID,
+ ClientName: "Twisted",
+ ClientURI: s.cfg.OAuthClientID,
+ RedirectURIs: s.cfg.OAuthRedirectURIs,
+ Scope: "atproto",
+ GrantTypes: []string{"authorization_code", "refresh_token"},
+ ResponseTypes: []string{"code"},
+ ApplicationType: "native",
+ DpopBoundAccessTokens: true,
+ TokenEndpointAuthMethod: "none",
+ DpopSigningAlgValuesSupported: []string{"ES256"},
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ _ = json.NewEncoder(w).Encode(metadata)
+}
diff --git a/packages/api/internal/config/config.go b/packages/api/internal/config/config.go
index d4dfb66..7c20947 100644
--- a/packages/api/internal/config/config.go
+++ b/packages/api/internal/config/config.go
@@ -12,27 +12,27 @@ import (
)
type Config struct {
- TursoURL string
- TursoToken string
- TapURL string
- TapAuthPassword string
- IndexedCollections string
- SearchDefaultLimit int
- SearchMaxLimit int
- SearchDefaultMode string
- EmbeddingProvider string
- EmbeddingModel string
- EmbeddingAPIKey string
- EmbeddingAPIURL string
- EmbeddingDim int
- EmbeddingBatchSize int
- HybridKeywordWeight float64
- HybridSemanticWeight float64
- HTTPBindAddr string
- IndexerHealthAddr string
- LogLevel string
- LogFormat string
- EnableAdminEndpoints bool
+ TursoURL string
+ TursoToken string
+ TapURL string
+ TapAuthPassword string
+ IndexedCollections string
+ SearchDefaultLimit int
+ SearchMaxLimit int
+ SearchDefaultMode string
+ EmbeddingProvider string
+ EmbeddingModel string
+ EmbeddingAPIKey string
+ EmbeddingAPIURL string
+ EmbeddingDim int
+ EmbeddingBatchSize int
+ HybridKeywordWeight float64
+ HybridSemanticWeight float64
+ HTTPBindAddr string
+ IndexerHealthAddr string
+ LogLevel string
+ LogFormat string
+ EnableAdminEndpoints bool
AdminAuthToken string
EnableIngestEnrichment bool
PLCDirectoryURL string
@@ -42,6 +42,8 @@ type Config struct {
ConstellationUserAgent string
ConstellationTimeout time.Duration
ConstellationCacheTTL time.Duration
+ OAuthClientID string
+ OAuthRedirectURIs []string
}
type LoadOptions struct {
@@ -53,28 +55,28 @@ func Load(opts LoadOptions) (*Config, error) {
loadDotEnv()
cfg := &Config{
- TursoURL: os.Getenv("TURSO_DATABASE_URL"),
- TursoToken: os.Getenv("TURSO_AUTH_TOKEN"),
- TapURL: os.Getenv("TAP_URL"),
- TapAuthPassword: os.Getenv("TAP_AUTH_PASSWORD"),
- IndexedCollections: os.Getenv("INDEXED_COLLECTIONS"),
- SearchDefaultMode: envOrDefault("SEARCH_DEFAULT_MODE", "keyword"),
- EmbeddingProvider: os.Getenv("EMBEDDING_PROVIDER"),
- EmbeddingModel: os.Getenv("EMBEDDING_MODEL"),
- EmbeddingAPIKey: os.Getenv("EMBEDDING_API_KEY"),
- EmbeddingAPIURL: os.Getenv("EMBEDDING_API_URL"),
- HTTPBindAddr: envOrDefault("HTTP_BIND_ADDR", ":8080"),
- IndexerHealthAddr: envOrDefault("INDEXER_HEALTH_ADDR", ":9090"),
- LogLevel: envOrDefault("LOG_LEVEL", "info"),
- LogFormat: envOrDefault("LOG_FORMAT", "json"),
- AdminAuthToken: os.Getenv("ADMIN_AUTH_TOKEN"),
- SearchDefaultLimit: envInt("SEARCH_DEFAULT_LIMIT", 20),
- SearchMaxLimit: envInt("SEARCH_MAX_LIMIT", 100),
- EmbeddingDim: envInt("EMBEDDING_DIM", 768),
- EmbeddingBatchSize: envInt("EMBEDDING_BATCH_SIZE", 32),
- HybridKeywordWeight: envFloat("HYBRID_KEYWORD_WEIGHT", 0.65),
- HybridSemanticWeight: envFloat("HYBRID_SEMANTIC_WEIGHT", 0.35),
- EnableAdminEndpoints: envBool("ENABLE_ADMIN_ENDPOINTS", false),
+ TursoURL: os.Getenv("TURSO_DATABASE_URL"),
+ TursoToken: os.Getenv("TURSO_AUTH_TOKEN"),
+ TapURL: os.Getenv("TAP_URL"),
+ TapAuthPassword: os.Getenv("TAP_AUTH_PASSWORD"),
+ IndexedCollections: os.Getenv("INDEXED_COLLECTIONS"),
+ SearchDefaultMode: envOrDefault("SEARCH_DEFAULT_MODE", "keyword"),
+ EmbeddingProvider: os.Getenv("EMBEDDING_PROVIDER"),
+ EmbeddingModel: os.Getenv("EMBEDDING_MODEL"),
+ EmbeddingAPIKey: os.Getenv("EMBEDDING_API_KEY"),
+ EmbeddingAPIURL: os.Getenv("EMBEDDING_API_URL"),
+ HTTPBindAddr: envOrDefault("HTTP_BIND_ADDR", ":8080"),
+ IndexerHealthAddr: envOrDefault("INDEXER_HEALTH_ADDR", ":9090"),
+ LogLevel: envOrDefault("LOG_LEVEL", "info"),
+ LogFormat: envOrDefault("LOG_FORMAT", "json"),
+ AdminAuthToken: os.Getenv("ADMIN_AUTH_TOKEN"),
+ SearchDefaultLimit: envInt("SEARCH_DEFAULT_LIMIT", 20),
+ SearchMaxLimit: envInt("SEARCH_MAX_LIMIT", 100),
+ EmbeddingDim: envInt("EMBEDDING_DIM", 768),
+ EmbeddingBatchSize: envInt("EMBEDDING_BATCH_SIZE", 32),
+ HybridKeywordWeight: envFloat("HYBRID_KEYWORD_WEIGHT", 0.65),
+ HybridSemanticWeight: envFloat("HYBRID_SEMANTIC_WEIGHT", 0.35),
+ EnableAdminEndpoints: envBool("ENABLE_ADMIN_ENDPOINTS", false),
EnableIngestEnrichment: envBool("ENABLE_INGEST_ENRICHMENT", true),
PLCDirectoryURL: envOrDefault("PLC_DIRECTORY_URL", "https://plc.directory"),
IdentityServiceURL: envOrDefault("IDENTITY_SERVICE_URL", "https://public.api.bsky.app"),
@@ -83,6 +85,8 @@ func Load(opts LoadOptions) (*Config, error) {
ConstellationUserAgent: envOrDefault("CONSTELLATION_USER_AGENT", "twister/1.0 (https://tangled.sh; Owais
)"),
ConstellationTimeout: envDuration("CONSTELLATION_TIMEOUT", 10*time.Second),
ConstellationCacheTTL: envDuration("CONSTELLATION_CACHE_TTL", 5*time.Minute),
+ OAuthClientID: os.Getenv("OAUTH_CLIENT_ID"),
+ OAuthRedirectURIs: envSlice("OAUTH_REDIRECT_URIS", nil),
}
if opts.Local {
@@ -200,3 +204,11 @@ func envBool(key string, def bool) bool {
}
return b
}
+
+func envSlice(key string, def []string) []string {
+ v := os.Getenv(key)
+ if v == "" {
+ return def
+ }
+ return strings.Split(v, ",")
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2b1dddc..2e7db7b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -16,6 +16,12 @@ importers:
'@atcute/client':
specifier: ^4.2.1
version: 4.2.1
+ '@atcute/identity-resolver':
+ specifier: ^1.2.2
+ version: 1.2.2(@atcute/identity@1.1.4)
+ '@atcute/lexicons':
+ specifier: ^1.2.9
+ version: 1.2.9
'@atcute/oauth-browser-client':
specifier: ^3.0.0
version: 3.0.0(@atcute/identity@1.1.4)