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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 }}

+
+
+
+ +

Saved content

+
+
+ +

+ Dev builds keep Profile for auth flows, so saved repos, strings, files, and READMEs live on a separate page. +

+ + Open bookmarks +
+ +
+
+
+ +

Auth features

+
+
+ + + +
Enable auth UI
+

+ Turn this off to force the app into bookmark-only mode locally without changing your build config. +

+
+ +
+
+
@@ -84,6 +118,7 @@