Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add allowlist auth gate with Bluesky follow CTA

Require AT Proto sign-in and check DID against an allowlist in
instance-info.json. Users not on the list see a waitlist modal
directing them to follow @scottlanoue.com on Bluesky for access.
Self-hosted instances bypass the gate entirely.

+262 -2
+7 -1
public/instance-info.json
··· 7 7 "sharing": false, 8 8 "ai": false 9 9 }, 10 - "notice": null 10 + "notice": null, 11 + "accessControl": { 12 + "mode": "allowlist", 13 + "allowlist": [ 14 + "did:plc:dy67wyyakm7u4v2lthy5zwbn" 15 + ] 16 + } 11 17 }
+54
src/css/app.css
··· 1797 1797 gap: var(--space-sm); 1798 1798 } 1799 1799 1800 + /* Waitlist modal */ 1801 + .waitlist-modal { 1802 + max-width: 460px; 1803 + text-align: center; 1804 + } 1805 + .waitlist-modal h2 { 1806 + margin-bottom: var(--space-sm); 1807 + } 1808 + .waitlist-handle { 1809 + font-size: 0.95rem; 1810 + font-weight: 600; 1811 + color: var(--color-accent); 1812 + margin-bottom: var(--space-md); 1813 + } 1814 + .waitlist-message { 1815 + font-size: 0.9rem; 1816 + color: var(--color-text-secondary); 1817 + line-height: 1.5; 1818 + margin-bottom: var(--space-md); 1819 + } 1820 + .waitlist-cta { 1821 + font-size: 0.9rem; 1822 + color: var(--color-text); 1823 + line-height: 1.5; 1824 + margin-bottom: var(--space-md); 1825 + } 1826 + .waitlist-cta a { 1827 + color: var(--color-accent); 1828 + font-weight: 600; 1829 + text-decoration: none; 1830 + } 1831 + .waitlist-cta a:hover { 1832 + text-decoration: underline; 1833 + } 1834 + .waitlist-source { 1835 + font-size: 0.8rem; 1836 + color: var(--color-text-muted); 1837 + line-height: 1.5; 1838 + margin-bottom: var(--space-lg); 1839 + } 1840 + .waitlist-source a { 1841 + color: var(--color-text-secondary); 1842 + text-decoration: underline; 1843 + } 1844 + .waitlist-actions { 1845 + display: flex; 1846 + justify-content: center; 1847 + } 1848 + .waitlist-actions .btn-primary { 1849 + text-decoration: none; 1850 + display: inline-flex; 1851 + align-items: center; 1852 + } 1853 + 1800 1854 /* Move to folder modal */ 1801 1855 .move-folder-list { 1802 1856 display: flex;
+14
src/index.html
··· 171 171 </div> 172 172 </div> 173 173 174 + <!-- Waitlist modal (shown when user is not on allowlist) --> 175 + <div class="modal-backdrop" id="waitlist-modal" style="display:none;"> 176 + <div class="modal waitlist-modal" role="dialog" aria-modal="true" aria-labelledby="waitlist-title"> 177 + <h2 id="waitlist-title">You're on the list</h2> 178 + <p class="waitlist-handle" id="waitlist-handle"></p> 179 + <p class="waitlist-message">Atmosphere Office is in private preview. Your account has been noted and you'll be added soon.</p> 180 + <p class="waitlist-cta">Want faster access? Follow <a href="https://bsky.app/profile/scottlanoue.com" target="_blank" rel="noopener">@scottlanoue.com</a> on Bluesky and send a DM &mdash; especially if you're a PDS operator.</p> 181 + <p class="waitlist-source">In the meantime, Atmosphere Office is open source. You can <a href="https://tangled.org/scottlanoue.com/atmosphere-office" target="_blank" rel="noopener">run your own instance</a> today.</p> 182 + <div class="waitlist-actions"> 183 + <a href="https://bsky.app/profile/scottlanoue.com" target="_blank" rel="noopener" class="btn-primary">Follow on Bluesky</a> 184 + </div> 185 + </div> 186 + </div> 187 + 174 188 <!-- Folder name modal --> 175 189 <div class="modal-backdrop" id="folder-modal" style="display:none;"> 176 190 <div class="modal folder-modal" role="dialog" aria-modal="true" aria-labelledby="folder-modal-title">
+19 -1
src/landing-events-identity.ts
··· 6 6 import type { EventDeps } from './landing-events.js'; 7 7 import { initAuth, signIn, getSession, onSessionChange } from './lib/auth.js'; 8 8 import { debouncedSearch, type HandleSuggestion } from './lib/handle-search.js'; 9 + import { checkAccess, applyAccessGate, showWaitlistModal } from './lib/access-gate.js'; 9 10 10 11 function showUserBadge(deps: EventDeps, name: string, avatar?: string | null): void { 11 12 if (avatar) { ··· 128 129 } 129 130 130 131 export async function initUsername(deps: EventDeps): Promise<void> { 132 + await applyAccessGate(); 133 + 131 134 const session = getSession(); 132 135 if (session) { 136 + const access = await checkAccess(session.did); 137 + if (!access.granted) { 138 + showWaitlistModal(session.handle); 139 + return; 140 + } 133 141 showUserBadge(deps, session.displayName, session.avatar); 134 142 deps.usernameModal.style.display = 'none'; 135 143 return; ··· 137 145 138 146 const restored = await initAuth(); 139 147 if (restored) { 148 + const access = await checkAccess(restored.did); 149 + if (!access.granted) { 150 + showWaitlistModal(restored.handle); 151 + return; 152 + } 140 153 showUserBadge(deps, restored.displayName, restored.avatar); 141 154 deps.usernameModal.style.display = 'none'; 142 155 return; ··· 190 203 } 191 204 }); 192 205 193 - onSessionChange((session) => { 206 + onSessionChange(async (session) => { 194 207 if (session) { 208 + const access = await checkAccess(session.did); 209 + if (!access.granted) { 210 + showWaitlistModal(session.handle); 211 + return; 212 + } 195 213 showUserBadge(deps, session.displayName, session.avatar); 196 214 deps.usernameModal.style.display = 'none'; 197 215 } else {
+45
src/lib/access-gate.ts
··· 1 + import { getInstanceInfo } from './instance-info.js'; 2 + 3 + export interface AccessResult { 4 + granted: boolean; 5 + reason?: 'auth-required' | 'not-allowed'; 6 + } 7 + 8 + export async function checkAccess(did: string | null): Promise<AccessResult> { 9 + const info = await getInstanceInfo(); 10 + 11 + if (info.flavor === 'self-hosted') return { granted: true }; 12 + 13 + const ac = info.accessControl; 14 + if (!ac || ac.mode === 'open') return { granted: true }; 15 + 16 + if (ac.mode !== 'allowlist') return { granted: true }; 17 + 18 + if (!did) return { granted: false, reason: 'auth-required' }; 19 + 20 + const allowed = ac.allowlist ?? []; 21 + if (allowed.includes(did)) return { granted: true }; 22 + 23 + return { granted: false, reason: 'not-allowed' }; 24 + } 25 + 26 + export async function applyAccessGate(): Promise<void> { 27 + const info = await getInstanceInfo(); 28 + const ac = info.accessControl; 29 + 30 + if (ac && ac.mode === 'allowlist') { 31 + const skip = document.getElementById('username-skip'); 32 + if (skip) skip.style.display = 'none'; 33 + } 34 + } 35 + 36 + export function showWaitlistModal(handle?: string): void { 37 + const modal = document.getElementById('waitlist-modal'); 38 + if (modal) modal.style.display = ''; 39 + 40 + const usernameModal = document.getElementById('username-modal'); 41 + if (usernameModal) usernameModal.style.display = 'none'; 42 + 43 + const handleEl = document.getElementById('waitlist-handle'); 44 + if (handleEl && handle) handleEl.textContent = `@${handle}`; 45 + }
+20
src/lib/instance-info.ts
··· 6 6 ai: boolean; 7 7 } 8 8 9 + export interface AccessControl { 10 + mode: 'open' | 'allowlist'; 11 + allowlist?: string[]; 12 + } 13 + 9 14 export interface InstanceInfo { 10 15 flavor: InstanceFlavor; 11 16 operator: string | null; 12 17 pds: string | null; 13 18 features: InstanceFeatures; 14 19 notice: string | null; 20 + accessControl: AccessControl | null; 15 21 } 16 22 17 23 const DEFAULT_INFO: InstanceInfo = { ··· 20 26 pds: null, 21 27 features: { sync: false, sharing: false, ai: false }, 22 28 notice: null, 29 + accessControl: null, 23 30 }; 24 31 25 32 let cached: InstanceInfo | null = null; ··· 44 51 ai: Boolean(data.features?.ai), 45 52 }, 46 53 notice: typeof data.notice === 'string' ? data.notice : null, 54 + accessControl: parseAccessControl(data.accessControl), 47 55 }; 48 56 } catch { 49 57 cached = DEFAULT_INFO; 50 58 } 51 59 52 60 return cached; 61 + } 62 + 63 + function parseAccessControl(v: unknown): AccessControl | null { 64 + if (!v || typeof v !== 'object') return null; 65 + const obj = v as Record<string, unknown>; 66 + const mode = obj.mode; 67 + if (mode !== 'open' && mode !== 'allowlist') return null; 68 + if (mode === 'allowlist') { 69 + const list = Array.isArray(obj.allowlist) ? obj.allowlist.filter((x): x is string => typeof x === 'string') : []; 70 + return { mode, allowlist: list }; 71 + } 72 + return { mode }; 53 73 } 54 74 55 75 function validateFlavor(v: unknown): InstanceFlavor {
+101
tests/access-gate.test.ts
··· 1 + /** 2 + * @vitest-environment jsdom 3 + */ 4 + import { describe, it, expect, beforeEach, vi } from 'vitest'; 5 + 6 + vi.mock('../src/lib/instance-info.js', () => ({ 7 + getInstanceInfo: vi.fn(), 8 + })); 9 + 10 + import { checkAccess, applyAccessGate } from '../src/lib/access-gate.js'; 11 + import { getInstanceInfo } from '../src/lib/instance-info.js'; 12 + 13 + const mockGetInstanceInfo = vi.mocked(getInstanceInfo); 14 + 15 + function makeInfo(accessControl?: { mode: string; allowlist?: string[] }) { 16 + return { 17 + flavor: 'public' as const, 18 + operator: null, 19 + pds: null, 20 + features: { sync: false, sharing: false, ai: false }, 21 + notice: null, 22 + accessControl: accessControl ?? null, 23 + }; 24 + } 25 + 26 + describe('checkAccess', () => { 27 + it('grants access when no accessControl is configured', async () => { 28 + mockGetInstanceInfo.mockResolvedValue(makeInfo()); 29 + const result = await checkAccess(null); 30 + expect(result.granted).toBe(true); 31 + }); 32 + 33 + it('grants access when mode is open', async () => { 34 + mockGetInstanceInfo.mockResolvedValue(makeInfo({ mode: 'open' })); 35 + const result = await checkAccess(null); 36 + expect(result.granted).toBe(true); 37 + }); 38 + 39 + it('denies access when mode is allowlist and no session', async () => { 40 + mockGetInstanceInfo.mockResolvedValue(makeInfo({ mode: 'allowlist', allowlist: ['did:plc:abc'] })); 41 + const result = await checkAccess(null); 42 + expect(result.granted).toBe(false); 43 + expect(result.reason).toBe('auth-required'); 44 + }); 45 + 46 + it('denies access when DID is not on allowlist', async () => { 47 + mockGetInstanceInfo.mockResolvedValue(makeInfo({ mode: 'allowlist', allowlist: ['did:plc:abc'] })); 48 + const result = await checkAccess('did:plc:xyz'); 49 + expect(result.granted).toBe(false); 50 + expect(result.reason).toBe('not-allowed'); 51 + }); 52 + 53 + it('grants access when DID is on allowlist', async () => { 54 + mockGetInstanceInfo.mockResolvedValue(makeInfo({ mode: 'allowlist', allowlist: ['did:plc:abc', 'did:plc:xyz'] })); 55 + const result = await checkAccess('did:plc:xyz'); 56 + expect(result.granted).toBe(true); 57 + }); 58 + 59 + it('denies access when allowlist is empty', async () => { 60 + mockGetInstanceInfo.mockResolvedValue(makeInfo({ mode: 'allowlist', allowlist: [] })); 61 + const result = await checkAccess('did:plc:abc'); 62 + expect(result.granted).toBe(false); 63 + expect(result.reason).toBe('not-allowed'); 64 + }); 65 + 66 + it('grants access for self-hosted flavor regardless of allowlist', async () => { 67 + const info = makeInfo({ mode: 'allowlist', allowlist: ['did:plc:abc'] }); 68 + info.flavor = 'self-hosted'; 69 + mockGetInstanceInfo.mockResolvedValue(info); 70 + const result = await checkAccess('did:plc:xyz'); 71 + expect(result.granted).toBe(true); 72 + }); 73 + }); 74 + 75 + describe('applyAccessGate', () => { 76 + beforeEach(() => { 77 + document.body.innerHTML = ` 78 + <div id="username-modal" style="display:none;"></div> 79 + <div id="waitlist-modal" style="display:none;"></div> 80 + <button id="username-skip"></button> 81 + `; 82 + }); 83 + 84 + it('hides skip button when allowlist mode is active', async () => { 85 + mockGetInstanceInfo.mockResolvedValue(makeInfo({ mode: 'allowlist', allowlist: [] })); 86 + await applyAccessGate(); 87 + expect(document.getElementById('username-skip')!.style.display).toBe('none'); 88 + }); 89 + 90 + it('keeps skip button visible when access is open', async () => { 91 + mockGetInstanceInfo.mockResolvedValue(makeInfo({ mode: 'open' })); 92 + await applyAccessGate(); 93 + expect(document.getElementById('username-skip')!.style.display).not.toBe('none'); 94 + }); 95 + 96 + it('keeps skip button visible when no access control', async () => { 97 + mockGetInstanceInfo.mockResolvedValue(makeInfo()); 98 + await applyAccessGate(); 99 + expect(document.getElementById('username-skip')!.style.display).not.toBe('none'); 100 + }); 101 + });
+1
tests/feature-gate.test.ts
··· 23 23 ai: features.ai ?? false, 24 24 }, 25 25 notice: null, 26 + accessControl: null, 26 27 }; 27 28 } 28 29
+1
tests/instance-info.test.ts
··· 11 11 pds: null, 12 12 features: { sync: false, sharing: false, ai: false }, 13 13 notice: null, 14 + accessControl: null, 14 15 ...overrides, 15 16 }; 16 17 }