@lightupai/polaris 0.0.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/.github/workflows/ci.yml +38 -0
- package/.mcp.json +12 -0
- package/LICENSE +201 -0
- package/Makefile +38 -0
- package/PLAN.md +438 -0
- package/docker-compose.yml +14 -0
- package/docs/README.md +377 -0
- package/hooks/capture.sh +19 -0
- package/hooks/statusline.sh +30 -0
- package/package.json +22 -0
- package/scripts/setup-google-oauth.sh +111 -0
- package/scripts/setup-slack-app.sh +115 -0
- package/skills/polaris/SKILL.md +29 -0
- package/src/cli/cli.ts +294 -0
- package/src/client/client.ts +245 -0
- package/src/daemon/daemon.ts +275 -0
- package/src/service/auth.ts +45 -0
- package/src/service/db.ts +275 -0
- package/src/service/server.ts +406 -0
- package/src/slack/system.ts +107 -0
- package/src/types.ts +108 -0
- package/src/web/app.ts +397 -0
- package/src/web/fixtures.ts +121 -0
- package/src/web/layout.ts +68 -0
- package/src/web/pages.ts +156 -0
- package/src/web/serve.ts +13 -0
- package/src/web/views.ts +356 -0
- package/tests/auth.test.ts +37 -0
- package/tests/client.test.ts +187 -0
- package/tests/daemon.test.ts +220 -0
- package/tests/db.test.ts +282 -0
- package/tests/e2e.test.ts +415 -0
- package/tests/service.test.ts +365 -0
- package/tests/types.test.ts +240 -0
- package/tests/web.test.ts +420 -0
- package/tsconfig.json +16 -0
package/src/web/pages.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// --- One-off pages (landing, welcome, errors) ---
|
|
2
|
+
|
|
3
|
+
import { nav, slackIcon } from "./layout";
|
|
4
|
+
import type { Org } from "../service/db";
|
|
5
|
+
|
|
6
|
+
export function renderLandingPage(): string {
|
|
7
|
+
return `
|
|
8
|
+
${nav()}
|
|
9
|
+
<div class="max-w-5xl mx-auto px-6">
|
|
10
|
+
<div class="pt-24 pb-16 text-center">
|
|
11
|
+
<h1 class="text-5xl font-bold tracking-tight text-gray-900 sm:text-6xl">
|
|
12
|
+
Multiplayer AI collaboration
|
|
13
|
+
</h1>
|
|
14
|
+
<p class="mt-6 text-lg leading-8 text-gray-600 max-w-2xl mx-auto">
|
|
15
|
+
Polaris connects your AI agent sessions to your team. Capture every interaction, pool context across workstreams, and let anyone contribute — all in real time.
|
|
16
|
+
</p>
|
|
17
|
+
<div class="mt-10 flex flex-col items-center gap-4">
|
|
18
|
+
<a href="/signup" class="inline-flex items-center gap-3 px-6 py-3 bg-white border border-gray-300 rounded-lg shadow-sm hover:bg-gray-50 transition text-sm font-semibold text-gray-700">
|
|
19
|
+
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 01-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z" fill="#4285F4"/><path d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 009 18z" fill="#34A853"/><path d="M3.964 10.71A5.41 5.41 0 013.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 000 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/><path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 00.957 4.958L3.964 6.29C4.672 4.163 6.656 2.58 9 3.58z" fill="#EA4335"/></svg>
|
|
20
|
+
Sign up with Google
|
|
21
|
+
</a>
|
|
22
|
+
<a href="/login" class="px-6 py-3 text-sm font-semibold text-gray-500 hover:text-gray-700 transition">Already have an account? Sign in</a>
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 py-16 border-t border-gray-200">
|
|
27
|
+
<div>
|
|
28
|
+
<div class="w-10 h-10 rounded-lg bg-polaris-100 flex items-center justify-center mb-4">
|
|
29
|
+
<svg class="w-5 h-5 text-polaris-700" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 6.523 5 10 5c3.477 0 6.268 2.943 7.542 7-.274.985-.633 1.928-1.065 2.813M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
|
30
|
+
</div>
|
|
31
|
+
<h3 class="text-sm font-semibold text-gray-900">Session capture</h3>
|
|
32
|
+
<p class="mt-2 text-sm text-gray-600">Every prompt, response, and tool call is captured and broadcast to your team's floor.</p>
|
|
33
|
+
</div>
|
|
34
|
+
<div>
|
|
35
|
+
<div class="w-10 h-10 rounded-lg bg-polaris-100 flex items-center justify-center mb-4">
|
|
36
|
+
<svg class="w-5 h-5 text-polaris-700" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"/></svg>
|
|
37
|
+
</div>
|
|
38
|
+
<h3 class="text-sm font-semibold text-gray-900">Context injection</h3>
|
|
39
|
+
<p class="mt-2 text-sm text-gray-600">Teammates inject expertise directly into your agent session from Slack, WhatsApp, or any floor.</p>
|
|
40
|
+
</div>
|
|
41
|
+
<div>
|
|
42
|
+
<div class="w-10 h-10 rounded-lg bg-polaris-100 flex items-center justify-center mb-4">
|
|
43
|
+
<svg class="w-5 h-5 text-polaris-700" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m3 5.197V21"/></svg>
|
|
44
|
+
</div>
|
|
45
|
+
<h3 class="text-sm font-semibold text-gray-900">Multiplayer</h3>
|
|
46
|
+
<p class="mt-2 text-sm text-gray-600">Multiple drivers, concurrent sessions, seamless handoffs. Humans and AI agents as first-class participants.</p>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<div class="py-16 border-t border-gray-200">
|
|
51
|
+
<h2 class="text-2xl font-bold text-gray-900 text-center">How it works</h2>
|
|
52
|
+
<p class="mt-2 text-center text-sm text-gray-500">Everything streams to the floor — your team's Slack channel.</p>
|
|
53
|
+
|
|
54
|
+
<div class="mt-8 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden">
|
|
55
|
+
<div class="flex">
|
|
56
|
+
<div class="w-14 bg-[#4A154B] shrink-0 flex flex-col items-center py-3 gap-3">
|
|
57
|
+
<div class="w-8 h-8 rounded-lg bg-white/20 flex items-center justify-center text-white text-xs font-bold">A</div>
|
|
58
|
+
<div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center">
|
|
59
|
+
<svg class="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center">
|
|
62
|
+
<svg class="w-4 h-4 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/></svg>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="flex-1">
|
|
66
|
+
<div class="border-b border-gray-200 px-4 py-2.5 flex items-center justify-between">
|
|
67
|
+
<div class="flex items-center gap-2">
|
|
68
|
+
<span class="text-gray-900 font-bold text-sm"># webapp</span>
|
|
69
|
+
<span class="text-gray-400 text-xs">|</span>
|
|
70
|
+
<span class="text-gray-400 text-xs">2 drivers, 1 advisor</span>
|
|
71
|
+
</div>
|
|
72
|
+
<div class="flex items-center gap-3">
|
|
73
|
+
<div class="flex -space-x-1.5">
|
|
74
|
+
<div class="w-5 h-5 rounded-full bg-blue-500 border-2 border-white"></div>
|
|
75
|
+
<div class="w-5 h-5 rounded-full bg-yellow-500 border-2 border-white"></div>
|
|
76
|
+
<div class="w-5 h-5 rounded-full bg-purple-600 border-2 border-white"></div>
|
|
77
|
+
</div>
|
|
78
|
+
<span class="text-gray-400 text-xs">3</span>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="px-5 py-4 space-y-4 text-sm">
|
|
82
|
+
<div class="flex gap-3">
|
|
83
|
+
<div class="w-8 h-8 rounded-md bg-blue-500 flex items-center justify-center text-white text-xs font-bold shrink-0 mt-0.5">M</div>
|
|
84
|
+
<div>
|
|
85
|
+
<div class="flex items-baseline gap-2"><span class="text-gray-900 font-bold text-sm">Manu</span><span class="text-gray-400 text-xs">auth</span><span class="text-gray-400 text-xs">10:31 AM</span></div>
|
|
86
|
+
<p class="text-gray-700">Let's implement the auth middleware</p>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="flex gap-3">
|
|
90
|
+
<div class="w-8 h-8 rounded-md bg-green-600 flex items-center justify-center text-white text-xs font-bold shrink-0 mt-0.5">AI</div>
|
|
91
|
+
<div>
|
|
92
|
+
<div class="flex items-baseline gap-2"><span class="text-gray-900 font-bold text-sm">Agent</span><span class="text-gray-400 text-xs">→ manu/auth</span><span class="text-gray-400 text-xs">10:31 AM</span></div>
|
|
93
|
+
<p class="text-gray-700">I'll create <code class="bg-gray-100 px-1 rounded text-red-600 text-xs">src/middleware/auth.ts</code> with JWT verification...</p>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="flex gap-3">
|
|
97
|
+
<div class="w-8 h-8 rounded-md bg-yellow-500 flex items-center justify-center text-white text-xs font-bold shrink-0 mt-0.5">P</div>
|
|
98
|
+
<div>
|
|
99
|
+
<div class="flex items-baseline gap-2"><span class="text-gray-900 font-bold text-sm">Priya</span><span class="text-gray-400 text-xs">→ auth</span><span class="text-gray-400 text-xs">10:33 AM</span></div>
|
|
100
|
+
<p class="text-gray-700">Use RS256, not HS256 — we need asymmetric keys for the microservices</p>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
<div class="flex gap-3">
|
|
104
|
+
<div class="w-8 h-8 rounded-md bg-green-600 flex items-center justify-center text-white text-xs font-bold shrink-0 mt-0.5">AI</div>
|
|
105
|
+
<div>
|
|
106
|
+
<div class="flex items-baseline gap-2"><span class="text-gray-900 font-bold text-sm">Agent</span><span class="text-gray-400 text-xs">→ manu/auth</span><span class="text-gray-400 text-xs">10:33 AM</span></div>
|
|
107
|
+
<p class="text-gray-700">Good point from Priya. Switching to RS256 and updating the key config...</p>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
<div class="flex gap-3">
|
|
111
|
+
<div class="w-8 h-8 rounded-md bg-purple-600 flex items-center justify-center text-white text-xs font-bold shrink-0 mt-0.5">
|
|
112
|
+
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
|
113
|
+
</div>
|
|
114
|
+
<div>
|
|
115
|
+
<div class="flex items-baseline gap-2"><span class="text-gray-900 font-bold text-sm">security-bot</span><span class="bg-gray-100 text-gray-600 text-xs px-1.5 py-0.5 rounded">agent</span><span class="text-gray-400 text-xs">→ auth</span><span class="text-gray-400 text-xs">10:34 AM</span></div>
|
|
116
|
+
<p class="text-gray-700">This auth endpoint needs rate limiting before going to production</p>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
<div class="flex gap-3">
|
|
120
|
+
<div class="w-8 h-8 rounded-md bg-green-600 flex items-center justify-center text-white text-xs font-bold shrink-0 mt-0.5">AI</div>
|
|
121
|
+
<div>
|
|
122
|
+
<div class="flex items-baseline gap-2"><span class="text-gray-900 font-bold text-sm">Agent</span><span class="text-gray-400 text-xs">→ manu/auth</span><span class="text-gray-400 text-xs">10:34 AM</span></div>
|
|
123
|
+
<p class="text-gray-700">Adding rate limiting middleware to the auth endpoints...</p>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
<p class="mt-4 text-center text-sm text-gray-500">The floor — a continuous, attributed log of how your work gets built.</p>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<footer class="border-t border-gray-200 mt-16">
|
|
135
|
+
<div class="max-w-5xl mx-auto px-6 py-8 text-center text-sm text-gray-500">
|
|
136
|
+
Polaris by Lightup
|
|
137
|
+
</div>
|
|
138
|
+
</footer>`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function renderWelcomePage(token: string, name: string, orgName: string, org: Org): string {
|
|
142
|
+
return `
|
|
143
|
+
${nav(token, { userName: name, orgName, email: "" })}
|
|
144
|
+
<div class="max-w-lg mx-auto px-6 pt-24">
|
|
145
|
+
<div class="text-center">
|
|
146
|
+
<div class="w-12 h-12 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-4">
|
|
147
|
+
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
|
148
|
+
</div>
|
|
149
|
+
<h1 class="text-2xl font-bold text-gray-900">Welcome to Polaris</h1>
|
|
150
|
+
<p class="mt-2 text-gray-600">${orgName} is ready to go.</p>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="mt-8">
|
|
153
|
+
<a href="/dashboard?token=${token}" class="block w-full text-center px-6 py-3 bg-polaris-700 text-white text-sm font-semibold rounded-lg hover:bg-polaris-800 transition">Go to dashboard</a>
|
|
154
|
+
</div>
|
|
155
|
+
</div>`;
|
|
156
|
+
}
|
package/src/web/serve.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createDb } from "../service/db";
|
|
2
|
+
import { createApp } from "./app";
|
|
3
|
+
|
|
4
|
+
const sql = await createDb();
|
|
5
|
+
const app = createApp(sql);
|
|
6
|
+
const port = Number(process.env.WEB_PORT ?? 3000);
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
port,
|
|
10
|
+
fetch: app.fetch,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
console.error(`Polaris web app listening on http://localhost:${port}`);
|
package/src/web/views.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// --- View functions for dashboard states ---
|
|
2
|
+
// All views follow three consistent sections: Floor, Devices, Projects & Sessions.
|
|
3
|
+
|
|
4
|
+
import { nav, slackIcon, type NavOpts } from "./layout";
|
|
5
|
+
import type { SessionFixture, ProjectFixture, DeviceFixture } from "./fixtures";
|
|
6
|
+
|
|
7
|
+
interface ViewContext {
|
|
8
|
+
token: string;
|
|
9
|
+
userName: string;
|
|
10
|
+
orgName: string;
|
|
11
|
+
email: string;
|
|
12
|
+
slackConnected: boolean;
|
|
13
|
+
cliInstalled: boolean;
|
|
14
|
+
hasConnectedSession: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function navOpts(ctx: ViewContext): NavOpts {
|
|
18
|
+
return { userName: ctx.userName, orgName: ctx.orgName, email: ctx.email };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// --- Copyable code block ---
|
|
22
|
+
|
|
23
|
+
function copyBlock(text: string): string {
|
|
24
|
+
const id = `copy-${Math.random().toString(36).slice(2, 8)}`;
|
|
25
|
+
return `
|
|
26
|
+
<div class="mt-3 relative">
|
|
27
|
+
<code id="${id}" class="block bg-gray-50 border border-gray-200 rounded px-3 py-2 pr-10 text-xs font-mono select-all">${text}</code>
|
|
28
|
+
<button onclick="navigator.clipboard.writeText(document.getElementById('${id}').textContent);this.innerHTML='<svg class=\\'w-3.5 h-3.5 text-green-500\\' fill=\\'none\\' stroke=\\'currentColor\\' viewBox=\\'0 0 24 24\\'><path stroke-linecap=\\'round\\' stroke-linejoin=\\'round\\' stroke-width=\\'2\\' d=\\'M5 13l4 4L19 7\\'/></svg>';setTimeout(()=>this.innerHTML='<svg class=\\'w-3.5 h-3.5\\' fill=\\'none\\' stroke=\\'currentColor\\' viewBox=\\'0 0 24 24\\'><path stroke-linecap=\\'round\\' stroke-linejoin=\\'round\\' stroke-width=\\'2\\' d=\\'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z\\'/></svg>',1500)"
|
|
29
|
+
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 transition rounded" title="Copy">
|
|
30
|
+
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
|
|
31
|
+
</button>
|
|
32
|
+
</div>`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- Shared section renderers ---
|
|
36
|
+
|
|
37
|
+
function sectionHeader(title: string): string {
|
|
38
|
+
return `<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">${title}</h2>`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function statusBadge(label: string, done: boolean): string {
|
|
42
|
+
return done
|
|
43
|
+
? `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
44
|
+
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/></svg>
|
|
45
|
+
${label}
|
|
46
|
+
</span>`
|
|
47
|
+
: `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500">${label}</span>`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- Floor section ---
|
|
51
|
+
|
|
52
|
+
type StepState = "done" | "active" | "future";
|
|
53
|
+
|
|
54
|
+
function cardClass(state: StepState): string {
|
|
55
|
+
if (state === "active") return "border-polaris-300 bg-polaris-50/30 shadow-sm";
|
|
56
|
+
return "border-gray-200";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sectionWrap(state: StepState, content: string): string {
|
|
60
|
+
if (state === "future") return `<div class="opacity-40 pointer-events-none">${content}</div>`;
|
|
61
|
+
return content;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderFloorSection(ctx: ViewContext, compact = false, state: StepState = "done"): string {
|
|
65
|
+
if (compact && ctx.slackConnected) {
|
|
66
|
+
return `
|
|
67
|
+
<div>
|
|
68
|
+
${sectionHeader("Floor")}
|
|
69
|
+
<div class="bg-white border border-gray-200 rounded-lg px-5 py-3 flex items-center gap-3">
|
|
70
|
+
<div class="w-8 h-8 rounded-lg bg-[#4A154B] flex items-center justify-center shrink-0">
|
|
71
|
+
${slackIcon.replace('class="w-4 h-4"', 'class="w-4 h-4 text-white"')}
|
|
72
|
+
</div>
|
|
73
|
+
<p class="text-sm font-medium text-gray-900">Slack</p>
|
|
74
|
+
${statusBadge("Live", true)}
|
|
75
|
+
</div>
|
|
76
|
+
</div>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (ctx.slackConnected) {
|
|
80
|
+
return `
|
|
81
|
+
<div>
|
|
82
|
+
${sectionHeader("Floor")}
|
|
83
|
+
<div class="bg-white border border-gray-200 rounded-lg p-5">
|
|
84
|
+
<div class="flex items-center gap-3">
|
|
85
|
+
<div class="w-10 h-10 rounded-lg bg-[#4A154B] flex items-center justify-center shrink-0">
|
|
86
|
+
${slackIcon.replace('class="w-4 h-4"', 'class="w-5 h-5 text-white"')}
|
|
87
|
+
</div>
|
|
88
|
+
<div>
|
|
89
|
+
<div class="flex items-center gap-2">
|
|
90
|
+
<p class="text-sm font-semibold text-gray-900">Slack</p>
|
|
91
|
+
${statusBadge("Connected", true)}
|
|
92
|
+
</div>
|
|
93
|
+
<p class="text-sm text-gray-500 mt-0.5">Workspace linked. Channels are auto-created for your projects.</p>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return sectionWrap(state, `
|
|
101
|
+
<div>
|
|
102
|
+
${sectionHeader("Floor")}
|
|
103
|
+
<div class="bg-white border ${state === "active" ? cardClass("active") : "border-amber-200"} rounded-lg p-5">
|
|
104
|
+
<div class="flex items-center gap-3">
|
|
105
|
+
<div class="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
|
|
106
|
+
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/></svg>
|
|
107
|
+
</div>
|
|
108
|
+
<div>
|
|
109
|
+
<div class="flex items-center gap-2">
|
|
110
|
+
<p class="text-sm font-semibold text-gray-900">Slack</p>
|
|
111
|
+
${statusBadge("Not connected", false)}
|
|
112
|
+
</div>
|
|
113
|
+
<p class="text-sm text-gray-500 mt-0.5">Connect your Slack workspace to enable the floor for your team.</p>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
<a href="/slack/install?token=${ctx.token}" class="mt-3 inline-flex items-center gap-2 px-4 py-2 bg-[#4A154B] text-white text-sm font-medium rounded-lg hover:bg-[#3a1039] transition">
|
|
117
|
+
${slackIcon}
|
|
118
|
+
Connect Slack
|
|
119
|
+
</a>
|
|
120
|
+
</div>
|
|
121
|
+
</div>`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- Devices section ---
|
|
125
|
+
|
|
126
|
+
function renderDevicesSection(ctx: ViewContext, devices: DeviceFixture[], state: StepState = "done"): string {
|
|
127
|
+
if (devices.length > 0) {
|
|
128
|
+
return `
|
|
129
|
+
<div>
|
|
130
|
+
${sectionHeader("Devices")}
|
|
131
|
+
<div class="bg-white border border-gray-200 rounded-lg divide-y divide-gray-100">
|
|
132
|
+
${devices.map((d) => renderDeviceRow(d)).join("")}
|
|
133
|
+
</div>
|
|
134
|
+
</div>`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Setup state — no devices yet
|
|
138
|
+
return sectionWrap(state, `
|
|
139
|
+
<div>
|
|
140
|
+
${sectionHeader("Devices")}
|
|
141
|
+
<div class="bg-white border ${state === "active" ? cardClass("active") : "border-gray-200"} rounded-lg p-5">
|
|
142
|
+
<div class="flex items-center gap-2">
|
|
143
|
+
<p class="text-sm font-semibold text-gray-900">Install the CLI on your first device</p>
|
|
144
|
+
${ctx.cliInstalled ? statusBadge("Installed", true) : statusBadge("Not installed", false)}
|
|
145
|
+
</div>
|
|
146
|
+
<p class="text-sm text-gray-500 mt-1">${ctx.cliInstalled
|
|
147
|
+
? "Polaris is set up. Run the same command on other machines to add them."
|
|
148
|
+
: "Run this in your terminal. Repeat on each machine you work from."}</p>
|
|
149
|
+
${ctx.cliInstalled
|
|
150
|
+
? ""
|
|
151
|
+
: copyBlock("npx @lightup/polaris login")}
|
|
152
|
+
</div>
|
|
153
|
+
</div>`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function renderDeviceRow(device: DeviceFixture): string {
|
|
157
|
+
const isOnline = device.activeSession;
|
|
158
|
+
return `
|
|
159
|
+
<div class="p-4 flex items-center justify-between">
|
|
160
|
+
<div class="flex items-center gap-3">
|
|
161
|
+
<div class="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
|
|
162
|
+
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/></svg>
|
|
163
|
+
</div>
|
|
164
|
+
<div>
|
|
165
|
+
<div class="flex items-center gap-2">
|
|
166
|
+
<p class="text-sm font-medium text-gray-900">${device.name}</p>
|
|
167
|
+
${isOnline
|
|
168
|
+
? '<span class="w-2 h-2 rounded-full bg-green-500"></span>'
|
|
169
|
+
: '<span class="w-2 h-2 rounded-full bg-gray-300"></span>'}
|
|
170
|
+
</div>
|
|
171
|
+
<p class="text-xs text-gray-400">${device.os}</p>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
<div class="text-right">
|
|
175
|
+
${isOnline
|
|
176
|
+
? `<p class="text-xs font-medium text-gray-700">${device.activeSession}</p>
|
|
177
|
+
<p class="text-xs text-gray-400">Active now</p>`
|
|
178
|
+
: `<p class="text-xs text-gray-400">Last seen ${new Date(device.lastSeen).toLocaleDateString()}</p>`}
|
|
179
|
+
</div>
|
|
180
|
+
</div>`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- Projects & Sessions section ---
|
|
184
|
+
|
|
185
|
+
function renderProjectsSessionsSection(ctx: ViewContext, sessions: SessionFixture[], projects: ProjectFixture[], state: StepState = "done"): string {
|
|
186
|
+
if (sessions.length > 0) {
|
|
187
|
+
return `
|
|
188
|
+
<div>
|
|
189
|
+
${sectionHeader("Projects & Sessions")}
|
|
190
|
+
<div class="space-y-3">
|
|
191
|
+
${sessions.map((s) => renderSessionCard(s, ctx.userName)).join("")}
|
|
192
|
+
</div>
|
|
193
|
+
${projects.length > 0 ? `
|
|
194
|
+
<div class="mt-4 space-y-3">
|
|
195
|
+
${projects.map((p) => renderProjectCard(p)).join("")}
|
|
196
|
+
</div>` : ""}
|
|
197
|
+
</div>`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Setup state — no sessions yet
|
|
201
|
+
return sectionWrap(state, `
|
|
202
|
+
<div>
|
|
203
|
+
${sectionHeader("Projects & Sessions")}
|
|
204
|
+
<div class="bg-white border ${state === "active" ? cardClass("active") : "border-gray-200"} rounded-lg p-5">
|
|
205
|
+
<div class="flex items-center gap-2">
|
|
206
|
+
<p class="text-sm font-semibold text-gray-900">Connect your first session</p>
|
|
207
|
+
${ctx.hasConnectedSession ? statusBadge("Connected", true) : statusBadge("Waiting", false)}
|
|
208
|
+
</div>
|
|
209
|
+
<p class="text-sm text-gray-500 mt-1">${ctx.hasConnectedSession
|
|
210
|
+
? "You've connected a session. You're ready to collaborate."
|
|
211
|
+
: "Inside your AI agent (Claude Code, Cursor, etc.), run:"}</p>
|
|
212
|
+
${ctx.hasConnectedSession
|
|
213
|
+
? ""
|
|
214
|
+
: copyBlock("/polaris join my-project my-session")}
|
|
215
|
+
</div>
|
|
216
|
+
</div>`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function renderSessionCard(session: SessionFixture, userName: string): string {
|
|
220
|
+
const isDriver = session.participants.some((p) => p.id === `user:${userName.toLowerCase().replace(/\s+/g, ".")}` && p.role === "driver");
|
|
221
|
+
const roleBadge = isDriver
|
|
222
|
+
? '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-polaris-100 text-polaris-800">Driver</span>'
|
|
223
|
+
: '<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">Advisor</span>';
|
|
224
|
+
|
|
225
|
+
const otherParticipants = session.participants
|
|
226
|
+
.filter((p) => p.id !== `user:${userName.toLowerCase().replace(/\s+/g, ".")}`)
|
|
227
|
+
.map((p) => `<span class="text-xs text-gray-500">${p.id}</span>`)
|
|
228
|
+
.join(", ");
|
|
229
|
+
|
|
230
|
+
return `
|
|
231
|
+
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
|
232
|
+
<div class="flex items-center justify-between">
|
|
233
|
+
<div class="flex items-center gap-2">
|
|
234
|
+
<div class="w-2 h-2 rounded-full bg-green-500"></div>
|
|
235
|
+
<p class="text-sm font-semibold text-gray-900">${session.project}/${session.name}</p>
|
|
236
|
+
${roleBadge}
|
|
237
|
+
</div>
|
|
238
|
+
<span class="text-xs text-gray-400">${session.eventCount} events</span>
|
|
239
|
+
</div>
|
|
240
|
+
<p class="text-sm text-gray-500 mt-1">${session.description}</p>
|
|
241
|
+
<div class="mt-2 flex items-center gap-1">
|
|
242
|
+
<span class="text-xs text-gray-400">with</span>
|
|
243
|
+
${otherParticipants}
|
|
244
|
+
</div>
|
|
245
|
+
</div>`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function renderProjectCard(project: ProjectFixture): string {
|
|
249
|
+
const activeSessions = project.sessions.length;
|
|
250
|
+
const drivers = project.sessions.map((s) => s.driver).filter((d, i, a) => a.indexOf(d) === i);
|
|
251
|
+
|
|
252
|
+
return `
|
|
253
|
+
<div class="bg-white border border-gray-200 rounded-lg p-4">
|
|
254
|
+
<div class="flex items-center justify-between">
|
|
255
|
+
<div class="flex items-center gap-2">
|
|
256
|
+
<p class="text-sm font-semibold text-gray-900">${project.name}</p>
|
|
257
|
+
<span class="text-xs text-gray-400">${project.slackChannel}</span>
|
|
258
|
+
</div>
|
|
259
|
+
<span class="text-xs text-gray-400">${activeSessions} session${activeSessions !== 1 ? "s" : ""}</span>
|
|
260
|
+
</div>
|
|
261
|
+
<div class="mt-2 flex items-center gap-2 flex-wrap">
|
|
262
|
+
${drivers.map((d) => `<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-gray-50 text-gray-600">${d}</span>`).join("")}
|
|
263
|
+
</div>
|
|
264
|
+
</div>`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- Setup view (zero state) ---
|
|
268
|
+
// Same three sections, but each shows its setup prompt instead of live data.
|
|
269
|
+
|
|
270
|
+
export function renderSetupView(ctx: ViewContext, devices: DeviceFixture[] = []): string {
|
|
271
|
+
const nextStep = !ctx.slackConnected ? "floor" : !ctx.cliInstalled ? "devices" : "sessions";
|
|
272
|
+
const stepState = (step: string): "done" | "active" | "future" => {
|
|
273
|
+
const order = ["floor", "devices", "sessions"];
|
|
274
|
+
const nextIdx = order.indexOf(nextStep);
|
|
275
|
+
const thisIdx = order.indexOf(step);
|
|
276
|
+
if (thisIdx < nextIdx) return "done";
|
|
277
|
+
if (thisIdx === nextIdx) return "active";
|
|
278
|
+
return "future";
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
return `
|
|
282
|
+
${nav(ctx.token, navOpts(ctx))}
|
|
283
|
+
<div class="max-w-3xl mx-auto px-6 pt-12 space-y-10">
|
|
284
|
+
${renderFloorSection(ctx, false, stepState("floor"))}
|
|
285
|
+
${renderDevicesSection(ctx, ctx.cliInstalled ? devices : [], stepState("devices"))}
|
|
286
|
+
${renderProjectsSessionsSection(ctx, [], [], stepState("sessions"))}
|
|
287
|
+
</div>`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// --- Error view ---
|
|
291
|
+
|
|
292
|
+
export function renderErrorView(message: string, linkText?: string, linkHref?: string): string {
|
|
293
|
+
return `
|
|
294
|
+
${nav()}
|
|
295
|
+
<div class="max-w-md mx-auto px-6 pt-24">
|
|
296
|
+
<div class="bg-white border border-red-200 rounded-lg p-6 text-center">
|
|
297
|
+
<div class="w-10 h-10 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-3">
|
|
298
|
+
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
|
|
299
|
+
</div>
|
|
300
|
+
<p class="text-sm text-red-700">${message}</p>
|
|
301
|
+
${linkText && linkHref ? `<a href="${linkHref}" class="mt-4 inline-block text-sm font-medium text-polaris-700 hover:text-polaris-800">${linkText}</a>` : ""}
|
|
302
|
+
</div>
|
|
303
|
+
</div>`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// --- Active view (user has sessions) ---
|
|
307
|
+
// Same three sections, populated with live data.
|
|
308
|
+
|
|
309
|
+
export function renderActiveView(ctx: ViewContext, sessions: SessionFixture[], projects: ProjectFixture[], devices: DeviceFixture[] = []): string {
|
|
310
|
+
return `
|
|
311
|
+
${nav(ctx.token, navOpts(ctx))}
|
|
312
|
+
<div class="max-w-3xl mx-auto px-6 pt-12 space-y-10">
|
|
313
|
+
${renderFloorSection(ctx, true)}
|
|
314
|
+
${renderDevicesSection(ctx, devices)}
|
|
315
|
+
${renderProjectsSessionsSection(ctx, sessions, projects)}
|
|
316
|
+
</div>`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// --- Profile view ---
|
|
320
|
+
|
|
321
|
+
export function renderProfileView(ctx: ViewContext, participantId: string): string {
|
|
322
|
+
return `
|
|
323
|
+
${nav(ctx.token, navOpts(ctx))}
|
|
324
|
+
<div class="max-w-3xl mx-auto px-6 pt-12">
|
|
325
|
+
<div>
|
|
326
|
+
${sectionHeader("Profile")}
|
|
327
|
+
<div class="bg-white border border-gray-200 rounded-lg divide-y divide-gray-100">
|
|
328
|
+
<div class="p-5 flex items-center gap-4">
|
|
329
|
+
<div class="w-12 h-12 rounded-full bg-polaris-600 flex items-center justify-center text-white text-lg font-bold shrink-0">${ctx.userName.charAt(0).toUpperCase()}</div>
|
|
330
|
+
<div>
|
|
331
|
+
<p class="text-sm font-semibold text-gray-900">${ctx.userName}</p>
|
|
332
|
+
<p class="text-sm text-gray-500">${ctx.email}</p>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
<div class="p-5 flex items-center justify-between">
|
|
336
|
+
<div>
|
|
337
|
+
<p class="text-xs text-gray-400">Participant ID</p>
|
|
338
|
+
<p class="text-sm font-mono text-gray-700">${participantId}</p>
|
|
339
|
+
</div>
|
|
340
|
+
<div class="text-right">
|
|
341
|
+
<p class="text-xs text-gray-400">Organization</p>
|
|
342
|
+
<p class="text-sm text-gray-700">${ctx.orgName}</p>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<div class="mt-10">
|
|
349
|
+
${sectionHeader("API token")}
|
|
350
|
+
<div class="bg-white border border-gray-200 rounded-lg p-5">
|
|
351
|
+
<p class="text-sm text-gray-500 mb-3">Use this for scripts and direct API access. The <code class="bg-gray-100 px-1 rounded text-xs">polaris login</code> CLI handles this automatically.</p>
|
|
352
|
+
${copyBlock(ctx.token)}
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
</div>`;
|
|
356
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createToken, verifyToken } from "../src/service/auth";
|
|
3
|
+
|
|
4
|
+
describe("JWT auth", () => {
|
|
5
|
+
const payload = {
|
|
6
|
+
sub: "user-123",
|
|
7
|
+
email: "manu@lightup.com",
|
|
8
|
+
name: "Manu Bansal",
|
|
9
|
+
org_id: "org-abc",
|
|
10
|
+
participant_id: "user:manu",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
test("creates and verifies a token", async () => {
|
|
14
|
+
const token = await createToken(payload);
|
|
15
|
+
expect(token).toBeString();
|
|
16
|
+
expect(token.split(".")).toHaveLength(3);
|
|
17
|
+
|
|
18
|
+
const verified = await verifyToken(token);
|
|
19
|
+
expect(verified).not.toBeNull();
|
|
20
|
+
expect(verified!.sub).toBe("user-123");
|
|
21
|
+
expect(verified!.email).toBe("manu@lightup.com");
|
|
22
|
+
expect(verified!.org_id).toBe("org-abc");
|
|
23
|
+
expect(verified!.participant_id).toBe("user:manu");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("rejects invalid token", async () => {
|
|
27
|
+
const result = await verifyToken("not.a.token");
|
|
28
|
+
expect(result).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("rejects tampered token", async () => {
|
|
32
|
+
const token = await createToken(payload);
|
|
33
|
+
const tampered = token.slice(0, -5) + "XXXXX";
|
|
34
|
+
const result = await verifyToken(tampered);
|
|
35
|
+
expect(result).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
});
|