@commonpub/layer 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -51,15 +51,15 @@
51
51
  "vue-router": "^4.3.0",
52
52
  "zod": "^4.3.6",
53
53
  "@commonpub/auth": "0.6.0",
54
+ "@commonpub/learning": "0.5.2",
54
55
  "@commonpub/docs": "0.6.2",
55
56
  "@commonpub/config": "0.12.0",
56
- "@commonpub/explainer": "0.7.12",
57
57
  "@commonpub/editor": "0.7.9",
58
58
  "@commonpub/protocol": "0.9.9",
59
- "@commonpub/learning": "0.5.2",
60
59
  "@commonpub/schema": "0.16.0",
61
- "@commonpub/server": "2.50.0",
62
- "@commonpub/ui": "0.8.5"
60
+ "@commonpub/explainer": "0.7.12",
61
+ "@commonpub/ui": "0.8.5",
62
+ "@commonpub/server": "2.51.0"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@testing-library/jest-dom": "^6.9.1",
@@ -7,7 +7,7 @@ useSeoMeta({
7
7
  });
8
8
 
9
9
  const { signIn, refreshSession } = useAuth();
10
- const { federation } = useFeatures();
10
+ const { federation, identity: identityFeatures } = useFeatures();
11
11
  const route = useRoute();
12
12
 
13
13
  const identity = ref('');
@@ -15,11 +15,23 @@ const password = ref('');
15
15
  const error = ref('');
16
16
  const loading = ref(false);
17
17
 
18
- // Federated login state
18
+ // CommonPub-to-CommonPub (v1 SSO) state — `features.federation` gate
19
19
  const federatedDomain = ref('');
20
20
  const federatedLoading = ref(false);
21
21
  const federatedError = ref('');
22
22
 
23
+ // Mastodon-API login state (Phase 2b) — `features.identity.signInWithRemote` gate.
24
+ // Accepts `@user@host`, `user@host`, or bare `host` (no leading @).
25
+ // On submit, parses out the host and redirects to /api/auth/mastodon/start.
26
+ const mastodonHandle = ref('');
27
+ const mastodonError = ref('');
28
+
29
+ // Surface server-side errors redirected back from the callback (?mastodon_error=...)
30
+ onMounted(() => {
31
+ const queryErr = route.query.mastodon_error;
32
+ if (typeof queryErr === 'string' && queryErr) mastodonError.value = queryErr;
33
+ });
34
+
23
35
  // Federated callback context — present when redirected back from OAuth callback.
24
36
  // Only an opaque linkToken is passed; the verified identity stays server-side.
25
37
  const federatedLinkToken = computed(() => {
@@ -99,6 +111,53 @@ async function handleFederatedLogin(): Promise<void> {
99
111
  federatedLoading.value = false;
100
112
  }
101
113
  }
114
+
115
+ /**
116
+ * Parse a handle string into a host. Accepts:
117
+ * - `@user@mastodon.social` → mastodon.social
118
+ * - `user@mastodon.social` → mastodon.social
119
+ * - `acct:user@mastodon.social` → mastodon.social
120
+ * - bare `mastodon.social` → mastodon.social
121
+ * Returns null for anything that doesn't look like a host (e.g., bare username, email).
122
+ */
123
+ function extractHost(input: string): string | null {
124
+ const trimmed = input.trim().replace(/^acct:/i, '');
125
+ if (!trimmed) return null;
126
+ // user@host shape (with or without leading @)
127
+ const stripped = trimmed.startsWith('@') ? trimmed.slice(1) : trimmed;
128
+ const atIdx = stripped.indexOf('@');
129
+ if (atIdx > 0 && atIdx === stripped.lastIndexOf('@')) {
130
+ const host = stripped.slice(atIdx + 1).toLowerCase();
131
+ return isHostShape(host) ? host : null;
132
+ }
133
+ // Bare host (no @ anywhere, must contain a dot)
134
+ if (atIdx === -1 && stripped.includes('.')) {
135
+ return isHostShape(stripped.toLowerCase());
136
+ }
137
+ return null;
138
+ }
139
+
140
+ function isHostShape(host: string): string | null {
141
+ if (!host || host.length > 253) return null;
142
+ if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:\d+)?$/i.test(host)) return null;
143
+ if (!host.includes('.')) return null;
144
+ return host;
145
+ }
146
+
147
+ function handleMastodonLogin(): void {
148
+ mastodonError.value = '';
149
+ const host = extractHost(mastodonHandle.value);
150
+ if (!host) {
151
+ mastodonError.value = 'Enter a handle like @user@mastodon.social or just mastodon.social';
152
+ return;
153
+ }
154
+ // The start route is a GET that redirects to the remote's /oauth/authorize.
155
+ // Use window.location.href so the browser actually navigates (and cookies
156
+ // for the eventual callback land correctly).
157
+ const params = new URLSearchParams({ host });
158
+ if (redirectTo.value && redirectTo.value !== '/') params.set('returnTo', redirectTo.value);
159
+ window.location.href = `/api/auth/mastodon/start?${params.toString()}`;
160
+ }
102
161
  </script>
103
162
 
104
163
  <template>
@@ -154,16 +213,16 @@ async function handleFederatedLogin(): Promise<void> {
154
213
  <NuxtLink to="/auth/forgot-password" class="forgot-link">Forgot your password?</NuxtLink>
155
214
  </form>
156
215
 
157
- <!-- Federated login section — only shown when federation is enabled -->
216
+ <!-- v1 SSO federation section — gated by features.federation; CommonPub-only via trustedInstances -->
158
217
  <div v-if="federation && !federatedLinkToken" class="cpub-federated-section">
159
218
  <div class="cpub-federated-divider">
160
219
  <span class="cpub-federated-divider-text">or</span>
161
220
  </div>
162
221
 
163
- <form class="cpub-federated-form" @submit.prevent="handleFederatedLogin" aria-label="Sign in with another instance">
222
+ <form class="cpub-federated-form" @submit.prevent="handleFederatedLogin" aria-label="Sign in with another CommonPub instance">
164
223
  <div v-if="federatedError" class="form-error" role="alert">{{ federatedError }}</div>
165
224
 
166
- <label for="federated-domain" class="field-label">Sign in with another instance</label>
225
+ <label for="federated-domain" class="field-label">Sign in with another CommonPub instance</label>
167
226
  <div class="cpub-federated-input-group">
168
227
  <input
169
228
  id="federated-domain"
@@ -174,13 +233,54 @@ async function handleFederatedLogin(): Promise<void> {
174
233
  required
175
234
  autocomplete="off"
176
235
  />
177
- <button type="submit" class="cpub-federated-btn" :disabled="federatedLoading" aria-label="Sign in with remote instance">
236
+ <button type="submit" class="cpub-federated-btn" :disabled="federatedLoading" aria-label="Sign in with remote CommonPub instance">
178
237
  {{ federatedLoading ? 'Connecting...' : 'Go' }}
179
238
  </button>
180
239
  </div>
181
240
  </form>
182
241
  </div>
183
242
 
243
+ <!--
244
+ Mastodon-API login section (Phase 2b) — gated by features.identity.signInWithRemote.
245
+ Works with any Mastodon-API-compatible host: Mastodon, Pleroma, Akkoma, GoToSocial,
246
+ Firefish, and other CommonPub instances. On submit, parses the input to extract a
247
+ host and navigates to /api/auth/mastodon/start, which registers our OAuth client at
248
+ the remote and redirects the user to their authorize page.
249
+ -->
250
+ <div v-if="identityFeatures.signInWithRemote && !federatedLinkToken" class="cpub-federated-section">
251
+ <div class="cpub-federated-divider">
252
+ <span class="cpub-federated-divider-text">or</span>
253
+ </div>
254
+
255
+ <form class="cpub-federated-form" @submit.prevent="handleMastodonLogin" aria-label="Sign in with Mastodon or any Fediverse instance">
256
+ <div v-if="mastodonError" class="form-error" role="alert">{{ mastodonError }}</div>
257
+
258
+ <label for="mastodon-handle" class="field-label">
259
+ Sign in with Mastodon
260
+ <span class="field-label-note">— or Pleroma, GoToSocial, Akkoma, Firefish</span>
261
+ </label>
262
+ <div class="cpub-federated-input-group">
263
+ <input
264
+ id="mastodon-handle"
265
+ v-model="mastodonHandle"
266
+ type="text"
267
+ class="field-input"
268
+ placeholder="@user@mastodon.social or mastodon.social"
269
+ autocomplete="off"
270
+ inputmode="email"
271
+ spellcheck="false"
272
+ autocapitalize="off"
273
+ />
274
+ <button type="submit" class="cpub-federated-btn" aria-label="Sign in with Fediverse instance">
275
+ Sign in
276
+ </button>
277
+ </div>
278
+ <p class="cpub-federated-hint">
279
+ You'll be redirected to your home instance to confirm. No new password needed.
280
+ </p>
281
+ </form>
282
+ </div>
283
+
184
284
  <p class="login-footer">
185
285
  Don't have an account?
186
286
  <NuxtLink to="/auth/register">Register</NuxtLink>
@@ -394,4 +494,20 @@ async function handleFederatedLogin(): Promise<void> {
394
494
  opacity: 0.7;
395
495
  cursor: not-allowed;
396
496
  }
497
+
498
+ .field-label-note {
499
+ font-weight: 400;
500
+ font-family: var(--font-sans);
501
+ text-transform: none;
502
+ letter-spacing: 0;
503
+ color: var(--text-faint);
504
+ font-size: 11px;
505
+ }
506
+
507
+ .cpub-federated-hint {
508
+ font-size: 11px;
509
+ color: var(--text-faint);
510
+ margin: 4px 0 0 0;
511
+ line-height: 1.4;
512
+ }
397
513
  </style>
@@ -0,0 +1,144 @@
1
+ /**
2
+ * GET /api/auth/mastodon/callback?code=<auth_code>&state=<state>
3
+ *
4
+ * Phase 2a — server side of the Mastodon-login flow. Exchanges the
5
+ * auth code for an access token, verifies the remote account, then
6
+ * routes to one of three outcomes:
7
+ *
8
+ * 1. **Already linked** — `findUserByFederatedAccount` finds a local
9
+ * user. Mint a Better Auth session, redirect to dashboard. (Or
10
+ * to `returnTo` if the start request specified it.)
11
+ *
12
+ * 2. **Currently logged-in user adding a link** — `event.context.auth`
13
+ * has a user. Call `linkFederatedAccount` with the grant; redirect
14
+ * to settings.
15
+ *
16
+ * 3. **Anonymous + first-time** — call `storePendingLink` (existing
17
+ * v1 SSO machinery). Redirect to login form with the link token;
18
+ * Phase 2b's UI will consume the token to auto-provision a fresh
19
+ * local account or link to an existing user the visitor signs in
20
+ * as.
21
+ *
22
+ * Gated by `features.identity.signInWithRemote`.
23
+ */
24
+ import { z } from 'zod';
25
+ import type { H3Event } from 'h3';
26
+ import {
27
+ consumeMastodonLoginState,
28
+ createFederatedSession,
29
+ exchangeCodeAndVerify,
30
+ findUserByFederatedAccount,
31
+ getOrRegisterRemoteClient,
32
+ linkFederatedAccount,
33
+ storePendingLink,
34
+ } from '@commonpub/server';
35
+
36
+ const callbackSchema = z.object({
37
+ code: z.string().min(1),
38
+ state: z.string().min(1),
39
+ // OAuth error responses come back as ?error=...&error_description=...
40
+ // We surface a friendly message rather than crashing.
41
+ error: z.string().optional(),
42
+ error_description: z.string().optional(),
43
+ });
44
+
45
+ function setSessionCookie(event: H3Event, token: string, expiresAt: Date): void {
46
+ setCookie(event, 'better-auth.session_token', token, {
47
+ httpOnly: true,
48
+ secure: process.env.NODE_ENV === 'production',
49
+ sameSite: 'lax',
50
+ path: '/',
51
+ expires: expiresAt,
52
+ });
53
+ }
54
+
55
+ export default defineEventHandler(async (event) => {
56
+ const config = useConfig();
57
+ if (!config.features.identity.signInWithRemote) {
58
+ throw createError({ statusCode: 404, statusMessage: 'Not found' });
59
+ }
60
+
61
+ const db = useDB();
62
+ const params = parseQueryParams(event, callbackSchema);
63
+
64
+ if (params.error) {
65
+ // Remote denied / errored. Surface back to the login page with the
66
+ // OAuth error message so the UI can render it cleanly.
67
+ const msg = encodeURIComponent(params.error_description || params.error);
68
+ return sendRedirect(event, `/auth/login?mastodon_error=${msg}`, 302);
69
+ }
70
+
71
+ // Single-use, atomic. Returns null if expired / already consumed.
72
+ const loginState = await consumeMastodonLoginState(db, params.state);
73
+ if (!loginState) {
74
+ throw createError({
75
+ statusCode: 400,
76
+ statusMessage: 'Invalid or expired login state. Please try again.',
77
+ });
78
+ }
79
+
80
+ // Re-read the cached client credentials for this host; they were
81
+ // stored by the start route during `getOrRegisterRemoteClient`.
82
+ // Pass the same redirectUri so we don't mistakenly re-register.
83
+ const creds = await getOrRegisterRemoteClient(db, loginState.host, loginState.redirectUri);
84
+
85
+ // Exchange + verify in one atomic-ish step. Throws on token-exchange
86
+ // failure or 4xx on verifyCredentials.
87
+ let verified: Awaited<ReturnType<typeof exchangeCodeAndVerify>>;
88
+ try {
89
+ verified = await exchangeCodeAndVerify(loginState.host, creds, params.code);
90
+ } catch (err) {
91
+ const msg = err instanceof Error ? err.message : 'Unknown error';
92
+ throw createError({
93
+ statusCode: 502,
94
+ statusMessage: `Sign-in via ${loginState.host} failed: ${msg}`,
95
+ });
96
+ }
97
+
98
+ const ipAddress =
99
+ getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
100
+ ?? getRequestHeader(event, 'x-real-ip')
101
+ ?? undefined;
102
+ const userAgent = getRequestHeader(event, 'user-agent') ?? undefined;
103
+
104
+ // Outcome 1: identity already linked → log them in, redirect.
105
+ const existingLink = await findUserByFederatedAccount(db, verified.profile.actorUri);
106
+ if (existingLink) {
107
+ const session = await createFederatedSession(db, existingLink.userId, ipAddress, userAgent);
108
+ setSessionCookie(event, session.sessionToken, session.expiresAt);
109
+ return sendRedirect(event, loginState.returnTo ?? '/dashboard', 302);
110
+ }
111
+
112
+ // Outcome 2: a user is signed in locally and wants to add this link.
113
+ const auth = event.context.auth;
114
+ if (auth?.user) {
115
+ await linkFederatedAccount(
116
+ db,
117
+ auth.user.id,
118
+ verified.profile.actorUri,
119
+ loginState.host,
120
+ {
121
+ preferredUsername: verified.profile.username,
122
+ displayName: verified.profile.displayName,
123
+ avatarUrl: verified.profile.avatarUrl,
124
+ },
125
+ {
126
+ accessToken: verified.accessToken,
127
+ scopes: verified.scopes,
128
+ softwareKind: verified.softwareKind,
129
+ },
130
+ );
131
+ return sendRedirect(event, '/settings/account?federated=linked', 302);
132
+ }
133
+
134
+ // Outcome 3: anonymous + first-time → store as pending link, hand
135
+ // the opaque token to the login UI. Phase 2b's UI consumes the token.
136
+ const linkToken = await storePendingLink(db, {
137
+ actorUri: verified.profile.actorUri,
138
+ username: verified.profile.username,
139
+ instanceDomain: loginState.host,
140
+ displayName: verified.profile.displayName,
141
+ avatarUrl: verified.profile.avatarUrl,
142
+ });
143
+ return sendRedirect(event, `/auth/login?federated=true&linkToken=${linkToken}`, 302);
144
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * GET /api/auth/mastodon/start?host=<domain>[&returnTo=<path>]
3
+ *
4
+ * Phase 2a — server side of "Sign in via @user@host" / "Link a
5
+ * Mastodon-API account". CommonPub plays the OAuth client role here:
6
+ * registers itself with the remote, then redirects the user to the
7
+ * remote's `/oauth/authorize`.
8
+ *
9
+ * Gated by `features.identity.signInWithRemote`. When the flag is off,
10
+ * this route 404s — no leakage of new endpoints to scrapers.
11
+ *
12
+ * The companion `callback.get.ts` handles the auth code return.
13
+ */
14
+ import { z } from 'zod';
15
+ import {
16
+ buildAuthorizeUrl,
17
+ getOrRegisterRemoteClient,
18
+ isValidHost,
19
+ storeMastodonLoginState,
20
+ } from '@commonpub/server';
21
+
22
+ const startSchema = z.object({
23
+ host: z.string().min(3).max(253),
24
+ returnTo: z.string().optional(),
25
+ });
26
+
27
+ export default defineEventHandler(async (event) => {
28
+ const config = useConfig();
29
+ if (!config.features.identity.signInWithRemote) {
30
+ throw createError({ statusCode: 404, statusMessage: 'Not found' });
31
+ }
32
+
33
+ const db = useDB();
34
+ const { host: rawHost, returnTo } = parseQueryParams(event, startSchema);
35
+ const host = rawHost.trim().toLowerCase();
36
+
37
+ if (!isValidHost(host)) {
38
+ throw createError({
39
+ statusCode: 400,
40
+ statusMessage: `Invalid host: ${rawHost}`,
41
+ });
42
+ }
43
+
44
+ const redirectUri = `https://${config.instance.domain}/api/auth/mastodon/callback`;
45
+
46
+ // Get or register our OAuth client at the remote. First call to a
47
+ // given host hits the network (megalodon's `registerApp` →
48
+ // POST /api/v1/apps); subsequent calls read the cache.
49
+ let creds: Awaited<ReturnType<typeof getOrRegisterRemoteClient>>;
50
+ try {
51
+ creds = await getOrRegisterRemoteClient(db, host, redirectUri);
52
+ } catch (err) {
53
+ const msg = err instanceof Error ? err.message : 'Unknown registration error';
54
+ throw createError({
55
+ statusCode: 502,
56
+ statusMessage: `Could not register CommonPub at ${host}: ${msg}`,
57
+ });
58
+ }
59
+
60
+ // Mint a CSRF state token bound to this (host, redirectUri, returnTo)
61
+ // tuple. Single-use, 10-min TTL.
62
+ const state = await storeMastodonLoginState(db, {
63
+ host,
64
+ redirectUri,
65
+ returnTo,
66
+ });
67
+
68
+ // Redirect the user to the remote's authorize endpoint.
69
+ const authUrl = buildAuthorizeUrl(host, creds, state);
70
+ return sendRedirect(event, authUrl, 302);
71
+ });