diff --git a/.gitignore b/.gitignore
index 889f125..5a4b7b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,4 +39,5 @@ www
*.db-wal
.env
+apps/twisted/.env.local
__pycache__/
diff --git a/README.md b/README.md
index 4762676..78211f1 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,8 @@ just test
just api-run-api
```
-To enable indexed search in the client, set `VITE_TWISTER_API_BASE_URL` in `apps/twisted/.env`.
+The committed `apps/twisted/.env` points at production. Use `apps/twisted/.env.local`
+for machine-local overrides such as a localhost API or OAuth callback.
## Run Locally
@@ -94,12 +95,15 @@ Typical local setup is three terminals:
2. `pnpm api:run:api`
3. `pnpm api:run:indexer`
-If you want the app to call the local API, set this in `apps/twisted/.env`:
+If you want the app to call the local API, put this in `apps/twisted/.env.local`:
```bash
VITE_TWISTER_API_BASE_URL=http://localhost:8080
```
+Dev builds keep the current OAuth flow available. Production builds are read-only
+and hide auth entry points for now.
+
### Local API DB
The experimental local API database lives at `packages/api/twister-dev.db`.
diff --git a/apps/twisted/.env.example b/apps/twisted/.env.example
index ad64629..2c993f2 100644
--- a/apps/twisted/.env.example
+++ b/apps/twisted/.env.example
@@ -1,6 +1,7 @@
-VITE_TWISTER_API_BASE_URL=http://127.0.0.1:8080
+VITE_TWISTER_API_BASE_URL=https://twister.stormlightlabs.org
# OAuth configuration
-# VITE_OAUTH_CLIENT_ID must be a publicly accessible URL (use a tunnel for local dev)
-# VITE_OAUTH_CLIENT_ID=https://your-tunnel.example.com/oauth/client-metadata.json
-VITE_OAUTH_REDIRECT_URI=http://127.0.0.1:5173/oauth-callback
+# Auth is currently exposed in dev builds only. Override these in .env.local
+# if you need local OAuth testing against localhost.
+VITE_OAUTH_CLIENT_ID=https://twister.stormlightlabs.org/oauth/client-metadata.json
+VITE_OAUTH_REDIRECT_URI=https://twister.stormlightlabs.org/oauth-callback
diff --git a/apps/twisted/README.md b/apps/twisted/README.md
index 8e7a36c..4ddef07 100644
--- a/apps/twisted/README.md
+++ b/apps/twisted/README.md
@@ -6,7 +6,7 @@ Ionic Vue client for Twisted — a Tangled browser and search app for Android &
- Node.js 20+
- pnpm
-- The Twister API running locally
+- The Twister API running locally if you want local API overrides
## Running locally
@@ -14,8 +14,8 @@ Ionic Vue client for Twisted — a Tangled browser and search app for Android &
# From the repo root, install dependencies
pnpm install
-# Copy env file and point it at your local Twister API
-cp apps/twisted/.env.example apps/twisted/.env
+# Local overrides live in .env.local
+cp apps/twisted/.env.example apps/twisted/.env.local
# Start the Vite dev server
cd apps/twisted
@@ -26,9 +26,21 @@ The dev server runs at `http://localhost:5173` by default.
## Environment variables
-| Variable | Default | Description |
-| --------------------------- | ----------------------- | --------------------------------------------------------------------------------------------------------------- |
-| `VITE_TWISTER_API_BASE_URL` | `http://localhost:8080` | Base URL of the Twister API. All app requests (AT Protocol, Jetstream, Constellation) are proxied through this. |
+Committed `.env` defaults point at production:
+
+- `VITE_TWISTER_API_BASE_URL=https://twister.stormlightlabs.org`
+- `VITE_OAUTH_CLIENT_ID=https://twister.stormlightlabs.org/oauth/client-metadata.json`
+- `VITE_OAUTH_REDIRECT_URI=https://twister.stormlightlabs.org/oauth-callback`
+
+Use `.env.local` for local overrides:
+
+| Variable | Local example | Description |
+| --------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| `VITE_TWISTER_API_BASE_URL` | `http://localhost:8080` | Base URL of the Twister API. All app requests (AT Protocol, Jetstream, Constellation) are proxied through this. |
+| `VITE_OAUTH_CLIENT_ID` | `http://127.0.0.1:8080/oauth/client-metadata.json` | Public OAuth metadata URL for local dev auth flows. |
+| `VITE_OAUTH_REDIRECT_URI` | `http://127.0.0.1:5173/oauth-callback` | Redirect URI used by the local Vite app during OAuth testing. |
+
+Production builds hide auth entry points for now. Dev builds keep OAuth enabled.
> All upstream requests — knot XRPC, PDS records, handle resolution, DID documents,
> Constellation backlink counts, and the Jetstream activity stream — are routed
diff --git a/apps/twisted/src/app/router/index.ts b/apps/twisted/src/app/router/index.ts
index d0c8a06..d26b640 100644
--- a/apps/twisted/src/app/router/index.ts
+++ b/apps/twisted/src/app/router/index.ts
@@ -1,12 +1,16 @@
import { createRouter, createWebHistory } from "@ionic/vue-router";
import type { RouteRecordRaw } from "vue-router";
-
import TabsPage from "@/views/TabsPage.vue";
+import { getIsDevAuthEnabled } from "@/core/auth/dev-access.ts";
const routes: RouteRecordRaw[] = [
{ path: "/", redirect: "/tabs/home" },
- { path: "/login", component: () => import("@/features/auth/LoginPage.vue") },
- { path: "/oauth-callback", component: () => import("@/features/auth/OAuthCallbackPage.vue") },
+ { path: "/login", component: () => import("@/features/auth/LoginPage.vue"), meta: { requiresDevAuth: true } },
+ {
+ path: "/oauth-callback",
+ component: () => import("@/features/auth/OAuthCallbackPage.vue"),
+ meta: { requiresDevAuth: true },
+ },
{
path: "/tabs/",
component: TabsPage,
@@ -45,7 +49,13 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/features/repo/PullRequestDetailPage.vue"),
},
{ path: "activity/user/:handle", component: () => import("@/features/profile/UserProfilePage.vue") },
- { path: "profile", component: () => import("@/features/profile/ProfilePage.vue") },
+ {
+ path: "profile",
+ component: () => import("@/features/profile/ProfilePage.vue"),
+ meta: { requiresDevAuth: true },
+ },
+ { path: "bookmarks", component: () => import("@/features/bookmarks/BookmarksPage.vue") },
+ { path: "bookmarks/:bookmarkId", component: () => import("@/features/bookmarks/BookmarkDetailPage.vue") },
{ path: "profile/settings", redirect: "/tabs/settings" },
{ path: "settings", component: () => import("@/features/profile/SettingsPage.vue") },
],
@@ -54,4 +64,11 @@ const routes: RouteRecordRaw[] = [
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes });
+router.beforeEach((to) => {
+ if (to.meta.requiresDevAuth && !getIsDevAuthEnabled()) {
+ return "/tabs/bookmarks";
+ }
+ return true;
+});
+
export default router;
diff --git a/apps/twisted/src/components/bookmarks/StoredContentView.vue b/apps/twisted/src/components/bookmarks/StoredContentView.vue
new file mode 100644
index 0000000..e79af20
--- /dev/null
+++ b/apps/twisted/src/components/bookmarks/StoredContentView.vue
@@ -0,0 +1,112 @@
+
+
+
![]()
+
+
+
+
+
+ {{ textContent }}
+ This saved item has no viewable content.
+
+
+
+
+
diff --git a/apps/twisted/src/core/auth/dev-access.ts b/apps/twisted/src/core/auth/dev-access.ts
new file mode 100644
index 0000000..7faf4f9
--- /dev/null
+++ b/apps/twisted/src/core/auth/dev-access.ts
@@ -0,0 +1,36 @@
+import { readonly, ref } from "vue";
+import { isDevBuild } from "@/core/config/app.ts";
+
+const STORAGE_KEY = "twisted-dev-auth-enabled";
+
+const devAuthEnabled = ref(readStoredDevAuthEnabled());
+
+export function useDevAuthFeatures() {
+ return {
+ canToggleDevAuth: isDevBuild,
+ isDevAuthEnabled: readonly(devAuthEnabled),
+ setDevAuthEnabled,
+ };
+}
+
+export function getIsDevAuthEnabled(): boolean {
+ return devAuthEnabled.value;
+}
+
+export function setDevAuthEnabled(value: boolean): void {
+ if (!isDevBuild) return;
+
+ devAuthEnabled.value = value;
+
+ if (typeof window !== "undefined") {
+ window.localStorage.setItem(STORAGE_KEY, value ? "true" : "false");
+ }
+}
+
+function readStoredDevAuthEnabled(): boolean {
+ if (!isDevBuild) return false;
+ if (typeof window === "undefined") return true;
+
+ const stored = window.localStorage.getItem(STORAGE_KEY);
+ return stored !== "false";
+}
diff --git a/apps/twisted/src/core/auth/oauth-config.ts b/apps/twisted/src/core/auth/oauth-config.ts
index e444607..6dcb4ce 100644
--- a/apps/twisted/src/core/auth/oauth-config.ts
+++ b/apps/twisted/src/core/auth/oauth-config.ts
@@ -8,15 +8,12 @@ import {
WellKnownHandleResolver,
DohJsonHandleResolver,
} from "@atcute/identity-resolver";
+import { oauthClientId, oauthRedirectUri } from "@/core/config/app.js";
const isNative = typeof window !== "undefined" && !window.location.origin.startsWith("http");
const baseUrl = isNative ? "io.ionic.starter://" : window.location.origin;
-const clientId =
- (!isNative && import.meta.env.VITE_OAUTH_CLIENT_ID?.trim()) ||
- `${baseUrl}/client-metadata.json`;
-const redirectUri =
- (!isNative && import.meta.env.VITE_OAUTH_REDIRECT_URI?.trim()) ||
- `${baseUrl}/oauth-callback`;
+const clientId = (!isNative && oauthClientId) || `${baseUrl}/client-metadata.json`;
+const redirectUri = (!isNative && oauthRedirectUri) || `${baseUrl}/oauth-callback`;
const didResolver = new CompositeDidDocumentResolver({
methods: { plc: new PlcDidDocumentResolver(), web: new WebDidDocumentResolver() },
diff --git a/apps/twisted/src/core/bookmarks/service.ts b/apps/twisted/src/core/bookmarks/service.ts
new file mode 100644
index 0000000..b8c2ce4
--- /dev/null
+++ b/apps/twisted/src/core/bookmarks/service.ts
@@ -0,0 +1,175 @@
+import { computed, readonly, shallowRef } from "vue";
+import { del, get, set } from "idb-keyval";
+import type { RepoSummary } from "@/domain/models/repo.js";
+import type { StringSummary } from "@/domain/models/string.js";
+import type { BlobContent } from "@/services/tangled/normalizers.js";
+import type { BookmarkItem, BookmarkKind, BookmarkedRepo, SavedFile, SavedString } from "@/domain/models/bookmark.js";
+
+const BOOKMARKS_KEY = "twisted-bookmarks";
+
+type BookmarkStorage = { load(): Promise; save(items: BookmarkItem[]): Promise };
+
+const state = shallowRef([]);
+let loaded = false;
+let loadPromise: Promise | null = null;
+const defaultStorage: BookmarkStorage = {
+ async load() {
+ return ((await get(BOOKMARKS_KEY)) ?? []).map(normalizeBookmarkItem);
+ },
+ async save(items) {
+ if (items.length === 0) {
+ await del(BOOKMARKS_KEY);
+ return;
+ }
+ await set(BOOKMARKS_KEY, items);
+ },
+};
+let storage: BookmarkStorage = defaultStorage;
+
+function normalizeBookmarkItem(item: BookmarkItem): BookmarkItem {
+ return item;
+}
+
+function sortItems(items: BookmarkItem[]): BookmarkItem[] {
+ return [...items].sort((a, b) => b.savedAt.localeCompare(a.savedAt));
+}
+
+async function persist(items: BookmarkItem[]): Promise {
+ const next = sortItems(items);
+ state.value = next;
+ loaded = true;
+ await storage.save(next);
+ return next;
+}
+
+export async function ensureBookmarksLoaded(): Promise {
+ if (loaded) return state.value;
+ if (!loadPromise) {
+ loadPromise = storage.load().then((items) => {
+ state.value = sortItems(items);
+ loaded = true;
+ loadPromise = null;
+ return state.value;
+ });
+ }
+ return loadPromise;
+}
+
+export function useBookmarks() {
+ void ensureBookmarksLoaded();
+ return { bookmarks: readonly(state), hasBookmarks: computed(() => state.value.length > 0) };
+}
+
+export function buildRepoBookmarkId(atUri: string): string {
+ return `repo:${atUri}`;
+}
+
+export function buildStringBookmarkId(atUri: string): string {
+ return `string:${atUri}`;
+}
+
+export function buildFileBookmarkId(owner: string, repo: string, branch: string, path: string): string {
+ return `file:${owner}/${repo}/${branch}/${path}`;
+}
+
+export async function listBookmarks(kind?: BookmarkKind): Promise {
+ const items = await ensureBookmarksLoaded();
+ return kind ? items.filter((item) => item.kind === kind) : items;
+}
+
+export async function getBookmark(id: string): Promise {
+ return (await ensureBookmarksLoaded()).find((item) => item.id === id);
+}
+
+export async function isBookmarked(id: string): Promise {
+ return (await ensureBookmarksLoaded()).some((item) => item.id === id);
+}
+
+export function hasBookmark(id: string): boolean {
+ return state.value.some((item) => item.id === id);
+}
+
+export async function removeBookmark(id: string): Promise {
+ await persist((await ensureBookmarksLoaded()).filter((item) => item.id !== id));
+}
+
+export async function removeRepoBookmark(id: string): Promise {
+ await removeBookmark(id);
+}
+
+export async function saveRepoBookmark(repo: RepoSummary): Promise {
+ const now = new Date().toISOString();
+ const bookmark: BookmarkedRepo = {
+ id: buildRepoBookmarkId(repo.atUri),
+ kind: "repo",
+ title: `${repo.ownerHandle}/${repo.name}`,
+ ownerHandle: repo.ownerHandle,
+ repoName: repo.name,
+ atUri: repo.atUri,
+ savedAt: now,
+ lastFetchedAt: now,
+ repo,
+ };
+ await persist([bookmark, ...(await ensureBookmarksLoaded()).filter((item) => item.id !== bookmark.id)]);
+ return bookmark;
+}
+
+export async function saveStringBookmark(ownerHandle: string, stringItem: StringSummary): Promise {
+ const now = new Date().toISOString();
+ const bookmark: SavedString = {
+ id: buildStringBookmarkId(stringItem.atUri),
+ kind: "string",
+ title: stringItem.filename,
+ ownerHandle,
+ atUri: stringItem.atUri,
+ filename: stringItem.filename,
+ description: stringItem.description,
+ contents: stringItem.contents,
+ createdAt: stringItem.createdAt,
+ savedAt: now,
+ lastFetchedAt: now,
+ };
+ await persist([bookmark, ...(await ensureBookmarksLoaded()).filter((item) => item.id !== bookmark.id)]);
+ return bookmark;
+}
+
+export async function saveFileBookmark(file: SavedFileInput): Promise {
+ const now = new Date().toISOString();
+ const bookmark: SavedFile = { ...file, savedAt: now, lastFetchedAt: now };
+ await persist([bookmark, ...(await ensureBookmarksLoaded()).filter((item) => item.id !== bookmark.id)]);
+ return bookmark;
+}
+
+type SavedFileInput = Omit;
+
+export function createSavedFileInput(
+ ownerHandle: string,
+ repoName: string,
+ branch: string,
+ path: string,
+ blob: BlobContent,
+ sourceKind: "file" | "readme",
+): SavedFileInput {
+ return {
+ id: buildFileBookmarkId(ownerHandle, repoName, branch, path),
+ kind: "file",
+ title: path.split("/").pop() ?? path,
+ ownerHandle,
+ repoName,
+ branch,
+ path,
+ sourceKind,
+ content: blob.content,
+ encoding: blob.encoding,
+ isBinary: blob.isBinary,
+ mimeType: blob.mimeType,
+ size: blob.size,
+ };
+}
+
+export function resetBookmarkStateForTests(nextStorage?: BookmarkStorage): void {
+ state.value = [];
+ loaded = false;
+ loadPromise = null;
+ storage = nextStorage ?? defaultStorage;
+}
diff --git a/apps/twisted/src/core/config/app.ts b/apps/twisted/src/core/config/app.ts
new file mode 100644
index 0000000..000173a
--- /dev/null
+++ b/apps/twisted/src/core/config/app.ts
@@ -0,0 +1,46 @@
+type AppEnv = {
+ DEV: boolean;
+ VITE_TWISTER_API_BASE_URL?: string;
+ VITE_OAUTH_CLIENT_ID?: string;
+ VITE_OAUTH_REDIRECT_URI?: string;
+};
+
+type AppConfig = {
+ isDevBuild: boolean;
+ isProductionReadOnly: boolean;
+ twisterApiBaseUrl: string;
+ hasTwisterApi: boolean;
+ oauthClientId?: string;
+ oauthRedirectUri?: string;
+};
+
+const DEFAULT_API_BASE_URL = "https://twister.stormlightlabs.org";
+
+export function normalizeConfiguredUrl(value?: string): string | undefined {
+ const trimmed = value?.trim();
+ if (!trimmed) return undefined;
+ return trimmed.replace(/\/+$/, "");
+}
+
+export function createAppConfig(env: AppEnv): AppConfig {
+ const twisterApiBaseUrl = normalizeConfiguredUrl(env.VITE_TWISTER_API_BASE_URL) ?? DEFAULT_API_BASE_URL;
+ const oauthClientId = normalizeConfiguredUrl(env.VITE_OAUTH_CLIENT_ID);
+ const oauthRedirectUri = normalizeConfiguredUrl(env.VITE_OAUTH_REDIRECT_URI);
+
+ return {
+ isDevBuild: env.DEV,
+ isProductionReadOnly: !env.DEV,
+ twisterApiBaseUrl,
+ hasTwisterApi: twisterApiBaseUrl.length > 0,
+ oauthClientId,
+ oauthRedirectUri,
+ };
+}
+
+export const appConfig = createAppConfig(import.meta.env);
+export const isDevBuild = appConfig.isDevBuild;
+export const isProductionReadOnly = appConfig.isProductionReadOnly;
+export const twisterApiBaseUrl = appConfig.twisterApiBaseUrl;
+export const hasTwisterApi = appConfig.hasTwisterApi;
+export const oauthClientId = appConfig.oauthClientId;
+export const oauthRedirectUri = appConfig.oauthRedirectUri;
diff --git a/apps/twisted/src/core/config/project.ts b/apps/twisted/src/core/config/project.ts
index 7946709..ff341e8 100644
--- a/apps/twisted/src/core/config/project.ts
+++ b/apps/twisted/src/core/config/project.ts
@@ -1,7 +1,6 @@
-const rawTwisterApiBaseUrl = import.meta.env.VITE_TWISTER_API_BASE_URL?.trim() ?? "http://127.0.0.1:8080";
+import { hasTwisterApi, twisterApiBaseUrl } from "./app.ts";
-export const twisterApiBaseUrl = rawTwisterApiBaseUrl.replace(/\/+$/, "");
-export const hasTwisterApi = twisterApiBaseUrl.length > 0;
+export { hasTwisterApi, twisterApiBaseUrl };
export function getTwisterApiUrl(path: string): string {
if (!hasTwisterApi) {
diff --git a/apps/twisted/src/core/query/client.ts b/apps/twisted/src/core/query/client.ts
index 10762a2..210becc 100644
--- a/apps/twisted/src/core/query/client.ts
+++ b/apps/twisted/src/core/query/client.ts
@@ -1,6 +1,7 @@
import { QueryClient } from "@tanstack/vue-query";
+import { isDevBuild } from "@/core/config/app.ts";
-const isDev = import.meta.env.DEV;
+const isDev = isDevBuild;
export const queryClient = new QueryClient({
defaultOptions: {
diff --git a/apps/twisted/src/domain/models/bookmark.ts b/apps/twisted/src/domain/models/bookmark.ts
new file mode 100644
index 0000000..6cc4294
--- /dev/null
+++ b/apps/twisted/src/domain/models/bookmark.ts
@@ -0,0 +1,40 @@
+import type { RepoSummary } from "./repo.ts";
+
+export type BookmarkKind = "repo" | "string" | "file";
+
+type BookmarkBase = { id: string; kind: BookmarkKind; title: string; savedAt: string; lastFetchedAt: string };
+
+export type BookmarkedRepo = BookmarkBase & {
+ kind: "repo";
+ ownerHandle: string;
+ repoName: string;
+ atUri: string;
+ repo: RepoSummary;
+};
+
+export type SavedString = BookmarkBase & {
+ kind: "string";
+ ownerHandle: string;
+ atUri: string;
+ filename: string;
+ description?: string;
+ contents: string;
+ createdAt: string;
+};
+
+export type SavedFile = BookmarkBase & {
+ kind: "file";
+ ownerHandle: string;
+ repoName: string;
+ branch: string;
+ path: string;
+ sourceAtUri?: string;
+ sourceKind: "file" | "readme";
+ content: string;
+ encoding: "utf-8" | "base64";
+ isBinary: boolean;
+ mimeType?: string;
+ size?: number;
+};
+
+export type BookmarkItem = BookmarkedRepo | SavedString | SavedFile;
diff --git a/apps/twisted/src/features/bookmarks/BookmarkDetailPage.vue b/apps/twisted/src/features/bookmarks/BookmarkDetailPage.vue
new file mode 100644
index 0000000..de95950
--- /dev/null
+++ b/apps/twisted/src/features/bookmarks/BookmarkDetailPage.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+ {{ bookmark?.title || "Saved Item" }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/twisted/src/features/bookmarks/BookmarksPage.vue b/apps/twisted/src/features/bookmarks/BookmarksPage.vue
new file mode 100644
index 0000000..0aa2609
--- /dev/null
+++ b/apps/twisted/src/features/bookmarks/BookmarksPage.vue
@@ -0,0 +1,134 @@
+
+
+
+
+ Bookmarks
+
+
+
+
+
+
+ Bookmarks
+
+
+
+
+ Repos
+ Strings
+ Files
+
+
+
+
+
+
+
+ {{ item.title }}
+ {{ itemMeta(item) }}
+ {{ itemCopy(item) }}
+
+ Remove
+
+
+
+
+
+
+
+
+
diff --git a/apps/twisted/src/features/profile/ProfilePage.vue b/apps/twisted/src/features/profile/ProfilePage.vue
index ec80c0f..acdce55 100644
--- a/apps/twisted/src/features/profile/ProfilePage.vue
+++ b/apps/twisted/src/features/profile/ProfilePage.vue
@@ -4,6 +4,9 @@
{{ profile?.handle || "Profile" }}
+
+
+
@@ -25,7 +28,7 @@
- {{ initials(profile?.handle || '') }}
+ {{ initials(profile?.handle || "") }}
@@ -106,7 +109,11 @@
message="You haven't created any repositories yet." />
-
+
{{ themeHelperCopy }}
+
+
+
+
Bookmarks
+
Saved content
+
+
+
+
+ Dev builds keep Profile for auth flows, so saved repos, strings, files, and READMEs live on a separate page.
+
+
+ Open bookmarks
+
+
+
+
+
+
Local Dev
+
Auth features
+
+
+
+
+
+ Enable auth UI
+
+ Turn this off to force the app into bookmark-only mode locally without changing your build config.
+
+
+
+
+
+