@startup-api/cloudflare 0.4.0 → 0.4.2

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/README.md CHANGED
@@ -69,8 +69,9 @@ Either way, set your production secrets (`SESSION_SECRET`, OAuth credentials) on
69
69
  | `PATREON_CLIENT_ID` | No | N/A | Patreon OAuth2 Client ID |
70
70
  | `PATREON_CLIENT_SECRET` | No | N/A | Patreon OAuth2 Client Secret |
71
71
  | `PATREON_WEBHOOK_SECRET` | No | N/A | Secret for verifying Patreon webhook signatures |
72
+ | `ATPROTO_ENABLED` | No | N/A | Set truthy (`true`/`1`/`yes`/`on`) to enable AT Protocol (Atmosphere) login |
72
73
 
73
- > AT Protocol (Bluesky) login needs **no environment variables at all** — it is a public OAuth client with no secret, so it is configured entirely through the `createStartupAPI` factory (see [Bluesky / AT Protocol](#bluesky--at-protocol-atproto) below).
74
+ > AT Protocol (Atmosphere) login needs **no client secret** — it is a public OAuth client. Enable it either by setting `ATPROTO_ENABLED` truthy (a per-deployment switch) **or** through the `createStartupAPI` factory (see [AT Protocol / Atmosphere](#at-protocol--atmosphere-atproto) below). A factory `atproto: { enabled: false }` overrides the env flag, so a deployment can force it off.
74
75
 
75
76
  > Environment variables hold only credentials/secrets (OAuth client IDs and all secrets) plus the per‑deployment values `ORIGIN_URL`, `AUTH_ORIGIN`, `USERS_PATH`, `ADMIN_IDS`, and `ENVIRONMENT`. **All other configuration — OAuth scopes, Patreon campaign id, the access policy, entitlement freshness — is passed to the `createStartupAPI` factory** (see [Access policy & provider entitlements](#access-policy--provider-entitlements)).
76
77
 
@@ -101,11 +102,16 @@ Either way, set your production secrets (`SESSION_SECRET`, OAuth credentials) on
101
102
  3. Add your authorized redirect URI: `https://<your-worker-url>/users/auth/patreon/callback`
102
103
  4. Copy the **Client ID** and **Client Secret** and add them to your Worker's environment variables
103
104
 
104
- #### Bluesky / AT Protocol (atproto)
105
+ #### AT Protocol / Atmosphere (atproto)
105
106
 
106
- atproto login is decentralized: there is **no central provider to register with and no client secret**. Instead the worker acts as a [public OAuth client](https://atproto.com/specs/oauth) identified by a client-metadata document it serves itself, and it discovers the right authorization server **per user** from their handle or DID — so it works with `bsky.social` and any self-hosted PDS alike, with no Bluesky host hardcoded.
107
+ atproto login is decentralized: there is **no central provider to register with and no client secret**. Instead the worker acts as a [public OAuth client](https://atproto.com/specs/oauth) identified by a client-metadata document it serves itself, and it discovers the right authorization server **per user** from their handle or DID — so it works with `bsky.social` and any self-hosted PDS alike, with no provider host hardcoded.
107
108
 
108
- Because it has no secrets, atproto is configured **entirely through the `createStartupAPI` factory** (not env vars). Just like the env-credential providers are enabled by the presence of their credentials, atproto is enabled simply by **including its config key** — an empty object is enough:
109
+ Because it has no secrets, atproto is enabled in one of two ways:
110
+
111
+ - **Env flag (no code):** set `ATPROTO_ENABLED` truthy (`true`/`1`/`yes`/`on`) — handy for toggling it per deployment (e.g. on in prod, off in preview) without touching code. This uses the default settings.
112
+ - **Factory config (for customization):** include its config key in `createStartupAPI` — an empty object is enough, and the optional fields below let you set the client name, resolvers, or scopes.
113
+
114
+ A factory `atproto: { enabled: false }` is an explicit opt-out that **overrides** the env flag, so a deployment can force the provider off.
109
115
 
110
116
  ```ts
111
117
  import { createStartupAPI } from '@startup-api/cloudflare';
@@ -128,9 +134,9 @@ export default api.default;
128
134
  export const { UserDO, AccountDO, SystemDO, CredentialDO } = api;
129
135
  ```
130
136
 
131
- 1. Include `atproto: {}` in the factory `providers` config (no client id/secret needed). Pass `enabled: false` to opt out explicitly.
137
+ 1. Enable it — set `ATPROTO_ENABLED` truthy, or include `atproto: {}` in the factory `providers` config (no client id/secret needed either way). A factory `enabled: false` forces it off.
132
138
  2. Deploy over **HTTPS** with a stable hostname. The worker automatically serves its client metadata at `https://<your-worker-url>/users/auth/atproto/client-metadata.json` (this URL is the OAuth `client_id`) and registers the redirect URI `https://<your-worker-url>/users/auth/atproto/callback`.
133
- 3. That's it. When a visitor clicks **Continue with Bluesky**, they're asked for their handle (e.g. `alice.bsky.social`) or DID; the worker then resolves it through the full atproto discovery chain and redirects them to _their own_ server to sign in:
139
+ 3. That's it. When a visitor clicks **Login with your Atmosphere account**, they're asked for their handle (e.g. `alice.bsky.social`) or DID; the worker then resolves it through the full atproto discovery chain and redirects them to _their own_ server to sign in:
134
140
 
135
141
  ```
136
142
  handle ─▶ DID (HTTPS .well-known/atproto-did, then DNS _atproto.<handle> via DoH)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startup-api/cloudflare",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "license": "Apache-2.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -37,6 +37,7 @@
37
37
  "devDependencies": {
38
38
  "@cloudflare/vitest-pool-workers": "^0.12.4",
39
39
  "@eslint/js": "^10.0.1",
40
+ "@types/he": "^1.2.3",
40
41
  "@vitest/coverage-istanbul": "^3.2.4",
41
42
  "@vitest/coverage-v8": "^3.2.4",
42
43
  "eslint": "^10.0.1",
@@ -47,6 +48,7 @@
47
48
  "wrangler": "^4.60.0"
48
49
  },
49
50
  "dependencies": {
51
+ "he": "^1.2.0",
50
52
  "prettier": "^3.8.1",
51
53
  "zod": "^3.25.76"
52
54
  }
@@ -1,3 +1,8 @@
1
+ // Atmosphere (atproto) logo mark — the "union" glyph, drawn on a 0 0 32 32 viewBox.
2
+ // Shared by the provider badge and the login button so the two never drift apart.
3
+ const ATMOSPHERE_MARK_PATH =
4
+ 'M16 0C18.1127 1.0372e-05 19.9555 1.15414 20.9332 2.8662C21.1045 3.16613 21.4647 3.31517 21.7979 3.22403C22.2761 3.09321 22.7795 3.02333 23.2992 3.02333C26.4347 3.02335 28.9765 5.56529 28.9765 8.70083C28.9765 9.22048 28.9066 9.72378 28.7758 10.2019C28.6846 10.5351 28.8337 10.8953 29.1336 11.0666C30.8458 12.0442 32 13.8872 32 16C32 16.0822 31.9981 16.1639 31.9946 16.2452C31.9946 16.2456 31.9944 16.246 31.9942 16.2463C31.9939 16.2465 31.9937 16.2469 31.9937 16.2473C31.8599 19.6701 29.2873 22.4 26.1329 22.4L25.9815 22.3979C24.3638 22.353 22.909 21.59 21.8719 20.3888C21.5701 20.0392 20.9949 20.0275 20.6799 20.3652C19.5117 21.6171 17.8474 22.4 16 22.4C12.4654 22.4 9.6 19.5346 9.6 16C9.6 12.4654 12.4654 9.6 16 9.6C17.4368 9.6 18.7629 10.0736 19.831 10.8731C20.0053 11.0036 20.2667 10.8843 20.2667 10.6667C20.2667 10.0776 20.7442 9.6 21.3333 9.6C21.9224 9.6 22.4 10.0776 22.4 10.6667V16C22.4158 18.5458 24.26 20.2666 26.1329 20.2667C27.9447 20.2667 29.7293 18.6564 29.8583 16.2467C29.8583 16.2466 29.8583 16.2464 29.8581 16.2463C29.858 16.2461 29.8579 16.2459 29.8579 16.2458C29.8591 16.2281 29.8597 16.2104 29.8606 16.1927C29.8634 16.1293 29.8654 16.0654 29.8658 16.001C29.8658 16.0006 29.866 16.0003 29.8663 16C29.8665 15.9997 29.8667 15.9994 29.8667 15.999C29.8663 14.5464 28.9914 13.2933 27.7308 12.7465L26.4045 12.1712C26.0876 12.0337 25.937 11.6696 26.0641 11.3484L26.5963 10.0042C26.7549 9.60324 26.8431 9.16479 26.8431 8.70083C26.8431 6.77399 25.3056 5.20644 23.3906 5.15792L23.2992 5.15667C22.8351 5.15667 22.3965 5.24487 21.9956 5.40354L20.6515 5.93568C20.3304 6.06282 19.9663 5.91222 19.8288 5.59538L19.2533 4.26917C18.7234 3.04774 17.5306 2.18836 16.1356 2.13583L16 2.13333C14.547 2.13333 13.2934 3.00824 12.7465 4.26896L12.1712 5.59529C12.0337 5.91217 11.6696 6.06282 11.3484 5.93568L10.0042 5.40354C9.62831 5.25477 9.21941 5.16798 8.7875 5.15771L8.70083 5.15667C6.74349 5.15667 5.15667 6.74349 5.15667 8.70083C5.15667 9.16475 5.24485 9.60323 5.40354 10.0042L5.93568 11.3484C6.06282 11.6696 5.91217 12.0337 5.59529 12.1712L4.26896 12.7465C3.00823 13.2934 2.13333 14.5469 2.13333 16C2.13333 17.4529 3.00832 18.7063 4.26917 19.2533L5.59538 19.8288C5.91222 19.9663 6.06282 20.3304 5.93568 20.6515L5.40354 21.9956C5.24487 22.3965 5.15667 22.835 5.15667 23.2992C5.15667 25.2565 6.74349 26.8433 8.70083 26.8433C9.1644 26.8433 9.60287 26.7551 10.0042 26.5963L11.3484 26.0641C11.6696 25.937 12.0337 26.0876 12.1712 26.4045L12.7465 27.7308C13.2934 28.9917 14.547 29.8667 16 29.8667C17.4529 29.8667 18.7063 28.9918 19.2533 27.7308L19.6555 26.8037C19.8871 26.2698 20.5083 26.0256 21.0415 26.2589C21.561 26.4862 21.8066 27.0847 21.5966 27.6115L21.2104 28.5798C20.3374 30.5922 18.3333 32 16 32C13.8872 32 12.0442 30.8458 11.0666 29.1336C10.8953 28.8337 10.5351 28.6846 10.2019 28.7758C9.72378 28.9067 9.22049 28.9767 8.70083 28.9767C5.56528 28.9767 3.02333 26.4347 3.02333 23.2992C3.02333 22.7795 3.09321 22.2761 3.22403 21.7979C3.31516 21.4647 3.16613 21.1045 2.86619 20.9332C1.15412 19.9555 0 18.1127 0 16C5.927e-07 13.8873 1.1541 12.0443 2.8662 11.0666C3.16613 10.8953 3.31517 10.5351 3.22403 10.2019C3.09323 9.72379 3.02333 9.22047 3.02333 8.70083C3.02333 5.56528 5.56528 3.02333 8.70083 3.02333C9.22047 3.02334 9.72379 3.09323 10.2019 3.22404C10.5351 3.31517 10.8953 3.16614 11.0666 2.86621C12.0443 1.15411 13.8873 0 16 0ZM16 11.7333C13.6436 11.7333 11.7333 13.6436 11.7333 16C11.7333 18.3564 13.6436 20.2667 16 20.2667C18.3564 20.2667 20.2667 18.3564 20.2667 16C20.2667 13.6436 18.3564 11.7333 16 11.7333Z';
5
+
1
6
  class PowerStrip extends HTMLElement {
2
7
  constructor() {
3
8
  super();
@@ -213,7 +218,7 @@ class PowerStrip extends HTMLElement {
213
218
  } else if (provider === 'patreon') {
214
219
  return `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="#FF424D" stroke="white" stroke-width="1"/><circle cx="14" cy="11" r="3.5" fill="white"/><rect x="6.5" y="6.5" width="2" height="11" fill="white"/></svg>`;
215
220
  } else if (provider === 'atproto') {
216
- return `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="11" fill="#0085FF" stroke="white" stroke-width="1"/><path d="M12 10.5C10.9 8.4 8.2 6.3 6.3 6c-1.5-.2-1.8.7-1.5 2 .2 1 1.5 5 2.3 6 .9 1.2 2 1.4 3 1.2-1.7.3-3.2 1-1.2 3 .9.9 1.6.3 2.1-.6.5-1 .8-2.1 1-2.6.2.5.5 1.6 1 2.6.5.9 1.2 1.5 2.1.6 2-2 .5-2.7-1.2-3 1 .2 2.1 0 3-1.2.8-1 2.1-5 2.3-6 .3-1.3 0-2.2-1.5-2-1.9.3-4.6 2.4-5.7 4.5z" fill="white"/></svg>`;
221
+ return `<svg viewBox="0 0 32 32"><circle cx="16" cy="16" r="16" fill="#4a8ad4"/><g transform="translate(3.2 3.2) scale(0.8)"><path fill-rule="evenodd" clip-rule="evenodd" d="${ATMOSPHERE_MARK_PATH}" fill="white"/></g></svg>`;
217
222
  }
218
223
  return '';
219
224
  }
@@ -263,10 +268,10 @@ class PowerStrip extends HTMLElement {
263
268
  if (providers.includes('atproto')) {
264
269
  authButtons += `
265
270
  <a href="${atprotoLink}" class="auth-btn atproto">
266
- <svg viewBox="0 0 24 24">
267
- <path d="M12 10.5C10.9 8.4 8.2 6.3 6.3 6c-1.5-.2-1.8.7-1.5 2 .2 1 1.5 5 2.3 6 .9 1.2 2 1.4 3 1.2-1.7.3-3.2 1-1.2 3 .9.9 1.6.3 2.1-.6.5-1 .8-2.1 1-2.6.2.5.5 1.6 1 2.6.5.9 1.2 1.5 2.1.6 2-2 .5-2.7-1.2-3 1 .2 2.1 0 3-1.2.8-1 2.1-5 2.3-6 .3-1.3 0-2.2-1.5-2-1.9.3-4.6 2.4-5.7 4.5z" fill="currentColor"/>
271
+ <svg viewBox="0 0 32 32">
272
+ <path fill-rule="evenodd" clip-rule="evenodd" d="${ATMOSPHERE_MARK_PATH}" fill="currentColor"/>
268
273
  </svg>
269
- Continue with Bluesky
274
+ Login with your Atmosphere account
270
275
  </a>`;
271
276
  }
272
277
 
@@ -597,7 +602,7 @@ class PowerStrip extends HTMLElement {
597
602
  .provider-badge.google { color: #3c4043; }
598
603
  .provider-badge.twitch { color: #9146FF; }
599
604
  .provider-badge.patreon { color: #FF424D; }
600
- .provider-badge.atproto { color: #0085FF; }
605
+ .provider-badge.atproto { color: #4a8ad4; }
601
606
 
602
607
  .user-info {
603
608
  display: flex;
@@ -671,7 +676,7 @@ class PowerStrip extends HTMLElement {
671
676
  box-shadow: var(--ps-dialog-shadow);
672
677
  background: var(--ps-dialog-bg);
673
678
  color: var(--ps-dialog-text);
674
- max-width: 20rem;
679
+ max-width: 25rem;
675
680
  width: 90%;
676
681
  overflow: hidden;
677
682
  }
@@ -736,7 +741,8 @@ class PowerStrip extends HTMLElement {
736
741
  justify-content: center;
737
742
  gap: 0.75rem;
738
743
  font-weight: 500;
739
- font-size: 1rem;
744
+ font-size: 0.95rem;
745
+ white-space: nowrap;
740
746
  transition: all 0.2s ease;
741
747
  text-decoration: none;
742
748
  color: inherit;
@@ -755,6 +761,7 @@ class PowerStrip extends HTMLElement {
755
761
  .auth-btn svg {
756
762
  width: 1.5rem;
757
763
  height: 1.5rem;
764
+ flex-shrink: 0;
758
765
  }
759
766
 
760
767
  .auth-btn.google {
@@ -787,13 +794,13 @@ class PowerStrip extends HTMLElement {
787
794
  }
788
795
 
789
796
  .auth-btn.atproto {
790
- background-color: #0085FF;
797
+ background-color: #4a8ad4;
791
798
  color: white;
792
- border-color: #0085FF;
799
+ border-color: #4a8ad4;
793
800
  }
794
801
  .auth-btn.atproto:hover {
795
- background-color: #006fd6;
796
- border-color: #006fd6;
802
+ background-color: #3d77ba;
803
+ border-color: #3d77ba;
797
804
  }
798
805
 
799
806
  /* Account Switcher Styling */
@@ -12,4 +12,7 @@ export type StartupAPIEnv = {
12
12
  ADMIN_IDS: string;
13
13
  SESSION_SECRET: string;
14
14
  ENVIRONMENT?: string;
15
+ // atproto has no credentials; this per-deployment flag enables it without touching the factory
16
+ // config (truthy = "true"/"1"/"yes"/"on"). A factory `atproto: { enabled: false }` still overrides it.
17
+ ATPROTO_ENABLED?: string;
15
18
  } & Env;
@@ -1,3 +1,6 @@
1
+ import { escape as escapeHtml } from 'he';
2
+
3
+ import { ATMOSPHERE_MARK_PATH } from './atmosphereMark';
1
4
  import type { StartupAPIEnv } from '../StartupAPIEnv';
2
5
  import type { ProviderOptions } from '../schemas/config';
3
6
 
@@ -27,18 +30,27 @@ interface AtprotoFlowState {
27
30
  returnUrl: string | null;
28
31
  }
29
32
 
33
+ /** A string env flag is truthy when it reads as "true"/"1"/"yes"/"on" (case-insensitive). */
34
+ function isEnvFlagTruthy(value: string | undefined): boolean {
35
+ return value !== undefined && ['true', '1', 'yes', 'on'].includes(value.trim().toLowerCase());
36
+ }
37
+
30
38
  /**
31
- * Whether the atproto provider is turned on. It has no client secret (public OAuth client), so like
32
- * the env-credential providers are enabled by the presence of their credentials — atproto is enabled
33
- * simply by including its config key (`providers: { atproto: {} }`). Pass `enabled: false` to opt out
34
- * explicitly (e.g. when the config is built dynamically).
39
+ * Whether the atproto provider is turned on. It has no client secret (public OAuth client), so it is
40
+ * enabled either by:
41
+ * - including its factory config key (`providers: { atproto: {} }`), or
42
+ * - setting the `ATPROTO_ENABLED` env var truthy (a per-deployment switch needing no code change).
43
+ * A factory `atproto: { enabled: false }` is an explicit opt-out that overrides the env flag, so a
44
+ * deployment can force the provider off regardless of the environment.
35
45
  */
36
- export function isAtprotoEnabled(options?: ProviderOptions): boolean {
37
- return options !== undefined && options.enabled !== false;
46
+ export function isAtprotoEnabled(options?: ProviderOptions, env?: Pick<StartupAPIEnv, 'ATPROTO_ENABLED'>): boolean {
47
+ if (options?.enabled === false) return false; // explicit factory opt-out always wins
48
+ if (options !== undefined) return true; // present in the factory config
49
+ return isEnvFlagTruthy(env?.ATPROTO_ENABLED); // otherwise honor the per-deployment env toggle
38
50
  }
39
51
 
40
52
  /**
41
- * AT Protocol (Bluesky and any atproto PDS) authentication.
53
+ * AT Protocol (Atmosphere) authentication — works with any atproto PDS.
42
54
  *
43
55
  * Unlike the classic OAuth2 providers, atproto requires PKCE, DPoP-bound tokens, Pushed Authorization
44
56
  * Requests (PAR), and per-user dynamic endpoints discovered from the identity (handle → DID → PDS →
@@ -52,8 +64,8 @@ export class AtprotoProvider extends OAuthProvider {
52
64
  private clientName = 'StartupAPI';
53
65
  private resolverOptions: ResolverOptions = {};
54
66
 
55
- static create(_env: StartupAPIEnv, redirectBase: string, options?: ProviderOptions): AtprotoProvider | null {
56
- if (!isAtprotoEnabled(options)) return null;
67
+ static create(env: StartupAPIEnv, redirectBase: string, options?: ProviderOptions): AtprotoProvider | null {
68
+ if (!isAtprotoEnabled(options, env)) return null;
57
69
  const provider = new AtprotoProvider('', '', redirectBase + '/atproto/callback', 'atproto', options?.scopes);
58
70
  provider.clientMetadataUrl = redirectBase + '/atproto/client-metadata.json';
59
71
  provider.clientUri = new URL(redirectBase).origin;
@@ -66,10 +78,10 @@ export class AtprotoProvider extends OAuthProvider {
66
78
  }
67
79
 
68
80
  getIcon(): string {
69
- // AT Protocol / Bluesky butterfly mark.
70
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
71
- <circle cx="12" cy="12" r="11" fill="#0085FF" stroke="white" stroke-width="1"/>
72
- <path d="M12 10.5C10.9 8.4 8.2 6.3 6.3 6c-1.5-.2-1.8.7-1.5 2 .2 1 1.5 5 2.3 6 .9 1.2 2 1.4 3 1.2-1.7.3-3.2 1-1.2 3 .9.9 1.6.3 2.1-.6.5-1 .8-2.1 1-2.6.2.5.5 1.6 1 2.6.5.9 1.2 1.5 2.1.6 2-2 .5-2.7-1.2-3 1 .2 2.1 0 3-1.2.8-1 2.1-5 2.3-6 .3-1.3 0-2.2-1.5-2-1.9.3-4.6 2.4-5.7 4.5z" fill="white"/>
81
+ // Atmosphere (atproto) "union" logo mark, white on the brand-blue badge.
82
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
83
+ <circle cx="16" cy="16" r="16" fill="#4a8ad4"/>
84
+ <g transform="translate(3.2 3.2) scale(0.8)"><path fill-rule="evenodd" clip-rule="evenodd" d="${ATMOSPHERE_MARK_PATH}" fill="white"/></g>
73
85
  </svg>`;
74
86
  }
75
87
 
@@ -109,6 +121,19 @@ export class AtprotoProvider extends OAuthProvider {
109
121
  return this.renderHandleForm(ctx, returnUrl);
110
122
  }
111
123
 
124
+ try {
125
+ return await this.startAuthorization(ctx, identifier, returnUrl);
126
+ } catch (e) {
127
+ // The user supplied a handle and is still on our side of the redirect, so the most useful
128
+ // recovery is to re-render the entry form with the failure shown and their handle pre-filled,
129
+ // rather than a dead-end error page. (Callback-phase failures fall back to renderAuthError.)
130
+ const message = e instanceof Error ? e.message : String(e);
131
+ return this.renderHandleForm(ctx, returnUrl, { error: message, handle: identifier });
132
+ }
133
+ }
134
+
135
+ /** Resolve the identity, run the DPoP-protected PAR, and redirect to the discovered auth endpoint. */
136
+ private async startAuthorization(ctx: AuthContext, identifier: string, returnUrl: string | null): Promise<Response> {
112
137
  const identity = await resolveIdentity(identifier, this.resolverOptions);
113
138
  const { verifier, challenge } = await generatePkce();
114
139
  const dpopKey = await generateDpopKey();
@@ -227,38 +252,69 @@ export class AtprotoProvider extends OAuthProvider {
227
252
  return { token, profile, returnUrl: flow.returnUrl ?? null, setCookies: [clearCookie] };
228
253
  }
229
254
 
230
- /** Minimal handle-entry page shown when the user starts the flow without an identifier. */
231
- private renderHandleForm(ctx: AuthContext, returnUrl: string | null): Response {
255
+ /**
256
+ * Handle-entry page. Shown when the user starts the flow without an identifier, and re-shown when a
257
+ * supplied handle fails to authorize — in which case `options.error` renders an inline banner and
258
+ * `options.handle` pre-fills the input so the user can correct and retry without leaving the page.
259
+ */
260
+ private renderHandleForm(
261
+ ctx: AuthContext,
262
+ returnUrl: string | null,
263
+ options: { error?: string; handle?: string } = {},
264
+ ): Response {
232
265
  const action = `${ctx.authPath}/atproto`;
233
266
  const returnField = returnUrl ? `<input type="hidden" name="return_url" value="${escapeHtml(returnUrl)}" />` : '';
267
+ const errorBanner = options.error ? `<div class="error" role="alert">${escapeHtml(options.error)}</div>` : '';
268
+ const handleValue = options.handle ? ` value="${escapeHtml(options.handle)}"` : '';
269
+ const status = options.error ? 400 : 200;
270
+ const stylesheet = `${escapeHtml(ctx.usersPath)}style.css`;
234
271
  const html = `<!doctype html>
235
272
  <html lang="en">
236
273
  <head>
237
274
  <meta charset="utf-8" />
238
275
  <meta name="viewport" content="width=device-width, initial-scale=1" />
239
- <title>Sign in with Bluesky / atproto</title>
276
+ <title>Login with your Atmosphere account</title>
277
+ <link rel="stylesheet" href="${stylesheet}" />
240
278
  <style>
241
- body { font-family: system-ui, sans-serif; background: #f5f7fb; margin: 0; display: flex; min-height: 100vh; align-items: center; justify-content: center; }
242
- .card { background: #fff; padding: 2rem; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.08); width: 320px; }
243
- h1 { font-size: 1.15rem; margin: 0 0 1rem; }
244
- label { display: block; font-size: 0.85rem; color: #444; margin-bottom: 0.35rem; }
245
- input[type=text] { width: 100%; box-sizing: border-box; padding: 0.6rem 0.7rem; border: 1px solid #ccd2dd; border-radius: 8px; font-size: 0.95rem; }
246
- button { margin-top: 1rem; width: 100%; padding: 0.65rem; border: 0; border-radius: 8px; background: #0085FF; color: #fff; font-size: 0.95rem; cursor: pointer; }
247
- p { font-size: 0.8rem; color: #777; margin-top: 0.75rem; }
279
+ body { margin: 0; padding: 1rem; box-sizing: border-box; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); color: var(--text); }
280
+ .auth-card { background: var(--surface); border: 1px solid var(--border); padding: 2rem; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.08); width: 23rem; max-width: 100%; box-sizing: border-box; }
281
+ .auth-card h1 { font-size: 1rem; margin: 0 0 1rem; color: var(--text); white-space: nowrap; }
282
+ .auth-card label { display: block; font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 0.35rem; }
283
+ .auth-card input[type=text] { width: 100%; box-sizing: border-box; padding: 0.6rem 0.7rem; border: 1px solid var(--border); border-radius: 8px; font-size: 0.95rem; background: var(--surface); color: var(--text); }
284
+ .auth-card input[type=text]:focus { outline: none; border-color: #4a8ad4; }
285
+ .auth-card button { margin-top: 1rem; width: 100%; padding: 0.65rem; border: 0; border-radius: 8px; background: #4a8ad4; color: #fff; font-size: 0.95rem; cursor: pointer; }
286
+ .auth-card button:hover { background: #3d77ba; }
287
+ .auth-card p { font-size: 0.8rem; color: var(--text-muted); margin: 0.75rem 0 0; }
288
+ .auth-card .error { background: var(--danger-soft-bg); border: 1px solid var(--danger); color: var(--danger); border-radius: 8px; padding: 0.6rem 0.7rem; font-size: 0.82rem; margin-bottom: 1rem; word-break: break-word; }
248
289
  </style>
249
290
  </head>
250
291
  <body>
251
- <form class="card" method="GET" action="${escapeHtml(action)}">
252
- <h1>Sign in with Bluesky / atproto</h1>
292
+ <form class="auth-card" method="GET" action="${escapeHtml(action)}">
293
+ <h1>Login with your Atmosphere account</h1>
294
+ ${errorBanner}
253
295
  <label for="handle">Your handle or DID</label>
254
- <input type="text" id="handle" name="handle" placeholder="alice.bsky.social" autocomplete="username" autofocus required />
296
+ <input type="text" id="handle" name="handle" placeholder="alice.bsky.social"${handleValue} autocomplete="username" autofocus required />
255
297
  ${returnField}
256
298
  <button type="submit">Continue</button>
257
299
  <p>Enter your atproto handle (e.g. alice.bsky.social) or DID. Your account's own server handles the login.</p>
258
300
  </form>
259
301
  </body>
260
302
  </html>`;
261
- return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
303
+ return new Response(html, {
304
+ status,
305
+ headers: {
306
+ 'Content-Type': 'text/html; charset=utf-8',
307
+ // Hardening: same-origin styles only, never framed, never cached (the form reflects the
308
+ // user-supplied handle). NOTE: deliberately NO `form-action` directive — submitting this form
309
+ // hits our endpoint, which 302-redirects to the user's *own* authorization server (any PDS).
310
+ // `form-action` is enforced across the whole redirect chain, so `'self'` (or any fixed list)
311
+ // would block that cross-origin redirect and the login would silently fail.
312
+ 'Content-Security-Policy': "default-src 'none'; style-src 'self' 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'",
313
+ 'X-Content-Type-Options': 'nosniff',
314
+ 'Referrer-Policy': 'no-referrer',
315
+ 'Cache-Control': 'no-store',
316
+ },
317
+ });
262
318
  }
263
319
  }
264
320
 
@@ -271,12 +327,3 @@ function readCookie(header: string | null, name: string): string | undefined {
271
327
  }
272
328
  return undefined;
273
329
  }
274
-
275
- function escapeHtml(value: string): string {
276
- return value
277
- .replace(/&/g, '&amp;')
278
- .replace(/</g, '&lt;')
279
- .replace(/>/g, '&gt;')
280
- .replace(/"/g, '&quot;')
281
- .replace(/'/g, '&#39;');
282
- }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Atmosphere (atproto) "union" logo mark, drawn on a `0 0 32 32` viewBox with `fill-rule: evenodd`.
3
+ * Shared by every server-rendered atproto icon so they never drift apart. The client-side
4
+ * `public/users/power-strip.js` keeps its own copy since it cannot import from `src/`.
5
+ */
6
+ export const ATMOSPHERE_MARK_PATH =
7
+ 'M16 0C18.1127 1.0372e-05 19.9555 1.15414 20.9332 2.8662C21.1045 3.16613 21.4647 3.31517 21.7979 3.22403C22.2761 3.09321 22.7795 3.02333 23.2992 3.02333C26.4347 3.02335 28.9765 5.56529 28.9765 8.70083C28.9765 9.22048 28.9066 9.72378 28.7758 10.2019C28.6846 10.5351 28.8337 10.8953 29.1336 11.0666C30.8458 12.0442 32 13.8872 32 16C32 16.0822 31.9981 16.1639 31.9946 16.2452C31.9946 16.2456 31.9944 16.246 31.9942 16.2463C31.9939 16.2465 31.9937 16.2469 31.9937 16.2473C31.8599 19.6701 29.2873 22.4 26.1329 22.4L25.9815 22.3979C24.3638 22.353 22.909 21.59 21.8719 20.3888C21.5701 20.0392 20.9949 20.0275 20.6799 20.3652C19.5117 21.6171 17.8474 22.4 16 22.4C12.4654 22.4 9.6 19.5346 9.6 16C9.6 12.4654 12.4654 9.6 16 9.6C17.4368 9.6 18.7629 10.0736 19.831 10.8731C20.0053 11.0036 20.2667 10.8843 20.2667 10.6667C20.2667 10.0776 20.7442 9.6 21.3333 9.6C21.9224 9.6 22.4 10.0776 22.4 10.6667V16C22.4158 18.5458 24.26 20.2666 26.1329 20.2667C27.9447 20.2667 29.7293 18.6564 29.8583 16.2467C29.8583 16.2466 29.8583 16.2464 29.8581 16.2463C29.858 16.2461 29.8579 16.2459 29.8579 16.2458C29.8591 16.2281 29.8597 16.2104 29.8606 16.1927C29.8634 16.1293 29.8654 16.0654 29.8658 16.001C29.8658 16.0006 29.866 16.0003 29.8663 16C29.8665 15.9997 29.8667 15.9994 29.8667 15.999C29.8663 14.5464 28.9914 13.2933 27.7308 12.7465L26.4045 12.1712C26.0876 12.0337 25.937 11.6696 26.0641 11.3484L26.5963 10.0042C26.7549 9.60324 26.8431 9.16479 26.8431 8.70083C26.8431 6.77399 25.3056 5.20644 23.3906 5.15792L23.2992 5.15667C22.8351 5.15667 22.3965 5.24487 21.9956 5.40354L20.6515 5.93568C20.3304 6.06282 19.9663 5.91222 19.8288 5.59538L19.2533 4.26917C18.7234 3.04774 17.5306 2.18836 16.1356 2.13583L16 2.13333C14.547 2.13333 13.2934 3.00824 12.7465 4.26896L12.1712 5.59529C12.0337 5.91217 11.6696 6.06282 11.3484 5.93568L10.0042 5.40354C9.62831 5.25477 9.21941 5.16798 8.7875 5.15771L8.70083 5.15667C6.74349 5.15667 5.15667 6.74349 5.15667 8.70083C5.15667 9.16475 5.24485 9.60323 5.40354 10.0042L5.93568 11.3484C6.06282 11.6696 5.91217 12.0337 5.59529 12.1712L4.26896 12.7465C3.00823 13.2934 2.13333 14.5469 2.13333 16C2.13333 17.4529 3.00832 18.7063 4.26917 19.2533L5.59538 19.8288C5.91222 19.9663 6.06282 20.3304 5.93568 20.6515L5.40354 21.9956C5.24487 22.3965 5.15667 22.835 5.15667 23.2992C5.15667 25.2565 6.74349 26.8433 8.70083 26.8433C9.1644 26.8433 9.60287 26.7551 10.0042 26.5963L11.3484 26.0641C11.6696 25.937 12.0337 26.0876 12.1712 26.4045L12.7465 27.7308C13.2934 28.9917 14.547 29.8667 16 29.8667C17.4529 29.8667 18.7063 28.9918 19.2533 27.7308L19.6555 26.8037C19.8871 26.2698 20.5083 26.0256 21.0415 26.2589C21.561 26.4862 21.8066 27.0847 21.5966 27.6115L21.2104 28.5798C20.3374 30.5922 18.3333 32 16 32C13.8872 32 12.0442 30.8458 11.0666 29.1336C10.8953 28.8337 10.5351 28.6846 10.2019 28.7758C9.72378 28.9067 9.22049 28.9767 8.70083 28.9767C5.56528 28.9767 3.02333 26.4347 3.02333 23.2992C3.02333 22.7795 3.09321 22.2761 3.22403 21.7979C3.31516 21.4647 3.16613 21.1045 2.86619 20.9332C1.15412 19.9555 0 18.1127 0 16C5.927e-07 13.8873 1.1541 12.0443 2.8662 11.0666C3.16613 10.8953 3.31517 10.5351 3.22403 10.2019C3.09323 9.72379 3.02333 9.22047 3.02333 8.70083C3.02333 5.56528 5.56528 3.02333 8.70083 3.02333C9.22047 3.02334 9.72379 3.09323 10.2019 3.22404C10.5351 3.31517 10.8953 3.16614 11.0666 2.86621C12.0443 1.15411 13.8873 0 16 0ZM16 11.7333C13.6436 11.7333 11.7333 13.6436 11.7333 16C11.7333 18.3564 13.6436 20.2667 16 20.2667C18.3564 20.2667 20.2667 18.3564 20.2667 16C20.2667 13.6436 18.3564 11.7333 16 11.7333Z';
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * AT Protocol identity resolution. Given a user-supplied handle or DID we walk the full discovery
3
- * chain — with NO hardcoded Bluesky/PDS hosts — to find the authorization server that owns the
3
+ * chain — with NO hardcoded provider/PDS hosts — to find the authorization server that owns the
4
4
  * identity:
5
5
  *
6
6
  * handle ──▶ DID (HTTPS `.well-known/atproto-did`, then DNS TXT `_atproto.<handle>` via DoH)
@@ -49,8 +49,12 @@ async function fetchJson(url: string, init?: RequestInit): Promise<any> {
49
49
  /** Resolve a handle to a DID via the HTTPS well-known method, falling back to DNS TXT over DoH. */
50
50
  async function resolveHandleToDid(handle: string, options: ResolverOptions): Promise<string> {
51
51
  // 1. HTTPS well-known (works for any host that serves it; no third-party dependency).
52
+ // Use `redirect: 'manual'` rather than `'error'`: the Cloudflare Workers runtime does not implement
53
+ // the `'error'` redirect mode and throws a TypeError when it is used, which would silently disable
54
+ // this method (and break resolution for *.bsky.social handles, which have no `_atproto` DNS record).
55
+ // With `'manual'`, a redirected response is not followed and `res.ok` is false, so we fall through.
52
56
  try {
53
- const res = await fetch(`https://${handle}/.well-known/atproto-did`, { redirect: 'error' });
57
+ const res = await fetch(`https://${handle}/.well-known/atproto-did`, { redirect: 'manual' });
54
58
  if (res.ok) {
55
59
  const did = (await res.text()).trim();
56
60
  if (DID_PREFIX.test(did)) return did;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Styled error page for the auth flow.
3
+ *
4
+ * Auth error messages frequently embed UNTRUSTED text from outside our trust boundary: the
5
+ * user-supplied handle, and raw response bodies from a user's PDS or authorization server
6
+ * (e.g. `Token request failed: 400 <body>`). Protection is layered:
7
+ *
8
+ * 1. Encode — the untrusted message is HTML-escaped with `he` (a battle-tested, dependency-free
9
+ * output encoder) before it ever reaches the markup. We do not hand-roll escaping.
10
+ * 2. Contain — the page is served under a restrictive Content-Security-Policy (no script may run)
11
+ * plus `nosniff`, so even an encoding bypass cannot execute code or be re-sniffed.
12
+ *
13
+ * Theming matches the rest of the app: we link `/users/style.css` and use its CSS variables, so the
14
+ * page follows the user's OS / chosen theme via `prefers-color-scheme` exactly like profile.html.
15
+ * The CSP allows `style-src 'self'` for that same-origin stylesheet; scripts remain fully blocked.
16
+ */
17
+ import { escape as escapeHtml } from 'he';
18
+
19
+ /** Hard cap on rendered detail length — external bodies can be arbitrarily large. */
20
+ const MAX_DETAIL_LENGTH = 300;
21
+
22
+ /**
23
+ * Content-Security-Policy for the error page. `default-src 'none'` blocks every resource type
24
+ * (scripts, images, frames, connections). We additively allow `style-src 'self' 'unsafe-inline'`
25
+ * for the linked `style.css` (same-origin) and the page's own inline `<style>`. With no `script-src`,
26
+ * no inline or external script can ever run — neutralizing HTML/script injection as a class.
27
+ * `frame-ancestors 'none'` is listed explicitly because it does NOT fall back to `default-src`, and
28
+ * without it the page could be framed by another origin (clickjacking) — unwanted for an auth endpoint.
29
+ */
30
+ const CONTENT_SECURITY_POLICY =
31
+ "default-src 'none'; style-src 'self' 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
32
+
33
+ /** Render a minimal styled "sign-in failed" page that matches the app theme. */
34
+ export function renderAuthError(message: string, status = 500, usersPath = '/users/'): Response {
35
+ const text = typeof message === 'string' ? message : String(message ?? '');
36
+ const bounded = text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH - 1)}…` : text;
37
+ const detail = escapeHtml(bounded).trim();
38
+ const stylesheet = `${escapeHtml(usersPath)}style.css`;
39
+ const html = `<!doctype html>
40
+ <html lang="en">
41
+ <head>
42
+ <meta charset="utf-8" />
43
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
44
+ <title>Sign-in failed</title>
45
+ <link rel="stylesheet" href="${stylesheet}" />
46
+ <style>
47
+ body { margin: 0; padding: 1rem; box-sizing: border-box; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: var(--bg); color: var(--text); }
48
+ .auth-card { background: var(--surface); border: 1px solid var(--border); padding: 2rem; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.08); width: 360px; max-width: 100%; box-sizing: border-box; }
49
+ .auth-card h1 { font-size: 1.15rem; margin: 0 0 0.75rem; color: var(--danger); }
50
+ .auth-card p { font-size: 0.9rem; color: var(--text-secondary); margin: 0 0 1rem; line-height: 1.45; }
51
+ .auth-card .detail { background: var(--surface-muted); border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.7rem; font-size: 0.82rem; color: var(--text-faint); word-break: break-word; white-space: pre-wrap; }
52
+ </style>
53
+ </head>
54
+ <body>
55
+ <div class="auth-card">
56
+ <h1>Sign-in failed</h1>
57
+ <p>We couldn't complete your sign-in. Please return to the sign-in page and try again.</p>
58
+ ${detail ? `<div class="detail">${detail}</div>` : ''}
59
+ </div>
60
+ </body>
61
+ </html>`;
62
+ return new Response(html, {
63
+ status,
64
+ headers: {
65
+ 'Content-Type': 'text/html; charset=utf-8',
66
+ 'Content-Security-Policy': CONTENT_SECURITY_POLICY,
67
+ 'X-Content-Type-Options': 'nosniff',
68
+ 'Referrer-Policy': 'no-referrer',
69
+ // Reflects a user handle / remote server message — never let a browser or intermediary cache it.
70
+ 'Cache-Control': 'no-store',
71
+ },
72
+ });
73
+ }
package/src/auth/index.ts CHANGED
@@ -2,6 +2,7 @@ import type { StartupAPIEnv } from '../StartupAPIEnv';
2
2
 
3
3
  import { CookieManager } from '../CookieManager';
4
4
  import { refreshEntitlements } from '../entitlements/service';
5
+ import { renderAuthError } from './errorPage';
5
6
  import { computeRedirectBase, createProviders } from './providers';
6
7
  import type { ProviderConfigs } from './providers';
7
8
  import type { AuthContext, ExchangeResult, OAuthProvider } from './OAuthProvider';
@@ -40,7 +41,7 @@ export async function handleAuth(
40
41
  try {
41
42
  return await provider.authorize(ctx);
42
43
  } catch (e) {
43
- return new Response(`Auth failed: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
44
+ return renderAuthError(e instanceof Error ? e.message : String(e), 500, usersPath);
44
45
  }
45
46
  }
46
47
  }
@@ -54,7 +55,7 @@ export async function handleAuth(
54
55
  return await finishLogin(provider, result, ctx);
55
56
  } catch (e) {
56
57
  const status = (e as { status?: number })?.status ?? 500;
57
- return new Response(`Auth failed: ${e instanceof Error ? e.message : String(e)}`, { status });
58
+ return renderAuthError(e instanceof Error ? e.message : String(e), status, usersPath);
58
59
  }
59
60
  }
60
61
  }
@@ -2,6 +2,7 @@ import { StartupAPIEnv } from '../StartupAPIEnv';
2
2
  import { CookieManager } from '../CookieManager';
3
3
  import { getUserFromSession, checkAndClearStaleSession, isAdmin, getActiveProviders } from './utils';
4
4
  import type { ProviderConfigs } from '../auth/providers';
5
+ import { ATMOSPHERE_MARK_PATH } from '../auth/atmosphereMark';
5
6
  import { Plan } from '../billing/Plan';
6
7
 
7
8
  export async function handleSSR(
@@ -215,7 +216,7 @@ function getProviderIcon(provider: string): string {
215
216
  } else if (provider === 'patreon') {
216
217
  return '<svg viewBox="0 0 24 24" width="24" height="24" class="patreon-icon"><path d="M14.82 2.41c3.96 0 7.18 3.24 7.18 7.21 0 3.96-3.22 7.18-7.18 7.18-3.97 0-7.21-3.22-7.21-7.18 0-3.97 3.24-7.21 7.21-7.21M2 21.6h3.5V2.41H2V21.6z" fill="currentColor"/></svg>';
217
218
  } else if (provider === 'atproto') {
218
- return '<svg viewBox="0 0 24 24" width="24" height="24" class="atproto-icon"><path d="M12 10.5C10.9 8.4 8.2 6.3 6.3 6c-1.5-.2-1.8.7-1.5 2 .2 1 1.5 5 2.3 6 .9 1.2 2 1.4 3 1.2-1.7.3-3.2 1-1.2 3 .9.9 1.6.3 2.1-.6.5-1 .8-2.1 1-2.6.2.5.5 1.6 1 2.6.5.9 1.2 1.5 2.1.6 2-2 .5-2.7-1.2-3 1 .2 2.1 0 3-1.2.8-1 2.1-5 2.3-6 .3-1.3 0-2.2-1.5-2-1.9.3-4.6 2.4-5.7 4.5z" fill="#0085FF"/></svg>';
219
+ return `<svg viewBox="0 0 32 32" width="24" height="24" class="atproto-icon"><path fill-rule="evenodd" clip-rule="evenodd" d="${ATMOSPHERE_MARK_PATH}" fill="#4a8ad4"/></svg>`;
219
220
  }
220
221
  return '';
221
222
  }
@@ -14,8 +14,8 @@ export function getActiveProviders(env: StartupAPIEnv, providerConfigs: Provider
14
14
  if (env.PATREON_CLIENT_ID && env.PATREON_CLIENT_SECRET) {
15
15
  providers.push('patreon');
16
16
  }
17
- // atproto has no env credentials; it is enabled purely via factory config.
18
- if (isAtprotoEnabled(providerConfigs.atproto)) {
17
+ // atproto has no env credentials; it is enabled via factory config or the ATPROTO_ENABLED env flag.
18
+ if (isAtprotoEnabled(providerConfigs.atproto, env)) {
19
19
  providers.push('atproto');
20
20
  }
21
21
  return providers;