Skip to content

Commit 5465dd3

Browse files
syropianclaude
andcommitted
Adds a token-authed API for the companion browser extension
- Versioned /api/v1 endpoints (Sanctum bearer) to list a user's tags and read/sync a repo's tags - Extracts the star/tag sync into a shared SyncStarTags action so the web and API paths can't diverge - Adds a single regenerable browser-extension access token, managed from the settings dialog Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0bd2155 commit 5465dd3

16 files changed

Lines changed: 657 additions & 43 deletions

File tree

app/Data/UserData.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,15 @@ public function __construct(
2626
public readonly ?string $scope,
2727
public readonly ?string $avatar,
2828
public readonly ?bool $is_sponsor,
29+
public readonly bool $has_browser_extension_token,
2930
public readonly UserSettingsData $settings,
3031
/** @var Collection<int, UserFlagData> */
3132
public readonly Collection $flags,
3233
#[RecordTypeScriptType(Limit::class, 'int')]
3334
public readonly array $limits,
3435
#[RecordTypeScriptType(Ability::class, 'bool')]
3536
public readonly array $abilities,
36-
) {
37-
}
37+
) {}
3838

3939
public static function fromModel(User $user): self
4040
{
@@ -48,6 +48,7 @@ public static function fromModel(User $user): self
4848
$user->scope,
4949
$user->avatar,
5050
$user->is_sponsor,
51+
$user->tokens()->where('name', User::BROWSER_EXTENSION_TOKEN)->exists(),
5152
UserSettingsData::from($user->settings),
5253
UserFlagData::collect($user->flags),
5354
$user->limits(),
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Exceptions;
6+
7+
use App\Data\Enums\Ability;
8+
use Exception;
9+
10+
class TagLimitExceededException extends Exception
11+
{
12+
public function __construct(public readonly Ability $ability)
13+
{
14+
parent::__construct('Tag limit exceeded.');
15+
}
16+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\Api\V1;
6+
7+
use App\Data\TagData;
8+
use App\Exceptions\TagLimitExceededException;
9+
use App\Http\Controllers\Controller;
10+
use App\Lib\SyncStarTags;
11+
use Illuminate\Http\JsonResponse;
12+
use Illuminate\Http\Request;
13+
14+
class ExtensionController extends Controller
15+
{
16+
/**
17+
* The authenticated user's full tag list, used for the extension's autocomplete.
18+
*/
19+
public function tags(Request $request): JsonResponse
20+
{
21+
return response()->json([
22+
'tags' => TagData::collect($request->user()->tags()->withStarCount()->get()),
23+
]);
24+
}
25+
26+
/**
27+
* The Astral tags currently applied to a repo, keyed by its GitHub database id.
28+
* Returns an empty set (not 404) when the repo has never been tagged.
29+
*/
30+
public function repoTags(Request $request, int $databaseId): JsonResponse
31+
{
32+
$star = $request->user()->stars()->with('tags')->where('repo_id', $databaseId)->first();
33+
34+
return response()->json([
35+
'databaseId' => $databaseId,
36+
'starExists' => (bool) $star,
37+
'tags' => $star ? TagData::collect($star->tags) : [],
38+
]);
39+
}
40+
41+
/**
42+
* Replace the full set of tags on a repo, creating the star and any missing
43+
* tags. Mirrors the dashboard sync via the shared SyncStarTags action.
44+
*/
45+
public function syncTags(Request $request, SyncStarTags $syncStarTags): JsonResponse
46+
{
47+
$request->validate([
48+
'databaseId' => ['required', 'integer'],
49+
'nameWithOwner' => ['required', 'string'],
50+
'url' => ['required', 'string', 'url'],
51+
'description' => ['nullable', 'string'],
52+
'tags' => 'array',
53+
'tags.*.name' => ['required_with:tags', 'string'],
54+
]);
55+
56+
try {
57+
$star = $syncStarTags->handle(
58+
$request->user(),
59+
(int) $request->input('databaseId'),
60+
$request->only(['nameWithOwner', 'url', 'description']),
61+
$request->input('tags', []),
62+
);
63+
} catch (TagLimitExceededException $e) {
64+
return response()->json([
65+
'message' => 'Tag limit reached. An active sponsorship is required to add more tags.',
66+
'sponsorshipRequired' => $e->ability->value,
67+
], 403);
68+
}
69+
70+
return response()->json([
71+
'databaseId' => $star->repo_id,
72+
'tags' => TagData::collect($star->load('tags')->tags),
73+
]);
74+
}
75+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers;
6+
7+
use App\Models\User;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\Response;
10+
11+
class BrowserExtensionTokenController extends Controller
12+
{
13+
/**
14+
* Generate (or regenerate) the user's single browser-extension token and
15+
* return the plaintext value once. Returns JSON so the secret never rides
16+
* through Hybridly's persisted shared props.
17+
*/
18+
public function store(): JsonResponse
19+
{
20+
/** @var User $user */
21+
$user = auth()->user();
22+
23+
$user->tokens()->where('name', User::BROWSER_EXTENSION_TOKEN)->delete();
24+
25+
return response()->json([
26+
'token' => $user->createToken(User::BROWSER_EXTENSION_TOKEN)->plainTextToken,
27+
]);
28+
}
29+
30+
public function destroy(): Response
31+
{
32+
auth()->user()->tokens()->where('name', User::BROWSER_EXTENSION_TOKEN)->delete();
33+
34+
return response()->noContent();
35+
}
36+
}

app/Http/Controllers/StarTagsController.php

Lines changed: 11 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@
44

55
namespace App\Http\Controllers;
66

7-
use App\Data\Enums\Ability;
8-
use App\Models\Tag;
7+
use App\Exceptions\TagLimitExceededException;
8+
use App\Lib\SyncStarTags;
99
use Illuminate\Http\Request;
10-
use Illuminate\Support\Facades\DB;
1110
use Illuminate\Validation\Rule;
1211

1312
class StarTagsController extends Controller
@@ -55,7 +54,7 @@ public function store(Request $request)
5554
*
5655
* @return \Illuminate\Http\Response
5756
*/
58-
public function update(Request $request)
57+
public function update(Request $request, SyncStarTags $syncStarTags)
5958
{
6059
$request->validate([
6160
'databaseId' => ['required', 'integer'],
@@ -66,45 +65,17 @@ public function update(Request $request)
6665
'tags.*.name' => ['required_with:tags', 'string'],
6766
]);
6867

69-
DB::beginTransaction();
70-
71-
$repoId = $request->input('databaseId');
72-
$tags = $request->input('tags');
73-
$meta = $request->only(['nameWithOwner', 'url', 'description']);
74-
75-
$star = auth()
76-
->user()
77-
->stars()
78-
->firstOrCreate(
79-
['repo_id' => $repoId],
80-
['meta' => $meta]
68+
try {
69+
$syncStarTags->handle(
70+
auth()->user(),
71+
(int) $request->input('databaseId'),
72+
$request->only(['nameWithOwner', 'url', 'description']),
73+
$request->input('tags', []),
8174
);
82-
83-
$star->meta = $meta;
84-
$star->save();
85-
86-
$ids = [];
87-
88-
if (empty($tags)) {
89-
$star->removeAllTags();
90-
} else {
91-
foreach ($tags as $tag) {
92-
$tag = auth()->user()->tags()->firstOrCreate(['name' => $tag['name']]);
93-
$ids[] = $tag->id;
94-
}
95-
$star->tags()->sync($ids);
75+
} catch (TagLimitExceededException $e) {
76+
return $this->sponsorshipRequired($e->ability);
9677
}
9778

98-
// Authorized after the writes: syncing can firstOrCreate new tags, so the
99-
// sponsorship cap is only knowable against the resulting tag count.
100-
if (auth()->user()->cannot('sync', Tag::class)) {
101-
DB::rollBack();
102-
103-
return $this->sponsorshipRequired(Ability::CREATE_TAG);
104-
}
105-
106-
DB::commit();
107-
10879
return redirect()->route('dashboard.show');
10980
}
11081
}

app/Lib/SyncStarTags.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Lib;
6+
7+
use App\Data\Enums\Ability;
8+
use App\Exceptions\TagLimitExceededException;
9+
use App\Models\Star;
10+
use App\Models\Tag;
11+
use App\Models\User;
12+
use Illuminate\Support\Facades\DB;
13+
14+
class SyncStarTags
15+
{
16+
/**
17+
* Sync the full set of tags on a user's star for the given repo, creating the
18+
* star and any missing tags as needed. Shared by the web dashboard and the
19+
* extension API so both stay identical.
20+
*
21+
* @param array<string, mixed> $meta nameWithOwner, url, description
22+
* @param array<int, array{name: string}> $tags
23+
*
24+
* @throws TagLimitExceededException when a non-sponsor exceeds the tag cap
25+
*/
26+
public function handle(User $user, int $repoId, array $meta, array $tags): Star
27+
{
28+
DB::beginTransaction();
29+
30+
$star = $user->stars()->firstOrCreate(
31+
['repo_id' => $repoId],
32+
['meta' => $meta]
33+
);
34+
35+
$star->meta = $meta;
36+
$star->save();
37+
38+
if (empty($tags)) {
39+
$star->removeAllTags();
40+
} else {
41+
$ids = [];
42+
foreach ($tags as $tag) {
43+
$ids[] = $user->tags()->firstOrCreate(['name' => $tag['name']])->id;
44+
}
45+
$star->tags()->sync($ids);
46+
}
47+
48+
// Authorized after the writes: syncing can firstOrCreate new tags, so the
49+
// sponsorship cap is only knowable against the resulting tag count.
50+
if ($user->cannot('sync', Tag::class)) {
51+
DB::rollBack();
52+
53+
throw new TagLimitExceededException(Ability::CREATE_TAG);
54+
}
55+
56+
DB::commit();
57+
58+
return $star;
59+
}
60+
}

app/Models/User.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@
99
use Illuminate\Foundation\Auth\User as Authenticatable;
1010
use Illuminate\Notifications\Notifiable;
1111
use Illuminate\Support\Facades\Http;
12+
use Laravel\Sanctum\HasApiTokens;
1213

1314
/**
1415
* @mixin IdeHelperUser
1516
*/
1617
class User extends Authenticatable
1718
{
18-
use HasFactory, Notifiable;
19+
use HasApiTokens, HasFactory, Notifiable;
20+
21+
public const BROWSER_EXTENSION_TOKEN = 'browser-extension';
1922

2023
protected $hidden = [
2124
'remember_token',
@@ -43,6 +46,7 @@ protected static function booted()
4346
$user->tags()->delete();
4447
$user->stars()->delete();
4548
$user->flags()->delete();
49+
$user->tokens()->delete();
4650
});
4751
}
4852

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::create('personal_access_tokens', function (Blueprint $table) {
15+
$table->id();
16+
$table->morphs('tokenable');
17+
$table->text('name');
18+
$table->string('token', 64)->unique();
19+
$table->text('abilities')->nullable();
20+
$table->timestamp('last_used_at')->nullable();
21+
$table->timestamp('expires_at')->nullable()->index();
22+
$table->timestamps();
23+
});
24+
}
25+
26+
/**
27+
* Reverse the migrations.
28+
*/
29+
public function down(): void
30+
{
31+
Schema::dropIfExists('personal_access_tokens');
32+
}
33+
};

0 commit comments

Comments
 (0)