+19
deno.json
+19
deno.json
···
1
+
{
2
+
"tasks": {
3
+
"start": "deno run -A --unstable-kv --env-file=.env src/main.ts",
4
+
"dev": "deno run -A --unstable-kv --env-file=.env --watch src/main.ts"
5
+
},
6
+
"compilerOptions": {
7
+
"jsx": "precompile",
8
+
"jsxImportSource": "preact"
9
+
},
10
+
"imports": {
11
+
"@slices/oauth": "jsr:@slices/oauth@^0.3.2",
12
+
"@slices/session": "jsr:@slices/session@^0.2.0",
13
+
"preact": "npm:preact@^10.27.1",
14
+
"preact-render-to-string": "npm:preact-render-to-string@^6.5.13",
15
+
"@std/http": "jsr:@std/http@^1.0.20",
16
+
"typed-htmx": "npm:typed-htmx@^0.3.1"
17
+
},
18
+
"nodeModulesDir": "auto"
19
+
}
+189
deno.lock
+189
deno.lock
···
1
+
{
2
+
"version": "5",
3
+
"specifiers": {
4
+
"jsr:@slices/oauth@~0.3.2": "0.3.2",
5
+
"jsr:@slices/session@0.2": "0.2.0",
6
+
"jsr:@std/cli@^1.0.21": "1.0.21",
7
+
"jsr:@std/encoding@^1.0.10": "1.0.10",
8
+
"jsr:@std/fmt@^1.0.8": "1.0.8",
9
+
"jsr:@std/fs@^1.0.19": "1.0.19",
10
+
"jsr:@std/html@^1.0.4": "1.0.4",
11
+
"jsr:@std/http@^1.0.20": "1.0.20",
12
+
"jsr:@std/internal@^1.0.10": "1.0.10",
13
+
"jsr:@std/media-types@^1.1.0": "1.1.0",
14
+
"jsr:@std/net@^1.0.4": "1.0.5",
15
+
"jsr:@std/path@^1.1.1": "1.1.2",
16
+
"jsr:@std/streams@^1.0.10": "1.0.11",
17
+
"npm:@types/node@*": "22.15.15",
18
+
"npm:pg@^8.16.3": "8.16.3",
19
+
"npm:preact-render-to-string@^6.5.13": "6.5.13_preact@10.27.1",
20
+
"npm:preact@^10.27.1": "10.27.1",
21
+
"npm:typed-htmx@~0.3.1": "0.3.1"
22
+
},
23
+
"jsr": {
24
+
"@slices/oauth@0.3.2": {
25
+
"integrity": "51feaa6be538a61a3278ee7f1d264ed937187d09da2be1f0a2a837128df82526"
26
+
},
27
+
"@slices/session@0.2.0": {
28
+
"integrity": "5245be75a1de2e397ba96b2ce80179cedaa95a33e2edf1e16c348d353271ff57",
29
+
"dependencies": [
30
+
"jsr:@slices/oauth",
31
+
"npm:pg"
32
+
]
33
+
},
34
+
"@std/cli@1.0.21": {
35
+
"integrity": "cd25b050bdf6282e321854e3822bee624f07aca7636a3a76d95f77a3a919ca2a"
36
+
},
37
+
"@std/encoding@1.0.10": {
38
+
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
39
+
},
40
+
"@std/fmt@1.0.8": {
41
+
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
42
+
},
43
+
"@std/fs@1.0.19": {
44
+
"integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06"
45
+
},
46
+
"@std/html@1.0.4": {
47
+
"integrity": "eff3497c08164e6ada49b7f81a28b5108087033823153d065e3f89467dd3d50e"
48
+
},
49
+
"@std/http@1.0.20": {
50
+
"integrity": "b5cc33fc001bccce65ed4c51815668c9891c69ccd908295997e983d8f56070a1",
51
+
"dependencies": [
52
+
"jsr:@std/cli",
53
+
"jsr:@std/encoding",
54
+
"jsr:@std/fmt",
55
+
"jsr:@std/fs",
56
+
"jsr:@std/html",
57
+
"jsr:@std/media-types",
58
+
"jsr:@std/net",
59
+
"jsr:@std/path",
60
+
"jsr:@std/streams"
61
+
]
62
+
},
63
+
"@std/internal@1.0.10": {
64
+
"integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7"
65
+
},
66
+
"@std/media-types@1.1.0": {
67
+
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
68
+
},
69
+
"@std/net@1.0.5": {
70
+
"integrity": "b759d8c5e17d997e164af6379d57764668c6714f30109685eec0fd5e194d501a"
71
+
},
72
+
"@std/path@1.1.2": {
73
+
"integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038",
74
+
"dependencies": [
75
+
"jsr:@std/internal"
76
+
]
77
+
},
78
+
"@std/streams@1.0.11": {
79
+
"integrity": "db583d27e28d133f389f1eec318cffdf4998305e5134c1d4b1c56b361cee6018"
80
+
}
81
+
},
82
+
"npm": {
83
+
"@types/node@22.15.15": {
84
+
"integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==",
85
+
"dependencies": [
86
+
"undici-types"
87
+
]
88
+
},
89
+
"pg-cloudflare@1.2.7": {
90
+
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg=="
91
+
},
92
+
"pg-connection-string@2.9.1": {
93
+
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="
94
+
},
95
+
"pg-int8@1.0.1": {
96
+
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="
97
+
},
98
+
"pg-pool@3.10.1_pg@8.16.3": {
99
+
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
100
+
"dependencies": [
101
+
"pg"
102
+
]
103
+
},
104
+
"pg-protocol@1.10.3": {
105
+
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="
106
+
},
107
+
"pg-types@2.2.0": {
108
+
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
109
+
"dependencies": [
110
+
"pg-int8",
111
+
"postgres-array",
112
+
"postgres-bytea",
113
+
"postgres-date",
114
+
"postgres-interval"
115
+
]
116
+
},
117
+
"pg@8.16.3": {
118
+
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
119
+
"dependencies": [
120
+
"pg-connection-string",
121
+
"pg-pool",
122
+
"pg-protocol",
123
+
"pg-types",
124
+
"pgpass"
125
+
],
126
+
"optionalDependencies": [
127
+
"pg-cloudflare"
128
+
]
129
+
},
130
+
"pgpass@1.0.5": {
131
+
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
132
+
"dependencies": [
133
+
"split2"
134
+
]
135
+
},
136
+
"postgres-array@2.0.0": {
137
+
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="
138
+
},
139
+
"postgres-bytea@1.0.0": {
140
+
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="
141
+
},
142
+
"postgres-date@1.0.7": {
143
+
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="
144
+
},
145
+
"postgres-interval@1.2.0": {
146
+
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
147
+
"dependencies": [
148
+
"xtend"
149
+
]
150
+
},
151
+
"preact-render-to-string@6.5.13_preact@10.27.1": {
152
+
"integrity": "sha512-iGPd+hKPMFKsfpR2vL4kJ6ZPcFIoWZEcBf0Dpm3zOpdVvj77aY8RlLiQji5OMrngEyaxGogeakTb54uS2FvA6w==",
153
+
"dependencies": [
154
+
"preact"
155
+
]
156
+
},
157
+
"preact@10.27.1": {
158
+
"integrity": "sha512-V79raXEWch/rbqoNc7nT9E4ep7lu+mI3+sBmfRD4i1M73R3WLYcCtdI0ibxGVf4eQL8ZIz2nFacqEC+rmnOORQ=="
159
+
},
160
+
"split2@4.2.0": {
161
+
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="
162
+
},
163
+
"typed-html@3.0.1": {
164
+
"integrity": "sha512-JKCM9zTfPDuPqQqdGZBWSEiItShliKkBFg5c6yOR8zth43v763XkAzTWaOlVqc0Y6p9ee8AaAbipGfUnCsYZUA=="
165
+
},
166
+
"typed-htmx@0.3.1": {
167
+
"integrity": "sha512-6WSPsukTIOEMsVbx5wzgVSvldLmgBUVcFIm2vJlBpRPtcbDOGC5y1IYrCWNX1yUlNsrv1Ngcw4gGM8jsPyNV7w==",
168
+
"dependencies": [
169
+
"typed-html"
170
+
]
171
+
},
172
+
"undici-types@6.21.0": {
173
+
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
174
+
},
175
+
"xtend@4.0.2": {
176
+
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="
177
+
}
178
+
},
179
+
"workspace": {
180
+
"dependencies": [
181
+
"jsr:@slices/oauth@~0.3.2",
182
+
"jsr:@slices/session@0.2",
183
+
"jsr:@std/http@^1.0.20",
184
+
"npm:preact-render-to-string@^6.5.13",
185
+
"npm:preact@^10.27.1",
186
+
"npm:typed-htmx@~0.3.1"
187
+
]
188
+
}
189
+
}
+123
scripts/register-oauth-client.sh
+123
scripts/register-oauth-client.sh
···
1
+
#!/bin/bash
2
+
3
+
# OAuth Dynamic Client Registration Script for AT Protocol
4
+
# Registers a new OAuth client with the AIP server per RFC 7591
5
+
# Usage: bash scripts/register-oauth-client.sh
6
+
7
+
set -e # Exit on any error
8
+
9
+
# Configuration
10
+
AIP_BASE="${AIP_BASE_URL:-http://localhost:8081}"
11
+
CLIENT_BASE_URL="${CLIENT_BASE_URL:-http://localhost:8080}"
12
+
CLIENT_NAME="${CLIENT_NAME:-Slice AT Proto Client}"
13
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
+
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
15
+
CONFIG_FILE="$ROOT_DIR/.env"
16
+
17
+
echo "๐ OAuth Dynamic Client Registration for Slice"
18
+
echo "AIP Server: $AIP_BASE"
19
+
echo "Client Base URL: $CLIENT_BASE_URL"
20
+
echo "Client Name: $CLIENT_NAME"
21
+
echo
22
+
23
+
# Check if client is already registered
24
+
if [ -f "$CONFIG_FILE" ]; then
25
+
echo "โ ๏ธ Existing OAuth client configuration found at $CONFIG_FILE"
26
+
echo -n "Do you want to register a new client? This will overwrite the existing config. (y/N): "
27
+
read -r OVERWRITE
28
+
if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then
29
+
echo "โ Registration cancelled"
30
+
exit 1
31
+
fi
32
+
fi
33
+
34
+
echo "๐ Using OAuth registration endpoint..."
35
+
REGISTRATION_ENDPOINT="$AIP_BASE/oauth/clients/register"
36
+
37
+
echo "โ
Registration endpoint: $REGISTRATION_ENDPOINT"
38
+
echo
39
+
40
+
# Create client registration request
41
+
echo "๐ Creating client registration request..."
42
+
REDIRECT_URI="$CLIENT_BASE_URL/oauth/callback"
43
+
44
+
REGISTRATION_REQUEST=$(cat <<EOF
45
+
{
46
+
"client_name": "$CLIENT_NAME",
47
+
"redirect_uris": ["$REDIRECT_URI"],
48
+
"scope": "atproto:atproto atproto:transition:generic openid profile email",
49
+
"grant_types": ["authorization_code", "refresh_token"],
50
+
"response_types": ["code"],
51
+
"token_endpoint_auth_method": "client_secret_basic"
52
+
}
53
+
EOF
54
+
)
55
+
56
+
echo "Registration request:"
57
+
echo "$REGISTRATION_REQUEST" | jq '.' 2>/dev/null || echo "$REGISTRATION_REQUEST"
58
+
echo
59
+
60
+
# Register the client
61
+
echo "๐ Registering client with AIP server..."
62
+
REGISTRATION_RESPONSE=$(curl -s -X POST "$REGISTRATION_ENDPOINT" \
63
+
-H "Content-Type: application/json" \
64
+
-d "$REGISTRATION_REQUEST" || {
65
+
echo "โ Failed to register client with AIP server"
66
+
echo "Make sure the AIP server is running at $AIP_BASE"
67
+
exit 1
68
+
})
69
+
70
+
echo "Registration response:"
71
+
echo "$REGISTRATION_RESPONSE" | jq '.' 2>/dev/null || echo "$REGISTRATION_RESPONSE"
72
+
echo
73
+
74
+
# Extract client credentials
75
+
CLIENT_ID=$(echo "$REGISTRATION_RESPONSE" | grep -o '"client_id":"[^"]*' | cut -d'"' -f4)
76
+
CLIENT_SECRET=$(echo "$REGISTRATION_RESPONSE" | grep -o '"client_secret":"[^"]*' | cut -d'"' -f4)
77
+
78
+
if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ]; then
79
+
echo "โ Failed to extract client credentials from registration response"
80
+
echo "Expected client_id and client_secret in response"
81
+
echo "Response was: $REGISTRATION_RESPONSE"
82
+
exit 1
83
+
fi
84
+
85
+
echo "โ
Client registered successfully!"
86
+
echo "Client ID: $CLIENT_ID"
87
+
echo "Client Secret: [REDACTED]"
88
+
echo
89
+
90
+
# Save credentials to .env.oauth file
91
+
echo "๐พ Saving client credentials to $CONFIG_FILE..."
92
+
cat > "$CONFIG_FILE" <<EOF
93
+
# OAuth Client Credentials for Slice AT Proto Client
94
+
# Generated on $(date)
95
+
# AIP Server: $AIP_BASE
96
+
97
+
OAUTH_CLIENT_ID="$CLIENT_ID"
98
+
OAUTH_CLIENT_SECRET="$CLIENT_SECRET"
99
+
OAUTH_REDIRECT_URI="$REDIRECT_URI"
100
+
OAUTH_AIP_BASE_URL="$AIP_BASE"
101
+
EOF
102
+
103
+
echo "โ
Client registration complete!"
104
+
echo
105
+
echo "๐ Summary:"
106
+
echo " - Client ID: $CLIENT_ID"
107
+
echo " - Client Name: $CLIENT_NAME"
108
+
echo " - Redirect URI: $REDIRECT_URI"
109
+
echo " - Scopes: atproto:atproto atproto:transition:generic openid profile email"
110
+
echo " - Config saved to: $CONFIG_FILE"
111
+
echo
112
+
echo "๐ง Environment variables saved to $CONFIG_FILE:"
113
+
echo " OAUTH_CLIENT_ID"
114
+
echo " OAUTH_CLIENT_SECRET"
115
+
echo " OAUTH_REDIRECT_URI"
116
+
echo " OAUTH_AIP_BASE_URL"
117
+
echo
118
+
echo "๐ก To use these credentials in your application:"
119
+
echo " source $CONFIG_FILE"
120
+
echo " # Or load them in your .env file"
121
+
echo
122
+
echo "๐งช To test the OAuth flow, you can now use the registered credentials"
123
+
echo " with your AtProtoClient in TypeScript/Deno."
+34
src/api.ts
+34
src/api.ts
···
1
+
import { atprotoClient } from "./config.ts";
2
+
import { HydratedStatus } from "./types.ts";
3
+
4
+
export async function fetchStatusesWithAuthors(): Promise<HydratedStatus[]> {
5
+
try {
6
+
const records = await atprotoClient.xyz.statusphere.status.listRecords({
7
+
sort: "createdAt:desc",
8
+
});
9
+
const statuses = records.records || [];
10
+
11
+
// Get unique DIDs from statuses
12
+
const dids = [...new Set(statuses.map((s) => s.did))];
13
+
14
+
// Fetch actors for all authors of the statuses
15
+
const actorsResponse = await atprotoClient.getActors({
16
+
dids,
17
+
});
18
+
19
+
// Create a map of DID to actor info
20
+
const actorMap = new Map();
21
+
actorsResponse.actors?.forEach((actor) => {
22
+
actorMap.set(actor.did, actor);
23
+
});
24
+
25
+
// Hydrate statuses with author info
26
+
return statuses.map((status) => ({
27
+
...status,
28
+
author: actorMap.get(status.did),
29
+
}));
30
+
} catch (error) {
31
+
console.error("Error fetching statuses:", error);
32
+
return [];
33
+
}
34
+
}
+31
src/components/App.tsx
+31
src/components/App.tsx
···
1
+
import { HomePage } from "./HomePage.tsx";
2
+
import { LoginPage } from "./LoginPage.tsx";
3
+
import { Layout } from "./Layout.tsx";
4
+
import { HydratedStatus } from "../types.ts";
5
+
6
+
interface AppProps {
7
+
currentUser: {
8
+
isAuthenticated: boolean;
9
+
handle?: string;
10
+
sub?: string;
11
+
};
12
+
statuses?: HydratedStatus[];
13
+
page?: string;
14
+
error?: string;
15
+
}
16
+
17
+
export function App({ currentUser, statuses = [], page, error }: AppProps) {
18
+
if (page === "login") {
19
+
return (
20
+
<Layout title="Login - Statusphere" currentUser={currentUser}>
21
+
<LoginPage error={error} />
22
+
</Layout>
23
+
);
24
+
}
25
+
26
+
return (
27
+
<Layout title="Statusphere" currentUser={currentUser}>
28
+
<HomePage currentUser={currentUser} statuses={statuses} />
29
+
</Layout>
30
+
);
31
+
}
+118
src/components/HomePage.tsx
+118
src/components/HomePage.tsx
···
1
+
import { HydratedStatus } from "../types.ts";
2
+
3
+
interface HomePageProps {
4
+
currentUser: {
5
+
isAuthenticated: boolean;
6
+
handle?: string;
7
+
};
8
+
statuses: HydratedStatus[];
9
+
}
10
+
11
+
const statusOptions = [
12
+
"๐",
13
+
"๐",
14
+
"๐",
15
+
"โค๏ธ",
16
+
"๐",
17
+
"๐คฉ",
18
+
"๐",
19
+
"๐ค",
20
+
"๐ด",
21
+
"๐",
22
+
"๐ฅ",
23
+
"๐ฏ",
24
+
"โจ",
25
+
"โญ",
26
+
"๐",
27
+
"๐",
28
+
"๐ช",
29
+
"๐",
30
+
"๐",
31
+
"๐ค",
32
+
"๐ค",
33
+
"๐",
34
+
"๐",
35
+
"๐",
36
+
"๐",
37
+
"๐ฅณ",
38
+
"๐คฏ",
39
+
"๐ฑ",
40
+
"๐ญ",
41
+
"๐",
42
+
"๐",
43
+
"๐ค",
44
+
"๐",
45
+
"๐ง ",
46
+
"๐ก",
47
+
];
48
+
49
+
export function HomePage({ currentUser, statuses }: HomePageProps) {
50
+
return (
51
+
<div>
52
+
<div class="card">
53
+
<h2>How are you feeling?</h2>
54
+
<div class="status-options">
55
+
{statusOptions.map((emoji) => (
56
+
<button
57
+
type="button"
58
+
key={emoji}
59
+
class="status-option"
60
+
title={`Set status to ${emoji}`}
61
+
hx-post="/status"
62
+
hx-vals={`{"status": "${emoji}"}`}
63
+
hx-target="#status-timeline"
64
+
hx-swap="outerHTML"
65
+
hx-indicator="#loading"
66
+
>
67
+
{emoji}
68
+
</button>
69
+
))}
70
+
</div>
71
+
<div id="loading" class="htmx-indicator">
72
+
Setting status...
73
+
</div>
74
+
</div>
75
+
76
+
<div class="card">
77
+
<h3>Recent Statuses</h3>
78
+
<StatusTimeline statuses={statuses} />
79
+
</div>
80
+
</div>
81
+
);
82
+
}
83
+
84
+
interface StatusTimelineProps {
85
+
statuses: HydratedStatus[];
86
+
}
87
+
88
+
export function StatusTimeline({ statuses }: StatusTimelineProps) {
89
+
return (
90
+
<div id="status-timeline">
91
+
{statuses.length === 0 ? (
92
+
<div class="status-timeline">
93
+
<p class="status-meta">No statuses yet. Be the first to share!</p>
94
+
</div>
95
+
) : (
96
+
<div class="status-timeline">
97
+
{statuses.map((status) => {
98
+
const authorHandle = status.author?.handle ||
99
+
status.did?.split(":").pop() ||
100
+
"unknown";
101
+
const createdAt = new Date(status.value.createdAt).toLocaleString();
102
+
103
+
return (
104
+
<div key={status.uri} class="status-item">
105
+
<div class="status-emoji">{status.value.status}</div>
106
+
<div>
107
+
<div class="status-meta">
108
+
@{authorHandle} โข {createdAt}
109
+
</div>
110
+
</div>
111
+
</div>
112
+
);
113
+
})}
114
+
</div>
115
+
)}
116
+
</div>
117
+
);
118
+
}
+320
src/components/Layout.tsx
+320
src/components/Layout.tsx
···
1
+
import type { ComponentChildren } from "preact";
2
+
3
+
interface LayoutProps {
4
+
title: string;
5
+
currentUser: {
6
+
isAuthenticated: boolean;
7
+
handle?: string;
8
+
};
9
+
children: ComponentChildren;
10
+
}
11
+
12
+
export function Layout({ title, currentUser, children }: LayoutProps) {
13
+
return (
14
+
<html lang="en">
15
+
<head>
16
+
<meta charset="UTF-8" />
17
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
18
+
<title>{title}</title>
19
+
<script src="https://unpkg.com/htmx.org@2.0.2"></script>
20
+
<style dangerouslySetInnerHTML={{ __html: styles }} />
21
+
</head>
22
+
<body>
23
+
<nav class="nav">
24
+
<div class="nav-container">
25
+
<a href="/" class="nav-brand">
26
+
Statusphere
27
+
</a>
28
+
<div class="nav-user">
29
+
{currentUser.isAuthenticated ? (
30
+
<div class="user-info">
31
+
<span class="handle">@{currentUser.handle}</span>
32
+
<form method="post" action="/logout">
33
+
<button type="submit" class="btn btn-secondary">
34
+
Logout
35
+
</button>
36
+
</form>
37
+
</div>
38
+
) : (
39
+
<a href="/login" class="btn btn-primary">
40
+
Login
41
+
</a>
42
+
)}
43
+
</div>
44
+
</div>
45
+
</nav>
46
+
<main class="container">
47
+
{children}
48
+
</main>
49
+
</body>
50
+
</html>
51
+
);
52
+
}
53
+
54
+
const styles = `
55
+
/* Josh's CSS Reset */
56
+
*, *::before, *::after { box-sizing: border-box; }
57
+
* { margin: 0; }
58
+
body { line-height: 1.5; -webkit-font-smoothing: antialiased; }
59
+
img, picture, video, canvas, svg { display: block; max-width: 100%; }
60
+
input, button, textarea, select { font: inherit; }
61
+
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
62
+
63
+
/* Custom Properties */
64
+
:root {
65
+
--primary-50: #eff6ff;
66
+
--primary-100: #dbeafe;
67
+
--primary-200: #bfdbfe;
68
+
--primary-300: #93c5fd;
69
+
--primary-400: #60a5fa;
70
+
--primary-500: #3b82f6;
71
+
--primary-600: #2563eb;
72
+
--primary-700: #1d4ed8;
73
+
--primary-800: #1e40af;
74
+
--primary-900: #1e3a8a;
75
+
76
+
--gray-50: #f9fafb;
77
+
--gray-100: #f3f4f6;
78
+
--gray-200: #e5e7eb;
79
+
--gray-300: #d1d5db;
80
+
--gray-400: #9ca3af;
81
+
--gray-500: #6b7280;
82
+
--gray-600: #4b5563;
83
+
--gray-700: #374151;
84
+
--gray-800: #1f2937;
85
+
--gray-900: #111827;
86
+
87
+
--error-500: #ef4444;
88
+
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
89
+
}
90
+
91
+
body {
92
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
93
+
background: var(--gray-50);
94
+
color: var(--gray-900);
95
+
}
96
+
97
+
.nav {
98
+
background: white;
99
+
border-bottom: 1px solid var(--gray-200);
100
+
padding: 1rem 0;
101
+
}
102
+
103
+
.nav-container {
104
+
max-width: 600px;
105
+
margin: 0 auto;
106
+
padding: 0 1rem;
107
+
display: flex;
108
+
justify-content: space-between;
109
+
align-items: center;
110
+
}
111
+
112
+
.nav-brand {
113
+
font-size: 1.25rem;
114
+
font-weight: 600;
115
+
color: var(--primary-600);
116
+
text-decoration: none;
117
+
}
118
+
119
+
.nav-user {
120
+
display: flex;
121
+
align-items: center;
122
+
gap: 1rem;
123
+
}
124
+
125
+
.user-info {
126
+
display: flex;
127
+
align-items: center;
128
+
gap: 1rem;
129
+
}
130
+
131
+
.handle {
132
+
color: var(--gray-600);
133
+
}
134
+
135
+
.container {
136
+
max-width: 600px;
137
+
margin: 0 auto;
138
+
padding: 2rem 1rem;
139
+
}
140
+
141
+
.btn {
142
+
padding: 0.5rem 1rem;
143
+
border: none;
144
+
border-radius: 0.375rem;
145
+
font-size: 0.875rem;
146
+
font-weight: 500;
147
+
cursor: pointer;
148
+
text-decoration: none;
149
+
display: inline-flex;
150
+
align-items: center;
151
+
justify-content: center;
152
+
transition: all 0.15s ease-in-out;
153
+
}
154
+
155
+
.btn-primary {
156
+
background: var(--primary-600);
157
+
color: white;
158
+
}
159
+
160
+
.btn-primary:hover {
161
+
background: var(--primary-700);
162
+
box-shadow: var(--shadow);
163
+
}
164
+
165
+
.btn-secondary {
166
+
background: var(--gray-100);
167
+
color: var(--gray-700);
168
+
border: 1px solid var(--gray-300);
169
+
}
170
+
171
+
.btn-secondary:hover {
172
+
background: var(--gray-200);
173
+
}
174
+
175
+
.card {
176
+
background: white;
177
+
border: 1px solid var(--gray-200);
178
+
border-radius: 0.5rem;
179
+
padding: 1.5rem;
180
+
box-shadow: var(--shadow);
181
+
margin-bottom: 1rem;
182
+
}
183
+
184
+
.form-group {
185
+
margin-bottom: 1rem;
186
+
}
187
+
188
+
.form-label {
189
+
display: block;
190
+
font-weight: 500;
191
+
margin-bottom: 0.25rem;
192
+
color: var(--gray-700);
193
+
}
194
+
195
+
.form-input {
196
+
width: 100%;
197
+
padding: 0.5rem 0.75rem;
198
+
border: 1px solid var(--gray-300);
199
+
border-radius: 0.375rem;
200
+
font-size: 0.875rem;
201
+
}
202
+
203
+
.form-input:focus {
204
+
outline: none;
205
+
border-color: var(--primary-500);
206
+
box-shadow: 0 0 0 3px rgb(59 130 246 / 0.1);
207
+
}
208
+
209
+
.status-options {
210
+
display: flex;
211
+
flex-wrap: wrap;
212
+
gap: 0.5rem;
213
+
margin: 1rem 0;
214
+
}
215
+
216
+
.status-option {
217
+
width: 3rem;
218
+
height: 3rem;
219
+
border: 2px solid var(--gray-200);
220
+
border-radius: 50%;
221
+
background: white;
222
+
display: flex;
223
+
align-items: center;
224
+
justify-content: center;
225
+
font-size: 1.5rem;
226
+
cursor: pointer;
227
+
transition: all 0.15s ease-in-out;
228
+
}
229
+
230
+
.status-option:hover {
231
+
border-color: var(--primary-300);
232
+
box-shadow: var(--shadow);
233
+
}
234
+
235
+
.status-timeline {
236
+
margin-top: 2rem;
237
+
}
238
+
239
+
.status-item {
240
+
display: flex;
241
+
align-items: center;
242
+
gap: 1rem;
243
+
padding: 1rem 0;
244
+
border-bottom: 1px solid var(--gray-100);
245
+
}
246
+
247
+
.status-item:last-child {
248
+
border-bottom: none;
249
+
}
250
+
251
+
.status-emoji {
252
+
font-size: 1.5rem;
253
+
width: 2.5rem;
254
+
text-align: center;
255
+
}
256
+
257
+
.status-meta {
258
+
color: var(--gray-600);
259
+
font-size: 0.875rem;
260
+
}
261
+
262
+
.error {
263
+
color: var(--error-500);
264
+
font-size: 0.875rem;
265
+
margin-top: 0.25rem;
266
+
padding: 0.75rem;
267
+
background: #fef2f2;
268
+
border: 1px solid #fecaca;
269
+
border-radius: 0.375rem;
270
+
margin-bottom: 1rem;
271
+
}
272
+
273
+
.form-container {
274
+
margin-top: 1.5rem;
275
+
}
276
+
277
+
.form-help {
278
+
font-size: 0.75rem;
279
+
color: var(--gray-500);
280
+
margin-top: 0.25rem;
281
+
}
282
+
283
+
.btn-block {
284
+
width: 100%;
285
+
}
286
+
287
+
.btn-text {
288
+
display: inline;
289
+
}
290
+
291
+
.signup-prompt {
292
+
margin-top: 1.5rem;
293
+
text-align: center;
294
+
padding-top: 1.5rem;
295
+
border-top: 1px solid var(--gray-200);
296
+
}
297
+
298
+
.link {
299
+
color: var(--primary-600);
300
+
text-decoration: none;
301
+
}
302
+
303
+
.link:hover {
304
+
text-decoration: underline;
305
+
}
306
+
307
+
.htmx-indicator {
308
+
display: none;
309
+
color: var(--gray-500);
310
+
font-size: 0.875rem;
311
+
}
312
+
313
+
.htmx-request .htmx-indicator {
314
+
display: inline;
315
+
}
316
+
317
+
.htmx-request .btn-text {
318
+
display: none;
319
+
}
320
+
`;
+56
src/components/LoginPage.tsx
+56
src/components/LoginPage.tsx
···
1
+
interface LoginPageProps {
2
+
error?: string;
3
+
}
4
+
5
+
export function LoginPage({ error }: LoginPageProps) {
6
+
return (
7
+
<div class="card">
8
+
<h1>Sign In to Statusphere</h1>
9
+
<p class="status-meta">
10
+
Share your current status with a single emoji using your AT Protocol identity.
11
+
</p>
12
+
13
+
{error && (
14
+
<div class="error" role="alert">
15
+
{error}
16
+
</div>
17
+
)}
18
+
19
+
<form
20
+
method="post"
21
+
action="/oauth/authorize"
22
+
class="form-container"
23
+
>
24
+
<div class="form-group">
25
+
<label class="form-label" for="loginHint">
26
+
AT Protocol Handle
27
+
</label>
28
+
<input
29
+
type="text"
30
+
id="loginHint"
31
+
name="loginHint"
32
+
class="form-input"
33
+
placeholder="alice.bsky.social"
34
+
required
35
+
/>
36
+
<div class="form-help">
37
+
Enter your Bluesky or AT Protocol handle
38
+
</div>
39
+
</div>
40
+
41
+
<button type="submit" class="btn btn-primary btn-block">
42
+
Sign In with OAuth
43
+
</button>
44
+
</form>
45
+
46
+
<div class="signup-prompt">
47
+
<p class="status-meta">
48
+
Don't have an account?
49
+
<a href="https://bsky.app" target="_blank" rel="noopener" class="link">
50
+
Sign up on Bluesky
51
+
</a>
52
+
</p>
53
+
</div>
54
+
</div>
55
+
);
56
+
}
+62
src/config.ts
+62
src/config.ts
···
1
+
import { OAuthClient, DenoKVOAuthStorage } from "@slices/oauth";
2
+
import { SessionStore, DenoKVAdapter, withOAuthSession } from "@slices/session";
3
+
import { AtProtoClient } from "./generated_client.ts";
4
+
5
+
// Environment configuration
6
+
export const PORT = parseInt(Deno.env.get("PORT") || "8080");
7
+
export const DATABASE_URL = Deno.env.get("DATABASE_URL") || "./statusphere.db";
8
+
export const OAUTH_CLIENT_ID = Deno.env.get("OAUTH_CLIENT_ID") || "";
9
+
export const OAUTH_CLIENT_SECRET = Deno.env.get("OAUTH_CLIENT_SECRET") || "";
10
+
export const OAUTH_REDIRECT_URI =
11
+
Deno.env.get("OAUTH_REDIRECT_URI") ||
12
+
`http://localhost:${PORT}/oauth/callback`;
13
+
export const OAUTH_AIP_BASE_URL =
14
+
Deno.env.get("OAUTH_AIP_BASE_URL") || "https://bsky.social";
15
+
export const SLICE_URI =
16
+
Deno.env.get("SLICE_URI") ||
17
+
"at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lx5y476bws2q";
18
+
export const SLICES_API_URL = Deno.env.get("SLICES_API_URL") || "";
19
+
20
+
// OAuth and Session setup
21
+
// In Deno Deploy, don't specify a path to use the default KV store
22
+
const kv = await Deno.openKv(
23
+
Deno.env.get("ENV") === "production" ? undefined : DATABASE_URL
24
+
);
25
+
const oauthStorage = new DenoKVOAuthStorage(kv);
26
+
export const oauthClient = new OAuthClient(
27
+
{
28
+
clientId: OAUTH_CLIENT_ID,
29
+
clientSecret: OAUTH_CLIENT_SECRET,
30
+
authBaseUrl: OAUTH_AIP_BASE_URL,
31
+
redirectUri: OAUTH_REDIRECT_URI,
32
+
scopes: [
33
+
"openid",
34
+
"profile",
35
+
"email",
36
+
"atproto:atproto",
37
+
"atproto:transition:generic",
38
+
],
39
+
},
40
+
oauthStorage
41
+
);
42
+
43
+
export const sessionStore = new SessionStore({
44
+
adapter: new DenoKVAdapter(kv),
45
+
cookieOptions: {
46
+
httpOnly: true,
47
+
secure: Deno.env.get("ENV") === "production",
48
+
sameSite: "lax",
49
+
path: "/",
50
+
},
51
+
});
52
+
53
+
export const oauthSessions = withOAuthSession(sessionStore, oauthClient, {
54
+
autoRefresh: true,
55
+
});
56
+
57
+
// Slices AT Protocol client
58
+
export const atprotoClient = new AtProtoClient(
59
+
SLICES_API_URL,
60
+
SLICE_URI,
61
+
oauthClient
62
+
);
+647
src/generated_client.ts
+647
src/generated_client.ts
···
1
+
// Generated TypeScript client for AT Protocol records
2
+
// Generated at: 2025-08-28 00:22:31 UTC
3
+
// Lexicons: 2
4
+
5
+
/**
6
+
* @example Usage
7
+
* ```ts
8
+
* import { AtProtoClient } from "./generated_client.ts";
9
+
*
10
+
* const client = new AtProtoClient(
11
+
* 'https://slices-api.fly.dev',
12
+
* 'at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/3lx5y476bws2q'
13
+
* );
14
+
*
15
+
* // List records from the app.bsky.actor.profile collection
16
+
* const records = await client.app.bsky.actor.profile.listRecords();
17
+
*
18
+
* // Get a specific record
19
+
* const record = await client.app.bsky.actor.profile.getRecord({
20
+
* uri: 'at://did:plc:example/app.bsky.actor.profile/3abc123'
21
+
* });
22
+
*
23
+
* // Search records in the collection
24
+
* const searchResults = await client.app.bsky.actor.profile.searchRecords({
25
+
* query: "example search term"
26
+
* });
27
+
*
28
+
* // Search specific field
29
+
* const fieldSearch = await client.app.bsky.actor.profile.searchRecords({
30
+
* query: "blog",
31
+
* field: "title"
32
+
* });
33
+
*
34
+
* // Serve the records as JSON
35
+
* Deno.serve(async () => new Response(JSON.stringify(records.records.map(r => r.value))));
36
+
* ```
37
+
*/
38
+
39
+
import { OAuthClient } from "@slices/oauth";
40
+
41
+
export interface RecordResponse<T> {
42
+
uri: string;
43
+
cid: string;
44
+
did: string;
45
+
collection: string;
46
+
value: T;
47
+
indexedAt: string;
48
+
}
49
+
50
+
export interface ListRecordsResponse<T> {
51
+
records: RecordResponse<T>[];
52
+
cursor?: string;
53
+
}
54
+
55
+
export interface GetActorsResponse {
56
+
actors: Actor[];
57
+
cursor?: string;
58
+
}
59
+
60
+
export interface ListRecordsParams<TSortField extends string = string> {
61
+
author?: string;
62
+
authors?: string[];
63
+
limit?: number;
64
+
cursor?: string;
65
+
sort?:
66
+
| `${TSortField}:${"asc" | "desc"}`
67
+
| `${TSortField}:${"asc" | "desc"},${TSortField}:${"asc" | "desc"}`;
68
+
}
69
+
70
+
export interface GetRecordParams {
71
+
uri: string;
72
+
}
73
+
74
+
export interface GetActorsParams {
75
+
search?: string;
76
+
dids?: string[];
77
+
limit?: number;
78
+
cursor?: string;
79
+
}
80
+
81
+
export interface SearchRecordsParams<TSortField extends string = string> {
82
+
query: string;
83
+
field?: string;
84
+
limit?: number;
85
+
cursor?: string;
86
+
sort?:
87
+
| `${TSortField}:${"asc" | "desc"}`
88
+
| `${TSortField}:${"asc" | "desc"},${TSortField}:${"asc" | "desc"}`;
89
+
}
90
+
91
+
export interface IndexedRecord {
92
+
uri: string;
93
+
cid: string;
94
+
did: string;
95
+
collection: string;
96
+
value: Record<string, unknown>;
97
+
indexedAt: string;
98
+
}
99
+
100
+
export interface Actor {
101
+
did: string;
102
+
handle?: string;
103
+
sliceUri: string;
104
+
indexedAt: string;
105
+
}
106
+
107
+
export interface CodegenXrpcRequest {
108
+
target: string;
109
+
slice: string;
110
+
}
111
+
112
+
export interface CodegenXrpcResponse {
113
+
success: boolean;
114
+
generatedCode?: string;
115
+
error?: string;
116
+
}
117
+
118
+
export interface BulkSyncParams {
119
+
collections?: string[];
120
+
externalCollections?: string[];
121
+
repos?: string[];
122
+
limitPerRepo?: number;
123
+
}
124
+
125
+
export interface BulkSyncOutput {
126
+
success: boolean;
127
+
totalRecords: number;
128
+
collectionsSynced: string[];
129
+
reposProcessed: number;
130
+
message: string;
131
+
}
132
+
133
+
export interface SyncJobResponse {
134
+
success: boolean;
135
+
jobId?: string;
136
+
message: string;
137
+
}
138
+
139
+
export interface SyncJobResult {
140
+
success: boolean;
141
+
totalRecords: number;
142
+
collectionsSynced: string[];
143
+
reposProcessed: number;
144
+
message: string;
145
+
}
146
+
147
+
export interface JobStatus {
148
+
jobId: string;
149
+
status: string;
150
+
createdAt: string;
151
+
startedAt?: string;
152
+
completedAt?: string;
153
+
result?: SyncJobResult;
154
+
error?: string;
155
+
retryCount: number;
156
+
}
157
+
158
+
export interface GetJobStatusParams {
159
+
jobId: string;
160
+
}
161
+
162
+
export interface GetJobHistoryParams {
163
+
userDid: string;
164
+
sliceUri: string;
165
+
limit?: number;
166
+
}
167
+
168
+
export type GetJobHistoryResponse = JobStatus[];
169
+
170
+
export interface CollectionStats {
171
+
collection: string;
172
+
recordCount: number;
173
+
uniqueActors: number;
174
+
}
175
+
176
+
export interface SliceStatsParams {
177
+
slice: string;
178
+
}
179
+
180
+
export interface SliceStatsOutput {
181
+
success: boolean;
182
+
collections: string[];
183
+
collectionStats: CollectionStats[];
184
+
totalLexicons: number;
185
+
totalRecords: number;
186
+
totalActors: number;
187
+
message?: string;
188
+
}
189
+
190
+
export interface SliceRecordsParams {
191
+
slice: string;
192
+
collection: string;
193
+
repo?: string;
194
+
limit?: number;
195
+
cursor?: string;
196
+
}
197
+
198
+
export interface SliceRecordsOutput {
199
+
success: boolean;
200
+
records: IndexedRecord[];
201
+
cursor?: string;
202
+
message?: string;
203
+
}
204
+
205
+
export interface UploadBlobRequest {
206
+
data: ArrayBuffer | Uint8Array;
207
+
mimeType: string;
208
+
}
209
+
210
+
export interface BlobRef {
211
+
$type: string;
212
+
ref: string;
213
+
mimeType: string;
214
+
size: number;
215
+
}
216
+
217
+
export interface UploadBlobResponse {
218
+
blob: BlobRef;
219
+
}
220
+
221
+
export interface CollectionOperations<T> {
222
+
listRecords(params?: ListRecordsParams): Promise<ListRecordsResponse<T>>;
223
+
getRecord(params: GetRecordParams): Promise<RecordResponse<T>>;
224
+
searchRecords(params: SearchRecordsParams): Promise<ListRecordsResponse<T>>;
225
+
}
226
+
227
+
export interface AppBskyActorProfileRecord {
228
+
/** Small image to be displayed next to posts from account. AKA, 'profile picture' */
229
+
avatar?: BlobRef;
230
+
/** Larger horizontal image to display behind profile view. */
231
+
banner?: BlobRef;
232
+
createdAt?: string;
233
+
/** Free-form profile description text. */
234
+
description?: string;
235
+
displayName?: string;
236
+
}
237
+
238
+
export type AppBskyActorProfileRecordSortFields =
239
+
| "createdAt"
240
+
| "description"
241
+
| "displayName";
242
+
243
+
export interface XyzStatusphereStatusRecord {
244
+
createdAt: string;
245
+
status: string;
246
+
}
247
+
248
+
export type XyzStatusphereStatusRecordSortFields = "createdAt" | "status";
249
+
250
+
class BaseClient {
251
+
protected readonly baseUrl: string;
252
+
protected oauthClient?: OAuthClient;
253
+
254
+
constructor(baseUrl: string, oauthClient?: OAuthClient) {
255
+
this.baseUrl = baseUrl;
256
+
this.oauthClient = oauthClient;
257
+
}
258
+
259
+
protected async ensureValidToken(): Promise<void> {
260
+
if (!this.oauthClient) {
261
+
throw new Error("OAuth client not configured");
262
+
}
263
+
264
+
await this.oauthClient.ensureValidToken();
265
+
}
266
+
267
+
protected async makeRequest<T = unknown>(
268
+
endpoint: string,
269
+
method?: "GET" | "POST" | "PUT" | "DELETE",
270
+
params?: Record<string, unknown> | unknown
271
+
): Promise<T> {
272
+
return this.makeRequestWithRetry(endpoint, method, params, false);
273
+
}
274
+
275
+
private async makeRequestWithRetry<T = unknown>(
276
+
endpoint: string,
277
+
method?: "GET" | "POST" | "PUT" | "DELETE",
278
+
params?: Record<string, unknown> | unknown,
279
+
isRetry?: boolean
280
+
): Promise<T> {
281
+
isRetry = isRetry ?? false;
282
+
const httpMethod = method || "GET";
283
+
let url = `${this.baseUrl}/xrpc/${endpoint}`;
284
+
285
+
const requestInit: RequestInit = {
286
+
method: httpMethod,
287
+
headers: {},
288
+
};
289
+
290
+
// Add authorization header if OAuth client is available
291
+
if (this.oauthClient) {
292
+
try {
293
+
const tokens = await this.oauthClient.ensureValidToken();
294
+
if (tokens.accessToken) {
295
+
(requestInit.headers as Record<string, string>)[
296
+
"Authorization"
297
+
] = `${tokens.tokenType} ${tokens.accessToken}`;
298
+
}
299
+
} catch (tokenError) {
300
+
// For write operations, OAuth tokens are required
301
+
if (httpMethod !== "GET") {
302
+
throw new Error(
303
+
`Authentication required: OAuth tokens are invalid or expired. Please log in again.`
304
+
);
305
+
}
306
+
307
+
// For read operations, continue without auth (allow read-only operations)
308
+
}
309
+
}
310
+
311
+
if (httpMethod === "GET" && params) {
312
+
const searchParams = new URLSearchParams();
313
+
Object.entries(params).forEach(([key, value]) => {
314
+
if (value !== undefined && value !== null) {
315
+
searchParams.append(key, String(value));
316
+
}
317
+
});
318
+
const queryString = searchParams.toString();
319
+
if (queryString) {
320
+
url += "?" + queryString;
321
+
}
322
+
} else if (httpMethod !== "GET" && params) {
323
+
// Regular API endpoints expect JSON
324
+
(requestInit.headers as Record<string, string>)["Content-Type"] =
325
+
"application/json";
326
+
requestInit.body = JSON.stringify(params);
327
+
}
328
+
329
+
const response = await fetch(url, requestInit);
330
+
if (!response.ok) {
331
+
// Handle 404 gracefully for GET requests
332
+
if (response.status === 404 && httpMethod === "GET") {
333
+
return null as T;
334
+
}
335
+
336
+
// Handle 401 Unauthorized - attempt token refresh and retry once
337
+
if (
338
+
response.status === 401 &&
339
+
!isRetry &&
340
+
this.oauthClient &&
341
+
httpMethod !== "GET"
342
+
) {
343
+
try {
344
+
// Force token refresh by calling ensureValidToken again
345
+
await this.oauthClient.ensureValidToken();
346
+
// Retry the request once with refreshed tokens
347
+
return this.makeRequestWithRetry(endpoint, method, params, true);
348
+
} catch (_refreshError) {
349
+
throw new Error(
350
+
`Authentication required: OAuth tokens are invalid or expired. Please log in again.`
351
+
);
352
+
}
353
+
}
354
+
355
+
throw new Error(
356
+
`Request failed: ${response.status} ${response.statusText}`
357
+
);
358
+
}
359
+
360
+
return (await response.json()) as T;
361
+
}
362
+
}
363
+
364
+
class ProfileActorBskyAppClient extends BaseClient {
365
+
private readonly sliceUri: string;
366
+
367
+
constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) {
368
+
super(baseUrl, oauthClient);
369
+
this.sliceUri = sliceUri;
370
+
}
371
+
372
+
async listRecords(
373
+
params?: ListRecordsParams<AppBskyActorProfileRecordSortFields>
374
+
): Promise<ListRecordsResponse<AppBskyActorProfileRecord>> {
375
+
const requestParams = { ...params, slice: this.sliceUri };
376
+
return await this.makeRequest<
377
+
ListRecordsResponse<AppBskyActorProfileRecord>
378
+
>("app.bsky.actor.profile.list", "GET", requestParams);
379
+
}
380
+
381
+
async getRecord(
382
+
params: GetRecordParams
383
+
): Promise<RecordResponse<AppBskyActorProfileRecord>> {
384
+
const requestParams = { ...params, slice: this.sliceUri };
385
+
return await this.makeRequest<RecordResponse<AppBskyActorProfileRecord>>(
386
+
"app.bsky.actor.profile.get",
387
+
"GET",
388
+
requestParams
389
+
);
390
+
}
391
+
392
+
async searchRecords(
393
+
params: SearchRecordsParams<AppBskyActorProfileRecordSortFields>
394
+
): Promise<ListRecordsResponse<AppBskyActorProfileRecord>> {
395
+
const requestParams = { ...params, slice: this.sliceUri };
396
+
return await this.makeRequest<
397
+
ListRecordsResponse<AppBskyActorProfileRecord>
398
+
>("app.bsky.actor.profile.searchRecords", "GET", requestParams);
399
+
}
400
+
401
+
async createRecord(
402
+
record: AppBskyActorProfileRecord,
403
+
useSelfRkey?: boolean
404
+
): Promise<{ uri: string; cid: string }> {
405
+
const recordWithType = { $type: "app.bsky.actor.profile", ...record };
406
+
const payload = useSelfRkey
407
+
? { ...recordWithType, rkey: "self" }
408
+
: recordWithType;
409
+
return await this.makeRequest<{ uri: string; cid: string }>(
410
+
"app.bsky.actor.profile.create",
411
+
"POST",
412
+
payload
413
+
);
414
+
}
415
+
416
+
async updateRecord(
417
+
rkey: string,
418
+
record: AppBskyActorProfileRecord
419
+
): Promise<{ uri: string; cid: string }> {
420
+
const recordWithType = { $type: "app.bsky.actor.profile", ...record };
421
+
return await this.makeRequest<{ uri: string; cid: string }>(
422
+
"app.bsky.actor.profile.update",
423
+
"POST",
424
+
{ rkey, record: recordWithType }
425
+
);
426
+
}
427
+
428
+
async deleteRecord(rkey: string): Promise<void> {
429
+
return await this.makeRequest<void>(
430
+
"app.bsky.actor.profile.delete",
431
+
"POST",
432
+
{ rkey }
433
+
);
434
+
}
435
+
}
436
+
437
+
class ActorBskyAppClient extends BaseClient {
438
+
readonly profile: ProfileActorBskyAppClient;
439
+
private readonly sliceUri: string;
440
+
441
+
constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) {
442
+
super(baseUrl, oauthClient);
443
+
this.sliceUri = sliceUri;
444
+
this.profile = new ProfileActorBskyAppClient(
445
+
baseUrl,
446
+
sliceUri,
447
+
oauthClient
448
+
);
449
+
}
450
+
}
451
+
452
+
class BskyAppClient extends BaseClient {
453
+
readonly actor: ActorBskyAppClient;
454
+
private readonly sliceUri: string;
455
+
456
+
constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) {
457
+
super(baseUrl, oauthClient);
458
+
this.sliceUri = sliceUri;
459
+
this.actor = new ActorBskyAppClient(baseUrl, sliceUri, oauthClient);
460
+
}
461
+
}
462
+
463
+
class AppClient extends BaseClient {
464
+
readonly bsky: BskyAppClient;
465
+
private readonly sliceUri: string;
466
+
467
+
constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) {
468
+
super(baseUrl, oauthClient);
469
+
this.sliceUri = sliceUri;
470
+
this.bsky = new BskyAppClient(baseUrl, sliceUri, oauthClient);
471
+
}
472
+
}
473
+
474
+
class StatusStatusphereXyzClient extends BaseClient {
475
+
private readonly sliceUri: string;
476
+
477
+
constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) {
478
+
super(baseUrl, oauthClient);
479
+
this.sliceUri = sliceUri;
480
+
}
481
+
482
+
async listRecords(
483
+
params?: ListRecordsParams<XyzStatusphereStatusRecordSortFields>
484
+
): Promise<ListRecordsResponse<XyzStatusphereStatusRecord>> {
485
+
const requestParams = { ...params, slice: this.sliceUri };
486
+
return await this.makeRequest<
487
+
ListRecordsResponse<XyzStatusphereStatusRecord>
488
+
>("xyz.statusphere.status.list", "GET", requestParams);
489
+
}
490
+
491
+
async getRecord(
492
+
params: GetRecordParams
493
+
): Promise<RecordResponse<XyzStatusphereStatusRecord>> {
494
+
const requestParams = { ...params, slice: this.sliceUri };
495
+
return await this.makeRequest<RecordResponse<XyzStatusphereStatusRecord>>(
496
+
"xyz.statusphere.status.get",
497
+
"GET",
498
+
requestParams
499
+
);
500
+
}
501
+
502
+
async searchRecords(
503
+
params: SearchRecordsParams<XyzStatusphereStatusRecordSortFields>
504
+
): Promise<ListRecordsResponse<XyzStatusphereStatusRecord>> {
505
+
const requestParams = { ...params, slice: this.sliceUri };
506
+
return await this.makeRequest<
507
+
ListRecordsResponse<XyzStatusphereStatusRecord>
508
+
>("xyz.statusphere.status.searchRecords", "GET", requestParams);
509
+
}
510
+
511
+
async createRecord(
512
+
record: XyzStatusphereStatusRecord,
513
+
useSelfRkey?: boolean
514
+
): Promise<{ uri: string; cid: string }> {
515
+
const recordWithType = { $type: "xyz.statusphere.status", ...record };
516
+
const payload = useSelfRkey
517
+
? { ...recordWithType, rkey: "self" }
518
+
: recordWithType;
519
+
return await this.makeRequest<{ uri: string; cid: string }>(
520
+
"xyz.statusphere.status.create",
521
+
"POST",
522
+
payload
523
+
);
524
+
}
525
+
526
+
async updateRecord(
527
+
rkey: string,
528
+
record: XyzStatusphereStatusRecord
529
+
): Promise<{ uri: string; cid: string }> {
530
+
const recordWithType = { $type: "xyz.statusphere.status", ...record };
531
+
return await this.makeRequest<{ uri: string; cid: string }>(
532
+
"xyz.statusphere.status.update",
533
+
"POST",
534
+
{ rkey, record: recordWithType }
535
+
);
536
+
}
537
+
538
+
async deleteRecord(rkey: string): Promise<void> {
539
+
return await this.makeRequest<void>(
540
+
"xyz.statusphere.status.delete",
541
+
"POST",
542
+
{ rkey }
543
+
);
544
+
}
545
+
}
546
+
547
+
class StatusphereXyzClient extends BaseClient {
548
+
readonly status: StatusStatusphereXyzClient;
549
+
private readonly sliceUri: string;
550
+
551
+
constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) {
552
+
super(baseUrl, oauthClient);
553
+
this.sliceUri = sliceUri;
554
+
this.status = new StatusStatusphereXyzClient(
555
+
baseUrl,
556
+
sliceUri,
557
+
oauthClient
558
+
);
559
+
}
560
+
}
561
+
562
+
class XyzClient extends BaseClient {
563
+
readonly statusphere: StatusphereXyzClient;
564
+
private readonly sliceUri: string;
565
+
566
+
constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) {
567
+
super(baseUrl, oauthClient);
568
+
this.sliceUri = sliceUri;
569
+
this.statusphere = new StatusphereXyzClient(baseUrl, sliceUri, oauthClient);
570
+
}
571
+
}
572
+
573
+
export class AtProtoClient extends BaseClient {
574
+
readonly app: AppClient;
575
+
readonly xyz: XyzClient;
576
+
readonly oauth?: OAuthClient;
577
+
private readonly sliceUri: string;
578
+
579
+
constructor(baseUrl: string, sliceUri: string, oauthClient?: OAuthClient) {
580
+
super(baseUrl, oauthClient);
581
+
this.sliceUri = sliceUri;
582
+
this.app = new AppClient(baseUrl, sliceUri, oauthClient);
583
+
this.xyz = new XyzClient(baseUrl, sliceUri, oauthClient);
584
+
this.oauth = this.oauthClient;
585
+
}
586
+
587
+
async getActors(params: GetActorsParams): Promise<GetActorsResponse> {
588
+
const requestParams = { ...params, slice: this.sliceUri };
589
+
return await this.makeRequest<GetActorsResponse>(
590
+
"social.slices.slice.getActors",
591
+
"GET",
592
+
requestParams
593
+
);
594
+
}
595
+
596
+
uploadBlob(request: UploadBlobRequest): Promise<UploadBlobResponse> {
597
+
return this.uploadBlobWithRetry(request, false);
598
+
}
599
+
600
+
private async uploadBlobWithRetry(
601
+
request: UploadBlobRequest,
602
+
isRetry?: boolean
603
+
): Promise<UploadBlobResponse> {
604
+
isRetry = isRetry ?? false;
605
+
// Special handling for blob upload with binary data
606
+
const httpMethod = "POST";
607
+
const url = `${this.baseUrl}/xrpc/com.atproto.repo.uploadBlob`;
608
+
609
+
if (!this.oauthClient) {
610
+
throw new Error("OAuth client not configured");
611
+
}
612
+
613
+
const tokens = await this.oauthClient.ensureValidToken();
614
+
615
+
const requestInit: RequestInit = {
616
+
method: httpMethod,
617
+
headers: {
618
+
"Content-Type": request.mimeType,
619
+
Authorization: `${tokens.tokenType} ${tokens.accessToken}`,
620
+
},
621
+
body: request.data,
622
+
};
623
+
624
+
const response = await fetch(url, requestInit);
625
+
if (!response.ok) {
626
+
// Handle 401 Unauthorized - attempt token refresh and retry once
627
+
if (response.status === 401 && !isRetry && this.oauthClient) {
628
+
try {
629
+
// Force token refresh by calling ensureValidToken again
630
+
await this.oauthClient.ensureValidToken();
631
+
// Retry the request once with refreshed tokens
632
+
return this.uploadBlobWithRetry(request, true);
633
+
} catch (_refreshError) {
634
+
throw new Error(
635
+
`Authentication required: OAuth tokens are invalid or expired. Please log in again.`
636
+
);
637
+
}
638
+
}
639
+
640
+
throw new Error(
641
+
`Blob upload failed: ${response.status} ${response.statusText}`
642
+
);
643
+
}
644
+
645
+
return await response.json();
646
+
}
647
+
}
+176
src/main.ts
+176
src/main.ts
···
1
+
import { render } from "preact-render-to-string";
2
+
import { App } from "./components/App.tsx";
3
+
import { StatusTimeline } from "./components/HomePage.tsx";
4
+
import { PORT, sessionStore, oauthSessions, atprotoClient } from "./config.ts";
5
+
import { AuthenticatedUser } from "./types.ts";
6
+
import { fetchStatusesWithAuthors } from "./api.ts";
7
+
8
+
async function handler(req: Request): Promise<Response> {
9
+
const url = new URL(req.url);
10
+
const pathname = url.pathname;
11
+
12
+
// Get current user session
13
+
const currentUser = await sessionStore.getCurrentUser(req);
14
+
15
+
// Routing
16
+
if (pathname === "/") {
17
+
return await handleHome(req, currentUser);
18
+
}
19
+
20
+
if (pathname === "/login") {
21
+
return handleLogin(req, currentUser);
22
+
}
23
+
24
+
if (pathname === "/oauth/authorize" && req.method === "POST") {
25
+
return await handleOAuthAuthorize(req);
26
+
}
27
+
28
+
if (pathname === "/oauth/callback") {
29
+
return await handleOAuthCallback(req);
30
+
}
31
+
32
+
if (pathname === "/logout" && req.method === "POST") {
33
+
return await handleLogout(req);
34
+
}
35
+
36
+
if (pathname === "/status" && req.method === "POST") {
37
+
return await handleSetStatus(req, currentUser);
38
+
}
39
+
40
+
return new Response("Not Found", { status: 404 });
41
+
}
42
+
43
+
async function handleHome(
44
+
_req: Request,
45
+
currentUser: AuthenticatedUser
46
+
): Promise<Response> {
47
+
const statuses = await fetchStatusesWithAuthors();
48
+
49
+
const html = render(App({ currentUser, statuses }));
50
+
51
+
return new Response(`<!DOCTYPE html>${html}`, {
52
+
headers: { "Content-Type": "text/html" },
53
+
});
54
+
}
55
+
56
+
function handleLogin(req: Request, currentUser: AuthenticatedUser): Response {
57
+
if (currentUser.isAuthenticated) {
58
+
return Response.redirect(new URL("/", req.url), 302);
59
+
}
60
+
61
+
const html = render(App({ currentUser, page: "login" }));
62
+
63
+
return new Response(`<!DOCTYPE html>${html}`, {
64
+
headers: { "Content-Type": "text/html" },
65
+
});
66
+
}
67
+
68
+
async function handleOAuthAuthorize(req: Request): Promise<Response> {
69
+
await atprotoClient.oauth?.logout();
70
+
71
+
const formData = await req.formData();
72
+
const loginHint = formData.get("loginHint") as string;
73
+
74
+
const authResult = await atprotoClient.oauth!.authorize({ loginHint });
75
+
76
+
return Response.redirect(authResult.authorizationUrl, 302);
77
+
}
78
+
79
+
async function handleOAuthCallback(req: Request): Promise<Response> {
80
+
const url = new URL(req.url);
81
+
const code = url.searchParams.get("code");
82
+
const state = url.searchParams.get("state");
83
+
84
+
if (!code || !state) {
85
+
return new Response("Missing OAuth parameters", { status: 400 });
86
+
}
87
+
88
+
await atprotoClient.oauth!.handleCallback({ code, state });
89
+
90
+
const sessionId = await oauthSessions.createOAuthSession();
91
+
92
+
if (!sessionId) {
93
+
return new Response("Failed to create session", { status: 500 });
94
+
}
95
+
96
+
const sessionCookie = sessionStore.createSessionCookie(sessionId);
97
+
98
+
return new Response(null, {
99
+
status: 302,
100
+
headers: {
101
+
Location: new URL("/", req.url).toString(),
102
+
"Set-Cookie": sessionCookie,
103
+
},
104
+
});
105
+
}
106
+
107
+
async function handleLogout(req: Request): Promise<Response> {
108
+
const session = await sessionStore.getSessionFromRequest(req);
109
+
110
+
if (session) {
111
+
await oauthSessions.logout(session.sessionId);
112
+
}
113
+
114
+
const clearCookie = sessionStore.createLogoutCookie();
115
+
116
+
return new Response(null, {
117
+
status: 302,
118
+
headers: {
119
+
Location: new URL("/login", req.url).toString(),
120
+
"Set-Cookie": clearCookie,
121
+
},
122
+
});
123
+
}
124
+
125
+
async function handleSetStatus(
126
+
req: Request,
127
+
currentUser: AuthenticatedUser
128
+
): Promise<Response> {
129
+
if (!currentUser.isAuthenticated) {
130
+
// For HTMX requests, send redirect header
131
+
const isHtmxRequest = req.headers.get("HX-Request") === "true";
132
+
if (isHtmxRequest) {
133
+
return new Response(null, {
134
+
status: 200,
135
+
headers: {
136
+
"HX-Redirect": "/login",
137
+
},
138
+
});
139
+
}
140
+
return Response.redirect(new URL("/login", req.url), 302);
141
+
}
142
+
143
+
const isHtmxRequest = req.headers.get("HX-Request") === "true";
144
+
145
+
const formData = await req.formData();
146
+
const status = formData.get("status") as string;
147
+
148
+
try {
149
+
await atprotoClient.xyz.statusphere.status.createRecord({
150
+
status,
151
+
createdAt: new Date().toISOString(),
152
+
});
153
+
} catch (error) {
154
+
console.error("Error setting status:", error);
155
+
return new Response("Error setting status", { status: 500 });
156
+
}
157
+
158
+
if (isHtmxRequest) {
159
+
// Return updated timeline for HTMX
160
+
try {
161
+
const statuses = await fetchStatusesWithAuthors();
162
+
const html = render(StatusTimeline({ statuses }));
163
+
164
+
return new Response(html, {
165
+
headers: { "Content-Type": "text/html" },
166
+
});
167
+
} catch (error) {
168
+
console.error("Error fetching updated statuses:", error);
169
+
return new Response("Error loading statuses", { status: 500 });
170
+
}
171
+
}
172
+
173
+
return Response.redirect(new URL("/", req.url), 302);
174
+
}
175
+
176
+
Deno.serve({ port: PORT, hostname: "0.0.0.0" }, handler);
+11
src/types.ts
+11
src/types.ts
···
1
+
import { XyzStatusphereStatusRecord, RecordResponse, Actor } from "./generated_client.ts";
2
+
3
+
export interface AuthenticatedUser {
4
+
isAuthenticated: boolean;
5
+
handle?: string;
6
+
sub?: string;
7
+
}
8
+
9
+
export interface HydratedStatus extends RecordResponse<XyzStatusphereStatusRecord> {
10
+
author?: Actor;
11
+
}